@lark-apaas/openclaw-scripts-diagnose-cli 0.1.3 → 0.1.4-alpha.1

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 +1650 -434
  2. package/package.json +3 -1
package/dist/index.cjs CHANGED
@@ -36,9 +36,26 @@ let node_os = require("node:os");
36
36
  node_os = __toESM(node_os);
37
37
  let node_stream = require("node:stream");
38
38
  let node_stream_promises = require("node:stream/promises");
39
+ let _lark_apaas_http_client = require("@lark-apaas/http-client");
39
40
  let node_assert = require("node:assert");
40
41
  node_assert = __toESM(node_assert);
41
- let _lark_apaas_http_client = require("@lark-apaas/http-client");
42
+ let json_diff = require("json-diff");
43
+ //#region src/version.ts
44
+ /**
45
+ * Machine-readable identifier of the running CLI build.
46
+ *
47
+ * prod build → "0.1.3" (the published semver)
48
+ * local-test → "local-test@2026-04-28T12:30:11.123Z"
49
+ *
50
+ * Exists separately from `help.ts`'s `versionBanner()`, which is for human
51
+ * display and includes prose like "(local test build, not a published
52
+ * release)". This one goes into telemetry payloads and log slicing — keep
53
+ * it terse and parseable.
54
+ */
55
+ function getVersion() {
56
+ return "0.1.4-alpha.1";
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 {
@@ -415,7 +432,7 @@ let TemplateVarsUnreplacedRule = class TemplateVarsUnreplacedRule extends Diagno
415
432
  };
416
433
  }
417
434
  repair(ctx) {
418
- const map = ctx.templateVars;
435
+ const map = ctx.vars.templateVars;
419
436
  if (!map || Object.keys(map).length === 0) return;
420
437
  replaceInPlace(ctx.config, Object.entries(map));
421
438
  }
@@ -423,7 +440,8 @@ let TemplateVarsUnreplacedRule = class TemplateVarsUnreplacedRule extends Diagno
423
440
  TemplateVarsUnreplacedRule = __decorate([Rule({
424
441
  key: "template_vars_unreplaced",
425
442
  dependsOn: ["config_syntax_check"],
426
- repairMode: "standard"
443
+ repairMode: "standard",
444
+ usesVars: ["templateVars"]
427
445
  })], TemplateVarsUnreplacedRule);
