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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/index.cjs +2172 -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-beta.0";
57
+ }
58
+ //#endregion
42
59
  //#region src/rule-engine/base.ts
43
60
  /** Abstract base class for all diagnose rules */
44
61
  var DiagnoseRule = class {
@@ -88,6 +105,9 @@ function topoSort(rules) {
88
105
  }
89
106
  //#endregion
90
107
  //#region src/utils.ts
108
+ function getExtensionsDir(configPath) {
109
+ return node_path.default.join(node_path.default.dirname(configPath), "extensions");
110
+ }
91
111
  /**
92
112
  * Canonical provider-ref for the feishu app secret. Both
93
113
  * `feishu_default_account` (multi-agent path) and `feishu_channel`
@@ -245,6 +265,32 @@ function shell(cmd, timeoutMs = 6e4) {
245
265
  }).trim();
246
266
  }
247
267
  //#endregion
268
+ //#region src/oc-runtime.ts
269
+ /**
270
+ * 探测 openclaw 运行时版本。能成功跑出版本号 = 已安装且可用。
271
+ *
272
+ * 比单纯 fs.existsSync 路径更可靠:能挡住 dangling symlink、依赖丢失、
273
+ * bin 是错误版本等"看似装好但跑不起来"的状态。
274
+ *
275
+ * 失败(命令缺失 / 解析失败 / 超时)返回 null。
276
+ */
277
+ function readOpenclawRuntimeVersion() {
278
+ try {
279
+ const lines = (0, node_child_process.execSync)("openclaw --version", {
280
+ encoding: "utf-8",
281
+ timeout: 5e3,
282
+ stdio: [
283
+ "ignore",
284
+ "pipe",
285
+ "ignore"
286
+ ]
287
+ }).split("\n").map((l) => l.trim()).filter(Boolean);
288
+ return (lines[lines.length - 1]?.match(/(?:OpenClaw\s+)?(\d+\.\d+\.\d+)/))?.[1] ?? null;
289
+ } catch {
290
+ return null;
291
+ }
292
+ }
293
+ //#endregion
248
294
  //#region \0@oxc-project+runtime@0.115.0/helpers/decorate.js
249
295
  function __decorate(decorators, target, key, desc) {
250
296
  var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
@@ -253,6 +299,33 @@ function __decorate(decorators, target, key, desc) {
253
299
  return c > 3 && r && Object.defineProperty(target, key, r), r;
254
300
  }
255
301
  //#endregion
302
+ //#region src/rules/openclaw-runtime-missing.ts
303
+ /**
304
+ * doctor 入口最先执行:检查 openclaw 是否已安装且可用(执行 `openclaw --version`)。
305
+ *
306
+ * 触发条件:openclaw 不可用 AND openclaw.json 存在。
307
+ * 配合 `config_file_missing`(reset)形成完整覆盖:
308
+ * - oc 可用 + config 存在 → 都 pass,进业务规则
309
+ * - oc 可用 + config 缺 → 仅 config_file_missing fail(reset)
310
+ * - oc 缺 + config 存在 → 仅本规则 fail(user_confirm: install_openclaw)
311
+ * - oc 缺 + config 缺 → 仅 config_file_missing fail(reset 同时装 oc + 初始化 config)
312
+ */
313
+ let OpenclawRuntimeMissingRule = class OpenclawRuntimeMissingRule extends DiagnoseRule {
314
+ validate(ctx) {
315
+ if (readOpenclawRuntimeVersion() != null) return { pass: true };
316
+ if (!node_fs.default.existsSync(ctx.configPath)) return { pass: true };
317
+ return {
318
+ pass: false,
319
+ action: "install_openclaw",
320
+ message: `openclaw 不可用(无法执行 'openclaw --version'),但 ${ctx.configPath} 已存在;请重新安装 openclaw`
321
+ };
322
+ }
323
+ };
324
+ OpenclawRuntimeMissingRule = __decorate([Rule({
325
+ key: "openclaw_runtime_missing",
326
+ repairMode: "user-confirm"
327
+ })], OpenclawRuntimeMissingRule);
328
+ //#endregion
256
329
  //#region src/rules/multi-process-detect.ts
257
330
  let ProcessStatusRule = class ProcessStatusRule extends DiagnoseRule {
258
331
  validate(_ctx) {
@@ -415,7 +488,7 @@ let TemplateVarsUnreplacedRule = class TemplateVarsUnreplacedRule extends Diagno
415
488
  };
416
489
  }
417
490
  repair(ctx) {
418
- const map = ctx.templateVars;
491
+ const map = ctx.vars.templateVars;
419
492
  if (!map || Object.keys(map).length === 0) return;
420
493
  replaceInPlace(ctx.config, Object.entries(map));
421
494
  }
@@ -423,7 +496,8 @@ let TemplateVarsUnreplacedRule = class TemplateVarsUnreplacedRule extends Diagno
423
496
  TemplateVarsUnreplacedRule = __decorate([Rule({
424
497
  key: "template_vars_unreplaced",
425
498
  dependsOn: ["config_syntax_check"],
426
- repairMode: "standard"
499
+ repairMode: "standard",
500
+ usesVars: ["templateVars"]
427
501
  })], TemplateVarsUnreplacedRule);
428
502
  function collectPlaceholders(value, found) {
429
503
  if (typeof value === "string") {
@@ -466,32 +540,33 @@ function applyVars(str, entries) {
466
540
  }
467
541
  //#endregion
468
542
  //#region src/rules/model-provider.ts
469
- 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,55 @@ function startAsyncReset(ctxBase64) {
1799
2686
  return { taskId };
1800
2687
  }
1801
2688
  //#endregion
2689
+ //#region src/oss/fetchWithDiag.ts
2690
+ async function fetchWithDiag(url, opts = {}) {
2691
+ const label = opts.label ?? originOf(url);
2692
+ const timeoutMs = opts.timeoutMs ?? 3e4;
2693
+ const start = Date.now();
2694
+ const ac = new AbortController();
2695
+ const timer = timeoutMs > 0 && Number.isFinite(timeoutMs) ? setTimeout(() => ac.abort(), timeoutMs) : void 0;
2696
+ try {
2697
+ return await fetch(url, { signal: ac.signal });
2698
+ } catch (e) {
2699
+ const durationMs = Date.now() - start;
2700
+ const causeStr = e.name === "AbortError" || ac.signal.aborted && timeoutMs > 0 ? `request aborted after ${timeoutMs}ms (timeout)` : describeCause(e.cause) || e.message;
2701
+ throw new Error(`fetch ${label} failed: ${causeStr} (url=${redactUrl(url)} durationMs=${durationMs})`);
2702
+ } finally {
2703
+ if (timer) clearTimeout(timer);
2704
+ }
2705
+ }
2706
+ /** Walk the Error.cause chain and produce a single-line summary like
2707
+ * `ENOTFOUND getaddrinfo ENOTFOUND oss.example.com`. */
2708
+ function describeCause(c, depth = 0) {
2709
+ if (!c || depth > 3) return "";
2710
+ if (c instanceof Error) {
2711
+ const code = c.code;
2712
+ const head = code ? `${code} ${c.message}` : c.message;
2713
+ const inner = describeCause(c.cause, depth + 1);
2714
+ return inner ? `${head} <- ${inner}` : head;
2715
+ }
2716
+ return String(c);
2717
+ }
2718
+ /** Strip query string — OSS signed URLs put the auth token there. Keeps
2719
+ * protocol/host/path so the operator can tell *which* OSS bucket / object
2720
+ * was being fetched. */
2721
+ function redactUrl(raw) {
2722
+ try {
2723
+ const u = new URL(raw);
2724
+ const tail = u.search ? "?<redacted>" : "";
2725
+ return `${u.protocol}//${u.host}${u.pathname}${tail}`;
2726
+ } catch {
2727
+ return "<invalid-url>";
2728
+ }
2729
+ }
2730
+ function originOf(raw) {
2731
+ try {
2732
+ return new URL(raw).host;
2733
+ } catch {
2734
+ return "unknown-host";
2735
+ }
2736
+ }
2737
+ //#endregion
1802
2738
  //#region src/oss/fetchManifest.ts
1803
2739
  const MANIFEST_PREFIX = "builtin/manifests/openclaw/recommended/";
1804
2740
  const MANIFEST_SUFFIX = ".json";
@@ -1810,9 +2746,23 @@ async function fetchManifest(ossFileMap, tag) {
1810
2746
  const availStr = available.length ? available.join(", ") : "(none)";
1811
2747
  throw new Error(`manifest signed URL missing for tag "${tag}" (key ${key}). Available tags in ossFileMap: ${availStr}. Either pass an available tag or update the studio_server TCC openclaw_upgrade_config supported_versions.`);
1812
2748
  }
1813
- const res = await fetch(url);
1814
- if (!res.ok) throw new Error(`fetch manifest failed: HTTP ${res.status} ${res.statusText}`);
1815
- return await res.json();
2749
+ const label = `openclaw manifest tag=${tag}`;
2750
+ const res = await fetchWithDiag(url, { label });
2751
+ if (!res.ok) {
2752
+ const body = await readPreview(res);
2753
+ throw new Error(`${label}: HTTP ${res.status} ${res.statusText}` + (body ? ` body=${body}` : ""));
2754
+ }
2755
+ return await res.json();
2756
+ }
2757
+ /** Best-effort body preview for HTTP-error diagnostics. OSS errors are
2758
+ * XML; first 256 chars usually contain the <Code> + <Message> we care
2759
+ * about. Failures here are swallowed (logging-only, not load-bearing). */
2760
+ async function readPreview(res) {
2761
+ try {
2762
+ return (await res.text()).slice(0, 256);
2763
+ } catch {
2764
+ return "";
2765
+ }
1816
2766
  }
1817
2767
  async function downloadWithCache(pkg, ossFileMap, opts = {}) {
1818
2768
  const cacheRoot = opts.cacheRoot ?? "/tmp/openclaw-diagnose/resources";
@@ -1827,9 +2777,19 @@ async function downloadWithCache(pkg, ossFileMap, opts = {}) {
1827
2777
  const expected = pkg.integrity.slice(7);
1828
2778
  const tmpFile = node_path.default.join(destDir, `.tmp.${process.pid}.${node_crypto.default.randomBytes(4).toString("hex")}`);
1829
2779
  try {
1830
- const 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}`);
2780
+ const label = `download ${pkg.ossKey}`;
2781
+ const res = await fetchWithDiag(url, {
2782
+ label,
2783
+ timeoutMs: 6e4
2784
+ });
2785
+ if (!res.ok) {
2786
+ let preview = "";
2787
+ try {
2788
+ preview = (await res.text()).slice(0, 256);
2789
+ } catch {}
2790
+ throw new Error(`${label}: HTTP ${res.status} ${res.statusText}` + (preview ? ` body=${preview}` : ""));
2791
+ }
2792
+ if (!res.body) throw new Error(`${label}: empty body`);
1833
2793
  const hasher = node_crypto.default.createHash("sha512");
1834
2794
  const source = node_stream.Readable.fromWeb(res.body);
1835
2795
  async function* teeAndHash(src) {
@@ -2354,16 +3314,6 @@ function writeSecretsAndRestart(vars, resetData, configDir, log) {
2354
3314
  log(`restart.sh done in ${Date.now() - t}ms`);
2355
3315
  } else log(`no restart.sh at ${restartScript}, skip`);
2356
3316
  }
2357
- /**
2358
- * Run the 9-step reset process. Called from the worker entry point.
2359
- *
2360
- * Each step is an independent function. The orchestrator handles progress
2361
- * reporting, error handling, and process-level exception guards.
2362
- *
2363
- * Template assets (openclaw.json + scripts/) are downloaded from OSS into a
2364
- * scratch dir via `stageTemplate` before step 1 — there is no bundled
2365
- * `template/` directory at runtime any more.
2366
- */
2367
3317
  async function runReset(input, taskId, resultFile) {
2368
3318
  const startedAt = (/* @__PURE__ */ new Date()).toISOString();
2369
3319
  const { configPath, vars, resetData } = input;
@@ -2371,6 +3321,7 @@ async function runReset(input, taskId, resultFile) {
2371
3321
  const stagedDir = node_path.default.join(DIAGNOSE_DIR, `reset-${taskId}-template`);
2372
3322
  let currentStep = 0;
2373
3323
  let stepStartedAt = Date.now();
3324
+ const stepResults = [];
2374
3325
  const log = makeLogger(resetLogFile(taskId));
2375
3326
  log(`=== reset started, taskId=${taskId}, pid=${process.pid} ===`);
2376
3327
  log(`configPath=${configPath}, configDir=${configDir}, stagedDir=${stagedDir}`);
@@ -2379,7 +3330,12 @@ async function runReset(input, taskId, resultFile) {
2379
3330
  const err = "resetData.ossFileMap missing or empty";
2380
3331
  log(`ERROR: ${err}`);
2381
3332
  markFailed(resultFile, 0, err, startedAt);
2382
- process.exit(1);
3333
+ return {
3334
+ success: false,
3335
+ steps: stepResults,
3336
+ error: err,
3337
+ failedStep: 0
3338
+ };
2383
3339
  }
2384
3340
  let openclawTag;
2385
3341
  if (resetData.openclawTag) openclawTag = resetData.openclawTag;
@@ -2389,7 +3345,12 @@ async function runReset(input, taskId, resultFile) {
2389
3345
  const err = e.message;
2390
3346
  log(`ERROR: ${err}`);
2391
3347
  markFailed(resultFile, 0, err, startedAt);
2392
- process.exit(1);
3348
+ return {
3349
+ success: false,
3350
+ steps: stepResults,
3351
+ error: err,
3352
+ failedStep: 0
3353
+ };
2393
3354
  }
2394
3355
  log(`openclawTag=${openclawTag}`);
2395
3356
  process.on("uncaughtException", (err) => {
@@ -2402,9 +3363,19 @@ async function runReset(input, taskId, resultFile) {
2402
3363
  markFailed(resultFile, currentStep, `unhandled rejection: ${reason}`, startedAt);
2403
3364
  process.exit(1);
2404
3365
  });
2405
- /** Advance to the next step, updating the progress file and logging a boundary. */
3366
+ /** Advance to the next step, recording the previous step's success
3367
+ * duration and updating the on-disk progress file. */
2406
3368
  const step = (n) => {
2407
- if (currentStep > 0) log(`step ${currentStep} "${STEPS[currentStep - 1]}" done in ${Date.now() - stepStartedAt}ms`);
3369
+ if (currentStep > 0) {
3370
+ const dur = Date.now() - stepStartedAt;
3371
+ log(`step ${currentStep} "${STEPS[currentStep - 1]}" done in ${dur}ms`);
3372
+ stepResults.push({
3373
+ step: currentStep,
3374
+ name: STEPS[currentStep - 1],
3375
+ status: "ok",
3376
+ durationMs: dur
3377
+ });
3378
+ }
2408
3379
  currentStep = n;
2409
3380
  stepStartedAt = Date.now();
2410
3381
  log(`--- step ${n}/${TOTAL_STEPS}: ${STEPS[n - 1]} ---`);
@@ -2430,14 +3401,38 @@ async function runReset(input, taskId, resultFile) {
2430
3401
  await step8InstallExtensions(openclawTag, ossFileMap, log);
2431
3402
  step(9);
2432
3403
  writeSecretsAndRestart(vars, resetData, configDir, log);
2433
- log(`step 9 "${STEPS[8]}" done in ${Date.now() - stepStartedAt}ms`);
3404
+ const lastDur = Date.now() - stepStartedAt;
3405
+ log(`step 9 "${STEPS[8]}" done in ${lastDur}ms`);
3406
+ stepResults.push({
3407
+ step: 9,
3408
+ name: STEPS[8],
3409
+ status: "ok",
3410
+ durationMs: lastDur
3411
+ });
2434
3412
  log("=== reset completed successfully ===");
2435
3413
  markDone(resultFile, startedAt);
3414
+ return {
3415
+ success: true,
3416
+ steps: stepResults
3417
+ };
2436
3418
  } catch (e) {
2437
3419
  const err = e.message;
2438
- log(`ERROR in step ${currentStep} "${STEPS[currentStep - 1] ?? "init"}" after ${Date.now() - stepStartedAt}ms: ${err}\n${e.stack ?? ""}`);
3420
+ const dur = Date.now() - stepStartedAt;
3421
+ log(`ERROR in step ${currentStep} "${STEPS[currentStep - 1] ?? "init"}" after ${dur}ms: ${err}\n${e.stack ?? ""}`);
3422
+ stepResults.push({
3423
+ step: currentStep,
3424
+ name: STEPS[currentStep - 1] ?? "init",
3425
+ status: "fail",
3426
+ durationMs: dur,
3427
+ error: err
3428
+ });
2439
3429
  markFailed(resultFile, currentStep, err, startedAt);
2440
- process.exit(1);
3430
+ return {
3431
+ success: false,
3432
+ steps: stepResults,
3433
+ error: err,
3434
+ failedStep: currentStep
3435
+ };
2441
3436
  } finally {
2442
3437
  try {
2443
3438
  node_fs.default.rmSync(stagedDir, {
@@ -2504,38 +3499,24 @@ function resolveOssFileMap(args) {
2504
3499
  throw new Error("ossFileMap missing: provide --oss_file_map flag, ctx.install.ossFileMap, or resetData.ossFileMap");
2505
3500
  }
2506
3501
  //#endregion
2507
- //#region src/innerapi/fetchCtx.ts
3502
+ //#region src/innerapi/client.ts
2508
3503
  /**
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`:
3504
+ * Shared innerapi HTTP client + path constants for `openclaw.*` calls.
2513
3505
  *
2514
3506
  * - `baseURL` from env `FORCE_AUTHN_INNERAPI_DOMAIN` (injected into every
2515
3507
  * openclaw sandbox).
2516
3508
  * - `platform: { enabled, tokenProvider: { type: 'file' } }` — the platform
2517
3509
  * plugin auto-attaches the sandbox's identity JWT loaded from the
2518
3510
  * rootfs token file. Same auth that the miaoda extension already uses.
2519
- * - POST `/api/v1/studio/innerapi/integration_apis/call`
2520
- * 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.
3511
+ * - POST `/api/v1/studio/innerapi/integration_apis/call` with body
3512
+ * `{ apiName, bizType, input }` is the universal dispatcher; specific
3513
+ * apiName values (e.g. `openclaw.get_doctor_ctx`,
3514
+ * `openclaw.report_doctor_result`) are owned by individual call-site
3515
+ * modules.
2533
3516
  */
2534
3517
  const INNERAPI_CALL_PATH = "/api/v1/studio/innerapi/integration_apis/call";
2535
- const API_NAME = "openclaw.get_doctor_ctx";
2536
3518
  const BIZ_TYPE = "openclaw";
2537
3519
  const API_TIMEOUT_MS = 3e4;
2538
- const MAX_LOG_BODY = 500;
2539
3520
  let clientInstance = null;
2540
3521
  function getHttpClient() {
2541
3522
  if (!clientInstance) {
@@ -2552,6 +3533,35 @@ function getHttpClient() {
2552
3533
  }
2553
3534
  return clientInstance;
2554
3535
  }
3536
+ //#endregion
3537
+ //#region src/innerapi/fetchCtx.ts
3538
+ /**
3539
+ * CLI-side client for studio_server's `openclaw.get_doctor_ctx` inner API.
3540
+ *
3541
+ * Mirrors the proven pattern in
3542
+ * `packages/openclaw/extensions/miaoda/src/shared/innerapi-client.ts`:
3543
+ *
3544
+ * - `baseURL` from env `FORCE_AUTHN_INNERAPI_DOMAIN` (injected into every
3545
+ * openclaw sandbox).
3546
+ * - `platform: { enabled, tokenProvider: { type: 'file' } }` — the platform
3547
+ * plugin auto-attaches the sandbox's identity JWT loaded from the
3548
+ * rootfs token file. Same auth that the miaoda extension already uses.
3549
+ * - POST `/api/v1/studio/innerapi/integration_apis/call`
3550
+ * body = { apiName: 'openclaw.get_doctor_ctx', input: {}, bizType: 'openclaw' }
3551
+ * — the server-side APICall dispatches by `apiName` to
3552
+ * `GetDoctorCtxAPICall.Execute` whose `Name()` returns that string.
3553
+ * - Response envelope: { status_code, error_msg?, data: { success, output, ... } }.
3554
+ * `status_code` is a *string* ('0' = success).
3555
+ * Actual DoctorCtx lives in `data.output`.
3556
+ * - `x-tt-logid` header is logged on every failure path for cross-service
3557
+ * traceability.
3558
+ *
3559
+ * On HTTP 401 (sandbox identity token expired/invalid) we `process.exit(77)`
3560
+ * instead of throwing — the outer catch in `index.ts` cannot then mask auth
3561
+ * failure as a generic "Error: ...". Caller (e.g. sandbox_console) sees the
3562
+ * exit code and can refresh the token + retry.
3563
+ */
3564
+ const API_NAME$1 = "openclaw.get_doctor_ctx";
2555
3565
  /**
2556
3566
  * Fetch the sandbox's DoctorCtx by calling the innerapi's generic
2557
3567
  * `integration_apis/call` dispatcher with apiName=openclaw.get_doctor_ctx.
@@ -2559,14 +3569,19 @@ function getHttpClient() {
2559
3569
  * Throws on HTTP (non-401) / decode / business errors. On 401 calls
2560
3570
  * `process.exit(77)` directly.
2561
3571
  */
2562
- async function fetchCtxViaInnerApi() {
3572
+ async function fetchCtxViaInnerApi(opts = {}) {
2563
3573
  const client = getHttpClient();
3574
+ const input = {};
3575
+ if (opts.populate !== void 0) input.populate = opts.populate;
3576
+ if (opts.caller) input.caller = opts.caller;
3577
+ if (opts.traceId) input.traceId = opts.traceId;
2564
3578
  const body = {
2565
- apiName: API_NAME,
2566
- input: {},
3579
+ apiName: API_NAME$1,
3580
+ input,
2567
3581
  bizType: BIZ_TYPE
2568
3582
  };
2569
3583
  const start = Date.now();
3584
+ console.error(`fetchCtx: start populate=${JSON.stringify(opts.populate ?? {})}${opts.caller ? ` caller=${opts.caller}` : ""}${opts.traceId ? ` traceId=${opts.traceId}` : ""}`);
2570
3585
  const headers = { "Content-Type": "application/json" };
2571
3586
  const ttEnv = process.env.X_TT_ENV;
2572
3587
  if (ttEnv) headers["x-tt-env"] = ttEnv;
@@ -2596,7 +3611,7 @@ async function fetchCtxViaInnerApi() {
2596
3611
  }
2597
3612
  let preview = "";
2598
3613
  try {
2599
- preview = (await response.text()).slice(0, MAX_LOG_BODY);
3614
+ preview = (await response.text()).slice(0, 500);
2600
3615
  } catch {}
2601
3616
  throw new Error(`fetchCtxViaInnerApi HTTP ${response.status} ${response.statusText} (logID: ${logId}, durationMs: ${durationMs})${preview ? ` body=${preview}` : ""}`);
2602
3617
  }
@@ -2610,6 +3625,7 @@ async function fetchCtxViaInnerApi() {
2610
3625
  if (envelope.data && envelope.data.success === false) throw new Error(`fetchCtxViaInnerApi business error (logID: ${logId}, durationMs: ${durationMs}): ${envelope.error_msg ?? JSON.stringify(envelope.data)}`);
2611
3626
  const output = envelope.data?.output;
2612
3627
  if (!output || typeof output !== "object") throw new Error(`fetchCtxViaInnerApi empty/invalid output (logID: ${logId}, durationMs: ${durationMs})`);
3628
+ console.error(`fetchCtx: ok logId=${logId} durationMs=${durationMs} groups=[${Object.keys(output).join(",")}]`);
2613
3629
  return output;
2614
3630
  }
2615
3631
  //#endregion
@@ -2628,26 +3644,40 @@ async function fetchCtxViaInnerApi() {
2628
3644
  */
2629
3645
  function normalizeCtx(raw) {
2630
3646
  const r = raw ?? {};
2631
- if (r.app && typeof r.app === "object" && r.install && typeof r.install === "object" && r.secrets && typeof r.secrets === "object" && r.reset && typeof r.reset === "object") 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
- };
3647
+ if (r.app && typeof r.app === "object" && r.install && typeof r.install === "object" && r.secrets && typeof r.secrets === "object" && r.reset && typeof r.reset === "object") {
3648
+ const templateVars = r.app.templateVars && typeof r.app.templateVars === "object" ? r.app.templateVars : r.reset.templateVars ?? {};
3649
+ const recommendedOpenclawTag = typeof r.app.recommendedOpenclawTag === "string" && r.app.recommendedOpenclawTag !== "" ? r.app.recommendedOpenclawTag : typeof r.install.openclawTag === "string" && r.install.openclawTag !== "" ? r.install.openclawTag : void 0;
3650
+ return {
3651
+ app: {
3652
+ ...fillApp(r.app),
3653
+ templateVars,
3654
+ recommendedOpenclawTag
3655
+ },
3656
+ install: {
3657
+ openclawTag: r.install.openclawTag,
3658
+ ossFileMap: r.install.ossFileMap ?? {}
3659
+ },
3660
+ secrets: {
3661
+ secretsContent: r.secrets.secretsContent ?? "",
3662
+ providerKeyContent: r.secrets.providerKeyContent ?? ""
3663
+ },
3664
+ reset: {
3665
+ templateVars: r.reset.templateVars ?? {},
3666
+ coreBackup: r.reset.coreBackup
3667
+ }
3668
+ };
3669
+ }
2646
3670
  const vars = r.vars ?? {};
2647
3671
  const resetData = r.resetData ?? {};
2648
3672
  const repairData = r.repairData ?? {};
3673
+ const legacyTemplateVars = vars.templateVars && typeof vars.templateVars === "object" ? vars.templateVars : resetData.templateVars ?? r.templateVars ?? {};
3674
+ const legacyRecommendedTag = typeof vars.recommendedOpenclawTag === "string" && vars.recommendedOpenclawTag !== "" ? vars.recommendedOpenclawTag : typeof resetData.openclawTag === "string" && resetData.openclawTag !== "" ? resetData.openclawTag : typeof r.openclawTag === "string" && r.openclawTag !== "" ? r.openclawTag : void 0;
2649
3675
  return {
2650
- app: fillApp(vars),
3676
+ app: {
3677
+ ...fillApp(vars),
3678
+ templateVars: legacyTemplateVars,
3679
+ recommendedOpenclawTag: legacyRecommendedTag
3680
+ },
2651
3681
  install: {
2652
3682
  openclawTag: r.install?.openclawTag ?? r.openclawTag,
2653
3683
  ossFileMap: r.install?.ossFileMap ?? resetData.ossFileMap ?? r.ossFileMap ?? {}
@@ -2700,11 +3730,15 @@ function fillApp(src) {
2700
3730
  function buildCheckInput(raw, configPathOverride) {
2701
3731
  const r = raw ?? {};
2702
3732
  if (r.configPath && r.vars) {
2703
- if (configPathOverride) return {
3733
+ const out = configPathOverride ? {
2704
3734
  ...r,
2705
3735
  configPath: configPathOverride
3736
+ } : { ...r };
3737
+ if (out.vars && !out.vars.templateVars) out.vars = {
3738
+ ...out.vars,
3739
+ templateVars: out.templateVars ?? {}
2706
3740
  };
2707
- return r;
3741
+ return out;
2708
3742
  }
2709
3743
  const ctx = normalizeCtx(raw);
2710
3744
  return {
@@ -2718,19 +3752,25 @@ function buildCheckInput(raw, configPathOverride) {
2718
3752
  baseURL: ctx.app.baseURL,
2719
3753
  expectedOrigins: ctx.app.expectedOrigins,
2720
3754
  providerFilePath: PROVIDER_FILE_PATH,
2721
- secretsFilePath: SECRETS_FILE_PATH
3755
+ secretsFilePath: SECRETS_FILE_PATH,
3756
+ templateVars: ctx.app.templateVars,
3757
+ recommendedOpenclawTag: ctx.app.recommendedOpenclawTag
2722
3758
  },
2723
- templateVars: ctx.reset.templateVars
3759
+ templateVars: ctx.app.templateVars
2724
3760
  };
2725
3761
  }
2726
3762
  function buildRepairInput(raw, configPathOverride) {
2727
3763
  const r = raw ?? {};
2728
3764
  if (r.configPath && r.vars) {
2729
- if (configPathOverride) return {
3765
+ const out = configPathOverride ? {
2730
3766
  ...r,
2731
3767
  configPath: configPathOverride
3768
+ } : { ...r };
3769
+ if (out.vars && !out.vars.templateVars) out.vars = {
3770
+ ...out.vars,
3771
+ templateVars: out.templateVars ?? {}
2732
3772
  };
2733
- return r;
3773
+ return out;
2734
3774
  }
2735
3775
  const ctx = normalizeCtx(raw);
2736
3776
  return {
@@ -2744,23 +3784,29 @@ function buildRepairInput(raw, configPathOverride) {
2744
3784
  baseURL: ctx.app.baseURL,
2745
3785
  expectedOrigins: ctx.app.expectedOrigins,
2746
3786
  providerFilePath: PROVIDER_FILE_PATH,
2747
- secretsFilePath: SECRETS_FILE_PATH
3787
+ secretsFilePath: SECRETS_FILE_PATH,
3788
+ templateVars: ctx.app.templateVars,
3789
+ recommendedOpenclawTag: ctx.app.recommendedOpenclawTag
2748
3790
  },
2749
3791
  repairData: {
2750
3792
  secretsContent: ctx.secrets.secretsContent,
2751
3793
  providerKeyContent: ctx.secrets.providerKeyContent
2752
3794
  },
2753
- templateVars: ctx.reset.templateVars
3795
+ templateVars: ctx.app.templateVars
2754
3796
  };
2755
3797
  }
2756
3798
  function buildResetInput(raw, configPathOverride) {
2757
3799
  const r = raw ?? {};
2758
3800
  if (r.configPath && r.vars && r.resetData) {
2759
- if (configPathOverride) return {
3801
+ const out = configPathOverride ? {
2760
3802
  ...r,
2761
3803
  configPath: configPathOverride
3804
+ } : { ...r };
3805
+ if (out.vars && !out.vars.templateVars) out.vars = {
3806
+ ...out.vars,
3807
+ templateVars: out.resetData?.templateVars ?? {}
2762
3808
  };
2763
- return r;
3809
+ return out;
2764
3810
  }
2765
3811
  const ctx = normalizeCtx(raw);
2766
3812
  return {
@@ -2774,7 +3820,9 @@ function buildResetInput(raw, configPathOverride) {
2774
3820
  baseURL: ctx.app.baseURL,
2775
3821
  expectedOrigins: ctx.app.expectedOrigins,
2776
3822
  providerFilePath: PROVIDER_FILE_PATH,
2777
- secretsFilePath: SECRETS_FILE_PATH
3823
+ secretsFilePath: SECRETS_FILE_PATH,
3824
+ templateVars: ctx.app.templateVars,
3825
+ recommendedOpenclawTag: ctx.app.recommendedOpenclawTag
2778
3826
  },
2779
3827
  resetData: {
2780
3828
  templateVars: ctx.reset.templateVars,
@@ -2788,30 +3836,381 @@ function buildResetInput(raw, configPathOverride) {
2788
3836
  }
2789
3837
  //#endregion
2790
3838
  //#region src/doctor.ts
3839
+ /**
3840
+ * Per-rule orchestration with telemetry-friendly outcomes.
3841
+ *
3842
+ * Flow per rule (in topological order, after disabledRules / dependsOn /
3843
+ * skipWhen filtering):
3844
+ *
3845
+ * 1. validate — exception → status='error', abort
3846
+ * 2. pass → status='pass'
3847
+ * 3. fail and !opts.fix → status='failed' (with v1.message)
3848
+ * 4. fail and opts.fix →
3849
+ * a. only standard-mode rules attempt repair; ai/reset stay 'failed'
3850
+ * b. repair — exception → status='error', abort
3851
+ * c. re-validate — exception → status='error', abort
3852
+ * d. v2.pass → status='fixed' (before = v1.message)
3853
+ * e. v2.fail → status='still-broken' (before+after)
3854
+ *
3855
+ * Aborted runs return a partial DoctorReport with `aborted: true`. The
3856
+ * caller should `console.log(JSON.stringify(report))` first, THEN
3857
+ * `process.exit(1)` so sweep / telemetry callers always get the partial
3858
+ * data plus the non-zero signal.
3859
+ *
3860
+ * Config writeback: if opts.fix and at least one rule transitioned
3861
+ * pass→fixed, the in-memory ctx.config is JSON-serialized and written
3862
+ * back to opts.configPath. On abort, no write — keep the on-disk file
3863
+ * matching whatever was successful before the failure.
3864
+ *
3865
+ * Secrets file / provider-key / restart-command writes are NOT performed
3866
+ * by doctor. Those belong to the standalone `repair` subcommand which
3867
+ * sandbox_console push uses. Doctor is for diagnosis + per-rule fix
3868
+ * with structured outcomes.
3869
+ */
2791
3870
  async function runDoctor(rawCtx, opts) {
2792
- 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;
3871
+ const results = [];
3872
+ console.error(`runDoctor: begin fix=${opts.fix} rules=[${opts.rules.join(",")}] configPath=${opts.configPath ?? "(default)"}`);
3873
+ const checkInput = buildCheckInput(rawCtx, opts.configPath);
3874
+ let configRaw;
3875
+ let config;
3876
+ try {
3877
+ configRaw = readFile(checkInput.configPath);
3878
+ } catch (e) {
3879
+ console.error(`runDoctor: read config failed at ${checkInput.configPath} message=${e.message}`);
3880
+ return finalize([{
3881
+ rule: "config_syntax_check",
3882
+ status: "error",
3883
+ message: "read config failed: " + e.message
3884
+ }], true);
3885
+ }
3886
+ try {
3887
+ config = loadJSON5().parse(configRaw);
3888
+ } catch (e) {
3889
+ console.error(`runDoctor: config JSON5 parse failed message=${e.message}`);
3890
+ results.push({
3891
+ rule: "config_syntax_check",
3892
+ status: opts.fix ? "still-broken" : "failed",
3893
+ message: "config JSON5 parse failed: " + e.message
3894
+ });
3895
+ return finalize(results, true);
3896
+ }
3897
+ const ctx = {
3898
+ config,
3899
+ configPath: checkInput.configPath,
3900
+ vars: checkInput.vars,
3901
+ providerDeps: analyzeProviderDeps(config)
3902
+ };
3903
+ const originalConfig = opts.showDiff && opts.fix ? deepClone(config) : null;
3904
+ const allRules = getAllRules();
3905
+ const onlyKeys = opts.rules.length > 0 ? new Set(opts.rules) : null;
3906
+ const skipRuleKeys = new Set(opts.skipRules ?? []);
3907
+ const disabledKeys = new Set([...checkInput.disabledRules ?? [], ...skipRuleKeys]);
3908
+ if (onlyKeys) {
3909
+ const registeredKeys = new Set(allRules.map((r) => r.meta.key));
3910
+ for (const k of opts.rules) if (!registeredKeys.has(k)) results.push({
3911
+ rule: k,
3912
+ status: "unknown"
3913
+ });
3914
+ }
3915
+ const failedKeys = /* @__PURE__ */ new Set();
3916
+ let configDirty = false;
3917
+ let aborted = false;
3918
+ const hasMiaoda = shouldCheckMiaodaRules(config);
3919
+ const plannedRules = allRules.filter((r) => !onlyKeys || onlyKeys.has(r.meta.key));
3920
+ console.error(`runDoctor: planned ${plannedRules.length} rules hasMiaoda=${hasMiaoda} providerDeps=${JSON.stringify(ctx.providerDeps)}`);
3921
+ outer: for (const rule of allRules) {
3922
+ const key = rule.meta.key;
3923
+ if (onlyKeys && !onlyKeys.has(key)) continue;
3924
+ if (disabledKeys.has(key)) {
3925
+ const reason = skipRuleKeys.has(key) ? "--skip-rule" : "disabledRules";
3926
+ console.error(`rule ${key}: skipped reason=${reason}`);
3927
+ results.push({
3928
+ rule: key,
3929
+ status: "skipped",
3930
+ message: reason
3931
+ });
3932
+ continue;
3933
+ }
3934
+ if (rule.meta.dependsOn?.some((d) => failedKeys.has(d))) {
3935
+ const blockers = rule.meta.dependsOn?.filter((d) => failedKeys.has(d)).join(",");
3936
+ console.error(`rule ${key}: skipped reason=dependsOn-failed blockers=${blockers}`);
3937
+ results.push({
3938
+ rule: key,
3939
+ status: "skipped",
3940
+ message: `dependsOn failed: ${blockers}`
3941
+ });
3942
+ continue;
3943
+ }
3944
+ if (rule.meta.skipWhen?.({
3945
+ hasMiaoda,
3946
+ deps: ctx.providerDeps
3947
+ })) {
3948
+ console.error(`rule ${key}: skipped reason=skipWhen`);
3949
+ results.push({
3950
+ rule: key,
3951
+ status: "skipped",
3952
+ message: "skipWhen"
3953
+ });
3954
+ continue;
3955
+ }
3956
+ const tValidate = Date.now();
3957
+ let v1;
3958
+ try {
3959
+ v1 = rule.validate(ctx);
3960
+ } catch (e) {
3961
+ const msg = e.message;
3962
+ console.error(`rule ${key}: validate -> error durationMs=${Date.now() - tValidate} message=${msg}`);
3963
+ results.push({
3964
+ rule: key,
3965
+ status: "error",
3966
+ message: "validate threw: " + msg
3967
+ });
3968
+ aborted = true;
3969
+ break outer;
3970
+ }
3971
+ if (v1.pass) {
3972
+ console.error(`rule ${key}: validate -> pass durationMs=${Date.now() - tValidate}`);
3973
+ results.push({
3974
+ rule: key,
3975
+ status: "pass"
3976
+ });
3977
+ continue;
3978
+ }
3979
+ if (!opts.fix) {
3980
+ console.error(`rule ${key}: validate -> failed durationMs=${Date.now() - tValidate} message=${v1.message ?? ""}`);
3981
+ results.push({
3982
+ rule: key,
3983
+ status: "failed",
3984
+ message: v1.message,
3985
+ action: v1.action
3986
+ });
3987
+ failedKeys.add(key);
3988
+ continue;
3989
+ }
3990
+ if (rule.meta.repairMode !== "standard") {
3991
+ console.error(`rule ${key}: validate -> failed (no auto-repair, repairMode=${rule.meta.repairMode}) durationMs=${Date.now() - tValidate} message=${v1.message ?? ""}`);
3992
+ results.push({
3993
+ rule: key,
3994
+ status: "failed",
3995
+ message: v1.message,
3996
+ action: v1.action
3997
+ });
3998
+ failedKeys.add(key);
3999
+ continue;
4000
+ }
4001
+ console.error(`rule ${key}: validate -> failed durationMs=${Date.now() - tValidate} message=${v1.message ?? ""}`);
4002
+ const tRepair = Date.now();
4003
+ try {
4004
+ rule.repair(ctx);
4005
+ } catch (e) {
4006
+ const msg = e.message;
4007
+ console.error(`rule ${key}: repair -> error durationMs=${Date.now() - tRepair} message=${msg}`);
4008
+ results.push({
4009
+ rule: key,
4010
+ status: "error",
4011
+ message: "repair threw: " + msg,
4012
+ before: v1.message
4013
+ });
4014
+ aborted = true;
4015
+ break outer;
4016
+ }
4017
+ console.error(`rule ${key}: repair -> ok durationMs=${Date.now() - tRepair}`);
4018
+ configDirty = true;
4019
+ const tRevalidate = Date.now();
4020
+ let v2;
4021
+ try {
4022
+ v2 = rule.validate(ctx);
4023
+ } catch (e) {
4024
+ const msg = e.message;
4025
+ console.error(`rule ${key}: revalidate -> error durationMs=${Date.now() - tRevalidate} message=${msg}`);
4026
+ results.push({
4027
+ rule: key,
4028
+ status: "error",
4029
+ message: "post-repair validate threw: " + msg,
4030
+ before: v1.message
4031
+ });
4032
+ aborted = true;
4033
+ break outer;
4034
+ }
4035
+ if (v2.pass) {
4036
+ console.error(`rule ${key}: revalidate -> pass (fixed) durationMs=${Date.now() - tRevalidate}`);
4037
+ results.push({
4038
+ rule: key,
4039
+ status: "fixed",
4040
+ before: v1.message
4041
+ });
4042
+ } else {
4043
+ console.error(`rule ${key}: revalidate -> still-broken durationMs=${Date.now() - tRevalidate} after=${v2.message ?? ""}`);
4044
+ results.push({
4045
+ rule: key,
4046
+ status: "still-broken",
4047
+ before: v1.message,
4048
+ after: v2.message
4049
+ });
4050
+ failedKeys.add(key);
4051
+ }
4052
+ }
4053
+ let backupPath = null;
4054
+ if (configDirty && !aborted) {
4055
+ backupPath = backupConfigSync(ctx.configPath);
4056
+ const serialized = JSON.stringify(ctx.config, null, 2);
4057
+ try {
4058
+ writeFile(ctx.configPath, serialized);
4059
+ console.error(`runDoctor: writeback ok path=${ctx.configPath} bytes=${serialized.length}`);
4060
+ } catch (e) {
4061
+ const msg = e.message;
4062
+ console.error(`runDoctor: writeback failed path=${ctx.configPath} message=${msg}`);
4063
+ results.push({
4064
+ rule: "*config-writeback*",
4065
+ status: "error",
4066
+ message: "config write failed: " + msg
4067
+ });
4068
+ aborted = true;
4069
+ }
4070
+ } else if (configDirty && aborted) console.error("runDoctor: writeback skipped (aborted, leaving on-disk config untouched)");
4071
+ else console.error("runDoctor: writeback skipped (no rule transitioned to fixed)");
4072
+ console.error(`runDoctor: end aborted=${aborted} results=${results.length}`);
4073
+ if (originalConfig !== null) {
4074
+ const d = (0, json_diff.diffString)(originalConfig, ctx.config, { color: false });
4075
+ console.error(`runDoctor: diff backupPath=${backupPath ?? "(none)"} changed=${!!d}`);
4076
+ if (d) {
4077
+ process.stdout.write(`original: ${backupPath ?? "(none)"}\n`);
4078
+ process.stdout.write(`after fixed: ${ctx.configPath}\n`);
4079
+ process.stdout.write("diff:\n");
4080
+ process.stdout.write(d);
4081
+ if (!d.endsWith("\n")) process.stdout.write("\n");
4082
+ } else process.stdout.write("(no openclaw.json changes)\n");
4083
+ }
4084
+ return finalize(results, aborted);
4085
+ }
4086
+ /** Deep-clone a JSON-shaped value. Uses structuredClone where available
4087
+ * (Node 17+); falls back to JSON round-trip otherwise. The config we
4088
+ * clone here is JSON5-parsed so it's strictly tree-shaped — no
4089
+ * references, no functions — making both paths equivalent. */
4090
+ function deepClone(v) {
4091
+ if (typeof structuredClone === "function") return structuredClone(v);
4092
+ return JSON.parse(JSON.stringify(v));
4093
+ }
4094
+ function finalize(results, aborted) {
4095
+ const summary = {
4096
+ pass: 0,
4097
+ failed: 0,
4098
+ fixed: 0,
4099
+ stillBroken: 0,
4100
+ skipped: 0,
4101
+ error: 0,
4102
+ unknown: 0
4103
+ };
4104
+ for (const r of results) switch (r.status) {
4105
+ case "pass":
4106
+ summary.pass++;
4107
+ break;
4108
+ case "failed":
4109
+ summary.failed++;
4110
+ break;
4111
+ case "fixed":
4112
+ summary.fixed++;
4113
+ break;
4114
+ case "still-broken":
4115
+ summary.stillBroken++;
4116
+ break;
4117
+ case "skipped":
4118
+ summary.skipped++;
4119
+ break;
4120
+ case "error":
4121
+ summary.error++;
4122
+ break;
4123
+ case "unknown":
4124
+ summary.unknown++;
4125
+ break;
4126
+ }
2805
4127
  return {
2806
- check,
2807
- repair: runRepair(repairInput)
4128
+ results,
4129
+ summary,
4130
+ aborted
4131
+ };
4132
+ }
4133
+ //#endregion
4134
+ //#region src/innerapi/reportCliRun.ts
4135
+ /**
4136
+ * CLI-side client for studio_server's `openclaw.report_cli_run` inner
4137
+ * API — the unified telemetry sink for every CLI command.
4138
+ *
4139
+ * Best-effort: gated by `DoctorCtx.telemetry.reportCliRun` returned by
4140
+ * `get_doctor_ctx`. When the server hasn't rolled out the API yet the
4141
+ * flag is absent and the CLI never invokes this. When enabled, the CLI
4142
+ * fires-and-forgets one report per command after work is done.
4143
+ *
4144
+ * Failures here MUST NOT change exit code or otherwise affect the
4145
+ * command's data path — telemetry is auxiliary. Caller is expected to
4146
+ * `try/catch` and just `console.error` on rejection.
4147
+ *
4148
+ * Replaces the earlier `openclaw.report_doctor_result` API. The schema
4149
+ * generalises so check / repair / install-* / download-resource / reset
4150
+ * (worker) can all use the same envelope; per-command shape lives under
4151
+ * `result`.
4152
+ */
4153
+ const API_NAME = "openclaw.report_cli_run";
4154
+ async function reportCliRun(opts) {
4155
+ const client = getHttpClient();
4156
+ const input = {
4157
+ command: opts.command,
4158
+ runId: opts.runId,
4159
+ version: opts.version,
4160
+ invocation: opts.invocation,
4161
+ durationMs: opts.durationMs,
4162
+ success: opts.success
4163
+ };
4164
+ if (opts.caller) input.caller = opts.caller;
4165
+ if (opts.traceId) input.traceId = opts.traceId;
4166
+ if (opts.result !== void 0) input.result = opts.result;
4167
+ if (opts.error) input.error = opts.error;
4168
+ const body = {
4169
+ apiName: API_NAME,
4170
+ input,
4171
+ bizType: BIZ_TYPE
2808
4172
  };
4173
+ const headers = { "Content-Type": "application/json" };
4174
+ const ttEnv = process.env.X_TT_ENV;
4175
+ if (ttEnv) headers["x-tt-env"] = ttEnv;
4176
+ const start = Date.now();
4177
+ console.error(`reportCliRun: start command=${opts.command} success=${opts.success} version=${opts.version} invocation=${JSON.stringify(opts.invocation)} durationMs=${opts.durationMs}`);
4178
+ let response;
4179
+ try {
4180
+ response = await client.post(INNERAPI_CALL_PATH, body, { headers });
4181
+ } catch (e) {
4182
+ if (e instanceof _lark_apaas_http_client.HttpError && e.response) {
4183
+ const status = e.response.status;
4184
+ const logId = e.response.headers.get("x-tt-logid") ?? "";
4185
+ throw new Error(`reportCliRun HTTP ${status} ${e.response.statusText} (logID: ${logId})`);
4186
+ }
4187
+ const msg = e instanceof Error ? e.message : String(e);
4188
+ throw new Error(`reportCliRun network error: ${msg}`);
4189
+ }
4190
+ const logId = response.headers.get("x-tt-logid") ?? "";
4191
+ if (!response.ok) {
4192
+ let preview = "";
4193
+ try {
4194
+ preview = (await response.text()).slice(0, 500);
4195
+ } catch {}
4196
+ throw new Error(`reportCliRun HTTP ${response.status} ${response.statusText} (logID: ${logId})${preview ? ` body=${preview}` : ""}`);
4197
+ }
4198
+ let envelope;
4199
+ try {
4200
+ envelope = await response.json();
4201
+ } catch {
4202
+ console.error(`reportCliRun: ok logId=${logId} httpDurationMs=${Date.now() - start} (body unparseable, treated as success)`);
4203
+ return;
4204
+ }
4205
+ if (envelope.status_code !== void 0 && envelope.status_code !== "0") throw new Error(`reportCliRun API error (logID: ${logId}): code=${envelope.status_code}, message=${envelope.error_msg ?? ""}`);
4206
+ if (envelope.data && envelope.data.success === false) throw new Error(`reportCliRun business error (logID: ${logId}): ${envelope.error_msg ?? JSON.stringify(envelope.data)}`);
4207
+ console.error(`reportCliRun: ok logId=${logId} httpDurationMs=${Date.now() - start}`);
2809
4208
  }
2810
4209
  //#endregion
2811
4210
  //#region src/help.ts
2812
4211
  const BIN = "mclaw-diagnose";
2813
4212
  function versionBanner() {
2814
- return `v0.1.4-alpha.0`;
4213
+ return `v0.1.4-beta.0`;
2815
4214
  }
2816
4215
  const COMMANDS = [
2817
4216
  {
@@ -2819,46 +4218,85 @@ const COMMANDS = [
2819
4218
  hidden: false,
2820
4219
  summary: "Diagnose openclaw config; apply repairs with --fix",
2821
4220
  help: `USAGE
2822
- ${BIN} doctor [--fix] [--rule=<key>]...
4221
+ ${BIN} doctor [--fix] [--show-diff] [--rule=<key>]... [--skip-rule=<key>]...
4222
+ [--caller=<n>] [--trace-id=<id>]
2823
4223
 
2824
4224
  DESCRIPTION
2825
- Fetches DoctorCtx via innerapi, then runs one of three modes depending
2826
- on the flags. Output is a single JSON object on stdout.
4225
+ Fetches DoctorCtx via innerapi, then orchestrates each rule:
4226
+
4227
+ validate (always)
4228
+ pass → status='pass'
4229
+ fail and !--fix → status='failed'
4230
+ fail and --fix
4231
+ repair → re-validate
4232
+ pass → status='fixed'
4233
+ still fail → status='still-broken'
2827
4234
 
2828
- 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.
4235
+ Output is a single JSON object on stdout:
2834
4236
 
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.
4237
+ {
4238
+ "results": [{ "rule": "...", "status": "...", "message"?: "...", ...}],
4239
+ "summary": { "pass": N, "failed": N, "fixed": N, "stillBroken": N,
4240
+ "skipped": N, "error": N, "unknown": N },
4241
+ "aborted": false
4242
+ }
2840
4243
 
2841
- --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.
4244
+ STATUSES
4245
+ pass Rule's validate passed; no repair attempted.
4246
+ failed Rule's validate failed; --fix was not given.
4247
+ fixed Rule's validate failed; repair ran; re-validate passed.
4248
+ still-broken Rule's validate failed; repair ran; re-validate still failed.
4249
+ skipped Rule excluded by disabledRules / --skip-rule / dependsOn /
4250
+ skipWhen.
4251
+ error validate or repair threw an exception. ABORTS the run —
4252
+ exit code becomes 1; partial 'results' still written to stdout.
4253
+ unknown --rule=<k> with k not registered. Reported, not aborting.
2847
4254
 
2848
4255
  OPTIONS
2849
- --fix Enable repair. See MODES above.
2850
- --rule=<key> Repair only this rule key. Repeatable. Only
2851
- meaningful together with --fix.
4256
+ --fix Enable repair. Without it, validate runs but failures
4257
+ are reported as 'failed' instead of being repaired.
4258
+ --show-diff With --fix: replace the JSON report on stdout with
4259
+ a plain-text section showing
4260
+ original: <backup path>
4261
+ after fixed: <openclaw.json path>
4262
+ diff:
4263
+ <unified-diff body, same format as \`json-diff\`>
4264
+ Interactive flag — sandbox_console's push path never
4265
+ sets it, so the JSON contract is preserved for that
4266
+ caller. No-op without --fix (no mutation, no diff).
4267
+ --rule=<key> Whitelist: restrict the run to this rule key (repeatable).
4268
+ Without --rule, every registered rule runs. With --rule,
4269
+ the planner narrows the innerapi vars fetch to just
4270
+ that rule's needs (see lazy-vars).
4271
+ --skip-rule=<key> Blacklist: skip the rule entirely — neither validate
4272
+ nor repair runs (repeatable). Outcome is 'skipped' with
4273
+ message='--skip-rule'. Stacks with the server's
4274
+ disabledRules. Skipped rules also subtract their vars
4275
+ from the planner's union.
4276
+ --caller=<name> Optional metadata; passed through verbatim to innerapi
4277
+ (fetchCtx + report_cli_run body.input.caller).
4278
+ E.g. "sweep_job", "user_yxy". Server uses for audit /
4279
+ correlation; CLI doesn't act on it.
4280
+ --trace-id=<id> Optional log-correlation id; passed through verbatim
4281
+ to innerapi body.input.traceId. Typically the upstream
4282
+ caller's logID (e.g. sandbox_console's request id).
2852
4283
 
2853
4284
  EXAMPLES
2854
- ${BIN} doctor # 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
4285
+ ${BIN} doctor # diagnose, no fixes
4286
+ ${BIN} doctor --fix # diagnose + fix everything
4287
+ ${BIN} doctor --fix --rule=gateway # fix only 'gateway'
4288
+ ${BIN} doctor --fix --skip-rule=feishu_channel # fix everything except feishu
4289
+ ${BIN} doctor --fix --skip-rule=feishu_channel --skip-rule=feishu_default_account
4290
+ # skip multiple rules
4291
+ ${BIN} doctor --fix --show-diff # see exactly what changed
4292
+ # (plain-text diff after
4293
+ # the JSON report)
2858
4294
 
2859
4295
  EXIT CODES
2860
- 0 success
2861
- 1 generic error
4296
+ 0 completed (results may include 'failed' / 'still-broken' — those
4297
+ are normal data outcomes, not run failures)
4298
+ 1 aborted because a rule's validate or repair threw. Partial
4299
+ results still on stdout.
2862
4300
  77 innerapi authentication failed (sandbox JWT expired/invalid)
2863
4301
  `
2864
4302
  },
@@ -3047,6 +4485,62 @@ function formatCommandHelp(name) {
3047
4485
  return cmd.help;
3048
4486
  }
3049
4487
  //#endregion
4488
+ //#region src/rule-engine/planner.ts
4489
+ /**
4490
+ * Compute the union of `Vars` field names that the **enabled** rules will
4491
+ * actually read. Sources from each rule's `@Rule({ usesVars: [...] })`
4492
+ * declaration; drift is caught at test time by `strict-vars.test.ts`.
4493
+ *
4494
+ * Inactive rules (skipped via skipWhen at runtime) are STILL counted —
4495
+ * skipWhen depends on parsed-config state we don't have at planning time.
4496
+ * Slight over-fetch in those cases, but no correctness issue.
4497
+ *
4498
+ * Result is stable-sorted for deterministic logging / test snapshots.
4499
+ */
4500
+ function planVarsFields(opts = {}) {
4501
+ const disabled = new Set(opts.disabled ?? []);
4502
+ const only = opts.onlyRules && opts.onlyRules.length > 0 ? new Set(opts.onlyRules) : null;
4503
+ const fields = /* @__PURE__ */ new Set();
4504
+ for (const rule of getAllRules()) {
4505
+ const key = rule.meta.key;
4506
+ if (disabled.has(key)) continue;
4507
+ if (only && !only.has(key)) continue;
4508
+ for (const f of rule.meta.usesVars ?? []) fields.add(f);
4509
+ }
4510
+ return [...fields].sort();
4511
+ }
4512
+ /**
4513
+ * Plan a `populate` selector for a given subcommand.
4514
+ *
4515
+ * Per-command group needs:
4516
+ *
4517
+ * doctor / check app (rule-driven)
4518
+ * repair app + secrets (writes secretsContent / providerKeyContent)
4519
+ * reset app + secrets + install + reset (the works)
4520
+ * install-* install only
4521
+ *
4522
+ * Empty result (`{}`) means "no group needed" — the CLI can skip the
4523
+ * `fetchCtxViaInnerApi` call entirely and run with a synthetic empty ctx.
4524
+ * Happens e.g. when the user pinned `--rule=<key>` to a vars-free rule on
4525
+ * `doctor`.
4526
+ */
4527
+ function planCtxPopulate(opts) {
4528
+ if (opts.command === "install") return { install: true };
4529
+ const populate = {};
4530
+ const appFields = planVarsFields({
4531
+ disabled: opts.disabled,
4532
+ onlyRules: opts.onlyRules
4533
+ });
4534
+ if (appFields.length > 0) populate.app = appFields;
4535
+ if (opts.command === "repair") populate.secrets = true;
4536
+ else if (opts.command === "reset") {
4537
+ populate.secrets = true;
4538
+ populate.install = true;
4539
+ populate.reset = true;
4540
+ }
4541
+ return populate;
4542
+ }
4543
+ //#endregion
3050
4544
  //#region src/index.ts
3051
4545
  const args = node_process.default.argv.slice(2);
3052
4546
  const mode = args.find((a) => !a.startsWith("-"));
@@ -3080,8 +4574,39 @@ function getMultiFlag(args, name) {
3080
4574
  const prefix = `--${name}=`;
3081
4575
  return args.filter((a) => a.startsWith(prefix)).map((a) => a.slice(prefix.length));
3082
4576
  }
4577
+ /**
4578
+ * Per-case telemetry helper: fires `openclaw.report_cli_run` once with
4579
+ * the command's outcome. Always called — the previous server-flag gate
4580
+ * was removed (server controls receipt by routing the apiName).
4581
+ *
4582
+ * Best-effort: a thrown reportCliRun is logged to cli.log and swallowed.
4583
+ * Never alters the data path's exit code — the user-visible work has
4584
+ * already finished by the time we report.
4585
+ *
4586
+ * `raw` is kept in the signature for backward symmetry with the doctor
4587
+ * case but is no longer consulted.
4588
+ */
4589
+ async function reportRun(command, rc, _raw, invocation, durationMs, outcome) {
4590
+ console.error(`${command}: telemetry calling report_cli_run`);
4591
+ try {
4592
+ await reportCliRun({
4593
+ command,
4594
+ runId: rc.runId,
4595
+ version: getVersion(),
4596
+ invocation,
4597
+ durationMs,
4598
+ caller: rc.caller,
4599
+ traceId: rc.traceId,
4600
+ success: outcome.success,
4601
+ result: outcome.result,
4602
+ error: outcome.error ? { message: outcome.error.message } : void 0
4603
+ });
4604
+ } catch (e) {
4605
+ console.error(`[telemetry] reportCliRun failed: ${e.message}`);
4606
+ }
4607
+ }
3083
4608
  async function main() {
3084
- installStderrMirror();
4609
+ installStderrMirror({ stderrEnabled: args.includes("-x") });
3085
4610
  const helpFlags = parseHelpFlags(args);
3086
4611
  if (mode && helpFlags.help) {
3087
4612
  const body = formatCommandHelp(mode);
@@ -3101,22 +4626,79 @@ async function main() {
3101
4626
  node_process.default.stderr.write(formatTopLevelHelp(helpFlags.expert));
3102
4627
  node_process.default.exit(1);
3103
4628
  }
4629
+ const caller = getFlag(args, "caller");
4630
+ const traceId = getFlag(args, "trace-id");
4631
+ const rc = setRunContext({
4632
+ caller,
4633
+ traceId
4634
+ });
4635
+ const t0 = Date.now();
4636
+ console.error(`${mode}: begin argv=[${args.join(" ")}] version=${getVersion()} traceId=${traceId ?? "-"} caller=${caller ?? "-"} runIdGenerated=${rc.generated}`);
3104
4637
  switch (mode) {
3105
- case "check":
4638
+ case "check": {
4639
+ const raw = parseCtxFlag(args) ?? await fetchCtxViaInnerApi({
4640
+ populate: planCtxPopulate({ command: "check" }),
4641
+ caller,
4642
+ traceId
4643
+ });
4644
+ const { legacy, report } = runCheckWithReport(buildCheckInput(raw));
4645
+ console.log(JSON.stringify(legacy));
4646
+ await reportRun("check", rc, raw, args.join(" "), Date.now() - t0, {
4647
+ success: !report.aborted,
4648
+ result: report,
4649
+ error: report.aborted ? /* @__PURE__ */ new Error("check run aborted") : void 0
4650
+ });
4651
+ break;
4652
+ }
3106
4653
  case "repair": {
3107
- const raw = parseCtxFlag(args) ?? await fetchCtxViaInnerApi();
3108
- if (mode === "check") console.log(JSON.stringify(runCheck(buildCheckInput(raw))));
3109
- else console.log(JSON.stringify(runRepair(buildRepairInput(raw))));
4654
+ const raw = parseCtxFlag(args) ?? await fetchCtxViaInnerApi({
4655
+ populate: planCtxPopulate({ command: "repair" }),
4656
+ caller,
4657
+ traceId
4658
+ });
4659
+ const { legacy, report } = runRepairWithReport(buildRepairInput(raw));
4660
+ console.log(JSON.stringify(legacy));
4661
+ await reportRun("repair", rc, raw, args.join(" "), Date.now() - t0, {
4662
+ success: legacy.success,
4663
+ result: report,
4664
+ error: legacy.success ? void 0 : new Error(legacy.error ?? "repair failed")
4665
+ });
3110
4666
  break;
3111
4667
  }
3112
4668
  case "doctor": {
3113
4669
  const fix = args.includes("--fix");
3114
4670
  const rules = getMultiFlag(args, "rule");
3115
- const result = await runDoctor(await fetchCtxViaInnerApi(), {
4671
+ const skipRules = getMultiFlag(args, "skip-rule");
4672
+ const showDiff = args.includes("--show-diff");
4673
+ const populate = planCtxPopulate({
4674
+ command: "doctor",
4675
+ onlyRules: fix && rules.length > 0 ? rules : void 0,
4676
+ disabled: skipRules
4677
+ });
4678
+ console.error(`doctor: populate=${JSON.stringify(populate)} fix=${fix} rules=[${rules.join(",")}] skipRules=[${skipRules.join(",")}]`);
4679
+ let raw;
4680
+ if (Object.keys(populate).length === 0) {
4681
+ console.error("doctor: skipping fetchCtx (empty populate)");
4682
+ raw = {};
4683
+ } else raw = await fetchCtxViaInnerApi({
4684
+ populate,
4685
+ caller,
4686
+ traceId
4687
+ });
4688
+ const result = await runDoctor(raw, {
3116
4689
  fix,
3117
- rules
4690
+ rules,
4691
+ skipRules,
4692
+ showDiff
3118
4693
  });
3119
- console.log(JSON.stringify(result));
4694
+ if (!(showDiff && fix)) console.log(JSON.stringify(result));
4695
+ await reportRun("doctor", rc, raw, args.join(" "), Date.now() - t0, {
4696
+ success: !result.aborted,
4697
+ result,
4698
+ error: result.aborted ? /* @__PURE__ */ new Error("doctor run aborted") : void 0
4699
+ });
4700
+ console.error(`doctor: end aborted=${result.aborted} totalMs=${Date.now() - t0} summary=${JSON.stringify(result.summary)}`);
4701
+ if (result.aborted) node_process.default.exit(1);
3120
4702
  break;
3121
4703
  }
3122
4704
  case "reset":
@@ -3125,7 +4707,11 @@ async function main() {
3125
4707
  let ctxBase64;
3126
4708
  if (ctxArg) ctxBase64 = ctxArg.slice(6);
3127
4709
  else {
3128
- const fetched = await fetchCtxViaInnerApi();
4710
+ const fetched = await fetchCtxViaInnerApi({
4711
+ populate: planCtxPopulate({ command: "reset" }),
4712
+ caller,
4713
+ traceId
4714
+ });
3129
4715
  ctxBase64 = Buffer.from(JSON.stringify(fetched), "utf-8").toString("base64");
3130
4716
  }
3131
4717
  console.log(JSON.stringify(startAsyncReset(ctxBase64)));
@@ -3136,7 +4722,22 @@ async function main() {
3136
4722
  node_process.default.exit(1);
3137
4723
  }
3138
4724
  const resultFile = resetResultFile(taskId);
3139
- await runReset(buildResetInput(parseCtxFlag(args) ?? await fetchCtxViaInnerApi()), taskId, resultFile);
4725
+ const raw = parseCtxFlag(args) ?? await fetchCtxViaInnerApi({
4726
+ populate: planCtxPopulate({ command: "reset" }),
4727
+ caller,
4728
+ traceId
4729
+ });
4730
+ const outcome = await runReset(buildResetInput(raw), taskId, resultFile);
4731
+ await reportRun("reset", rc, raw, args.join(" "), Date.now() - t0, {
4732
+ success: outcome.success,
4733
+ result: {
4734
+ taskId,
4735
+ steps: outcome.steps,
4736
+ failedStep: outcome.failedStep
4737
+ },
4738
+ error: outcome.success ? void 0 : new Error(outcome.error ?? "reset failed")
4739
+ });
4740
+ if (!outcome.success) node_process.default.exit(1);
3140
4741
  } else {
3141
4742
  console.error("Usage: reset --async [--ctx=<base64>] | reset --worker --task-id=<id> [--ctx=<base64>]");
3142
4743
  node_process.default.exit(1);
@@ -3159,12 +4760,34 @@ async function main() {
3159
4760
  }
3160
4761
  const ossFileMapFlag = getFlag(args, "oss_file_map");
3161
4762
  let installOssFileMap;
3162
- if (!ossFileMapFlag) installOssFileMap = normalizeCtx(parseCtxFlag(args) ?? await fetchCtxViaInnerApi()).install.ossFileMap;
3163
- await installOpenclaw(tag, resolveOssFileMap({
4763
+ let rawForTelemetry;
4764
+ if (!ossFileMapFlag) {
4765
+ rawForTelemetry = parseCtxFlag(args) ?? await fetchCtxViaInnerApi({
4766
+ populate: planCtxPopulate({ command: "install" }),
4767
+ caller,
4768
+ traceId
4769
+ });
4770
+ installOssFileMap = normalizeCtx(rawForTelemetry).install.ossFileMap;
4771
+ }
4772
+ const ossFileMap = resolveOssFileMap({
3164
4773
  ossFileMapFlag,
3165
4774
  installOssFileMap
3166
- }));
3167
- console.log(JSON.stringify({ ok: true }));
4775
+ });
4776
+ let success = true;
4777
+ let error;
4778
+ try {
4779
+ await installOpenclaw(tag, ossFileMap);
4780
+ } catch (e) {
4781
+ success = false;
4782
+ error = e;
4783
+ }
4784
+ if (success) console.log(JSON.stringify({ ok: true }));
4785
+ await reportRun("install-openclaw", rc, rawForTelemetry, args.join(" "), Date.now() - t0, {
4786
+ success,
4787
+ result: { tag },
4788
+ error
4789
+ });
4790
+ if (error) throw error;
3168
4791
  break;
3169
4792
  }
3170
4793
  case "install-extension": {
@@ -3180,18 +4803,45 @@ async function main() {
3180
4803
  const skipConfigUpdate = args.includes("--skip-config-update");
3181
4804
  const ossFileMapFlag = getFlag(args, "oss_file_map");
3182
4805
  let installOssFileMap;
3183
- if (!ossFileMapFlag) installOssFileMap = normalizeCtx(parseCtxFlag(args) ?? await fetchCtxViaInnerApi()).install.ossFileMap;
3184
- await installExtension(tag, resolveOssFileMap({
4806
+ let rawForTelemetry;
4807
+ if (!ossFileMapFlag) {
4808
+ rawForTelemetry = parseCtxFlag(args) ?? await fetchCtxViaInnerApi({
4809
+ populate: planCtxPopulate({ command: "install" }),
4810
+ caller,
4811
+ traceId
4812
+ });
4813
+ installOssFileMap = normalizeCtx(rawForTelemetry).install.ossFileMap;
4814
+ }
4815
+ const ossFileMap = resolveOssFileMap({
3185
4816
  ossFileMapFlag,
3186
4817
  installOssFileMap
3187
- }), {
3188
- all,
3189
- names: names.length > 0 ? names : void 0,
3190
- homeBase,
3191
- configPath,
3192
- skipConfigUpdate
3193
4818
  });
3194
- console.log(JSON.stringify({ ok: true }));
4819
+ let success = true;
4820
+ let error;
4821
+ try {
4822
+ await installExtension(tag, ossFileMap, {
4823
+ all,
4824
+ names: names.length > 0 ? names : void 0,
4825
+ homeBase,
4826
+ configPath,
4827
+ skipConfigUpdate
4828
+ });
4829
+ } catch (e) {
4830
+ success = false;
4831
+ error = e;
4832
+ }
4833
+ if (success) console.log(JSON.stringify({ ok: true }));
4834
+ await reportRun("install-extension", rc, rawForTelemetry, args.join(" "), Date.now() - t0, {
4835
+ success,
4836
+ result: {
4837
+ tag,
4838
+ all,
4839
+ names,
4840
+ skipConfigUpdate
4841
+ },
4842
+ error
4843
+ });
4844
+ if (error) throw error;
3195
4845
  break;
3196
4846
  }
3197
4847
  case "download-resource": {
@@ -3209,16 +4859,43 @@ async function main() {
3209
4859
  }
3210
4860
  const ossFileMapFlag = getFlag(args, "oss_file_map");
3211
4861
  let installOssFileMap;
3212
- if (!ossFileMapFlag) installOssFileMap = normalizeCtx(parseCtxFlag(args) ?? await fetchCtxViaInnerApi()).install.ossFileMap;
3213
- await downloadResource(tag, resolveOssFileMap({
4862
+ let rawForTelemetry;
4863
+ if (!ossFileMapFlag) {
4864
+ rawForTelemetry = parseCtxFlag(args) ?? await fetchCtxViaInnerApi({
4865
+ populate: planCtxPopulate({ command: "install" }),
4866
+ caller,
4867
+ traceId
4868
+ });
4869
+ installOssFileMap = normalizeCtx(rawForTelemetry).install.ossFileMap;
4870
+ }
4871
+ const ossFileMap = resolveOssFileMap({
3214
4872
  ossFileMapFlag,
3215
4873
  installOssFileMap
3216
- }), {
3217
- role,
3218
- name,
3219
- dir
3220
4874
  });
3221
- console.log(JSON.stringify({ ok: true }));
4875
+ let success = true;
4876
+ let error;
4877
+ try {
4878
+ await downloadResource(tag, ossFileMap, {
4879
+ role,
4880
+ name,
4881
+ dir
4882
+ });
4883
+ } catch (e) {
4884
+ success = false;
4885
+ error = e;
4886
+ }
4887
+ if (success) console.log(JSON.stringify({ ok: true }));
4888
+ await reportRun("download-resource", rc, rawForTelemetry, args.join(" "), Date.now() - t0, {
4889
+ success,
4890
+ result: {
4891
+ tag,
4892
+ role,
4893
+ name,
4894
+ dir
4895
+ },
4896
+ error
4897
+ });
4898
+ if (error) throw error;
3222
4899
  break;
3223
4900
  }
3224
4901
  default:
@@ -3226,10 +4903,12 @@ async function main() {
3226
4903
  node_process.default.stderr.write(formatTopLevelHelp(helpFlags.expert));
3227
4904
  node_process.default.exit(1);
3228
4905
  }
4906
+ console.error(`${mode}: end totalMs=${Date.now() - t0}`);
3229
4907
  }
3230
4908
  main().catch((err) => {
3231
4909
  const msg = err instanceof Error ? err.message : String(err);
3232
- console.error(`Error: ${msg}`);
4910
+ console.error(`${mode ?? "<no-mode>"}: error message=${msg}`);
4911
+ node_process.default.stderr.write(`Error: ${msg}\n`);
3233
4912
  node_process.default.exit(1);
3234
4913
  });
3235
4914
  //#endregion