428
446
  function collectPlaceholders(value, found) {
429
447
  if (typeof value === "string") {
@@ -466,32 +484,33 @@ function applyVars(str, entries) {
466
484
  }
467
485
  //#endregion
468
486
  //#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"
487
+ const DEFAULT_API_KEY = {
488
+ source: "file",
489
+ provider: "miaoda-provider",
490
+ id: "value"
491
+ };
492
+ const DEFAULT_X_API_KEY_HEADER = {
493
+ source: "file",
494
+ provider: "miaoda-secret-provider",
495
+ id: "/models_providers_miaoda_headers_x_api_key"
496
+ };
497
+ const DEFAULT_API = "openai-completions";
498
+ function getExpected(vars) {
499
+ return {
500
+ baseUrl: vars.baseURL + "/api/v1/sgw/model/proxy",
501
+ apiKey: DEFAULT_API_KEY,
502
+ api: DEFAULT_API,
503
+ headers: { "x-api-key": DEFAULT_X_API_KEY_HEADER }
483
504
  };
484
- static DEFAULT_API = "openai-completions";
485
- static getExpectedBaseUrl(vars) {
486
- return vars.baseURL + "/api/v1/sgw/model/proxy";
487
- }
505
+ }
506
+ let ModelProviderRule = class ModelProviderRule extends DiagnoseRule {
488
507
  validate(ctx) {
489
508
  const provider = getNestedMap(ctx.config, "models", "providers", "miaoda");
490
509
  if (!provider) return {
491
510
  pass: false,
492
511
  message: "models.providers.miaoda not found"
493
512
  };
494
- const expected = this.getExpected(ctx.vars);
513
+ const expected = getExpected(ctx.vars);
495
514
  if (provider.baseUrl !== expected.baseUrl) return {
496
515
  pass: false,
497
516
  message: "baseUrl mismatch: got " + provider.baseUrl + ", expected " + expected.baseUrl
@@ -539,7 +558,7 @@ let ModelProviderRule = class ModelProviderRule extends DiagnoseRule {
539
558
  return { pass: true };
540
559
  }
541
560
  repair(ctx) {
542
- const expected = this.getExpected(ctx.vars);
561
+ const expected = getExpected(ctx.vars);
543
562
  setNestedValue(ctx.config, [
544
563
  "models",
545
564
  "providers",
@@ -565,19 +584,12 @@ let ModelProviderRule = class ModelProviderRule extends DiagnoseRule {
565
584
  "headers"
566
585
  ], expected.headers);
567
586
  }
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
587
  };
577
- ModelProviderRule = _ModelProviderRule = __decorate([Rule({
588
+ ModelProviderRule = __decorate([Rule({
578
589
  key: "model_provider",
579
590
  dependsOn: ["config_syntax_check"],
580
591
  repairMode: "standard",
592
+ usesVars: ["innerAPIKey", "baseURL"],
581
593
  skipWhen: ({ hasMiaoda }) => !hasMiaoda
582
594
  })], ModelProviderRule);
583
595
  //#endregion
@@ -707,7 +719,8 @@ let FeishuChannelRule = class FeishuChannelRule extends DiagnoseRule {
707
719
  FeishuChannelRule = __decorate([Rule({
708
720
  key: "feishu_channel",
709
721
  dependsOn: ["config_syntax_check", "feishu_default_account"],
710
- repairMode: "standard"
722
+ repairMode: "standard",
723
+ usesVars: ["feishuAppID", "feishuAppSecret"]
711
724
  })], FeishuChannelRule);
712
725
  //#endregion
713
726
  //#region src/rules/feishu-default-account.ts
@@ -717,6 +730,13 @@ FeishuChannelRule = __decorate([Rule({
717
730
  * detects + fixes drift on the main bot's appId/appSecret. Single-agent
718
731
  * configs (no `accounts`) are out of scope — handled by `feishu_channel`.
719
732
  */
733
+ /** Top-level `channels.feishu.*` account-policy fields migrated into the main bot. */
734
+ const TOP_FIELDS_TO_MIGRATE = [
735
+ "dmPolicy",
736
+ "allowFrom",
737
+ "groupPolicy",
738
+ "groupAllowFrom"
739
+ ];
720
740
  let FeishuDefaultAccountRule = class FeishuDefaultAccountRule extends DiagnoseRule {
721
741
  validate(ctx) {
722
742
  const feishu = getNestedMap(ctx.config, "channels", "feishu");
@@ -763,10 +783,18 @@ let FeishuDefaultAccountRule = class FeishuDefaultAccountRule extends DiagnoseRu
763
783
  const merged = {
764
784
  ...existingBot,
765
785
  ...defaultAccount,
766
- ...defaultAcc,
767
- appId: effectiveAppId,
768
- appSecret: DEFAULT_FEISHU_APP_SECRET
786
+ ...defaultAcc
787
+ };
788
+ for (const k of TOP_FIELDS_TO_MIGRATE) if (merged[k] === void 0 && feishu[k] !== void 0) merged[k] = feishu[k];
789
+ const accountGroups = asRecord(merged.groups) ?? {};
790
+ const topGroups = asRecord(feishu.groups) ?? {};
791
+ const fusedGroups = {
792
+ ...accountGroups,
793
+ ...topGroups
769
794
  };
795
+ if (Object.keys(fusedGroups).length > 0) merged.groups = fusedGroups;
796
+ merged.appId = effectiveAppId;
797
+ merged.appSecret = DEFAULT_FEISHU_APP_SECRET;
770
798
  const chatID = ctx.vars?.teamChatID;
771
799
  if (typeof chatID === "string" && chatID !== "") {
772
800
  const existingGroups = asRecord(merged.groups) ?? {};
@@ -780,6 +808,7 @@ let FeishuDefaultAccountRule = class FeishuDefaultAccountRule extends DiagnoseRu
780
808
  delete accounts.default;
781
809
  delete feishu.appId;
782
810
  delete feishu.appSecret;
811
+ for (const k of TOP_FIELDS_TO_MIGRATE) delete feishu[k];
783
812
  this.rewireBindings(ctx.config, expectedKey);
784
813
  }
785
814
  enforceMainBotValues(ctx, accounts) {
@@ -832,7 +861,8 @@ let FeishuDefaultAccountRule = class FeishuDefaultAccountRule extends DiagnoseRu
832
861
  FeishuDefaultAccountRule = __decorate([Rule({
833
862
  key: "feishu_default_account",
834
863
  dependsOn: ["config_syntax_check"],
835
- repairMode: "standard"
864
+ repairMode: "standard",
865
+ usesVars: ["feishuAppID", "teamChatID"]
836
866
  })], FeishuDefaultAccountRule);
837
867
  function nonEmpty(v) {
838
868
  return typeof v === "string" && v !== "" ? v : void 0;
@@ -863,23 +893,19 @@ function secretMatchesCanonical(secret) {
863
893
  }
864
894
  //#endregion
865
895
  //#region src/rules/gateway.ts
866
- var _GatewayRule;
896
+ const DEFAULT_PORT = 18789;
897
+ const DEFAULT_MODE = "local";
898
+ const DEFAULT_BIND = "loopback";
899
+ const DEFAULT_AUTH_MODE = "token";
900
+ const DEFAULT_AUTH_TOKEN = {
901
+ source: "file",
902
+ provider: "miaoda-secret-provider",
903
+ id: "/gateway_auth_token"
904
+ };
905
+ /** Required entries in gateway.trustedProxies. Repair appends any missing
906
+ * entries while preserving caller-added extras (no overwrite). */
907
+ const DEFAULT_TRUSTED_PROXIES = ["::1", "127.0.0.1"];
867
908
  let GatewayRule = class GatewayRule extends DiagnoseRule {
868
- static {
869
- _GatewayRule = this;
870
- }
871
- static DEFAULT_PORT = 18789;
872
- static DEFAULT_MODE = "local";
873
- static DEFAULT_BIND = "loopback";
874
- static DEFAULT_AUTH_MODE = "token";
875
- static DEFAULT_AUTH_TOKEN = {
876
- source: "file",
877
- provider: "miaoda-secret-provider",
878
- id: "/gateway_auth_token"
879
- };
880
- /** Required entries in gateway.trustedProxies. Repair appends any missing
881
- * entries while preserving caller-added extras (no overwrite). */
882
- static DEFAULT_TRUSTED_PROXIES = ["::1", "127.0.0.1"];
883
909
  validate(ctx) {
884
910
  const gateway = ctx.config.gateway;
885
911
  if (!gateway || typeof gateway !== "object") return {
@@ -887,17 +913,17 @@ let GatewayRule = class GatewayRule extends DiagnoseRule {
887
913
  message: "gateway not found"
888
914
  };
889
915
  const gw = gateway;
890
- if (gw.port !== _GatewayRule.DEFAULT_PORT) return {
916
+ if (gw.port !== DEFAULT_PORT) return {
891
917
  pass: false,
892
- message: "gateway.port mismatch: got " + gw.port + ", expected " + _GatewayRule.DEFAULT_PORT
918
+ message: "gateway.port mismatch: got " + gw.port + ", expected 18789"
893
919
  };
894
- if (gw.mode !== _GatewayRule.DEFAULT_MODE) return {
920
+ if (gw.mode !== DEFAULT_MODE) return {
895
921
  pass: false,
896
- message: "gateway.mode mismatch: got " + gw.mode + ", expected " + _GatewayRule.DEFAULT_MODE
922
+ message: "gateway.mode mismatch: got " + gw.mode + ", expected local"
897
923
  };
898
- if (gw.bind !== _GatewayRule.DEFAULT_BIND) return {
924
+ if (gw.bind !== DEFAULT_BIND) return {
899
925
  pass: false,
900
- message: "gateway.bind mismatch: got " + gw.bind + ", expected " + _GatewayRule.DEFAULT_BIND
926
+ message: "gateway.bind mismatch: got " + gw.bind + ", expected loopback"
901
927
  };
902
928
  const auth = gw.auth;
903
929
  if (!auth || typeof auth !== "object") return {
@@ -905,9 +931,9 @@ let GatewayRule = class GatewayRule extends DiagnoseRule {
905
931
  message: "gateway.auth not found"
906
932
  };
907
933
  const authObj = auth;
908
- if (authObj.mode !== _GatewayRule.DEFAULT_AUTH_MODE) return {
934
+ if (authObj.mode !== DEFAULT_AUTH_MODE) return {
909
935
  pass: false,
910
- message: "gateway.auth.mode mismatch: got " + authObj.mode + ", expected " + _GatewayRule.DEFAULT_AUTH_MODE
936
+ message: "gateway.auth.mode mismatch: got " + authObj.mode + ", expected token"
911
937
  };
912
938
  const token = authObj.token;
913
939
  if (typeof token === "string") {
@@ -916,7 +942,7 @@ let GatewayRule = class GatewayRule extends DiagnoseRule {
916
942
  message: "gateway.auth.token string value mismatch"
917
943
  };
918
944
  } else if (typeof token === "object" && token !== null && !Array.isArray(token)) {
919
- if (!matchMap(token, _GatewayRule.DEFAULT_AUTH_TOKEN)) return {
945
+ if (!matchMap(token, DEFAULT_AUTH_TOKEN)) return {
920
946
  pass: false,
921
947
  message: "gateway.auth.token object mismatch: got " + JSON.stringify(token)
922
948
  };
@@ -938,7 +964,7 @@ let GatewayRule = class GatewayRule extends DiagnoseRule {
938
964
  pass: false,
939
965
  message: "gateway.trustedProxies missing or not an array"
940
966
  };
941
- const missing = _GatewayRule.DEFAULT_TRUSTED_PROXIES.filter((p) => !proxies.includes(p));
967
+ const missing = DEFAULT_TRUSTED_PROXIES.filter((p) => !proxies.includes(p));
942
968
  if (missing.length > 0) return {
943
969
  pass: false,
944
970
  message: "gateway.trustedProxies missing: " + JSON.stringify(missing)
@@ -946,19 +972,19 @@ let GatewayRule = class GatewayRule extends DiagnoseRule {
946
972
  return { pass: true };
947
973
  }
948
974
  repair(ctx) {
949
- setNestedValue(ctx.config, ["gateway", "port"], _GatewayRule.DEFAULT_PORT);
950
- setNestedValue(ctx.config, ["gateway", "mode"], _GatewayRule.DEFAULT_MODE);
951
- setNestedValue(ctx.config, ["gateway", "bind"], _GatewayRule.DEFAULT_BIND);
975
+ setNestedValue(ctx.config, ["gateway", "port"], DEFAULT_PORT);
976
+ setNestedValue(ctx.config, ["gateway", "mode"], DEFAULT_MODE);
977
+ setNestedValue(ctx.config, ["gateway", "bind"], DEFAULT_BIND);
952
978
  setNestedValue(ctx.config, [
953
979
  "gateway",
954
980
  "auth",
955
981
  "mode"
956
- ], _GatewayRule.DEFAULT_AUTH_MODE);
982
+ ], DEFAULT_AUTH_MODE);
957
983
  setNestedValue(ctx.config, [
958
984
  "gateway",
959
985
  "auth",
960
986
  "token"
961
- ], _GatewayRule.DEFAULT_AUTH_TOKEN);
987
+ ], DEFAULT_AUTH_TOKEN);
962
988
  setNestedValue(ctx.config, [
963
989
  "gateway",
964
990
  "controlUi",
@@ -967,17 +993,18 @@ let GatewayRule = class GatewayRule extends DiagnoseRule {
967
993
  const gw = ctx.config.gateway ?? {};
968
994
  const current = Array.isArray(gw.trustedProxies) ? gw.trustedProxies.slice() : [];
969
995
  const seen = new Set(current.map((v) => String(v)));
970
- for (const p of _GatewayRule.DEFAULT_TRUSTED_PROXIES) if (!seen.has(p)) {
996
+ for (const p of DEFAULT_TRUSTED_PROXIES) if (!seen.has(p)) {
971
997
  current.push(p);
972
998
  seen.add(p);
973
999
  }
974
1000
  setNestedValue(ctx.config, ["gateway", "trustedProxies"], current);
975
1001
  }
976
1002
  };
977
- GatewayRule = _GatewayRule = __decorate([Rule({
1003
+ GatewayRule = __decorate([Rule({
978
1004
  key: "gateway",
979
1005
  dependsOn: ["config_syntax_check"],
980
- repairMode: "standard"
1006
+ repairMode: "standard",
1007
+ usesVars: ["gatewayToken"]
981
1008
  })], GatewayRule);
982
1009
  //#endregion
983
1010
  //#region src/rules/allowed-origins.ts
@@ -1021,7 +1048,8 @@ let AllowedOriginsRule = class AllowedOriginsRule extends DiagnoseRule {
1021
1048
  AllowedOriginsRule = __decorate([Rule({
1022
1049
  key: "allowed_origins",
1023
1050
  dependsOn: ["config_syntax_check"],
1024
- repairMode: "standard"
1051
+ repairMode: "standard",
1052
+ usesVars: ["expectedOrigins"]
1025
1053
  })], AllowedOriginsRule);
1026
1054
  function getExpectedOrigins(vars) {
1027
1055
  return Array.isArray(vars.expectedOrigins) ? vars.expectedOrigins : [];
@@ -1100,11 +1128,12 @@ JwtTokenRule = __decorate([Rule({
1100
1128
  key: "jwt_token",
1101
1129
  dependsOn: ["config_syntax_check"],
1102
1130
  repairMode: "standard",
1131
+ usesVars: ["providerFilePath"],
1103
1132
  skipWhen: ({ hasMiaoda, deps }) => !hasMiaoda || !deps.usesMiaodaProvider
1104
1133
  })], JwtTokenRule);
1105
1134
  //#endregion
1106
1135
  //#region src/rules/secrets-file.ts
1107
- let SecretsRule = class SecretsRule extends DiagnoseRule {
1136
+ let SecretsFileRule = class SecretsFileRule extends DiagnoseRule {
1108
1137
  validate(ctx) {
1109
1138
  const filePath = ctx.vars.secretsFilePath;
1110
1139
  if (!filePath) return {
@@ -1151,12 +1180,18 @@ let SecretsRule = class SecretsRule extends DiagnoseRule {
1151
1180
  };
1152
1181
  }
1153
1182
  };
1154
- SecretsRule = __decorate([Rule({
1183
+ SecretsFileRule = __decorate([Rule({
1155
1184
  key: "secrets_file",
1156
1185
  dependsOn: ["config_syntax_check"],
1157
1186
  repairMode: "standard",
1187
+ usesVars: [
1188
+ "secretsFilePath",
1189
+ "feishuAppSecret",
1190
+ "gatewayToken",
1191
+ "innerAPIKey"
1192
+ ],
1158
1193
  skipWhen: ({ hasMiaoda, deps }) => !hasMiaoda || !deps.usesMiaodaSecretProvider
1159
- })], SecretsRule);
1194
+ })], SecretsFileRule);
1160
1195
  //#endregion
1161
1196
  //#region src/rules/cleanup-install-backup-dirs.ts
1162
1197
  const DIR_PREFIX = ".openclaw-install-";
@@ -1370,16 +1405,26 @@ function getAllow(config) {
1370
1405
  }
1371
1406
  //#endregion
1372
1407
  //#region src/check.ts
1373
- function runCheck(input) {
1408
+ /** Telemetry-aware entry: returns both the legacy CheckResult (for stdout)
1409
+ * AND a DoctorReport-shape payload (for `openclaw.report_cli_run`). The
1410
+ * two views are computed from the same single rule-loop pass. */
1411
+ function runCheckWithReport(input) {
1412
+ return runCheckImpl(input);
1413
+ }
1414
+ function runCheckImpl(input) {
1415
+ const tStart = Date.now();
1374
1416
  const result = { failedRules: {
1375
1417
  standard: [],
1376
1418
  ai: [],
1377
1419
  reset: []
1378
1420
  } };
1421
+ const outcomes = [];
1379
1422
  const disabledSet = new Set(input.disabledRules || []);
1380
1423
  const rules = getAllRules();
1381
1424
  const failedKeys = /* @__PURE__ */ new Set();
1382
1425
  let configParsed = false;
1426
+ let aborted = false;
1427
+ console.error(`runCheck: begin configPath=${input.configPath} disabledRules=[${[...disabledSet].join(",")}] rules=${rules.length}`);
1383
1428
  let ctx = {
1384
1429
  config: {},
1385
1430
  configPath: input.configPath,
@@ -1387,13 +1432,29 @@ function runCheck(input) {
1387
1432
  providerDeps: {
1388
1433
  usesMiaodaProvider: false,
1389
1434
  usesMiaodaSecretProvider: false
1390
- },
1391
- templateVars: input.templateVars
1435
+ }
1392
1436
  };
1393
1437
  for (const rule of rules) {
1394
1438
  const meta = rule.meta;
1395
- if (disabledSet.has(meta.key)) continue;
1396
- if (meta.dependsOn?.some((dep) => failedKeys.has(dep))) continue;
1439
+ if (disabledSet.has(meta.key)) {
1440
+ console.error(`rule ${meta.key}: skipped reason=disabledRules`);
1441
+ outcomes.push({
1442
+ rule: meta.key,
1443
+ status: "skipped",
1444
+ message: "disabledRules"
1445
+ });
1446
+ continue;
1447
+ }
1448
+ if (meta.dependsOn?.some((dep) => failedKeys.has(dep))) {
1449
+ const blockers = meta.dependsOn?.filter((d) => failedKeys.has(d)).join(",");
1450
+ console.error(`rule ${meta.key}: skipped reason=dependsOn-failed blockers=${blockers}`);
1451
+ outcomes.push({
1452
+ rule: meta.key,
1453
+ status: "skipped",
1454
+ message: `dependsOn failed: ${blockers}`
1455
+ });
1456
+ continue;
1457
+ }
1397
1458
  if (meta.dependsOn?.includes("config_syntax_check") && !configParsed) try {
1398
1459
  const parsed = loadJSON5().parse(readFile(input.configPath));
1399
1460
  const deps = analyzeProviderDeps(parsed);
@@ -1401,11 +1462,17 @@ function runCheck(input) {
1401
1462
  config: parsed,
1402
1463
  configPath: input.configPath,
1403
1464
  vars: input.vars,
1404
- providerDeps: deps,
1405
- templateVars: input.templateVars
1465
+ providerDeps: deps
1406
1466
  };
1407
1467
  configParsed = true;
1408
- } catch {
1468
+ } catch (e) {
1469
+ console.error(`runCheck: config parse failed at ${meta.key} message=${e.message}`);
1470
+ outcomes.push({
1471
+ rule: meta.key,
1472
+ status: "error",
1473
+ message: "config parse failed: " + e.message
1474
+ });
1475
+ aborted = true;
1409
1476
  break;
1410
1477
  }
1411
1478
  if (meta.skipWhen && configParsed) {
@@ -1413,15 +1480,53 @@ function runCheck(input) {
1413
1480
  if (meta.skipWhen({
1414
1481
  hasMiaoda,
1415
1482
  deps: ctx.providerDeps
1416
- })) continue;
1483
+ })) {
1484
+ console.error(`rule ${meta.key}: skipped reason=skipWhen`);
1485
+ outcomes.push({
1486
+ rule: meta.key,
1487
+ status: "skipped",
1488
+ message: "skipWhen"
1489
+ });
1490
+ continue;
1491
+ }
1492
+ }
1493
+ const t = Date.now();
1494
+ let r;
1495
+ try {
1496
+ r = rule.validate(ctx);
1497
+ } catch (e) {
1498
+ const msg = e.message;
1499
+ console.error(`rule ${meta.key}: validate -> error durationMs=${Date.now() - t} message=${msg}`);
1500
+ outcomes.push({
1501
+ rule: meta.key,
1502
+ status: "error",
1503
+ message: "validate threw: " + msg
1504
+ });
1505
+ failedKeys.add(meta.key);
1506
+ continue;
1417
1507
  }
1418
- const r = rule.validate(ctx);
1419
- if (!r.pass) {
1508
+ if (r.pass) {
1509
+ console.error(`rule ${meta.key}: validate -> pass durationMs=${Date.now() - t}`);
1510
+ outcomes.push({
1511
+ rule: meta.key,
1512
+ status: "pass"
1513
+ });
1514
+ } else {
1515
+ console.error(`rule ${meta.key}: validate -> failed durationMs=${Date.now() - t} message=${r.message ?? ""}`);
1516
+ outcomes.push({
1517
+ rule: meta.key,
1518
+ status: "failed",
1519
+ message: r.message
1520
+ });
1420
1521
  failedKeys.add(meta.key);
1421
1522
  pushFailedRule(result, meta.repairMode, meta.key, r.message || "");
1422
1523
  }
1423
1524
  }
1424
- return result;
1525
+ console.error(`runCheck: end totalMs=${Date.now() - tStart} failed={standard:${result.failedRules.standard.length},ai:${result.failedRules.ai.length},reset:${result.failedRules.reset.length}}`);
1526
+ return {
1527
+ legacy: result,
1528
+ report: finalize$2(outcomes, aborted)
1529
+ };
1425
1530
  }
1426
1531
  function pushFailedRule(result, repairMode, key, detail) {
1427
1532
  switch (repairMode) {
@@ -1442,158 +1547,43 @@ function pushFailedRule(result, repairMode, key, detail) {
1442
1547
  break;
1443
1548
  }
1444
1549
  }
1445
- //#endregion
1446
- //#region src/repair.ts
1447
- function runRepair(input) {
1448
- try {
1449
- const failedSet = new Set(input.failedRules || []);
1450
- const repairData = input.repairData || {};
1451
- const rules = getAllRules();
1452
- for (const rule of rules) {
1453
- if (!failedSet.has(rule.meta.key)) continue;
1454
- if (rule.meta.repairMode !== "standard") continue;
1455
- if (rule.meta.dependsOn?.includes("config_syntax_check")) continue;
1456
- rule.repair({
1457
- config: {},
1458
- configPath: input.configPath,
1459
- vars: input.vars,
1460
- providerDeps: {
1461
- usesMiaodaProvider: false,
1462
- usesMiaodaSecretProvider: false
1463
- },
1464
- templateVars: input.templateVars
1465
- });
1466
- }
1467
- const JSON5 = loadJSON5();
1468
- let config;
1469
- try {
1470
- config = JSON5.parse(readFile(input.configPath));
1471
- } catch (e) {
1472
- return {
1473
- success: false,
1474
- error: "cannot parse config for repair: " + e.message
1475
- };
1476
- }
1477
- const deps = analyzeProviderDeps(config);
1478
- const ctx = {
1479
- config,
1480
- configPath: input.configPath,
1481
- vars: input.vars,
1482
- providerDeps: deps,
1483
- templateVars: input.templateVars
1484
- };
1485
- let configDirty = false;
1486
- for (const rule of rules) {
1487
- if (!failedSet.has(rule.meta.key)) continue;
1488
- if (rule.meta.repairMode !== "standard") continue;
1489
- if (!rule.meta.dependsOn?.includes("config_syntax_check")) continue;
1490
- rule.repair(ctx);
1491
- configDirty = true;
1492
- }
1493
- if (configDirty) writeFile(input.configPath, JSON.stringify(config, null, 2));
1494
- if (repairData.secretsContent && input.vars.secretsFilePath) writeFile(input.vars.secretsFilePath, repairData.secretsContent);
1495
- if (repairData.providerKeyContent && input.vars.providerFilePath) writeFile(input.vars.providerFilePath, repairData.providerKeyContent);
1496
- if (repairData.restartCommand) try {
1497
- shell(repairData.restartCommand, 3e4);
1498
- } catch (e) {
1499
- return {
1500
- success: false,
1501
- error: "restart command failed: " + e.message
1502
- };
1503
- }
1504
- return { success: true };
1505
- } catch (e) {
1506
- return {
1507
- success: false,
1508
- error: "repair failed: " + e.message
1509
- };
1510
- }
1511
- }
1512
- //#endregion
1513
- //#region src/paths.ts
1514
- /**
1515
- * Central directory for all ephemeral diagnose/reset artifacts: task status
1516
- * files (`reset-<taskId>.json`) and human-readable step logs
1517
- * (`reset-<taskId>.log`). Having everything under one dir makes debugging a
1518
- * stuck reset much easier — `ls /tmp/openclaw-diagnose/` shows every recent
1519
- * run, and each run's log is right next to its state.
1520
- */
1521
- const DIAGNOSE_DIR = "/tmp/openclaw-diagnose";
1522
- function resetResultFile(taskId) {
1523
- return `${DIAGNOSE_DIR}/reset-${taskId}.json`;
1524
- }
1525
- function resetLogFile(taskId) {
1526
- return `${DIAGNOSE_DIR}/reset-${taskId}.log`;
1527
- }
1528
- /** Sandbox workspace root where openclaw config + agent state lives. */
1529
- const WORKSPACE_DIR = "/home/gem/workspace/agent";
1530
- /** File containing the provider key used by the openclaw miaoda provider. */
1531
- const PROVIDER_FILE_PATH = "/home/gem/workspace/.force/openclaw/miaoda-provider-key";
1532
- /** File containing the miaoda openclaw secrets JSON. */
1533
- const SECRETS_FILE_PATH = "/home/gem/workspace/.force/openclaw/miaoda-openclaw-secrets.json";
1534
- /** Absolute path to the openclaw config JSON. */
1535
- const CONFIG_PATH = `${WORKSPACE_DIR}/openclaw.json`;
1536
- //#endregion
1537
- //#region src/logger.ts
1538
- /**
1539
- * Shared CLI log file. Every log line the CLI emits — whether through
1540
- * `console.error` (rules, helpers, errors) or through the per-task
1541
- * `makeLogger` (reset worker) — is tee'd here so operators have a single
1542
- * file to tail when diagnosing a sandbox.
1543
- *
1544
- * `/tmp` is ephemeral on sandbox restart; we rely on that for rotation
1545
- * (no size-based rotation implemented).
1546
- */
1547
- const CLI_LOG_FILE = "/tmp/openclaw-diagnose/cli.log";
1548
- /** Append one line to the shared cli.log. Swallows any fs error —
1549
- * logging must never break the business flow. */
1550
- function appendCliLog(line) {
1551
- try {
1552
- const dir = node_path.default.dirname(CLI_LOG_FILE);
1553
- if (!node_fs.default.existsSync(dir)) node_fs.default.mkdirSync(dir, { recursive: true });
1554
- node_fs.default.appendFileSync(CLI_LOG_FILE, line);
1555
- } catch {}
1556
- }
1557
- let stderrMirrorInstalled = false;
1558
- /**
1559
- * Install a process-wide `console.error` interceptor that mirrors each
1560
- * line to BOTH the original stderr AND cli.log. Call once at CLI entry
1561
- * before any subcommand dispatch; idempotent.
1562
- *
1563
- * Why console.error and not console.log: the CLI's stdout carries the
1564
- * structured JSON result protocol consumed by sandbox_console and other
1565
- * callers — any log line on stdout would corrupt JSON parsing. Rules,
1566
- * helpers, and error paths therefore must route debug output through
1567
- * console.error (stderr).
1568
- */
1569
- function installStderrMirror() {
1570
- if (stderrMirrorInstalled) return;
1571
- stderrMirrorInstalled = true;
1572
- const original = console.error.bind(console);
1573
- console.error = (...args) => {
1574
- original(...args);
1575
- const body = args.map((a) => typeof a === "string" ? a : safeStringify(a)).join(" ");
1576
- appendCliLog(`[${(/* @__PURE__ */ new Date()).toISOString()}] ${body}\n`);
1550
+ function finalize$2(results, aborted) {
1551
+ const summary = {
1552
+ pass: 0,
1553
+ failed: 0,
1554
+ fixed: 0,
1555
+ stillBroken: 0,
1556
+ skipped: 0,
1557
+ error: 0,
1558
+ unknown: 0
1577
1559
  };
1578
- }
1579
- function safeStringify(v) {
1580
- try {
1581
- return JSON.stringify(v);
1582
- } catch {
1583
- return String(v);
1560
+ for (const r of results) switch (r.status) {
1561
+ case "pass":
1562
+ summary.pass++;
1563
+ break;
1564
+ case "failed":
1565
+ summary.failed++;
1566
+ break;
1567
+ case "fixed":
1568
+ summary.fixed++;
1569
+ break;
1570
+ case "still-broken":
1571
+ summary.stillBroken++;
1572
+ break;
1573
+ case "skipped":
1574
+ summary.skipped++;
1575
+ break;
1576
+ case "error":
1577
+ summary.error++;
1578
+ break;
1579
+ case "unknown":
1580
+ summary.unknown++;
1581
+ break;
1584
1582
  }
1585
- }
1586
- function makeLogger(logFile) {
1587
- try {
1588
- const dir = node_path.default.dirname(logFile);
1589
- if (!node_fs.default.existsSync(dir)) node_fs.default.mkdirSync(dir, { recursive: true });
1590
- } catch {}
1591
- return (msg) => {
1592
- const line = `[${(/* @__PURE__ */ new Date()).toISOString()}] ${msg}\n`;
1593
- try {
1594
- node_fs.default.appendFileSync(logFile, line);
1595
- } catch {}
1596
- appendCliLog(line);
1583
+ return {
1584
+ results,
1585
+ summary,
1586
+ aborted
1597
1587
  };
1598
1588
  }
1599
1589
  //#endregion
@@ -1657,6 +1647,40 @@ function shellQuote(s) {
1657
1647
  return `'${s.replace(/'/g, `'\\''`)}'`;
1658
1648
  }
1659
1649
  /**
1650
+ * Snapshot the current `openclaw.json` to `<configPath>.<YYYYMMDD_HHMMSS>.bak`
1651
+ * before a repair / fix flow rewrites it. Local-time stamp matches the
1652
+ * shell-style backup convention (`cp openclaw.json openclaw.json.$(date
1653
+ * +%Y%m%d_%H%M%S).bak`) so operators triaging on the sandbox see the
1654
+ * familiar filename pattern.
1655
+ *
1656
+ * Returns the absolute backup path on success, or `null` when:
1657
+ * - source doesn't exist (first-time setup; nothing to back up)
1658
+ * - copy fails for any reason (disk full, permission denied, etc.)
1659
+ *
1660
+ * Failure is logged to cli.log via console.error and swallowed — the
1661
+ * caller's repair/fix work goes ahead either way. The backup is a safety
1662
+ * net, not a correctness gate; better to do the repair than refuse over
1663
+ * a missing rollback file.
1664
+ */
1665
+ function backupConfigSync(configPath) {
1666
+ if (!node_fs.default.existsSync(configPath)) {
1667
+ console.error(`backupConfigSync: skip — source missing path=${configPath}`);
1668
+ return null;
1669
+ }
1670
+ const d = /* @__PURE__ */ new Date();
1671
+ const pad = (n) => String(n).padStart(2, "0");
1672
+ const backupPath = `${configPath}.${`${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}_${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`}.bak`;
1673
+ try {
1674
+ node_fs.default.copyFileSync(configPath, backupPath);
1675
+ const bytes = node_fs.default.statSync(backupPath).size;
1676
+ console.error(`backupConfigSync: ok src=${configPath} dst=${backupPath} bytes=${bytes}`);
1677
+ return backupPath;
1678
+ } catch (e) {
1679
+ console.error(`backupConfigSync: failed src=${configPath} message=${e.message}`);
1680
+ return null;
1681
+ }
1682
+ }
1683
+ /**
1660
1684
  * Extract an npm-packed gzipped tarball.
1661
1685
  *
1662
1686
  * ## The problem this works around
@@ -1730,59 +1754,510 @@ function extractTarballTolerant(tarball, destDir, opts = {}) {
1730
1754
  }
1731
1755
  }
1732
1756
  //#endregion
1733
- //#region src/reset-async.ts
1734
- /**
1735
- * Start an async reset task: spawn a detached child process and return the taskId.
1736
- *
1737
- * The child process runs: node cli.js reset --worker --task-id=xxx --ctx=base64
1738
- */
1739
- function startAsyncReset(ctxBase64) {
1740
- const taskId = (0, node_crypto.randomUUID)();
1741
- const resultFile = resetResultFile(taskId);
1742
- const log = makeLogger(resetLogFile(taskId));
1743
- log(`=== startAsyncReset spawning worker for taskId=${taskId} ===`);
1744
- const initial = {
1745
- status: "running",
1746
- step: 0,
1747
- totalSteps: 9,
1748
- progress: "初始化...",
1749
- startedAt: (/* @__PURE__ */ new Date()).toISOString()
1750
- };
1751
- const tmpPath = resultFile + ".tmp";
1752
- const dir = node_path.default.dirname(resultFile);
1753
- if (!node_fs.default.existsSync(dir)) node_fs.default.mkdirSync(dir, { recursive: true });
1754
- node_fs.default.writeFileSync(tmpPath, JSON.stringify(initial), "utf-8");
1755
- moveSafe(tmpPath, resultFile);
1756
- const child = (0, node_child_process.spawn)(process.execPath, [
1757
- process.argv[1],
1758
- "reset",
1759
- "--worker",
1760
- `--task-id=${taskId}`,
1761
- `--ctx=${ctxBase64}`
1762
- ], {
1763
- detached: true,
1764
- stdio: "ignore"
1765
- });
1766
- child.on("error", (err) => {
1767
- log(`FATAL worker failed to start: ${err.message}`);
1768
- const failResult = {
1769
- status: "failed",
1770
- step: 0,
1771
- totalSteps: 9,
1772
- progress: "Worker process failed to start",
1773
- error: err.message,
1774
- startedAt: initial.startedAt,
1775
- completedAt: (/* @__PURE__ */ new Date()).toISOString()
1776
- };
1777
- const errTmpPath = resultFile + ".tmp";
1778
- node_fs.default.writeFileSync(errTmpPath, JSON.stringify(failResult));
1779
- moveSafe(errTmpPath, resultFile);
1780
- });
1757
+ //#region src/repair.ts
1758
+ /** Telemetry-aware entry. Same per-rule pass + side effects, but also
1759
+ * builds a `DoctorReport`-shape payload from per-rule revalidate
1760
+ * outcomes (`fixed` / `still-broken` / `error`) so `report_cli_run`
1761
+ * carries the same shape doctor produces. */
1762
+ function runRepairWithReport(input) {
1763
+ return runRepairImpl(input);
1764
+ }
1765
+ function runRepairImpl(input) {
1766
+ const tStart = Date.now();
1767
+ console.error(`runRepair: begin configPath=${input.configPath} failedRules=[${(input.failedRules ?? []).join(",")}]`);
1768
+ const outcomes = [];
1769
+ let aborted = false;
1770
+ let legacy = { success: true };
1771
+ try {
1772
+ const failedSet = new Set(input.failedRules || []);
1773
+ const repairData = input.repairData || {};
1774
+ const rules = getAllRules();
1775
+ const phase1Ctx = {
1776
+ config: {},
1777
+ configPath: input.configPath,
1778
+ vars: input.vars,
1779
+ providerDeps: {
1780
+ usesMiaodaProvider: false,
1781
+ usesMiaodaSecretProvider: false
1782
+ }
1783
+ };
1784
+ for (const rule of rules) {
1785
+ if (!failedSet.has(rule.meta.key)) continue;
1786
+ if (rule.meta.repairMode !== "standard") continue;
1787
+ if (rule.meta.dependsOn?.includes("config_syntax_check")) continue;
1788
+ const tRepair = Date.now();
1789
+ try {
1790
+ rule.repair(phase1Ctx);
1791
+ } catch (e) {
1792
+ const msg = e.message;
1793
+ console.error(`rule ${rule.meta.key}: repair (phase1) -> error durationMs=${Date.now() - tRepair} message=${msg}`);
1794
+ outcomes.push({
1795
+ rule: rule.meta.key,
1796
+ status: "error",
1797
+ message: "repair threw: " + msg
1798
+ });
1799
+ aborted = true;
1800
+ continue;
1801
+ }
1802
+ console.error(`rule ${rule.meta.key}: repair (phase1) -> ok durationMs=${Date.now() - tRepair}`);
1803
+ const tRev = Date.now();
1804
+ try {
1805
+ const v = rule.validate(phase1Ctx);
1806
+ if (v.pass) {
1807
+ console.error(`rule ${rule.meta.key}: revalidate (phase1) -> pass (fixed) durationMs=${Date.now() - tRev}`);
1808
+ outcomes.push({
1809
+ rule: rule.meta.key,
1810
+ status: "fixed"
1811
+ });
1812
+ } else {
1813
+ console.error(`rule ${rule.meta.key}: revalidate (phase1) -> still-broken durationMs=${Date.now() - tRev} after=${v.message ?? ""}`);
1814
+ outcomes.push({
1815
+ rule: rule.meta.key,
1816
+ status: "still-broken",
1817
+ after: v.message
1818
+ });
1819
+ }
1820
+ } catch (e) {
1821
+ const msg = e.message;
1822
+ console.error(`rule ${rule.meta.key}: revalidate (phase1) -> error durationMs=${Date.now() - tRev} message=${msg}`);
1823
+ outcomes.push({
1824
+ rule: rule.meta.key,
1825
+ status: "error",
1826
+ message: "post-repair validate threw: " + msg
1827
+ });
1828
+ aborted = true;
1829
+ }
1830
+ }
1831
+ const JSON5 = loadJSON5();
1832
+ let config;
1833
+ try {
1834
+ config = JSON5.parse(readFile(input.configPath));
1835
+ } catch (e) {
1836
+ const msg = e.message;
1837
+ console.error(`runRepair: config parse failed message=${msg}`);
1838
+ outcomes.push({
1839
+ rule: "config_syntax_check",
1840
+ status: "error",
1841
+ message: "config parse failed: " + msg
1842
+ });
1843
+ legacy = {
1844
+ success: false,
1845
+ error: "cannot parse config for repair: " + msg
1846
+ };
1847
+ return {
1848
+ legacy,
1849
+ report: finalize$1(outcomes, true)
1850
+ };
1851
+ }
1852
+ const deps = analyzeProviderDeps(config);
1853
+ const ctx = {
1854
+ config,
1855
+ configPath: input.configPath,
1856
+ vars: input.vars,
1857
+ providerDeps: deps
1858
+ };
1859
+ let configDirty = false;
1860
+ for (const rule of rules) {
1861
+ if (!failedSet.has(rule.meta.key)) continue;
1862
+ if (rule.meta.repairMode !== "standard") continue;
1863
+ if (!rule.meta.dependsOn?.includes("config_syntax_check")) continue;
1864
+ const tRepair = Date.now();
1865
+ try {
1866
+ rule.repair(ctx);
1867
+ } catch (e) {
1868
+ const msg = e.message;
1869
+ console.error(`rule ${rule.meta.key}: repair (phase2) -> error durationMs=${Date.now() - tRepair} message=${msg}`);
1870
+ outcomes.push({
1871
+ rule: rule.meta.key,
1872
+ status: "error",
1873
+ message: "repair threw: " + msg
1874
+ });
1875
+ aborted = true;
1876
+ continue;
1877
+ }
1878
+ console.error(`rule ${rule.meta.key}: repair (phase2) -> ok durationMs=${Date.now() - tRepair}`);
1879
+ configDirty = true;
1880
+ const tRev = Date.now();
1881
+ try {
1882
+ const v = rule.validate(ctx);
1883
+ if (v.pass) {
1884
+ console.error(`rule ${rule.meta.key}: revalidate (phase2) -> pass (fixed) durationMs=${Date.now() - tRev}`);
1885
+ outcomes.push({
1886
+ rule: rule.meta.key,
1887
+ status: "fixed"
1888
+ });
1889
+ } else {
1890
+ console.error(`rule ${rule.meta.key}: revalidate (phase2) -> still-broken durationMs=${Date.now() - tRev} after=${v.message ?? ""}`);
1891
+ outcomes.push({
1892
+ rule: rule.meta.key,
1893
+ status: "still-broken",
1894
+ after: v.message
1895
+ });
1896
+ }
1897
+ } catch (e) {
1898
+ const msg = e.message;
1899
+ console.error(`rule ${rule.meta.key}: revalidate (phase2) -> error durationMs=${Date.now() - tRev} message=${msg}`);
1900
+ outcomes.push({
1901
+ rule: rule.meta.key,
1902
+ status: "error",
1903
+ message: "post-repair validate threw: " + msg
1904
+ });
1905
+ aborted = true;
1906
+ }
1907
+ }
1908
+ if (configDirty) {
1909
+ backupConfigSync(input.configPath);
1910
+ const serialized = JSON.stringify(config, null, 2);
1911
+ writeFile(input.configPath, serialized);
1912
+ console.error(`runRepair: config writeback ok path=${input.configPath} bytes=${serialized.length}`);
1913
+ }
1914
+ if (repairData.secretsContent && input.vars.secretsFilePath) {
1915
+ writeFile(input.vars.secretsFilePath, repairData.secretsContent);
1916
+ console.error(`runRepair: secrets writeback ok path=${input.vars.secretsFilePath} bytes=${repairData.secretsContent.length}`);
1917
+ }
1918
+ if (repairData.providerKeyContent && input.vars.providerFilePath) {
1919
+ writeFile(input.vars.providerFilePath, repairData.providerKeyContent);
1920
+ console.error(`runRepair: provider key writeback ok path=${input.vars.providerFilePath} bytes=${repairData.providerKeyContent.length}`);
1921
+ }
1922
+ if (repairData.restartCommand) {
1923
+ const t = Date.now();
1924
+ try {
1925
+ console.error(`runRepair: restart begin cmd=${JSON.stringify(repairData.restartCommand)}`);
1926
+ shell(repairData.restartCommand, 3e4);
1927
+ console.error(`runRepair: restart ok durationMs=${Date.now() - t}`);
1928
+ } catch (e) {
1929
+ const msg = e.message;
1930
+ console.error(`runRepair: restart failed durationMs=${Date.now() - t} message=${msg}`);
1931
+ legacy = {
1932
+ success: false,
1933
+ error: "restart command failed: " + msg
1934
+ };
1935
+ return {
1936
+ legacy,
1937
+ report: finalize$1(outcomes, true)
1938
+ };
1939
+ }
1940
+ }
1941
+ console.error(`runRepair: end success=true totalMs=${Date.now() - tStart}`);
1942
+ legacy = { success: true };
1943
+ return {
1944
+ legacy,
1945
+ report: finalize$1(outcomes, aborted)
1946
+ };
1947
+ } catch (e) {
1948
+ const msg = e.message;
1949
+ console.error(`runRepair: end success=false totalMs=${Date.now() - tStart} message=${msg}`);
1950
+ legacy = {
1951
+ success: false,
1952
+ error: "repair failed: " + msg
1953
+ };
1954
+ return {
1955
+ legacy,
1956
+ report: finalize$1(outcomes, true)
1957
+ };
1958
+ }
1959
+ }
1960
+ function finalize$1(results, aborted) {
1961
+ const summary = {
1962
+ pass: 0,
1963
+ failed: 0,
1964
+ fixed: 0,
1965
+ stillBroken: 0,
1966
+ skipped: 0,
1967
+ error: 0,
1968
+ unknown: 0
1969
+ };
1970
+ for (const r of results) switch (r.status) {
1971
+ case "pass":
1972
+ summary.pass++;
1973
+ break;
1974
+ case "failed":
1975
+ summary.failed++;
1976
+ break;
1977
+ case "fixed":
1978
+ summary.fixed++;
1979
+ break;
1980
+ case "still-broken":
1981
+ summary.stillBroken++;
1982
+ break;
1983
+ case "skipped":
1984
+ summary.skipped++;
1985
+ break;
1986
+ case "error":
1987
+ summary.error++;
1988
+ break;
1989
+ case "unknown":
1990
+ summary.unknown++;
1991
+ break;
1992
+ }
1993
+ return {
1994
+ results,
1995
+ summary,
1996
+ aborted
1997
+ };
1998
+ }
1999
+ //#endregion
2000
+ //#region src/paths.ts
2001
+ /**
2002
+ * Central directory for all ephemeral diagnose/reset artifacts: task status
2003
+ * files (`reset-<taskId>.json`) and human-readable step logs
2004
+ * (`reset-<taskId>.log`). Having everything under one dir makes debugging a
2005
+ * stuck reset much easier — `ls /tmp/openclaw-diagnose/` shows every recent
2006
+ * run, and each run's log is right next to its state.
2007
+ */
2008
+ const DIAGNOSE_DIR = "/tmp/openclaw-diagnose";
2009
+ function resetResultFile(taskId) {
2010
+ return `${DIAGNOSE_DIR}/reset-${taskId}.json`;
2011
+ }
2012
+ function resetLogFile(taskId) {
2013
+ return `${DIAGNOSE_DIR}/reset-${taskId}.log`;
2014
+ }
2015
+ /** Sandbox workspace root where openclaw config + agent state lives. */
2016
+ const WORKSPACE_DIR = "/home/gem/workspace/agent";
2017
+ /** File containing the provider key used by the openclaw miaoda provider. */
2018
+ const PROVIDER_FILE_PATH = "/home/gem/workspace/.force/openclaw/miaoda-provider-key";
2019
+ /** File containing the miaoda openclaw secrets JSON. */
2020
+ const SECRETS_FILE_PATH = "/home/gem/workspace/.force/openclaw/miaoda-openclaw-secrets.json";
2021
+ /** Absolute path to the openclaw config JSON. */
2022
+ const CONFIG_PATH = `${WORKSPACE_DIR}/openclaw.json`;
2023
+ //#endregion
2024
+ //#region src/run-log.ts
2025
+ let currentRunContext;
2026
+ /**
2027
+ * Install the run context for this CLI execution. Must be called once at
2028
+ * the top of `main()` before any subcommand work; idempotent (last call
2029
+ * wins, but `main` only calls it once).
2030
+ *
2031
+ * Resolution order for `runId`:
2032
+ * 1. `OPENCLAW_RUN_ID` env var — set by a parent CLI process (e.g. the
2033
+ * `reset --async` dispatcher exports it before spawning the worker)
2034
+ * so cli.log lines for both processes carry the same `[run=...]`
2035
+ * tag. Any non-empty value is honoured verbatim.
2036
+ * 2. `--trace-id=<id>` flag — caller-supplied upstream trace id.
2037
+ * 3. Generated locally as `gen-<8 hex>`.
2038
+ *
2039
+ * Safe to call without any input — runId falls through to the generated
2040
+ * branch and both metadata fields stay undefined.
2041
+ */
2042
+ function setRunContext(opts) {
2043
+ const inherited = process.env.OPENCLAW_RUN_ID;
2044
+ if (inherited && inherited !== "") currentRunContext = {
2045
+ runId: inherited,
2046
+ caller: opts.caller,
2047
+ traceId: opts.traceId,
2048
+ generated: false
2049
+ };
2050
+ else if (opts.traceId) currentRunContext = {
2051
+ runId: opts.traceId,
2052
+ caller: opts.caller,
2053
+ traceId: opts.traceId,
2054
+ generated: false
2055
+ };
2056
+ else currentRunContext = {
2057
+ runId: "gen-" + (0, node_crypto.randomBytes)(4).toString("hex"),
2058
+ caller: opts.caller,
2059
+ traceId: void 0,
2060
+ generated: true
2061
+ };
2062
+ return currentRunContext;
2063
+ }
2064
+ function getRunContext() {
2065
+ return currentRunContext;
2066
+ }
2067
+ /**
2068
+ * Format the run-scope tag the stderr mirror injects into cli.log lines.
2069
+ * Empty string when no context is set (mirror falls back to plain
2070
+ * timestamped lines, preserving pre-context startup logging).
2071
+ */
2072
+ function runTag(rc = currentRunContext) {
2073
+ if (!rc) return "";
2074
+ const parts = [`run=${rc.runId}`];
2075
+ if (rc.caller) parts.push(`caller=${rc.caller}`);
2076
+ return `[${parts.join(" ")}]`;
2077
+ }
2078
+ //#endregion
2079
+ //#region src/logger.ts
2080
+ /**
2081
+ * Shared CLI log file. Every log line the CLI emits — whether through
2082
+ * `console.error` (rules, helpers, errors) or through the per-task
2083
+ * `makeLogger` (reset worker) — is tee'd here so operators have a single
2084
+ * file to tail when diagnosing a sandbox.
2085
+ *
2086
+ * `/tmp` is ephemeral on sandbox restart; we rely on that for rotation
2087
+ * (no size-based rotation implemented).
2088
+ */
2089
+ const CLI_LOG_FILE = "/tmp/openclaw-diagnose/cli.log";
2090
+ /** Append one line to the shared cli.log. Swallows any fs error —
2091
+ * logging must never break the business flow. */
2092
+ function appendCliLog(line) {
2093
+ try {
2094
+ const dir = node_path.default.dirname(CLI_LOG_FILE);
2095
+ if (!node_fs.default.existsSync(dir)) node_fs.default.mkdirSync(dir, { recursive: true });
2096
+ node_fs.default.appendFileSync(CLI_LOG_FILE, line);
2097
+ } catch {}
2098
+ }
2099
+ let stderrMirrorInstalled = false;
2100
+ /**
2101
+ * Install a process-wide `console.error` interceptor. Always tees each
2102
+ * line to cli.log; the stderr passthrough is opt-in via `stderrEnabled`.
2103
+ *
2104
+ * Default (`stderrEnabled: false`): the terminal sees ONLY this command's
2105
+ * stdout (typically a single JSON result line). Lifecycle / progress /
2106
+ * error logs are fully captured in `/tmp/openclaw-diagnose/cli.log`,
2107
+ * which is the canonical debugging surface and already carries
2108
+ * timestamps and `[run=...]` tags.
2109
+ *
2110
+ * Verbose (`stderrEnabled: true`, opt in with the top-level `-x` flag):
2111
+ * lifecycle logs also stream to stderr in real time. Useful when
2112
+ * iterating on a single sandbox.
2113
+ *
2114
+ * Idempotent: calling twice is a no-op (first install wins). Tests that
2115
+ * need to swap mode should call `resetStderrMirrorForTests()` first.
2116
+ *
2117
+ * Why console.error (and not console.log): the CLI's stdout carries the
2118
+ * structured JSON result protocol consumed by sandbox_console and other
2119
+ * callers — any log line on stdout would corrupt JSON parsing.
2120
+ */
2121
+ function installStderrMirror(opts = {}) {
2122
+ if (stderrMirrorInstalled) return;
2123
+ stderrMirrorInstalled = true;
2124
+ const stderrEnabled = opts.stderrEnabled ?? false;
2125
+ const original = console.error.bind(console);
2126
+ console.error = (...args) => {
2127
+ if (stderrEnabled) original(...args);
2128
+ const body = args.map((a) => typeof a === "string" ? a : safeStringify(a)).join(" ");
2129
+ const tag = runTag();
2130
+ appendCliLog(`[${(/* @__PURE__ */ new Date()).toISOString()}]${tag ? ` ${tag}` : ""} ${body}\n`);
2131
+ };
2132
+ }
2133
+ function safeStringify(v) {
2134
+ try {
2135
+ return JSON.stringify(v);
2136
+ } catch {
2137
+ return String(v);
2138
+ }
2139
+ }
2140
+ function makeLogger(logFile) {
2141
+ try {
2142
+ const dir = node_path.default.dirname(logFile);
2143
+ if (!node_fs.default.existsSync(dir)) node_fs.default.mkdirSync(dir, { recursive: true });
2144
+ } catch {}
2145
+ return (msg) => {
2146
+ const tag = runTag();
2147
+ const line = `[${(/* @__PURE__ */ new Date()).toISOString()}]${tag ? ` ${tag}` : ""} ${msg}\n`;
2148
+ try {
2149
+ node_fs.default.appendFileSync(logFile, line);
2150
+ } catch {}
2151
+ appendCliLog(line);
2152
+ };
2153
+ }
2154
+ //#endregion
2155
+ //#region src/reset-async.ts
2156
+ /**
2157
+ * Start an async reset task: spawn a detached child process and return the taskId.
2158
+ *
2159
+ * The child process runs: node cli.js reset --worker --task-id=xxx --ctx=base64
2160
+ */
2161
+ function startAsyncReset(ctxBase64) {
2162
+ const taskId = (0, node_crypto.randomUUID)();
2163
+ const resultFile = resetResultFile(taskId);
2164
+ const log = makeLogger(resetLogFile(taskId));
2165
+ log(`=== startAsyncReset spawning worker for taskId=${taskId} ===`);
2166
+ const initial = {
2167
+ status: "running",
2168
+ step: 0,
2169
+ totalSteps: 9,
2170
+ progress: "初始化...",
2171
+ startedAt: (/* @__PURE__ */ new Date()).toISOString()
2172
+ };
2173
+ const tmpPath = resultFile + ".tmp";
2174
+ const dir = node_path.default.dirname(resultFile);
2175
+ if (!node_fs.default.existsSync(dir)) node_fs.default.mkdirSync(dir, { recursive: true });
2176
+ node_fs.default.writeFileSync(tmpPath, JSON.stringify(initial), "utf-8");
2177
+ moveSafe(tmpPath, resultFile);
2178
+ const rc = getRunContext();
2179
+ const childEnv = { ...process.env };
2180
+ if (rc?.runId) childEnv.OPENCLAW_RUN_ID = rc.runId;
2181
+ const child = (0, node_child_process.spawn)(process.execPath, [
2182
+ process.argv[1],
2183
+ "reset",
2184
+ "--worker",
2185
+ `--task-id=${taskId}`,
2186
+ `--ctx=${ctxBase64}`
2187
+ ], {
2188
+ detached: true,
2189
+ stdio: "ignore",
2190
+ env: childEnv
2191
+ });
2192
+ child.on("error", (err) => {
2193
+ log(`FATAL worker failed to start: ${err.message}`);
2194
+ const failResult = {
2195
+ status: "failed",
2196
+ step: 0,
2197
+ totalSteps: 9,
2198
+ progress: "Worker process failed to start",
2199
+ error: err.message,
2200
+ startedAt: initial.startedAt,
2201
+ completedAt: (/* @__PURE__ */ new Date()).toISOString()
2202
+ };
2203
+ const errTmpPath = resultFile + ".tmp";
2204
+ node_fs.default.writeFileSync(errTmpPath, JSON.stringify(failResult));
2205
+ moveSafe(errTmpPath, resultFile);
2206
+ });
1781
2207
  child.unref();
1782
2208
  log(`spawned worker pid=${child.pid}`);
1783
2209
  return { taskId };
1784
2210
  }
1785
2211
  //#endregion
2212
+ //#region src/oss/fetchWithDiag.ts
2213
+ async function fetchWithDiag(url, opts = {}) {
2214
+ const label = opts.label ?? originOf(url);
2215
+ const timeoutMs = opts.timeoutMs ?? 3e4;
2216
+ const start = Date.now();
2217
+ const ac = new AbortController();
2218
+ const timer = timeoutMs > 0 && Number.isFinite(timeoutMs) ? setTimeout(() => ac.abort(), timeoutMs) : void 0;
2219
+ try {
2220
+ return await fetch(url, { signal: ac.signal });
2221
+ } catch (e) {
2222
+ const durationMs = Date.now() - start;
2223
+ const causeStr = e.name === "AbortError" || ac.signal.aborted && timeoutMs > 0 ? `request aborted after ${timeoutMs}ms (timeout)` : describeCause(e.cause) || e.message;
2224
+ throw new Error(`fetch ${label} failed: ${causeStr} (url=${redactUrl(url)} durationMs=${durationMs})`);
2225
+ } finally {
2226
+ if (timer) clearTimeout(timer);
2227
+ }
2228
+ }
2229
+ /** Walk the Error.cause chain and produce a single-line summary like
2230
+ * `ENOTFOUND getaddrinfo ENOTFOUND oss.example.com`. */
2231
+ function describeCause(c, depth = 0) {
2232
+ if (!c || depth > 3) return "";
2233
+ if (c instanceof Error) {
2234
+ const code = c.code;
2235
+ const head = code ? `${code} ${c.message}` : c.message;
2236
+ const inner = describeCause(c.cause, depth + 1);
2237
+ return inner ? `${head} <- ${inner}` : head;
2238
+ }
2239
+ return String(c);
2240
+ }
2241
+ /** Strip query string — OSS signed URLs put the auth token there. Keeps
2242
+ * protocol/host/path so the operator can tell *which* OSS bucket / object
2243
+ * was being fetched. */
2244
+ function redactUrl(raw) {
2245
+ try {
2246
+ const u = new URL(raw);
2247
+ const tail = u.search ? "?<redacted>" : "";
2248
+ return `${u.protocol}//${u.host}${u.pathname}${tail}`;
2249
+ } catch {
2250
+ return "<invalid-url>";
2251
+ }
2252
+ }
2253
+ function originOf(raw) {
2254
+ try {
2255
+ return new URL(raw).host;
2256
+ } catch {
2257
+ return "unknown-host";
2258
+ }
2259
+ }
2260
+ //#endregion
1786
2261
  //#region src/oss/fetchManifest.ts
1787
2262
  const MANIFEST_PREFIX = "builtin/manifests/openclaw/recommended/";
1788
2263
  const MANIFEST_SUFFIX = ".json";
@@ -1794,10 +2269,24 @@ async function fetchManifest(ossFileMap, tag) {
1794
2269
  const availStr = available.length ? available.join(", ") : "(none)";
1795
2270
  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.`);
1796
2271
  }
1797
- const res = await fetch(url);
1798
- if (!res.ok) throw new Error(`fetch manifest failed: HTTP ${res.status} ${res.statusText}`);
2272
+ const label = `openclaw manifest tag=${tag}`;
2273
+ const res = await fetchWithDiag(url, { label });
2274
+ if (!res.ok) {
2275
+ const body = await readPreview(res);
2276
+ throw new Error(`${label}: HTTP ${res.status} ${res.statusText}` + (body ? ` body=${body}` : ""));
2277
+ }
1799
2278
  return await res.json();
1800
2279
  }
2280
+ /** Best-effort body preview for HTTP-error diagnostics. OSS errors are
2281
+ * XML; first 256 chars usually contain the <Code> + <Message> we care
2282
+ * about. Failures here are swallowed (logging-only, not load-bearing). */
2283
+ async function readPreview(res) {
2284
+ try {
2285
+ return (await res.text()).slice(0, 256);
2286
+ } catch {
2287
+ return "";
2288
+ }
2289
+ }
1801
2290
  async function downloadWithCache(pkg, ossFileMap, opts = {}) {
1802
2291
  const cacheRoot = opts.cacheRoot ?? "/tmp/openclaw-diagnose/resources";
1803
2292
  const shortHash = pkg.shasum.slice(0, 16);
@@ -1811,9 +2300,19 @@ async function downloadWithCache(pkg, ossFileMap, opts = {}) {
1811
2300
  const expected = pkg.integrity.slice(7);
1812
2301
  const tmpFile = node_path.default.join(destDir, `.tmp.${process.pid}.${node_crypto.default.randomBytes(4).toString("hex")}`);
1813
2302
  try {
1814
- const res = await fetch(url);
1815
- if (!res.ok) throw new Error(`download failed: HTTP ${res.status}`);
1816
- if (!res.body) throw new Error(`download failed: empty body for ${pkg.ossKey}`);
2303
+ const label = `download ${pkg.ossKey}`;
2304
+ const res = await fetchWithDiag(url, {
2305
+ label,
2306
+ timeoutMs: 6e4
2307
+ });
2308
+ if (!res.ok) {
2309
+ let preview = "";
2310
+ try {
2311
+ preview = (await res.text()).slice(0, 256);
2312
+ } catch {}
2313
+ throw new Error(`${label}: HTTP ${res.status} ${res.statusText}` + (preview ? ` body=${preview}` : ""));
2314
+ }
2315
+ if (!res.body) throw new Error(`${label}: empty body`);
1817
2316
  const hasher = node_crypto.default.createHash("sha512");
1818
2317
  const source = node_stream.Readable.fromWeb(res.body);
1819
2318
  async function* teeAndHash(src) {
@@ -2338,16 +2837,6 @@ function writeSecretsAndRestart(vars, resetData, configDir, log) {
2338
2837
  log(`restart.sh done in ${Date.now() - t}ms`);
2339
2838
  } else log(`no restart.sh at ${restartScript}, skip`);
2340
2839
  }
2341
- /**
2342
- * Run the 9-step reset process. Called from the worker entry point.
2343
- *
2344
- * Each step is an independent function. The orchestrator handles progress
2345
- * reporting, error handling, and process-level exception guards.
2346
- *
2347
- * Template assets (openclaw.json + scripts/) are downloaded from OSS into a
2348
- * scratch dir via `stageTemplate` before step 1 — there is no bundled
2349
- * `template/` directory at runtime any more.
2350
- */
2351
2840
  async function runReset(input, taskId, resultFile) {
2352
2841
  const startedAt = (/* @__PURE__ */ new Date()).toISOString();
2353
2842
  const { configPath, vars, resetData } = input;
@@ -2355,6 +2844,7 @@ async function runReset(input, taskId, resultFile) {
2355
2844
  const stagedDir = node_path.default.join(DIAGNOSE_DIR, `reset-${taskId}-template`);
2356
2845
  let currentStep = 0;
2357
2846
  let stepStartedAt = Date.now();
2847
+ const stepResults = [];
2358
2848
  const log = makeLogger(resetLogFile(taskId));
2359
2849
  log(`=== reset started, taskId=${taskId}, pid=${process.pid} ===`);
2360
2850
  log(`configPath=${configPath}, configDir=${configDir}, stagedDir=${stagedDir}`);
@@ -2363,7 +2853,12 @@ async function runReset(input, taskId, resultFile) {
2363
2853
  const err = "resetData.ossFileMap missing or empty";
2364
2854
  log(`ERROR: ${err}`);
2365
2855
  markFailed(resultFile, 0, err, startedAt);
2366
- process.exit(1);
2856
+ return {
2857
+ success: false,
2858
+ steps: stepResults,
2859
+ error: err,
2860
+ failedStep: 0
2861
+ };
2367
2862
  }
2368
2863
  let openclawTag;
2369
2864
  if (resetData.openclawTag) openclawTag = resetData.openclawTag;
@@ -2373,7 +2868,12 @@ async function runReset(input, taskId, resultFile) {
2373
2868
  const err = e.message;
2374
2869
  log(`ERROR: ${err}`);
2375
2870
  markFailed(resultFile, 0, err, startedAt);
2376
- process.exit(1);
2871
+ return {
2872
+ success: false,
2873
+ steps: stepResults,
2874
+ error: err,
2875
+ failedStep: 0
2876
+ };
2377
2877
  }
2378
2878
  log(`openclawTag=${openclawTag}`);
2379
2879
  process.on("uncaughtException", (err) => {
@@ -2386,9 +2886,19 @@ async function runReset(input, taskId, resultFile) {
2386
2886
  markFailed(resultFile, currentStep, `unhandled rejection: ${reason}`, startedAt);
2387
2887
  process.exit(1);
2388
2888
  });
2389
- /** Advance to the next step, updating the progress file and logging a boundary. */
2889
+ /** Advance to the next step, recording the previous step's success
2890
+ * duration and updating the on-disk progress file. */
2390
2891
  const step = (n) => {
2391
- if (currentStep > 0) log(`step ${currentStep} "${STEPS[currentStep - 1]}" done in ${Date.now() - stepStartedAt}ms`);
2892
+ if (currentStep > 0) {
2893
+ const dur = Date.now() - stepStartedAt;
2894
+ log(`step ${currentStep} "${STEPS[currentStep - 1]}" done in ${dur}ms`);
2895
+ stepResults.push({
2896
+ step: currentStep,
2897
+ name: STEPS[currentStep - 1],
2898
+ status: "ok",
2899
+ durationMs: dur
2900
+ });
2901
+ }
2392
2902
  currentStep = n;
2393
2903
  stepStartedAt = Date.now();
2394
2904
  log(`--- step ${n}/${TOTAL_STEPS}: ${STEPS[n - 1]} ---`);
@@ -2414,14 +2924,38 @@ async function runReset(input, taskId, resultFile) {
2414
2924
  await step8InstallExtensions(openclawTag, ossFileMap, log);
2415
2925
  step(9);
2416
2926
  writeSecretsAndRestart(vars, resetData, configDir, log);
2417
- log(`step 9 "${STEPS[8]}" done in ${Date.now() - stepStartedAt}ms`);
2927
+ const lastDur = Date.now() - stepStartedAt;
2928
+ log(`step 9 "${STEPS[8]}" done in ${lastDur}ms`);
2929
+ stepResults.push({
2930
+ step: 9,
2931
+ name: STEPS[8],
2932
+ status: "ok",
2933
+ durationMs: lastDur
2934
+ });
2418
2935
  log("=== reset completed successfully ===");
2419
2936
  markDone(resultFile, startedAt);
2937
+ return {
2938
+ success: true,
2939
+ steps: stepResults
2940
+ };
2420
2941
  } catch (e) {
2421
2942
  const err = e.message;
2422
- log(`ERROR in step ${currentStep} "${STEPS[currentStep - 1] ?? "init"}" after ${Date.now() - stepStartedAt}ms: ${err}\n${e.stack ?? ""}`);
2943
+ const dur = Date.now() - stepStartedAt;
2944
+ log(`ERROR in step ${currentStep} "${STEPS[currentStep - 1] ?? "init"}" after ${dur}ms: ${err}\n${e.stack ?? ""}`);
2945
+ stepResults.push({
2946
+ step: currentStep,
2947
+ name: STEPS[currentStep - 1] ?? "init",
2948
+ status: "fail",
2949
+ durationMs: dur,
2950
+ error: err
2951
+ });
2423
2952
  markFailed(resultFile, currentStep, err, startedAt);
2424
- process.exit(1);
2953
+ return {
2954
+ success: false,
2955
+ steps: stepResults,
2956
+ error: err,
2957
+ failedStep: currentStep
2958
+ };
2425
2959
  } finally {
2426
2960
  try {
2427
2961
  node_fs.default.rmSync(stagedDir, {
@@ -2488,38 +3022,24 @@ function resolveOssFileMap(args) {
2488
3022
  throw new Error("ossFileMap missing: provide --oss_file_map flag, ctx.install.ossFileMap, or resetData.ossFileMap");
2489
3023
  }
2490
3024
  //#endregion
2491
- //#region src/innerapi/fetchCtx.ts
3025
+ //#region src/innerapi/client.ts
2492
3026
  /**
2493
- * CLI-side client for studio_server's `openclaw.get_doctor_ctx` inner API.
2494
- *
2495
- * Mirrors the proven pattern in
2496
- * `packages/openclaw/extensions/miaoda/src/shared/innerapi-client.ts`:
3027
+ * Shared innerapi HTTP client + path constants for `openclaw.*` calls.
2497
3028
  *
2498
3029
  * - `baseURL` from env `FORCE_AUTHN_INNERAPI_DOMAIN` (injected into every
2499
3030
  * openclaw sandbox).
2500
3031
  * - `platform: { enabled, tokenProvider: { type: 'file' } }` — the platform
2501
3032
  * plugin auto-attaches the sandbox's identity JWT loaded from the
2502
3033
  * rootfs token file. Same auth that the miaoda extension already uses.
2503
- * - POST `/api/v1/studio/innerapi/integration_apis/call`
2504
- * body = { apiName: 'openclaw.get_doctor_ctx', input: {}, bizType: 'openclaw' }
2505
- * the server-side APICall dispatches by `apiName` to
2506
- * `GetDoctorCtxAPICall.Execute` whose `Name()` returns that string.
2507
- * - Response envelope: { status_code, error_msg?, data: { success, output, ... } }.
2508
- * `status_code` is a *string* ('0' = success).
2509
- * Actual DoctorCtx lives in `data.output`.
2510
- * - `x-tt-logid` header is logged on every failure path for cross-service
2511
- * traceability.
2512
- *
2513
- * On HTTP 401 (sandbox identity token expired/invalid) we `process.exit(77)`
2514
- * instead of throwing — the outer catch in `index.ts` cannot then mask auth
2515
- * failure as a generic "Error: ...". Caller (e.g. sandbox_console) sees the
2516
- * exit code and can refresh the token + retry.
3034
+ * - POST `/api/v1/studio/innerapi/integration_apis/call` with body
3035
+ * `{ apiName, bizType, input }` is the universal dispatcher; specific
3036
+ * apiName values (e.g. `openclaw.get_doctor_ctx`,
3037
+ * `openclaw.report_doctor_result`) are owned by individual call-site
3038
+ * modules.
2517
3039
  */
2518
3040
  const INNERAPI_CALL_PATH = "/api/v1/studio/innerapi/integration_apis/call";
2519
- const API_NAME = "openclaw.get_doctor_ctx";
2520
3041
  const BIZ_TYPE = "openclaw";
2521
3042
  const API_TIMEOUT_MS = 3e4;
2522
- const MAX_LOG_BODY = 500;
2523
3043
  let clientInstance = null;
2524
3044
  function getHttpClient() {
2525
3045
  if (!clientInstance) {
@@ -2536,6 +3056,35 @@ function getHttpClient() {
2536
3056
  }
2537
3057
  return clientInstance;
2538
3058
  }
3059
+ //#endregion
3060
+ //#region src/innerapi/fetchCtx.ts
3061
+ /**
3062
+ * CLI-side client for studio_server's `openclaw.get_doctor_ctx` inner API.
3063
+ *
3064
+ * Mirrors the proven pattern in
3065
+ * `packages/openclaw/extensions/miaoda/src/shared/innerapi-client.ts`:
3066
+ *
3067
+ * - `baseURL` from env `FORCE_AUTHN_INNERAPI_DOMAIN` (injected into every
3068
+ * openclaw sandbox).
3069
+ * - `platform: { enabled, tokenProvider: { type: 'file' } }` — the platform
3070
+ * plugin auto-attaches the sandbox's identity JWT loaded from the
3071
+ * rootfs token file. Same auth that the miaoda extension already uses.
3072
+ * - POST `/api/v1/studio/innerapi/integration_apis/call`
3073
+ * body = { apiName: 'openclaw.get_doctor_ctx', input: {}, bizType: 'openclaw' }
3074
+ * — the server-side APICall dispatches by `apiName` to
3075
+ * `GetDoctorCtxAPICall.Execute` whose `Name()` returns that string.
3076
+ * - Response envelope: { status_code, error_msg?, data: { success, output, ... } }.
3077
+ * `status_code` is a *string* ('0' = success).
3078
+ * Actual DoctorCtx lives in `data.output`.
3079
+ * - `x-tt-logid` header is logged on every failure path for cross-service
3080
+ * traceability.
3081
+ *
3082
+ * On HTTP 401 (sandbox identity token expired/invalid) we `process.exit(77)`
3083
+ * instead of throwing — the outer catch in `index.ts` cannot then mask auth
3084
+ * failure as a generic "Error: ...". Caller (e.g. sandbox_console) sees the
3085
+ * exit code and can refresh the token + retry.
3086
+ */
3087
+ const API_NAME$1 = "openclaw.get_doctor_ctx";
2539
3088
  /**
2540
3089
  * Fetch the sandbox's DoctorCtx by calling the innerapi's generic
2541
3090
  * `integration_apis/call` dispatcher with apiName=openclaw.get_doctor_ctx.
@@ -2543,14 +3092,19 @@ function getHttpClient() {
2543
3092
  * Throws on HTTP (non-401) / decode / business errors. On 401 calls
2544
3093
  * `process.exit(77)` directly.
2545
3094
  */
2546
- async function fetchCtxViaInnerApi() {
3095
+ async function fetchCtxViaInnerApi(opts = {}) {
2547
3096
  const client = getHttpClient();
3097
+ const input = {};
3098
+ if (opts.populate !== void 0) input.populate = opts.populate;
3099
+ if (opts.caller) input.caller = opts.caller;
3100
+ if (opts.traceId) input.traceId = opts.traceId;
2548
3101
  const body = {
2549
- apiName: API_NAME,
2550
- input: {},
3102
+ apiName: API_NAME$1,
3103
+ input,
2551
3104
  bizType: BIZ_TYPE
2552
3105
  };
2553
3106
  const start = Date.now();
3107
+ console.error(`fetchCtx: start populate=${JSON.stringify(opts.populate ?? {})}${opts.caller ? ` caller=${opts.caller}` : ""}${opts.traceId ? ` traceId=${opts.traceId}` : ""}`);
2554
3108
  const headers = { "Content-Type": "application/json" };
2555
3109
  const ttEnv = process.env.X_TT_ENV;
2556
3110
  if (ttEnv) headers["x-tt-env"] = ttEnv;
@@ -2580,7 +3134,7 @@ async function fetchCtxViaInnerApi() {
2580
3134
  }
2581
3135
  let preview = "";
2582
3136
  try {
2583
- preview = (await response.text()).slice(0, MAX_LOG_BODY);
3137
+ preview = (await response.text()).slice(0, 500);
2584
3138
  } catch {}
2585
3139
  throw new Error(`fetchCtxViaInnerApi HTTP ${response.status} ${response.statusText} (logID: ${logId}, durationMs: ${durationMs})${preview ? ` body=${preview}` : ""}`);
2586
3140
  }
@@ -2594,6 +3148,7 @@ async function fetchCtxViaInnerApi() {
2594
3148
  if (envelope.data && envelope.data.success === false) throw new Error(`fetchCtxViaInnerApi business error (logID: ${logId}, durationMs: ${durationMs}): ${envelope.error_msg ?? JSON.stringify(envelope.data)}`);
2595
3149
  const output = envelope.data?.output;
2596
3150
  if (!output || typeof output !== "object") throw new Error(`fetchCtxViaInnerApi empty/invalid output (logID: ${logId}, durationMs: ${durationMs})`);
3151
+ console.error(`fetchCtx: ok logId=${logId} durationMs=${durationMs} groups=[${Object.keys(output).join(",")}]`);
2597
3152
  return output;
2598
3153
  }
2599
3154
  //#endregion
@@ -2612,26 +3167,40 @@ async function fetchCtxViaInnerApi() {
2612
3167
  */
2613
3168
  function normalizeCtx(raw) {
2614
3169
  const r = raw ?? {};
2615
- 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 {
2616
- app: fillApp(r.app),
2617
- install: {
2618
- openclawTag: r.install.openclawTag,
2619
- ossFileMap: r.install.ossFileMap ?? {}
2620
- },
2621
- secrets: {
2622
- secretsContent: r.secrets.secretsContent ?? "",
2623
- providerKeyContent: r.secrets.providerKeyContent ?? ""
2624
- },
2625
- reset: {
2626
- templateVars: r.reset.templateVars ?? {},
2627
- coreBackup: r.reset.coreBackup
2628
- }
2629
- };
3170
+ 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") {
3171
+ const templateVars = r.app.templateVars && typeof r.app.templateVars === "object" ? r.app.templateVars : r.reset.templateVars ?? {};
3172
+ 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;
3173
+ return {
3174
+ app: {
3175
+ ...fillApp(r.app),
3176
+ templateVars,
3177
+ recommendedOpenclawTag
3178
+ },
3179
+ install: {
3180
+ openclawTag: r.install.openclawTag,
3181
+ ossFileMap: r.install.ossFileMap ?? {}
3182
+ },
3183
+ secrets: {
3184
+ secretsContent: r.secrets.secretsContent ?? "",
3185
+ providerKeyContent: r.secrets.providerKeyContent ?? ""
3186
+ },
3187
+ reset: {
3188
+ templateVars: r.reset.templateVars ?? {},
3189
+ coreBackup: r.reset.coreBackup
3190
+ }
3191
+ };
3192
+ }
2630
3193
  const vars = r.vars ?? {};
2631
3194
  const resetData = r.resetData ?? {};
2632
3195
  const repairData = r.repairData ?? {};
3196
+ const legacyTemplateVars = vars.templateVars && typeof vars.templateVars === "object" ? vars.templateVars : resetData.templateVars ?? r.templateVars ?? {};
3197
+ 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;
2633
3198
  return {
2634
- app: fillApp(vars),
3199
+ app: {
3200
+ ...fillApp(vars),
3201
+ templateVars: legacyTemplateVars,
3202
+ recommendedOpenclawTag: legacyRecommendedTag
3203
+ },
2635
3204
  install: {
2636
3205
  openclawTag: r.install?.openclawTag ?? r.openclawTag,
2637
3206
  ossFileMap: r.install?.ossFileMap ?? resetData.ossFileMap ?? r.ossFileMap ?? {}
@@ -2684,11 +3253,15 @@ function fillApp(src) {
2684
3253
  function buildCheckInput(raw, configPathOverride) {
2685
3254
  const r = raw ?? {};
2686
3255
  if (r.configPath && r.vars) {
2687
- if (configPathOverride) return {
3256
+ const out = configPathOverride ? {
2688
3257
  ...r,
2689
3258
  configPath: configPathOverride
3259
+ } : { ...r };
3260
+ if (out.vars && !out.vars.templateVars) out.vars = {
3261
+ ...out.vars,
3262
+ templateVars: out.templateVars ?? {}
2690
3263
  };
2691
- return r;
3264
+ return out;
2692
3265
  }
2693
3266
  const ctx = normalizeCtx(raw);
2694
3267
  return {
@@ -2702,19 +3275,25 @@ function buildCheckInput(raw, configPathOverride) {
2702
3275
  baseURL: ctx.app.baseURL,
2703
3276
  expectedOrigins: ctx.app.expectedOrigins,
2704
3277
  providerFilePath: PROVIDER_FILE_PATH,
2705
- secretsFilePath: SECRETS_FILE_PATH
3278
+ secretsFilePath: SECRETS_FILE_PATH,
3279
+ templateVars: ctx.app.templateVars,
3280
+ recommendedOpenclawTag: ctx.app.recommendedOpenclawTag
2706
3281
  },
2707
- templateVars: ctx.reset.templateVars
3282
+ templateVars: ctx.app.templateVars
2708
3283
  };
2709
3284
  }
2710
3285
  function buildRepairInput(raw, configPathOverride) {
2711
3286
  const r = raw ?? {};
2712
3287
  if (r.configPath && r.vars) {
2713
- if (configPathOverride) return {
3288
+ const out = configPathOverride ? {
2714
3289
  ...r,
2715
3290
  configPath: configPathOverride
3291
+ } : { ...r };
3292
+ if (out.vars && !out.vars.templateVars) out.vars = {
3293
+ ...out.vars,
3294
+ templateVars: out.templateVars ?? {}
2716
3295
  };
2717
- return r;
3296
+ return out;
2718
3297
  }
2719
3298
  const ctx = normalizeCtx(raw);
2720
3299
  return {
@@ -2728,23 +3307,29 @@ function buildRepairInput(raw, configPathOverride) {
2728
3307
  baseURL: ctx.app.baseURL,
2729
3308
  expectedOrigins: ctx.app.expectedOrigins,
2730
3309
  providerFilePath: PROVIDER_FILE_PATH,
2731
- secretsFilePath: SECRETS_FILE_PATH
3310
+ secretsFilePath: SECRETS_FILE_PATH,
3311
+ templateVars: ctx.app.templateVars,
3312
+ recommendedOpenclawTag: ctx.app.recommendedOpenclawTag
2732
3313
  },
2733
3314
  repairData: {
2734
3315
  secretsContent: ctx.secrets.secretsContent,
2735
3316
  providerKeyContent: ctx.secrets.providerKeyContent
2736
3317
  },
2737
- templateVars: ctx.reset.templateVars
3318
+ templateVars: ctx.app.templateVars
2738
3319
  };
2739
3320
  }
2740
3321
  function buildResetInput(raw, configPathOverride) {
2741
3322
  const r = raw ?? {};
2742
3323
  if (r.configPath && r.vars && r.resetData) {
2743
- if (configPathOverride) return {
3324
+ const out = configPathOverride ? {
2744
3325
  ...r,
2745
3326
  configPath: configPathOverride
3327
+ } : { ...r };
3328
+ if (out.vars && !out.vars.templateVars) out.vars = {
3329
+ ...out.vars,
3330
+ templateVars: out.resetData?.templateVars ?? {}
2746
3331
  };
2747
- return r;
3332
+ return out;
2748
3333
  }
2749
3334
  const ctx = normalizeCtx(raw);
2750
3335
  return {
@@ -2758,7 +3343,9 @@ function buildResetInput(raw, configPathOverride) {
2758
3343
  baseURL: ctx.app.baseURL,
2759
3344
  expectedOrigins: ctx.app.expectedOrigins,
2760
3345
  providerFilePath: PROVIDER_FILE_PATH,
2761
- secretsFilePath: SECRETS_FILE_PATH
3346
+ secretsFilePath: SECRETS_FILE_PATH,
3347
+ templateVars: ctx.app.templateVars,
3348
+ recommendedOpenclawTag: ctx.app.recommendedOpenclawTag
2762
3349
  },
2763
3350
  resetData: {
2764
3351
  templateVars: ctx.reset.templateVars,
@@ -2772,30 +3359,379 @@ function buildResetInput(raw, configPathOverride) {
2772
3359
  }
2773
3360
  //#endregion
2774
3361
  //#region src/doctor.ts
3362
+ /**
3363
+ * Per-rule orchestration with telemetry-friendly outcomes.
3364
+ *
3365
+ * Flow per rule (in topological order, after disabledRules / dependsOn /
3366
+ * skipWhen filtering):
3367
+ *
3368
+ * 1. validate — exception → status='error', abort
3369
+ * 2. pass → status='pass'
3370
+ * 3. fail and !opts.fix → status='failed' (with v1.message)
3371
+ * 4. fail and opts.fix →
3372
+ * a. only standard-mode rules attempt repair; ai/reset stay 'failed'
3373
+ * b. repair — exception → status='error', abort
3374
+ * c. re-validate — exception → status='error', abort
3375
+ * d. v2.pass → status='fixed' (before = v1.message)
3376
+ * e. v2.fail → status='still-broken' (before+after)
3377
+ *
3378
+ * Aborted runs return a partial DoctorReport with `aborted: true`. The
3379
+ * caller should `console.log(JSON.stringify(report))` first, THEN
3380
+ * `process.exit(1)` so sweep / telemetry callers always get the partial
3381
+ * data plus the non-zero signal.
3382
+ *
3383
+ * Config writeback: if opts.fix and at least one rule transitioned
3384
+ * pass→fixed, the in-memory ctx.config is JSON-serialized and written
3385
+ * back to opts.configPath. On abort, no write — keep the on-disk file
3386
+ * matching whatever was successful before the failure.
3387
+ *
3388
+ * Secrets file / provider-key / restart-command writes are NOT performed
3389
+ * by doctor. Those belong to the standalone `repair` subcommand which
3390
+ * sandbox_console push uses. Doctor is for diagnosis + per-rule fix
3391
+ * with structured outcomes.
3392
+ */
2775
3393
  async function runDoctor(rawCtx, opts) {
2776
- if (opts.fix && opts.rules.length > 0) {
2777
- const repairInput = buildRepairInput(rawCtx, opts.configPath);
2778
- repairInput.failedRules = opts.rules;
2779
- repairInput.repairData = {
2780
- ...repairInput.repairData ?? {},
2781
- restartCommand: ""
2782
- };
2783
- return { repair: runRepair(repairInput) };
3394
+ const results = [];
3395
+ console.error(`runDoctor: begin fix=${opts.fix} rules=[${opts.rules.join(",")}] configPath=${opts.configPath ?? "(default)"}`);
3396
+ const checkInput = buildCheckInput(rawCtx, opts.configPath);
3397
+ let configRaw;
3398
+ let config;
3399
+ try {
3400
+ configRaw = readFile(checkInput.configPath);
3401
+ } catch (e) {
3402
+ console.error(`runDoctor: read config failed at ${checkInput.configPath} message=${e.message}`);
3403
+ return finalize([{
3404
+ rule: "config_syntax_check",
3405
+ status: "error",
3406
+ message: "read config failed: " + e.message
3407
+ }], true);
3408
+ }
3409
+ try {
3410
+ config = loadJSON5().parse(configRaw);
3411
+ } catch (e) {
3412
+ console.error(`runDoctor: config JSON5 parse failed message=${e.message}`);
3413
+ results.push({
3414
+ rule: "config_syntax_check",
3415
+ status: opts.fix ? "still-broken" : "failed",
3416
+ message: "config JSON5 parse failed: " + e.message
3417
+ });
3418
+ return finalize(results, true);
3419
+ }
3420
+ const ctx = {
3421
+ config,
3422
+ configPath: checkInput.configPath,
3423
+ vars: checkInput.vars,
3424
+ providerDeps: analyzeProviderDeps(config)
3425
+ };
3426
+ const originalConfig = opts.showDiff && opts.fix ? deepClone(config) : null;
3427
+ const allRules = getAllRules();
3428
+ const onlyKeys = opts.rules.length > 0 ? new Set(opts.rules) : null;
3429
+ const skipRuleKeys = new Set(opts.skipRules ?? []);
3430
+ const disabledKeys = new Set([...checkInput.disabledRules ?? [], ...skipRuleKeys]);
3431
+ if (onlyKeys) {
3432
+ const registeredKeys = new Set(allRules.map((r) => r.meta.key));
3433
+ for (const k of opts.rules) if (!registeredKeys.has(k)) results.push({
3434
+ rule: k,
3435
+ status: "unknown"
3436
+ });
3437
+ }
3438
+ const failedKeys = /* @__PURE__ */ new Set();
3439
+ let configDirty = false;
3440
+ let aborted = false;
3441
+ const hasMiaoda = shouldCheckMiaodaRules(config);
3442
+ const plannedRules = allRules.filter((r) => !onlyKeys || onlyKeys.has(r.meta.key));
3443
+ console.error(`runDoctor: planned ${plannedRules.length} rules hasMiaoda=${hasMiaoda} providerDeps=${JSON.stringify(ctx.providerDeps)}`);
3444
+ outer: for (const rule of allRules) {
3445
+ const key = rule.meta.key;
3446
+ if (onlyKeys && !onlyKeys.has(key)) continue;
3447
+ if (disabledKeys.has(key)) {
3448
+ const reason = skipRuleKeys.has(key) ? "--skip-rule" : "disabledRules";
3449
+ console.error(`rule ${key}: skipped reason=${reason}`);
3450
+ results.push({
3451
+ rule: key,
3452
+ status: "skipped",
3453
+ message: reason
3454
+ });
3455
+ continue;
3456
+ }
3457
+ if (rule.meta.dependsOn?.some((d) => failedKeys.has(d))) {
3458
+ const blockers = rule.meta.dependsOn?.filter((d) => failedKeys.has(d)).join(",");
3459
+ console.error(`rule ${key}: skipped reason=dependsOn-failed blockers=${blockers}`);
3460
+ results.push({
3461
+ rule: key,
3462
+ status: "skipped",
3463
+ message: `dependsOn failed: ${blockers}`
3464
+ });
3465
+ continue;
3466
+ }
3467
+ if (rule.meta.skipWhen?.({
3468
+ hasMiaoda,
3469
+ deps: ctx.providerDeps
3470
+ })) {
3471
+ console.error(`rule ${key}: skipped reason=skipWhen`);
3472
+ results.push({
3473
+ rule: key,
3474
+ status: "skipped",
3475
+ message: "skipWhen"
3476
+ });
3477
+ continue;
3478
+ }
3479
+ const tValidate = Date.now();
3480
+ let v1;
3481
+ try {
3482
+ v1 = rule.validate(ctx);
3483
+ } catch (e) {
3484
+ const msg = e.message;
3485
+ console.error(`rule ${key}: validate -> error durationMs=${Date.now() - tValidate} message=${msg}`);
3486
+ results.push({
3487
+ rule: key,
3488
+ status: "error",
3489
+ message: "validate threw: " + msg
3490
+ });
3491
+ aborted = true;
3492
+ break outer;
3493
+ }
3494
+ if (v1.pass) {
3495
+ console.error(`rule ${key}: validate -> pass durationMs=${Date.now() - tValidate}`);
3496
+ results.push({
3497
+ rule: key,
3498
+ status: "pass"
3499
+ });
3500
+ continue;
3501
+ }
3502
+ if (!opts.fix) {
3503
+ console.error(`rule ${key}: validate -> failed durationMs=${Date.now() - tValidate} message=${v1.message ?? ""}`);
3504
+ results.push({
3505
+ rule: key,
3506
+ status: "failed",
3507
+ message: v1.message
3508
+ });
3509
+ failedKeys.add(key);
3510
+ continue;
3511
+ }
3512
+ if (rule.meta.repairMode !== "standard") {
3513
+ console.error(`rule ${key}: validate -> failed (no auto-repair, repairMode=${rule.meta.repairMode}) durationMs=${Date.now() - tValidate} message=${v1.message ?? ""}`);
3514
+ results.push({
3515
+ rule: key,
3516
+ status: "failed",
3517
+ message: v1.message
3518
+ });
3519
+ failedKeys.add(key);
3520
+ continue;
3521
+ }
3522
+ console.error(`rule ${key}: validate -> failed durationMs=${Date.now() - tValidate} message=${v1.message ?? ""}`);
3523
+ const tRepair = Date.now();
3524
+ try {
3525
+ rule.repair(ctx);
3526
+ } catch (e) {
3527
+ const msg = e.message;
3528
+ console.error(`rule ${key}: repair -> error durationMs=${Date.now() - tRepair} message=${msg}`);
3529
+ results.push({
3530
+ rule: key,
3531
+ status: "error",
3532
+ message: "repair threw: " + msg,
3533
+ before: v1.message
3534
+ });
3535
+ aborted = true;
3536
+ break outer;
3537
+ }
3538
+ console.error(`rule ${key}: repair -> ok durationMs=${Date.now() - tRepair}`);
3539
+ configDirty = true;
3540
+ const tRevalidate = Date.now();
3541
+ let v2;
3542
+ try {
3543
+ v2 = rule.validate(ctx);
3544
+ } catch (e) {
3545
+ const msg = e.message;
3546
+ console.error(`rule ${key}: revalidate -> error durationMs=${Date.now() - tRevalidate} message=${msg}`);
3547
+ results.push({
3548
+ rule: key,
3549
+ status: "error",
3550
+ message: "post-repair validate threw: " + msg,
3551
+ before: v1.message
3552
+ });
3553
+ aborted = true;
3554
+ break outer;
3555
+ }
3556
+ if (v2.pass) {
3557
+ console.error(`rule ${key}: revalidate -> pass (fixed) durationMs=${Date.now() - tRevalidate}`);
3558
+ results.push({
3559
+ rule: key,
3560
+ status: "fixed",
3561
+ before: v1.message
3562
+ });
3563
+ } else {
3564
+ console.error(`rule ${key}: revalidate -> still-broken durationMs=${Date.now() - tRevalidate} after=${v2.message ?? ""}`);
3565
+ results.push({
3566
+ rule: key,
3567
+ status: "still-broken",
3568
+ before: v1.message,
3569
+ after: v2.message
3570
+ });
3571
+ failedKeys.add(key);
3572
+ }
3573
+ }
3574
+ let backupPath = null;
3575
+ if (configDirty && !aborted) {
3576
+ backupPath = backupConfigSync(ctx.configPath);
3577
+ const serialized = JSON.stringify(ctx.config, null, 2);
3578
+ try {
3579
+ writeFile(ctx.configPath, serialized);
3580
+ console.error(`runDoctor: writeback ok path=${ctx.configPath} bytes=${serialized.length}`);
3581
+ } catch (e) {
3582
+ const msg = e.message;
3583
+ console.error(`runDoctor: writeback failed path=${ctx.configPath} message=${msg}`);
3584
+ results.push({
3585
+ rule: "*config-writeback*",
3586
+ status: "error",
3587
+ message: "config write failed: " + msg
3588
+ });
3589
+ aborted = true;
3590
+ }
3591
+ } else if (configDirty && aborted) console.error("runDoctor: writeback skipped (aborted, leaving on-disk config untouched)");
3592
+ else console.error("runDoctor: writeback skipped (no rule transitioned to fixed)");
3593
+ console.error(`runDoctor: end aborted=${aborted} results=${results.length}`);
3594
+ if (originalConfig !== null) {
3595
+ const d = (0, json_diff.diffString)(originalConfig, ctx.config, { color: false });
3596
+ console.error(`runDoctor: diff backupPath=${backupPath ?? "(none)"} changed=${!!d}`);
3597
+ if (d) {
3598
+ process.stdout.write(`original: ${backupPath ?? "(none)"}\n`);
3599
+ process.stdout.write(`after fixed: ${ctx.configPath}\n`);
3600
+ process.stdout.write("diff:\n");
3601
+ process.stdout.write(d);
3602
+ if (!d.endsWith("\n")) process.stdout.write("\n");
3603
+ } else process.stdout.write("(no openclaw.json changes)\n");
3604
+ }
3605
+ return finalize(results, aborted);
3606
+ }
3607
+ /** Deep-clone a JSON-shaped value. Uses structuredClone where available
3608
+ * (Node 17+); falls back to JSON round-trip otherwise. The config we
3609
+ * clone here is JSON5-parsed so it's strictly tree-shaped — no
3610
+ * references, no functions — making both paths equivalent. */
3611
+ function deepClone(v) {
3612
+ if (typeof structuredClone === "function") return structuredClone(v);
3613
+ return JSON.parse(JSON.stringify(v));
3614
+ }
3615
+ function finalize(results, aborted) {
3616
+ const summary = {
3617
+ pass: 0,
3618
+ failed: 0,
3619
+ fixed: 0,
3620
+ stillBroken: 0,
3621
+ skipped: 0,
3622
+ error: 0,
3623
+ unknown: 0
3624
+ };
3625
+ for (const r of results) switch (r.status) {
3626
+ case "pass":
3627
+ summary.pass++;
3628
+ break;
3629
+ case "failed":
3630
+ summary.failed++;
3631
+ break;
3632
+ case "fixed":
3633
+ summary.fixed++;
3634
+ break;
3635
+ case "still-broken":
3636
+ summary.stillBroken++;
3637
+ break;
3638
+ case "skipped":
3639
+ summary.skipped++;
3640
+ break;
3641
+ case "error":
3642
+ summary.error++;
3643
+ break;
3644
+ case "unknown":
3645
+ summary.unknown++;
3646
+ break;
2784
3647
  }
2785
- const check = runCheck(buildCheckInput(rawCtx, opts.configPath));
2786
- if (!opts.fix) return { failedRules: check.failedRules };
2787
- const repairInput = buildRepairInput(rawCtx, opts.configPath);
2788
- repairInput.failedRules = check.failedRules.standard;
2789
3648
  return {
2790
- check,
2791
- repair: runRepair(repairInput)
3649
+ results,
3650
+ summary,
3651
+ aborted
2792
3652
  };
2793
3653
  }
2794
3654
  //#endregion
3655
+ //#region src/innerapi/reportCliRun.ts
3656
+ /**
3657
+ * CLI-side client for studio_server's `openclaw.report_cli_run` inner
3658
+ * API — the unified telemetry sink for every CLI command.
3659
+ *
3660
+ * Best-effort: gated by `DoctorCtx.telemetry.reportCliRun` returned by
3661
+ * `get_doctor_ctx`. When the server hasn't rolled out the API yet the
3662
+ * flag is absent and the CLI never invokes this. When enabled, the CLI
3663
+ * fires-and-forgets one report per command after work is done.
3664
+ *
3665
+ * Failures here MUST NOT change exit code or otherwise affect the
3666
+ * command's data path — telemetry is auxiliary. Caller is expected to
3667
+ * `try/catch` and just `console.error` on rejection.
3668
+ *
3669
+ * Replaces the earlier `openclaw.report_doctor_result` API. The schema
3670
+ * generalises so check / repair / install-* / download-resource / reset
3671
+ * (worker) can all use the same envelope; per-command shape lives under
3672
+ * `result`.
3673
+ */
3674
+ const API_NAME = "openclaw.report_cli_run";
3675
+ async function reportCliRun(opts) {
3676
+ const client = getHttpClient();
3677
+ const input = {
3678
+ command: opts.command,
3679
+ runId: opts.runId,
3680
+ version: opts.version,
3681
+ invocation: opts.invocation,
3682
+ durationMs: opts.durationMs,
3683
+ success: opts.success
3684
+ };
3685
+ if (opts.caller) input.caller = opts.caller;
3686
+ if (opts.traceId) input.traceId = opts.traceId;
3687
+ if (opts.result !== void 0) input.result = opts.result;
3688
+ if (opts.error) input.error = opts.error;
3689
+ const body = {
3690
+ apiName: API_NAME,
3691
+ input,
3692
+ bizType: BIZ_TYPE
3693
+ };
3694
+ const headers = { "Content-Type": "application/json" };
3695
+ const ttEnv = process.env.X_TT_ENV;
3696
+ if (ttEnv) headers["x-tt-env"] = ttEnv;
3697
+ const start = Date.now();
3698
+ console.error(`reportCliRun: start command=${opts.command} success=${opts.success} version=${opts.version} invocation=${JSON.stringify(opts.invocation)} durationMs=${opts.durationMs}`);
3699
+ let response;
3700
+ try {
3701
+ response = await client.post(INNERAPI_CALL_PATH, body, { headers });
3702
+ } catch (e) {
3703
+ if (e instanceof _lark_apaas_http_client.HttpError && e.response) {
3704
+ const status = e.response.status;
3705
+ const logId = e.response.headers.get("x-tt-logid") ?? "";
3706
+ throw new Error(`reportCliRun HTTP ${status} ${e.response.statusText} (logID: ${logId})`);
3707
+ }
3708
+ const msg = e instanceof Error ? e.message : String(e);
3709
+ throw new Error(`reportCliRun network error: ${msg}`);
3710
+ }
3711
+ const logId = response.headers.get("x-tt-logid") ?? "";
3712
+ if (!response.ok) {
3713
+ let preview = "";
3714
+ try {
3715
+ preview = (await response.text()).slice(0, 500);
3716
+ } catch {}
3717
+ throw new Error(`reportCliRun HTTP ${response.status} ${response.statusText} (logID: ${logId})${preview ? ` body=${preview}` : ""}`);
3718
+ }
3719
+ let envelope;
3720
+ try {
3721
+ envelope = await response.json();
3722
+ } catch {
3723
+ console.error(`reportCliRun: ok logId=${logId} httpDurationMs=${Date.now() - start} (body unparseable, treated as success)`);
3724
+ return;
3725
+ }
3726
+ 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 ?? ""}`);
3727
+ if (envelope.data && envelope.data.success === false) throw new Error(`reportCliRun business error (logID: ${logId}): ${envelope.error_msg ?? JSON.stringify(envelope.data)}`);
3728
+ console.error(`reportCliRun: ok logId=${logId} httpDurationMs=${Date.now() - start}`);
3729
+ }
3730
+ //#endregion
2795
3731
  //#region src/help.ts
2796
3732
  const BIN = "mclaw-diagnose";
2797
3733
  function versionBanner() {
2798
- return `v0.1.3`;
3734
+ return `v0.1.4-alpha.1`;
2799
3735
  }
2800
3736
  const COMMANDS = [
2801
3737
  {
@@ -2803,46 +3739,85 @@ const COMMANDS = [
2803
3739
  hidden: false,
2804
3740
  summary: "Diagnose openclaw config; apply repairs with --fix",
2805
3741
  help: `USAGE
2806
- ${BIN} doctor [--fix] [--rule=<key>]...
3742
+ ${BIN} doctor [--fix] [--show-diff] [--rule=<key>]... [--skip-rule=<key>]...
3743
+ [--caller=<n>] [--trace-id=<id>]
2807
3744
 
2808
3745
  DESCRIPTION
2809
- Fetches DoctorCtx via innerapi, then runs one of three modes depending
2810
- on the flags. Output is a single JSON object on stdout.
3746
+ Fetches DoctorCtx via innerapi, then orchestrates each rule:
2811
3747
 
2812
- MODES
2813
- (no flags) Check-only. Runs the rule engine against the
2814
- sandbox's current openclaw config and returns
2815
- { failedRules: { standard, ai, reset } }
2816
- No files are mutated. Use this when you just
2817
- want to know what's wrong.
3748
+ validate (always)
3749
+ pass → status='pass'
3750
+ fail and !--fix → status='failed'
3751
+ fail and --fix
3752
+ repair re-validate
3753
+ pass → status='fixed'
3754
+ still fail → status='still-broken'
2818
3755
 
2819
- --fix Check + repair-all. First runs the rule engine,
2820
- then repairs every failing standard-mode rule.
2821
- Returns
2822
- { check: {...}, repair: {...} }
2823
- Use this as the default "fix everything" action.
3756
+ Output is a single JSON object on stdout:
2824
3757
 
2825
- --fix --rule=<key>... Targeted repair. Skips the check pass entirely
2826
- and runs repair against the listed rule keys
2827
- only. Unknown keys are silently ignored.
2828
- Returns { repair: {...} } with only those
2829
- rules' outcomes. Use this when you already
2830
- know which rules need fixing.
3758
+ {
3759
+ "results": [{ "rule": "...", "status": "...", "message"?: "...", ...}],
3760
+ "summary": { "pass": N, "failed": N, "fixed": N, "stillBroken": N,
3761
+ "skipped": N, "error": N, "unknown": N },
3762
+ "aborted": false
3763
+ }
3764
+
3765
+ STATUSES
3766
+ pass Rule's validate passed; no repair attempted.
3767
+ failed Rule's validate failed; --fix was not given.
3768
+ fixed Rule's validate failed; repair ran; re-validate passed.
3769
+ still-broken Rule's validate failed; repair ran; re-validate still failed.
3770
+ skipped Rule excluded by disabledRules / --skip-rule / dependsOn /
3771
+ skipWhen.
3772
+ error validate or repair threw an exception. ABORTS the run —
3773
+ exit code becomes 1; partial 'results' still written to stdout.
3774
+ unknown --rule=<k> with k not registered. Reported, not aborting.
2831
3775
 
2832
3776
  OPTIONS
2833
- --fix Enable repair. See MODES above.
2834
- --rule=<key> Repair only this rule key. Repeatable. Only
2835
- meaningful together with --fix.
3777
+ --fix Enable repair. Without it, validate runs but failures
3778
+ are reported as 'failed' instead of being repaired.
3779
+ --show-diff With --fix: replace the JSON report on stdout with
3780
+ a plain-text section showing
3781
+ original: <backup path>
3782
+ after fixed: <openclaw.json path>
3783
+ diff:
3784
+ <unified-diff body, same format as \`json-diff\`>
3785
+ Interactive flag — sandbox_console's push path never
3786
+ sets it, so the JSON contract is preserved for that
3787
+ caller. No-op without --fix (no mutation, no diff).
3788
+ --rule=<key> Whitelist: restrict the run to this rule key (repeatable).
3789
+ Without --rule, every registered rule runs. With --rule,
3790
+ the planner narrows the innerapi vars fetch to just
3791
+ that rule's needs (see lazy-vars).
3792
+ --skip-rule=<key> Blacklist: skip the rule entirely — neither validate
3793
+ nor repair runs (repeatable). Outcome is 'skipped' with
3794
+ message='--skip-rule'. Stacks with the server's
3795
+ disabledRules. Skipped rules also subtract their vars
3796
+ from the planner's union.
3797
+ --caller=<name> Optional metadata; passed through verbatim to innerapi
3798
+ (fetchCtx + report_cli_run body.input.caller).
3799
+ E.g. "sweep_job", "user_yxy". Server uses for audit /
3800
+ correlation; CLI doesn't act on it.
3801
+ --trace-id=<id> Optional log-correlation id; passed through verbatim
3802
+ to innerapi body.input.traceId. Typically the upstream
3803
+ caller's logID (e.g. sandbox_console's request id).
2836
3804
 
2837
3805
  EXAMPLES
2838
- ${BIN} doctor # check only
2839
- ${BIN} doctor --fix # check then repair all
2840
- ${BIN} doctor --fix --rule=gateway # repair 'gateway' only
2841
- ${BIN} doctor --fix --rule=gateway --rule=jwt_token # repair multiple
3806
+ ${BIN} doctor # diagnose, no fixes
3807
+ ${BIN} doctor --fix # diagnose + fix everything
3808
+ ${BIN} doctor --fix --rule=gateway # fix only 'gateway'
3809
+ ${BIN} doctor --fix --skip-rule=feishu_channel # fix everything except feishu
3810
+ ${BIN} doctor --fix --skip-rule=feishu_channel --skip-rule=feishu_default_account
3811
+ # skip multiple rules
3812
+ ${BIN} doctor --fix --show-diff # see exactly what changed
3813
+ # (plain-text diff after
3814
+ # the JSON report)
2842
3815
 
2843
3816
  EXIT CODES
2844
- 0 success
2845
- 1 generic error
3817
+ 0 completed (results may include 'failed' / 'still-broken' — those
3818
+ are normal data outcomes, not run failures)
3819
+ 1 aborted because a rule's validate or repair threw. Partial
3820
+ results still on stdout.
2846
3821
  77 innerapi authentication failed (sandbox JWT expired/invalid)
2847
3822
  `
2848
3823
  },
@@ -3031,6 +4006,62 @@ function formatCommandHelp(name) {
3031
4006
  return cmd.help;
3032
4007
  }
3033
4008
  //#endregion
4009
+ //#region src/rule-engine/planner.ts
4010
+ /**
4011
+ * Compute the union of `Vars` field names that the **enabled** rules will
4012
+ * actually read. Sources from each rule's `@Rule({ usesVars: [...] })`
4013
+ * declaration; drift is caught at test time by `strict-vars.test.ts`.
4014
+ *
4015
+ * Inactive rules (skipped via skipWhen at runtime) are STILL counted —
4016
+ * skipWhen depends on parsed-config state we don't have at planning time.
4017
+ * Slight over-fetch in those cases, but no correctness issue.
4018
+ *
4019
+ * Result is stable-sorted for deterministic logging / test snapshots.
4020
+ */
4021
+ function planVarsFields(opts = {}) {
4022
+ const disabled = new Set(opts.disabled ?? []);
4023
+ const only = opts.onlyRules && opts.onlyRules.length > 0 ? new Set(opts.onlyRules) : null;
4024
+ const fields = /* @__PURE__ */ new Set();
4025
+ for (const rule of getAllRules()) {
4026
+ const key = rule.meta.key;
4027
+ if (disabled.has(key)) continue;
4028
+ if (only && !only.has(key)) continue;
4029
+ for (const f of rule.meta.usesVars ?? []) fields.add(f);
4030
+ }
4031
+ return [...fields].sort();
4032
+ }
4033
+ /**
4034
+ * Plan a `populate` selector for a given subcommand.
4035
+ *
4036
+ * Per-command group needs:
4037
+ *
4038
+ * doctor / check app (rule-driven)
4039
+ * repair app + secrets (writes secretsContent / providerKeyContent)
4040
+ * reset app + secrets + install + reset (the works)
4041
+ * install-* install only
4042
+ *
4043
+ * Empty result (`{}`) means "no group needed" — the CLI can skip the
4044
+ * `fetchCtxViaInnerApi` call entirely and run with a synthetic empty ctx.
4045
+ * Happens e.g. when the user pinned `--rule=<key>` to a vars-free rule on
4046
+ * `doctor`.
4047
+ */
4048
+ function planCtxPopulate(opts) {
4049
+ if (opts.command === "install") return { install: true };
4050
+ const populate = {};
4051
+ const appFields = planVarsFields({
4052
+ disabled: opts.disabled,
4053
+ onlyRules: opts.onlyRules
4054
+ });
4055
+ if (appFields.length > 0) populate.app = appFields;
4056
+ if (opts.command === "repair") populate.secrets = true;
4057
+ else if (opts.command === "reset") {
4058
+ populate.secrets = true;
4059
+ populate.install = true;
4060
+ populate.reset = true;
4061
+ }
4062
+ return populate;
4063
+ }
4064
+ //#endregion
3034
4065
  //#region src/index.ts
3035
4066
  const args = node_process.default.argv.slice(2);
3036
4067
  const mode = args.find((a) => !a.startsWith("-"));
@@ -3064,8 +4095,39 @@ function getMultiFlag(args, name) {
3064
4095
  const prefix = `--${name}=`;
3065
4096
  return args.filter((a) => a.startsWith(prefix)).map((a) => a.slice(prefix.length));
3066
4097
  }
4098
+ /**
4099
+ * Per-case telemetry helper: fires `openclaw.report_cli_run` once with
4100
+ * the command's outcome. Always called — the previous server-flag gate
4101
+ * was removed (server controls receipt by routing the apiName).
4102
+ *
4103
+ * Best-effort: a thrown reportCliRun is logged to cli.log and swallowed.
4104
+ * Never alters the data path's exit code — the user-visible work has
4105
+ * already finished by the time we report.
4106
+ *
4107
+ * `raw` is kept in the signature for backward symmetry with the doctor
4108
+ * case but is no longer consulted.
4109
+ */
4110
+ async function reportRun(command, rc, _raw, invocation, durationMs, outcome) {
4111
+ console.error(`${command}: telemetry calling report_cli_run`);
4112
+ try {
4113
+ await reportCliRun({
4114
+ command,
4115
+ runId: rc.runId,
4116
+ version: getVersion(),
4117
+ invocation,
4118
+ durationMs,
4119
+ caller: rc.caller,
4120
+ traceId: rc.traceId,
4121
+ success: outcome.success,
4122
+ result: outcome.result,
4123
+ error: outcome.error ? { message: outcome.error.message } : void 0
4124
+ });
4125
+ } catch (e) {
4126
+ console.error(`[telemetry] reportCliRun failed: ${e.message}`);
4127
+ }
4128
+ }
3067
4129
  async function main() {
3068
- installStderrMirror();
4130
+ installStderrMirror({ stderrEnabled: args.includes("-x") });
3069
4131
  const helpFlags = parseHelpFlags(args);
3070
4132
  if (mode && helpFlags.help) {
3071
4133
  const body = formatCommandHelp(mode);
@@ -3085,22 +4147,79 @@ async function main() {
3085
4147
  node_process.default.stderr.write(formatTopLevelHelp(helpFlags.expert));
3086
4148
  node_process.default.exit(1);
3087
4149
  }
4150
+ const caller = getFlag(args, "caller");
4151
+ const traceId = getFlag(args, "trace-id");
4152
+ const rc = setRunContext({
4153
+ caller,
4154
+ traceId
4155
+ });
4156
+ const t0 = Date.now();
4157
+ console.error(`${mode}: begin argv=[${args.join(" ")}] version=${getVersion()} traceId=${traceId ?? "-"} caller=${caller ?? "-"} runIdGenerated=${rc.generated}`);
3088
4158
  switch (mode) {
3089
- case "check":
4159
+ case "check": {
4160
+ const raw = parseCtxFlag(args) ?? await fetchCtxViaInnerApi({
4161
+ populate: planCtxPopulate({ command: "check" }),
4162
+ caller,
4163
+ traceId
4164
+ });
4165
+ const { legacy, report } = runCheckWithReport(buildCheckInput(raw));
4166
+ console.log(JSON.stringify(legacy));
4167
+ await reportRun("check", rc, raw, args.join(" "), Date.now() - t0, {
4168
+ success: !report.aborted,
4169
+ result: report,
4170
+ error: report.aborted ? /* @__PURE__ */ new Error("check run aborted") : void 0
4171
+ });
4172
+ break;
4173
+ }
3090
4174
  case "repair": {
3091
- const raw = parseCtxFlag(args) ?? await fetchCtxViaInnerApi();
3092
- if (mode === "check") console.log(JSON.stringify(runCheck(buildCheckInput(raw))));
3093
- else console.log(JSON.stringify(runRepair(buildRepairInput(raw))));
4175
+ const raw = parseCtxFlag(args) ?? await fetchCtxViaInnerApi({
4176
+ populate: planCtxPopulate({ command: "repair" }),
4177
+ caller,
4178
+ traceId
4179
+ });
4180
+ const { legacy, report } = runRepairWithReport(buildRepairInput(raw));
4181
+ console.log(JSON.stringify(legacy));
4182
+ await reportRun("repair", rc, raw, args.join(" "), Date.now() - t0, {
4183
+ success: legacy.success,
4184
+ result: report,
4185
+ error: legacy.success ? void 0 : new Error(legacy.error ?? "repair failed")
4186
+ });
3094
4187
  break;
3095
4188
  }
3096
4189
  case "doctor": {
3097
4190
  const fix = args.includes("--fix");
3098
4191
  const rules = getMultiFlag(args, "rule");
3099
- const result = await runDoctor(await fetchCtxViaInnerApi(), {
4192
+ const skipRules = getMultiFlag(args, "skip-rule");
4193
+ const showDiff = args.includes("--show-diff");
4194
+ const populate = planCtxPopulate({
4195
+ command: "doctor",
4196
+ onlyRules: fix && rules.length > 0 ? rules : void 0,
4197
+ disabled: skipRules
4198
+ });
4199
+ console.error(`doctor: populate=${JSON.stringify(populate)} fix=${fix} rules=[${rules.join(",")}] skipRules=[${skipRules.join(",")}]`);
4200
+ let raw;
4201
+ if (Object.keys(populate).length === 0) {
4202
+ console.error("doctor: skipping fetchCtx (empty populate)");
4203
+ raw = {};
4204
+ } else raw = await fetchCtxViaInnerApi({
4205
+ populate,
4206
+ caller,
4207
+ traceId
4208
+ });
4209
+ const result = await runDoctor(raw, {
3100
4210
  fix,
3101
- rules
4211
+ rules,
4212
+ skipRules,
4213
+ showDiff
4214
+ });
4215
+ if (!(showDiff && fix)) console.log(JSON.stringify(result));
4216
+ await reportRun("doctor", rc, raw, args.join(" "), Date.now() - t0, {
4217
+ success: !result.aborted,
4218
+ result,
4219
+ error: result.aborted ? /* @__PURE__ */ new Error("doctor run aborted") : void 0
3102
4220
  });
3103
- console.log(JSON.stringify(result));
4221
+ console.error(`doctor: end aborted=${result.aborted} totalMs=${Date.now() - t0} summary=${JSON.stringify(result.summary)}`);
4222
+ if (result.aborted) node_process.default.exit(1);
3104
4223
  break;
3105
4224
  }
3106
4225
  case "reset":
@@ -3109,7 +4228,11 @@ async function main() {
3109
4228
  let ctxBase64;
3110
4229
  if (ctxArg) ctxBase64 = ctxArg.slice(6);
3111
4230
  else {
3112
- const fetched = await fetchCtxViaInnerApi();
4231
+ const fetched = await fetchCtxViaInnerApi({
4232
+ populate: planCtxPopulate({ command: "reset" }),
4233
+ caller,
4234
+ traceId
4235
+ });
3113
4236
  ctxBase64 = Buffer.from(JSON.stringify(fetched), "utf-8").toString("base64");
3114
4237
  }
3115
4238
  console.log(JSON.stringify(startAsyncReset(ctxBase64)));
@@ -3120,7 +4243,22 @@ async function main() {
3120
4243
  node_process.default.exit(1);
3121
4244
  }
3122
4245
  const resultFile = resetResultFile(taskId);
3123
- await runReset(buildResetInput(parseCtxFlag(args) ?? await fetchCtxViaInnerApi()), taskId, resultFile);
4246
+ const raw = parseCtxFlag(args) ?? await fetchCtxViaInnerApi({
4247
+ populate: planCtxPopulate({ command: "reset" }),
4248
+ caller,
4249
+ traceId
4250
+ });
4251
+ const outcome = await runReset(buildResetInput(raw), taskId, resultFile);
4252
+ await reportRun("reset", rc, raw, args.join(" "), Date.now() - t0, {
4253
+ success: outcome.success,
4254
+ result: {
4255
+ taskId,
4256
+ steps: outcome.steps,
4257
+ failedStep: outcome.failedStep
4258
+ },
4259
+ error: outcome.success ? void 0 : new Error(outcome.error ?? "reset failed")
4260
+ });
4261
+ if (!outcome.success) node_process.default.exit(1);
3124
4262
  } else {
3125
4263
  console.error("Usage: reset --async [--ctx=<base64>] | reset --worker --task-id=<id> [--ctx=<base64>]");
3126
4264
  node_process.default.exit(1);
@@ -3143,12 +4281,34 @@ async function main() {
3143
4281
  }
3144
4282
  const ossFileMapFlag = getFlag(args, "oss_file_map");
3145
4283
  let installOssFileMap;
3146
- if (!ossFileMapFlag) installOssFileMap = normalizeCtx(parseCtxFlag(args) ?? await fetchCtxViaInnerApi()).install.ossFileMap;
3147
- await installOpenclaw(tag, resolveOssFileMap({
4284
+ let rawForTelemetry;
4285
+ if (!ossFileMapFlag) {
4286
+ rawForTelemetry = parseCtxFlag(args) ?? await fetchCtxViaInnerApi({
4287
+ populate: planCtxPopulate({ command: "install" }),
4288
+ caller,
4289
+ traceId
4290
+ });
4291
+ installOssFileMap = normalizeCtx(rawForTelemetry).install.ossFileMap;
4292
+ }
4293
+ const ossFileMap = resolveOssFileMap({
3148
4294
  ossFileMapFlag,
3149
4295
  installOssFileMap
3150
- }));
3151
- console.log(JSON.stringify({ ok: true }));
4296
+ });
4297
+ let success = true;
4298
+ let error;
4299
+ try {
4300
+ await installOpenclaw(tag, ossFileMap);
4301
+ } catch (e) {
4302
+ success = false;
4303
+ error = e;
4304
+ }
4305
+ if (success) console.log(JSON.stringify({ ok: true }));
4306
+ await reportRun("install-openclaw", rc, rawForTelemetry, args.join(" "), Date.now() - t0, {
4307
+ success,
4308
+ result: { tag },
4309
+ error
4310
+ });
4311
+ if (error) throw error;
3152
4312
  break;
3153
4313
  }
3154
4314
  case "install-extension": {
@@ -3164,18 +4324,45 @@ async function main() {
3164
4324
  const skipConfigUpdate = args.includes("--skip-config-update");
3165
4325
  const ossFileMapFlag = getFlag(args, "oss_file_map");
3166
4326
  let installOssFileMap;
3167
- if (!ossFileMapFlag) installOssFileMap = normalizeCtx(parseCtxFlag(args) ?? await fetchCtxViaInnerApi()).install.ossFileMap;
3168
- await installExtension(tag, resolveOssFileMap({
4327
+ let rawForTelemetry;
4328
+ if (!ossFileMapFlag) {
4329
+ rawForTelemetry = parseCtxFlag(args) ?? await fetchCtxViaInnerApi({
4330
+ populate: planCtxPopulate({ command: "install" }),
4331
+ caller,
4332
+ traceId
4333
+ });
4334
+ installOssFileMap = normalizeCtx(rawForTelemetry).install.ossFileMap;
4335
+ }
4336
+ const ossFileMap = resolveOssFileMap({
3169
4337
  ossFileMapFlag,
3170
4338
  installOssFileMap
3171
- }), {
3172
- all,
3173
- names: names.length > 0 ? names : void 0,
3174
- homeBase,
3175
- configPath,
3176
- skipConfigUpdate
3177
4339
  });
3178
- console.log(JSON.stringify({ ok: true }));
4340
+ let success = true;
4341
+ let error;
4342
+ try {
4343
+ await installExtension(tag, ossFileMap, {
4344
+ all,
4345
+ names: names.length > 0 ? names : void 0,
4346
+ homeBase,
4347
+ configPath,
4348
+ skipConfigUpdate
4349
+ });
4350
+ } catch (e) {
4351
+ success = false;
4352
+ error = e;
4353
+ }
4354
+ if (success) console.log(JSON.stringify({ ok: true }));
4355
+ await reportRun("install-extension", rc, rawForTelemetry, args.join(" "), Date.now() - t0, {
4356
+ success,
4357
+ result: {
4358
+ tag,
4359
+ all,
4360
+ names,
4361
+ skipConfigUpdate
4362
+ },
4363
+ error
4364
+ });
4365
+ if (error) throw error;
3179
4366
  break;
3180
4367
  }
3181
4368
  case "download-resource": {
@@ -3193,16 +4380,43 @@ async function main() {
3193
4380
  }
3194
4381
  const ossFileMapFlag = getFlag(args, "oss_file_map");
3195
4382
  let installOssFileMap;
3196
- if (!ossFileMapFlag) installOssFileMap = normalizeCtx(parseCtxFlag(args) ?? await fetchCtxViaInnerApi()).install.ossFileMap;
3197
- await downloadResource(tag, resolveOssFileMap({
4383
+ let rawForTelemetry;
4384
+ if (!ossFileMapFlag) {
4385
+ rawForTelemetry = parseCtxFlag(args) ?? await fetchCtxViaInnerApi({
4386
+ populate: planCtxPopulate({ command: "install" }),
4387
+ caller,
4388
+ traceId
4389
+ });
4390
+ installOssFileMap = normalizeCtx(rawForTelemetry).install.ossFileMap;
4391
+ }
4392
+ const ossFileMap = resolveOssFileMap({
3198
4393
  ossFileMapFlag,
3199
4394
  installOssFileMap
3200
- }), {
3201
- role,
3202
- name,
3203
- dir
3204
4395
  });
3205
- console.log(JSON.stringify({ ok: true }));
4396
+ let success = true;
4397
+ let error;
4398
+ try {
4399
+ await downloadResource(tag, ossFileMap, {
4400
+ role,
4401
+ name,
4402
+ dir
4403
+ });
4404
+ } catch (e) {
4405
+ success = false;
4406
+ error = e;
4407
+ }
4408
+ if (success) console.log(JSON.stringify({ ok: true }));
4409
+ await reportRun("download-resource", rc, rawForTelemetry, args.join(" "), Date.now() - t0, {
4410
+ success,
4411
+ result: {
4412
+ tag,
4413
+ role,
4414
+ name,
4415
+ dir
4416
+ },
4417
+ error
4418
+ });
4419
+ if (error) throw error;
3206
4420
  break;
3207
4421
  }
3208
4422
  default:
@@ -3210,10 +4424,12 @@ async function main() {
3210
4424
  node_process.default.stderr.write(formatTopLevelHelp(helpFlags.expert));
3211
4425
  node_process.default.exit(1);
3212
4426
  }
4427
+ console.error(`${mode}: end totalMs=${Date.now() - t0}`);
3213
4428
  }
3214
4429
  main().catch((err) => {
3215
4430
  const msg = err instanceof Error ? err.message : String(err);
3216
- console.error(`Error: ${msg}`);
4431
+ console.error(`${mode ?? "<no-mode>"}: error message=${msg}`);
4432
+ node_process.default.stderr.write(`Error: ${msg}\n`);
3217
4433
  node_process.default.exit(1);
3218
4434
  });
3219
4435
  //#endregion