@lark-apaas/openclaw-scripts-diagnose-cli 0.1.1-alpha.3 → 0.1.1-alpha.31

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -31,6 +31,14 @@ let node_path = require("node:path");
31
31
  node_path = __toESM(node_path);
32
32
  let node_child_process = require("node:child_process");
33
33
  let node_crypto = require("node:crypto");
34
+ node_crypto = __toESM(node_crypto);
35
+ let node_os = require("node:os");
36
+ node_os = __toESM(node_os);
37
+ let node_stream = require("node:stream");
38
+ let node_stream_promises = require("node:stream/promises");
39
+ let node_assert = require("node:assert");
40
+ node_assert = __toESM(node_assert);
41
+ let _lark_apaas_http_client = require("@lark-apaas/http-client");
34
42
  //#region src/rule-engine/base.ts
35
43
  /** Abstract base class for all diagnose rules */
36
44
  var DiagnoseRule = class {
@@ -81,6 +89,17 @@ function topoSort(rules) {
81
89
  //#endregion
82
90
  //#region src/utils.ts
83
91
  /**
92
+ * Canonical provider-ref for the feishu app secret. Both
93
+ * `feishu_default_account` (multi-agent path) and `feishu_channel`
94
+ * (single-agent path) use this as the source-of-truth `appSecret`
95
+ * value when repairing.
96
+ */
97
+ const DEFAULT_FEISHU_APP_SECRET = {
98
+ source: "file",
99
+ provider: "miaoda-secret-provider",
100
+ id: "/channels_feishu_app_secret"
101
+ };
102
+ /**
84
103
  * Navigate nested object by keys, returning the value if it's a non-array object,
85
104
  * or undefined otherwise.
86
105
  */
@@ -127,6 +146,14 @@ function isValidJWT(token) {
127
146
  return false;
128
147
  }
129
148
  }
149
+ /**
150
+ * Return `val` as a plain-object record (non-null, non-array object), or
151
+ * `undefined` otherwise. Cheaper than `getNestedMap` when the value is already
152
+ * at hand.
153
+ */
154
+ function asRecord(val) {
155
+ return val != null && typeof val === "object" && !Array.isArray(val) ? val : void 0;
156
+ }
130
157
  /** Set a deeply nested value, creating intermediate objects as needed. */
131
158
  function setNestedValue(obj, keys, value) {
132
159
  let current = obj;
@@ -137,6 +164,25 @@ function setNestedValue(obj, keys, value) {
137
164
  }
138
165
  current[keys[keys.length - 1]] = value;
139
166
  }
167
+ /**
168
+ * Locate the "main" agent in `agents.list`. Preference order:
169
+ * 1. Explicit `default: true` entry.
170
+ * 2. Entry with `id === 'main'` (project naming convention).
171
+ * 3. First entry in the list (positional fallback).
172
+ * Returns `undefined` when `agents.list` is missing or empty.
173
+ */
174
+ function findMainAgent(config) {
175
+ const agents = getNestedMap(config, "agents");
176
+ if (!agents) return void 0;
177
+ const list = agents.list;
178
+ if (!Array.isArray(list) || list.length === 0) return void 0;
179
+ const isObj = (a) => a != null && typeof a === "object" && !Array.isArray(a);
180
+ const explicit = list.find((a) => isObj(a) && a.default === true);
181
+ if (explicit) return explicit;
182
+ const namedMain = list.find((a) => isObj(a) && a.id === "main");
183
+ if (namedMain) return namedMain;
184
+ return isObj(list[0]) ? list[0] : void 0;
185
+ }
140
186
  /** Analyze which miaoda providers the config references. */
141
187
  function analyzeProviderDeps(config) {
142
188
  const deps = {
@@ -192,14 +238,14 @@ function fileExists(filePath) {
192
238
  return node_fs.default.existsSync(filePath);
193
239
  }
194
240
  /** Execute a shell command, return stdout. Throws on failure. */
195
- function shell(cmd, timeoutMs = 1e4) {
241
+ function shell(cmd, timeoutMs = 6e4) {
196
242
  return (0, node_child_process.execSync)(cmd, {
197
243
  encoding: "utf-8",
198
244
  timeout: timeoutMs
199
245
  }).trim();
200
246
  }
201
247
  //#endregion
202
- //#region \0@oxc-project+runtime@0.121.0/helpers/decorate.js
248
+ //#region \0@oxc-project+runtime@0.115.0/helpers/decorate.js
203
249
  function __decorate(decorators, target, key, desc) {
204
250
  var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
205
251
  if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
@@ -245,11 +291,15 @@ function findBackupFiles(configPath) {
245
291
  }
246
292
  /**
247
293
  * Among backup files, find the one with the highest numeric suffix.
248
- * `.bak` (no number) is treated as 0, `.bak1` as 1, `.bak2` as 2, etc.
294
+ * Supports all three naming styles used by the current backup code and its
295
+ * older variants:
296
+ * `.bak` → n = 0 (legacy single-slot backup)
297
+ * `.bakN` → n = N (older style, dot-less)
298
+ * `.bak.N` → n = N (current style written by reset Step 1)
249
299
  */
250
300
  function findHighestBackup(backupFiles) {
251
301
  if (backupFiles.length === 0) return null;
252
- const bakRegex = /\.bak(\d*)$/;
302
+ const bakRegex = /\.bak\.?(\d*)$/;
253
303
  let best = null;
254
304
  for (const f of backupFiles) {
255
305
  const match = bakRegex.exec(f);
@@ -264,7 +314,7 @@ function findHighestBackup(backupFiles) {
264
314
  }
265
315
  let ConfigFileBackupRule = class ConfigFileBackupRule extends DiagnoseRule {
266
316
  validate(ctx) {
267
- const configPath = ctx.config.__configPath;
317
+ const { configPath } = ctx;
268
318
  if (!configPath) return {
269
319
  pass: false,
270
320
  message: "configPath not provided"
@@ -277,7 +327,7 @@ let ConfigFileBackupRule = class ConfigFileBackupRule extends DiagnoseRule {
277
327
  return { pass: true };
278
328
  }
279
329
  repair(ctx) {
280
- const configPath = ctx.config.__configPath;
330
+ const { configPath } = ctx;
281
331
  if (!configPath) return;
282
332
  const best = findHighestBackup(findBackupFiles(configPath));
283
333
  if (!best) return;
@@ -306,7 +356,7 @@ function hasBackupFiles(configPath) {
306
356
  }
307
357
  let ConfigFileMissingRule = class ConfigFileMissingRule extends DiagnoseRule {
308
358
  validate(ctx) {
309
- const configPath = ctx.config.__configPath;
359
+ const { configPath } = ctx;
310
360
  if (!configPath) return {
311
361
  pass: false,
312
362
  message: "configPath not provided"
@@ -328,7 +378,7 @@ ConfigFileMissingRule = __decorate([Rule({
328
378
  //#region src/rules/config-syntax.ts
329
379
  let ConfigSyntaxRule = class ConfigSyntaxRule extends DiagnoseRule {
330
380
  validate(ctx) {
331
- const configPath = ctx.config.__configPath;
381
+ const { configPath } = ctx;
332
382
  if (!fileExists(configPath)) return { pass: true };
333
383
  try {
334
384
  loadJSON5().parse(readFile(configPath));
@@ -347,6 +397,74 @@ ConfigSyntaxRule = __decorate([Rule({
347
397
  repairMode: "ai"
348
398
  })], ConfigSyntaxRule);
349
399
  //#endregion
400
+ //#region src/rules/template-vars-unreplaced.ts
401
+ /**
402
+ * Placeholder format used by miaoda-openclaw-template and Go-side templateVars,
403
+ * e.g. `$$__FEISHU_APP_ID__`. Double underscores on both sides act as a natural
404
+ * boundary so split-join replacement can't accidentally overlap between keys.
405
+ */
406
+ const PLACEHOLDER_RE = /\$\$__[A-Z0-9_]+__/g;
407
+ let TemplateVarsUnreplacedRule = class TemplateVarsUnreplacedRule extends DiagnoseRule {
408
+ validate(ctx) {
409
+ const found = /* @__PURE__ */ new Set();
410
+ collectPlaceholders(ctx.config, found);
411
+ if (found.size === 0) return { pass: true };
412
+ return {
413
+ pass: false,
414
+ message: "存在未替换的模板占位符: " + [...found].sort().join(", ")
415
+ };
416
+ }
417
+ repair(ctx) {
418
+ const map = ctx.templateVars;
419
+ if (!map || Object.keys(map).length === 0) return;
420
+ replaceInPlace(ctx.config, Object.entries(map));
421
+ }
422
+ };
423
+ TemplateVarsUnreplacedRule = __decorate([Rule({
424
+ key: "template_vars_unreplaced",
425
+ dependsOn: ["config_syntax_check"],
426
+ repairMode: "standard"
427
+ })], TemplateVarsUnreplacedRule);
428
+ function collectPlaceholders(value, found) {
429
+ if (typeof value === "string") {
430
+ const matches = value.match(PLACEHOLDER_RE);
431
+ if (matches) for (const m of matches) found.add(m);
432
+ return;
433
+ }
434
+ if (Array.isArray(value)) {
435
+ for (const v of value) collectPlaceholders(v, found);
436
+ return;
437
+ }
438
+ if (value && typeof value === "object") for (const v of Object.values(value)) collectPlaceholders(v, found);
439
+ }
440
+ function replaceInPlace(value, entries) {
441
+ if (Array.isArray(value)) {
442
+ for (let i = 0; i < value.length; i++) {
443
+ const el = value[i];
444
+ if (typeof el === "string") value[i] = applyVars(el, entries);
445
+ else replaceInPlace(el, entries);
446
+ }
447
+ return;
448
+ }
449
+ if (value && typeof value === "object") {
450
+ const obj = value;
451
+ for (const key of Object.keys(obj)) {
452
+ const v = obj[key];
453
+ if (typeof v === "string") obj[key] = applyVars(v, entries);
454
+ else replaceInPlace(v, entries);
455
+ }
456
+ }
457
+ }
458
+ /** Split-join replacement — matches the algorithm in reset.ts:120 and avoids regex-escaping `$$`. */
459
+ function applyVars(str, entries) {
460
+ let out = str;
461
+ for (const [placeholder, value] of entries) {
462
+ if (!value) continue;
463
+ if (out.includes(placeholder)) out = out.split(placeholder).join(value);
464
+ }
465
+ return out;
466
+ }
467
+ //#endregion
350
468
  //#region src/rules/model-provider.ts
351
469
  var _ModelProviderRule;
352
470
  let ModelProviderRule = class ModelProviderRule extends DiagnoseRule {
@@ -530,16 +648,11 @@ SecretProviderRule = _SecretProviderRule = __decorate([Rule({
530
648
  })], SecretProviderRule);
531
649
  //#endregion
532
650
  //#region src/rules/feishu-channel.ts
533
- var _FeishuChannelRule;
651
+ /**
652
+ * Owns `channels.feishu.enabled` + single-agent top-level appId/appSecret.
653
+ * Multi-agent shape (`accounts` present) belongs to `feishu_default_account`.
654
+ */
534
655
  let FeishuChannelRule = class FeishuChannelRule extends DiagnoseRule {
535
- static {
536
- _FeishuChannelRule = this;
537
- }
538
- static DEFAULT_APP_SECRET = {
539
- source: "file",
540
- provider: "miaoda-secret-provider",
541
- id: "/channels_feishu_app_secret"
542
- };
543
656
  validate(ctx) {
544
657
  const feishu = getNestedMap(ctx.config, "channels", "feishu");
545
658
  if (!feishu) return {
@@ -550,16 +663,16 @@ let FeishuChannelRule = class FeishuChannelRule extends DiagnoseRule {
550
663
  pass: false,
551
664
  message: "channels.feishu.enabled mismatch: got " + feishu.enabled + ", expected true"
552
665
  };
666
+ if (asRecord(feishu.accounts)) return { pass: true };
553
667
  if (feishu.appId !== ctx.vars.feishuAppID) return {
554
668
  pass: false,
555
- message: "channels.feishu.appId mismatch: got " + feishu.appId + ", expected " + ctx.vars.feishuAppID
669
+ message: `channels.feishu.appId mismatch: got ${feishu.appId}, expected ${ctx.vars.feishuAppID}`
556
670
  };
557
- const expectedSecret = _FeishuChannelRule.DEFAULT_APP_SECRET;
558
671
  const secret = feishu.appSecret;
559
672
  if (typeof secret === "object" && secret !== null && !Array.isArray(secret)) {
560
- if (!matchMap(secret, expectedSecret)) return {
673
+ if (!matchMap(secret, DEFAULT_FEISHU_APP_SECRET)) return {
561
674
  pass: false,
562
- message: "channels.feishu.appSecret object mismatch: got " + JSON.stringify(secret)
675
+ message: `channels.feishu.appSecret object mismatch: got ${JSON.stringify(secret)}`
563
676
  };
564
677
  } else if (typeof secret === "string") {
565
678
  if (secret !== ctx.vars.feishuAppSecret) return {
@@ -578,6 +691,7 @@ let FeishuChannelRule = class FeishuChannelRule extends DiagnoseRule {
578
691
  "feishu",
579
692
  "enabled"
580
693
  ], true);
694
+ if (asRecord(getNestedMap(ctx.config, "channels", "feishu").accounts)) return;
581
695
  setNestedValue(ctx.config, [
582
696
  "channels",
583
697
  "feishu",
@@ -587,15 +701,167 @@ let FeishuChannelRule = class FeishuChannelRule extends DiagnoseRule {
587
701
  "channels",
588
702
  "feishu",
589
703
  "appSecret"
590
- ], _FeishuChannelRule.DEFAULT_APP_SECRET);
704
+ ], DEFAULT_FEISHU_APP_SECRET);
591
705
  }
592
706
  };
593
- FeishuChannelRule = _FeishuChannelRule = __decorate([Rule({
707
+ FeishuChannelRule = __decorate([Rule({
594
708
  key: "feishu_channel",
595
- dependsOn: ["config_syntax_check"],
709
+ dependsOn: ["config_syntax_check", "feishu_default_account"],
596
710
  repairMode: "standard"
597
711
  })], FeishuChannelRule);
598
712
  //#endregion
713
+ //#region src/rules/feishu-default-account.ts
714
+ /**
715
+ * Owner of the multi-agent feishu channel shape: migrates legacy v1/v2
716
+ * (top-level appId + defaultAccount/default) into v3 (`bot-<appId>` account),
717
+ * detects + fixes drift on the main bot's appId/appSecret. Single-agent
718
+ * configs (no `accounts`) are out of scope — handled by `feishu_channel`.
719
+ */
720
+ let FeishuDefaultAccountRule = class FeishuDefaultAccountRule extends DiagnoseRule {
721
+ validate(ctx) {
722
+ const feishu = getNestedMap(ctx.config, "channels", "feishu");
723
+ if (!feishu) return { pass: true };
724
+ const accounts = asRecord(feishu.accounts);
725
+ if (!accounts) return { pass: true };
726
+ const topAppId = feishu.appId;
727
+ if (typeof topAppId === "string" && topAppId !== "") return {
728
+ pass: false,
729
+ message: "channels.feishu has legacy shape; needs migration to accounts.bot-<appId>"
730
+ };
731
+ const mainBot = findMainBotAccount(ctx.config, accounts);
732
+ if (mainBot) {
733
+ const expectedAppId = ctx.vars?.feishuAppID;
734
+ if (typeof expectedAppId === "string" && expectedAppId !== "" && mainBot.acc.appId !== expectedAppId) return {
735
+ pass: false,
736
+ message: `accounts.${mainBot.accountId}.appId mismatch: got ${mainBot.acc.appId}, expected ${expectedAppId}`
737
+ };
738
+ if (!secretMatchesCanonical(mainBot.acc.appSecret)) return {
739
+ pass: false,
740
+ message: `accounts.${mainBot.accountId}.appSecret drift`
741
+ };
742
+ }
743
+ return { pass: true };
744
+ }
745
+ repair(ctx) {
746
+ const feishu = getNestedMap(ctx.config, "channels", "feishu");
747
+ if (!feishu) return;
748
+ const accounts = asRecord(feishu.accounts);
749
+ if (!accounts) return;
750
+ const topAppId = feishu.appId;
751
+ if (typeof topAppId === "string" && topAppId !== "") {
752
+ this.migrate(ctx, feishu, accounts, topAppId);
753
+ return;
754
+ }
755
+ this.enforceMainBotValues(ctx, accounts);
756
+ }
757
+ migrate(ctx, feishu, accounts, topAppId) {
758
+ const effectiveAppId = nonEmpty(ctx.vars?.feishuAppID) ?? topAppId;
759
+ const expectedKey = `bot-${effectiveAppId}`;
760
+ const existingBot = asRecord(accounts[expectedKey]) ?? {};
761
+ const defaultAccount = asRecord(accounts.defaultAccount) ?? {};
762
+ const defaultAcc = asRecord(accounts.default) ?? {};
763
+ const merged = {
764
+ ...existingBot,
765
+ ...defaultAccount,
766
+ ...defaultAcc,
767
+ appId: effectiveAppId,
768
+ appSecret: DEFAULT_FEISHU_APP_SECRET
769
+ };
770
+ const chatID = ctx.vars?.teamChatID;
771
+ if (typeof chatID === "string" && chatID !== "") {
772
+ const existingGroups = asRecord(merged.groups) ?? {};
773
+ if (!(chatID in existingGroups)) merged.groups = {
774
+ ...existingGroups,
775
+ [chatID]: { requireMention: false }
776
+ };
777
+ }
778
+ accounts[expectedKey] = merged;
779
+ delete accounts.defaultAccount;
780
+ delete accounts.default;
781
+ delete feishu.appId;
782
+ delete feishu.appSecret;
783
+ this.rewireBindings(ctx.config, expectedKey);
784
+ }
785
+ enforceMainBotValues(ctx, accounts) {
786
+ const mainBot = findMainBotAccount(ctx.config, accounts);
787
+ if (!mainBot) return;
788
+ const acc = accounts[mainBot.accountId];
789
+ const expectedAppId = nonEmpty(ctx.vars?.feishuAppID);
790
+ if (expectedAppId !== void 0 && acc.appId !== expectedAppId) acc.appId = expectedAppId;
791
+ if (!secretMatchesCanonical(acc.appSecret)) acc.appSecret = DEFAULT_FEISHU_APP_SECRET;
792
+ }
793
+ rewireBindings(config, expectedKey) {
794
+ if (!Array.isArray(config.bindings)) return;
795
+ const bindings = config.bindings;
796
+ for (const b of bindings) {
797
+ const match = asRecord(asRecord(b)?.match);
798
+ if (match && match.channel === "feishu" && (match.accountId === "defaultAccount" || match.accountId === "default")) match.accountId = expectedKey;
799
+ }
800
+ const seen = /* @__PURE__ */ new Set();
801
+ const deduped = [];
802
+ for (const b of bindings) {
803
+ const rec = asRecord(b);
804
+ if (!rec) continue;
805
+ const match = asRecord(rec.match);
806
+ const key = JSON.stringify([
807
+ rec.agentId,
808
+ match?.channel,
809
+ match?.accountId
810
+ ]);
811
+ if (seen.has(key)) continue;
812
+ seen.add(key);
813
+ deduped.push(rec);
814
+ }
815
+ const mainId = findMainAgent(config)?.id;
816
+ if (typeof mainId === "string" && mainId !== "") {
817
+ if (!deduped.some((b) => {
818
+ const m = asRecord(b.match);
819
+ return b.agentId === mainId && m?.channel === "feishu" && m?.accountId === expectedKey;
820
+ })) deduped.push({
821
+ type: "route",
822
+ agentId: mainId,
823
+ match: {
824
+ channel: "feishu",
825
+ accountId: expectedKey
826
+ }
827
+ });
828
+ }
829
+ config.bindings = deduped;
830
+ }
831
+ };
832
+ FeishuDefaultAccountRule = __decorate([Rule({
833
+ key: "feishu_default_account",
834
+ dependsOn: ["config_syntax_check"],
835
+ repairMode: "standard"
836
+ })], FeishuDefaultAccountRule);
837
+ function nonEmpty(v) {
838
+ return typeof v === "string" && v !== "" ? v : void 0;
839
+ }
840
+ function findMainBotAccount(config, accounts) {
841
+ const mainId = findMainAgent(config)?.id;
842
+ if (typeof mainId !== "string" || mainId === "") return void 0;
843
+ const bindings = Array.isArray(config.bindings) ? config.bindings : [];
844
+ for (const b of bindings) {
845
+ const rec = asRecord(b);
846
+ const match = asRecord(rec?.match);
847
+ if (rec && match && rec.agentId === mainId && match.channel === "feishu") {
848
+ const accountId = match.accountId;
849
+ if (typeof accountId === "string") {
850
+ const acc = asRecord(accounts[accountId]);
851
+ if (acc) return {
852
+ accountId,
853
+ acc
854
+ };
855
+ }
856
+ }
857
+ }
858
+ }
859
+ /** Bot accounts must carry the canonical provider-ref `appSecret`. */
860
+ function secretMatchesCanonical(secret) {
861
+ if (typeof secret !== "object" || secret === null || Array.isArray(secret)) return false;
862
+ return matchMap(secret, DEFAULT_FEISHU_APP_SECRET);
863
+ }
864
+ //#endregion
599
865
  //#region src/rules/gateway.ts
600
866
  var _GatewayRule;
601
867
  let GatewayRule = class GatewayRule extends DiagnoseRule {
@@ -686,7 +952,9 @@ let AllowedOriginsRule = class AllowedOriginsRule extends DiagnoseRule {
686
952
  validate(ctx) {
687
953
  const expected = getExpectedOrigins(ctx.vars);
688
954
  if (expected.length === 0) return { pass: true };
689
- const missing = findMissing(getCurrentOrigins(ctx.config), expected);
955
+ const current = getCurrentOrigins(ctx.config);
956
+ if (hasWildcard(current)) return { pass: true };
957
+ const missing = findMissing(current, expected);
690
958
  if (missing.length === 0) return { pass: true };
691
959
  return {
692
960
  pass: false,
@@ -696,6 +964,7 @@ let AllowedOriginsRule = class AllowedOriginsRule extends DiagnoseRule {
696
964
  repair(ctx) {
697
965
  const expected = getExpectedOrigins(ctx.vars);
698
966
  const current = getCurrentOrigins(ctx.config);
967
+ if (hasWildcard(current)) return;
699
968
  const missing = findMissing(current, expected);
700
969
  if (missing.length > 0) {
701
970
  const seen = /* @__PURE__ */ new Set();
@@ -735,6 +1004,10 @@ function findMissing(current, expected) {
735
1004
  const set = new Set(current);
736
1005
  return expected.filter((o) => !set.has(o));
737
1006
  }
1007
+ /** Exact "*" entry means allow-all; pattern globs like "https://*.example.com" don't count. */
1008
+ function hasWildcard(origins) {
1009
+ return origins.includes("*");
1010
+ }
738
1011
  //#endregion
739
1012
  //#region src/rules/jwt-token.ts
740
1013
  let JwtTokenRule = class JwtTokenRule extends DiagnoseRule {
@@ -852,6 +1125,217 @@ SecretsRule = __decorate([Rule({
852
1125
  skipWhen: ({ hasMiaoda, deps }) => !hasMiaoda || !deps.usesMiaodaSecretProvider
853
1126
  })], SecretsRule);
854
1127
  //#endregion
1128
+ //#region src/rules/cleanup-install-backup-dirs.ts
1129
+ const DIR_PREFIX = ".openclaw-install-";
1130
+ function resolveExtensionsDir(configPath) {
1131
+ return node_path.default.join(node_path.default.dirname(configPath), "extensions");
1132
+ }
1133
+ function findLeftoverDirs(extensionsDir) {
1134
+ if (!fileExists(extensionsDir)) return [];
1135
+ let entries;
1136
+ try {
1137
+ entries = node_fs.default.readdirSync(extensionsDir, { withFileTypes: true });
1138
+ } catch {
1139
+ return [];
1140
+ }
1141
+ return entries.filter((e) => e.isDirectory() && e.name.startsWith(DIR_PREFIX)).map((e) => node_path.default.join(extensionsDir, e.name));
1142
+ }
1143
+ let CleanupInstallBackupDirsRule = class CleanupInstallBackupDirsRule extends DiagnoseRule {
1144
+ validate(ctx) {
1145
+ const { configPath } = ctx;
1146
+ if (!configPath) return { pass: true };
1147
+ const dirs = findLeftoverDirs(resolveExtensionsDir(configPath));
1148
+ if (dirs.length === 0) return { pass: true };
1149
+ return {
1150
+ pass: false,
1151
+ message: `extensions 目录下发现 ${dirs.length} 个 ${DIR_PREFIX}* 脏目录需要清理`
1152
+ };
1153
+ }
1154
+ repair(ctx) {
1155
+ const { configPath } = ctx;
1156
+ if (!configPath) return;
1157
+ const dirs = findLeftoverDirs(resolveExtensionsDir(configPath));
1158
+ const failures = [];
1159
+ for (const dir of dirs) try {
1160
+ node_fs.default.rmSync(dir, {
1161
+ recursive: true,
1162
+ force: true
1163
+ });
1164
+ } catch (e) {
1165
+ failures.push(`${node_path.default.basename(dir)}: ${e.message}`);
1166
+ }
1167
+ if (dirs.length > 0 && failures.length === dirs.length) throw new Error(`cleanup_install_backup_dirs: 全部清理失败: ${failures.join("; ")}`);
1168
+ }
1169
+ };
1170
+ CleanupInstallBackupDirsRule = __decorate([Rule({
1171
+ key: "cleanup_install_backup_dirs",
1172
+ repairMode: "standard"
1173
+ })], CleanupInstallBackupDirsRule);
1174
+ //#endregion
1175
+ //#region src/rules/miaoda-official-plugins-install-spec-unlock.ts
1176
+ /**
1177
+ * Official miaoda-side plugins that must track manifest — version-locked specs
1178
+ * here block upgrades. Third-party / user-installed plugins are intentionally
1179
+ * out of scope (users may pin them deliberately).
1180
+ */
1181
+ const OFFICIAL_PLUGIN_NAMES = new Set([
1182
+ "openclaw-extension-miaoda",
1183
+ "openclaw-extension-miaoda-coding",
1184
+ "openclaw-guardian-plugin",
1185
+ "openclaw-mem0-plugin",
1186
+ "openclaw-lark"
1187
+ ]);
1188
+ const LOCKED_NPM_SPEC = /^(@[a-z0-9][\w.-]*\/)?[a-z0-9][\w.-]*@[^@/:#\s]+$/i;
1189
+ function isLockedNpmSpec(spec) {
1190
+ return typeof spec === "string" && LOCKED_NPM_SPEC.test(spec);
1191
+ }
1192
+ function unlockSpec(spec) {
1193
+ const slash = spec.indexOf("/");
1194
+ const cut = slash === -1 ? spec.indexOf("@") : spec.indexOf("@", slash + 1);
1195
+ return spec.slice(0, cut);
1196
+ }
1197
+ /** Yield `[key, lockedSpec]` for every official-plugin install whose `spec` is locked. */
1198
+ function* iterLockedOfficialInstalls(config) {
1199
+ const installs = getNestedMap(config, "plugins", "installs");
1200
+ if (!installs) return;
1201
+ for (const [key, entry] of Object.entries(installs)) {
1202
+ if (!OFFICIAL_PLUGIN_NAMES.has(key)) continue;
1203
+ const spec = asRecord(entry)?.spec;
1204
+ if (isLockedNpmSpec(spec)) yield [key, spec];
1205
+ }
1206
+ }
1207
+ let MiaodaOfficialPluginsInstallSpecUnlockRule = class MiaodaOfficialPluginsInstallSpecUnlockRule extends DiagnoseRule {
1208
+ validate(ctx) {
1209
+ const locked = [...iterLockedOfficialInstalls(ctx.config)].map(([k]) => k);
1210
+ if (locked.length === 0) return { pass: true };
1211
+ return {
1212
+ pass: false,
1213
+ message: "plugins.installs 中官方插件存在锁版本的 spec: " + locked.sort().join(",")
1214
+ };
1215
+ }
1216
+ repair(ctx) {
1217
+ for (const [key, spec] of iterLockedOfficialInstalls(ctx.config)) setNestedValue(ctx.config, [
1218
+ "plugins",
1219
+ "installs",
1220
+ key,
1221
+ "spec"
1222
+ ], unlockSpec(spec));
1223
+ }
1224
+ };
1225
+ MiaodaOfficialPluginsInstallSpecUnlockRule = __decorate([Rule({
1226
+ key: "miaoda_official_plugins_install_spec_unlock",
1227
+ dependsOn: ["config_syntax_check"],
1228
+ repairMode: "standard"
1229
+ })], MiaodaOfficialPluginsInstallSpecUnlockRule);
1230
+ //#endregion
1231
+ //#region src/rules/old-miaoda-plugins-cleanup.ts
1232
+ const NEW_MIAODA = "openclaw-extension-miaoda";
1233
+ const OLD_PLUGIN_NAMES = Object.freeze([
1234
+ "openclaw-feishu-greeting",
1235
+ "openclaw-miaoda-keepalive",
1236
+ "feishu-greeting",
1237
+ "miaoda-keepalive"
1238
+ ]);
1239
+ function getPluginMaps(config) {
1240
+ const rawAllow = asRecord(config.plugins)?.allow;
1241
+ return {
1242
+ entries: getNestedMap(config, "plugins", "entries"),
1243
+ installs: getNestedMap(config, "plugins", "installs"),
1244
+ allow: Array.isArray(rawAllow) ? rawAllow : void 0
1245
+ };
1246
+ }
1247
+ function getExtensionsDir(configPath) {
1248
+ return node_path.default.join(node_path.default.dirname(configPath), "extensions");
1249
+ }
1250
+ function hasNewMiaoda({ entries, installs, allow }) {
1251
+ return asRecord(entries?.[NEW_MIAODA]) != null || asRecord(installs?.[NEW_MIAODA]) != null || (allow?.includes(NEW_MIAODA) ?? false);
1252
+ }
1253
+ function findResiduals({ entries, installs, allow }, extensionsDir) {
1254
+ return OLD_PLUGIN_NAMES.filter((name) => entries?.[name] != null || installs?.[name] != null || (allow?.includes(name) ?? false) || node_fs.default.existsSync(node_path.default.join(extensionsDir, name)));
1255
+ }
1256
+ let OldMiaodaPluginsCleanupRule = class OldMiaodaPluginsCleanupRule extends DiagnoseRule {
1257
+ validate(ctx) {
1258
+ const maps = getPluginMaps(ctx.config);
1259
+ if (!hasNewMiaoda(maps)) return { pass: true };
1260
+ const residuals = findResiduals(maps, getExtensionsDir(ctx.configPath));
1261
+ if (residuals.length === 0) return { pass: true };
1262
+ return {
1263
+ pass: false,
1264
+ message: "旧 miaoda 插件残留: " + residuals.sort().join(",")
1265
+ };
1266
+ }
1267
+ repair(ctx) {
1268
+ const maps = getPluginMaps(ctx.config);
1269
+ if (!hasNewMiaoda(maps)) return;
1270
+ const extensionsDir = getExtensionsDir(ctx.configPath);
1271
+ const { entries, installs, allow } = maps;
1272
+ const oldSet = new Set(OLD_PLUGIN_NAMES);
1273
+ if (allow) for (let i = allow.length - 1; i >= 0; i--) {
1274
+ const v = allow[i];
1275
+ if (typeof v === "string" && oldSet.has(v)) allow.splice(i, 1);
1276
+ }
1277
+ for (const name of OLD_PLUGIN_NAMES) {
1278
+ if (entries && name in entries) delete entries[name];
1279
+ if (installs && name in installs) delete installs[name];
1280
+ const target = node_path.default.join(extensionsDir, name);
1281
+ const rel = node_path.default.relative(extensionsDir, target);
1282
+ if (!rel || rel.startsWith("..") || node_path.default.isAbsolute(rel)) continue;
1283
+ try {
1284
+ node_fs.default.rmSync(target, {
1285
+ recursive: true,
1286
+ force: true
1287
+ });
1288
+ } catch (e) {
1289
+ console.error(`[old_miaoda_plugins_cleanup] rmSync ${target} failed: ${e.message}`);
1290
+ }
1291
+ }
1292
+ }
1293
+ };
1294
+ OldMiaodaPluginsCleanupRule = __decorate([Rule({
1295
+ key: "old_miaoda_plugins_cleanup",
1296
+ dependsOn: ["config_syntax_check"],
1297
+ repairMode: "standard"
1298
+ })], OldMiaodaPluginsCleanupRule);
1299
+ //#endregion
1300
+ //#region src/rules/lark-plugin-allow.ts
1301
+ const LARK_PLUGIN = "openclaw-lark";
1302
+ const LARK_PLUGIN_NAMES = [LARK_PLUGIN, "feishu-openclaw-plugin"];
1303
+ let LarkPluginAllowRule = class LarkPluginAllowRule extends DiagnoseRule {
1304
+ validate(ctx) {
1305
+ const allow = getAllow(ctx.config);
1306
+ if (LARK_PLUGIN_NAMES.some((name) => allow.includes(name))) return { pass: true };
1307
+ return {
1308
+ pass: false,
1309
+ message: `plugins.allow 缺少飞书插件 (expected one of: ${LARK_PLUGIN_NAMES.join(", ")})`
1310
+ };
1311
+ }
1312
+ repair(ctx) {
1313
+ if (ctx.config.plugins == null || typeof ctx.config.plugins !== "object" || Array.isArray(ctx.config.plugins)) {
1314
+ ctx.config.plugins = { allow: [LARK_PLUGIN] };
1315
+ return;
1316
+ }
1317
+ const pluginsMap = ctx.config.plugins;
1318
+ const rawAllow = pluginsMap.allow;
1319
+ const original = Array.isArray(rawAllow) ? rawAllow : [];
1320
+ const stringAllow = original.filter((e) => typeof e === "string");
1321
+ if (LARK_PLUGIN_NAMES.some((name) => stringAllow.includes(name))) return;
1322
+ original.push(LARK_PLUGIN);
1323
+ pluginsMap.allow = original;
1324
+ }
1325
+ };
1326
+ LarkPluginAllowRule = __decorate([Rule({
1327
+ key: "lark_plugin_allow",
1328
+ dependsOn: ["config_syntax_check"],
1329
+ repairMode: "standard"
1330
+ })], LarkPluginAllowRule);
1331
+ function getAllow(config) {
1332
+ const plugins = config.plugins;
1333
+ if (plugins == null || typeof plugins !== "object" || Array.isArray(plugins)) return [];
1334
+ const allow = plugins.allow;
1335
+ if (!Array.isArray(allow)) return [];
1336
+ return allow.filter((e) => typeof e === "string");
1337
+ }
1338
+ //#endregion
855
1339
  //#region src/check.ts
856
1340
  function runCheck(input) {
857
1341
  const result = { failedRules: {
@@ -864,12 +1348,14 @@ function runCheck(input) {
864
1348
  const failedKeys = /* @__PURE__ */ new Set();
865
1349
  let configParsed = false;
866
1350
  let ctx = {
867
- config: { __configPath: input.configPath },
1351
+ config: {},
1352
+ configPath: input.configPath,
868
1353
  vars: input.vars,
869
1354
  providerDeps: {
870
1355
  usesMiaodaProvider: false,
871
1356
  usesMiaodaSecretProvider: false
872
- }
1357
+ },
1358
+ templateVars: input.templateVars
873
1359
  };
874
1360
  for (const rule of rules) {
875
1361
  const meta = rule.meta;
@@ -880,8 +1366,10 @@ function runCheck(input) {
880
1366
  const deps = analyzeProviderDeps(parsed);
881
1367
  ctx = {
882
1368
  config: parsed,
1369
+ configPath: input.configPath,
883
1370
  vars: input.vars,
884
- providerDeps: deps
1371
+ providerDeps: deps,
1372
+ templateVars: input.templateVars
885
1373
  };
886
1374
  configParsed = true;
887
1375
  } catch {
@@ -933,12 +1421,14 @@ function runRepair(input) {
933
1421
  if (rule.meta.repairMode !== "standard") continue;
934
1422
  if (rule.meta.dependsOn?.includes("config_syntax_check")) continue;
935
1423
  rule.repair({
936
- config: { __configPath: input.configPath },
1424
+ config: {},
1425
+ configPath: input.configPath,
937
1426
  vars: input.vars,
938
1427
  providerDeps: {
939
1428
  usesMiaodaProvider: false,
940
1429
  usesMiaodaSecretProvider: false
941
- }
1430
+ },
1431
+ templateVars: input.templateVars
942
1432
  });
943
1433
  }
944
1434
  const JSON5 = loadJSON5();
@@ -954,8 +1444,10 @@ function runRepair(input) {
954
1444
  const deps = analyzeProviderDeps(config);
955
1445
  const ctx = {
956
1446
  config,
1447
+ configPath: input.configPath,
957
1448
  vars: input.vars,
958
- providerDeps: deps
1449
+ providerDeps: deps,
1450
+ templateVars: input.templateVars
959
1451
  };
960
1452
  let configDirty = false;
961
1453
  for (const rule of rules) {
@@ -985,44 +1477,223 @@ function runRepair(input) {
985
1477
  }
986
1478
  }
987
1479
  //#endregion
988
- //#region src/backup.ts
989
- const BACKUP_PATH = "/home/gem/workspace/.force/openclaw/core-backup.json";
990
- function runBackup(input) {
1480
+ //#region src/paths.ts
1481
+ /**
1482
+ * Central directory for all ephemeral diagnose/reset artifacts: task status
1483
+ * files (`reset-<taskId>.json`) and human-readable step logs
1484
+ * (`reset-<taskId>.log`). Having everything under one dir makes debugging a
1485
+ * stuck reset much easier — `ls /tmp/openclaw-diagnose/` shows every recent
1486
+ * run, and each run's log is right next to its state.
1487
+ */
1488
+ const DIAGNOSE_DIR = "/tmp/openclaw-diagnose";
1489
+ function resetResultFile(taskId) {
1490
+ return `${DIAGNOSE_DIR}/reset-${taskId}.json`;
1491
+ }
1492
+ function resetLogFile(taskId) {
1493
+ return `${DIAGNOSE_DIR}/reset-${taskId}.log`;
1494
+ }
1495
+ /** Sandbox workspace root where openclaw config + agent state lives. */
1496
+ const WORKSPACE_DIR = "/home/gem/workspace/agent";
1497
+ /** File containing the provider key used by the openclaw miaoda provider. */
1498
+ const PROVIDER_FILE_PATH = "/home/gem/workspace/.force/openclaw/miaoda-provider-key";
1499
+ /** File containing the miaoda openclaw secrets JSON. */
1500
+ const SECRETS_FILE_PATH = "/home/gem/workspace/.force/openclaw/miaoda-openclaw-secrets.json";
1501
+ /** Absolute path to the openclaw config JSON. */
1502
+ const CONFIG_PATH = `${WORKSPACE_DIR}/openclaw.json`;
1503
+ //#endregion
1504
+ //#region src/logger.ts
1505
+ /**
1506
+ * Shared CLI log file. Every log line the CLI emits — whether through
1507
+ * `console.error` (rules, helpers, errors) or through the per-task
1508
+ * `makeLogger` (reset worker) — is tee'd here so operators have a single
1509
+ * file to tail when diagnosing a sandbox.
1510
+ *
1511
+ * `/tmp` is ephemeral on sandbox restart; we rely on that for rotation
1512
+ * (no size-based rotation implemented).
1513
+ */
1514
+ const CLI_LOG_FILE = "/tmp/openclaw-diagnose/cli.log";
1515
+ /** Append one line to the shared cli.log. Swallows any fs error —
1516
+ * logging must never break the business flow. */
1517
+ function appendCliLog(line) {
1518
+ try {
1519
+ const dir = node_path.default.dirname(CLI_LOG_FILE);
1520
+ if (!node_fs.default.existsSync(dir)) node_fs.default.mkdirSync(dir, { recursive: true });
1521
+ node_fs.default.appendFileSync(CLI_LOG_FILE, line);
1522
+ } catch {}
1523
+ }
1524
+ let stderrMirrorInstalled = false;
1525
+ /**
1526
+ * Install a process-wide `console.error` interceptor that mirrors each
1527
+ * line to BOTH the original stderr AND cli.log. Call once at CLI entry
1528
+ * before any subcommand dispatch; idempotent.
1529
+ *
1530
+ * Why console.error and not console.log: the CLI's stdout carries the
1531
+ * structured JSON result protocol consumed by sandbox_console and other
1532
+ * callers — any log line on stdout would corrupt JSON parsing. Rules,
1533
+ * helpers, and error paths therefore must route debug output through
1534
+ * console.error (stderr).
1535
+ */
1536
+ function installStderrMirror() {
1537
+ if (stderrMirrorInstalled) return;
1538
+ stderrMirrorInstalled = true;
1539
+ const original = console.error.bind(console);
1540
+ console.error = (...args) => {
1541
+ original(...args);
1542
+ const body = args.map((a) => typeof a === "string" ? a : safeStringify(a)).join(" ");
1543
+ appendCliLog(`[${(/* @__PURE__ */ new Date()).toISOString()}] ${body}\n`);
1544
+ };
1545
+ }
1546
+ function safeStringify(v) {
991
1547
  try {
992
- const { configPath } = input;
1548
+ return JSON.stringify(v);
1549
+ } catch {
1550
+ return String(v);
1551
+ }
1552
+ }
1553
+ function makeLogger(logFile) {
1554
+ try {
1555
+ const dir = node_path.default.dirname(logFile);
1556
+ if (!node_fs.default.existsSync(dir)) node_fs.default.mkdirSync(dir, { recursive: true });
1557
+ } catch {}
1558
+ return (msg) => {
1559
+ const line = `[${(/* @__PURE__ */ new Date()).toISOString()}] ${msg}\n`;
993
1560
  try {
994
- const validateOutput = shell("openclaw config validate --json");
995
- if (!JSON.parse(validateOutput).valid) return {
996
- success: false,
997
- error: "config validation failed"
998
- };
999
- } catch (e) {
1000
- return {
1001
- success: false,
1002
- error: "config validate command failed: " + e.message
1003
- };
1004
- }
1005
- if (!fileExists(configPath)) return {
1006
- success: false,
1007
- error: "config file not found: " + configPath
1008
- };
1009
- const config = loadJSON5().parse(readFile(configPath));
1010
- const backup = { _backup_meta: { created_at: (/* @__PURE__ */ new Date()).toISOString() } };
1011
- if (config.agents) backup.agents = config.agents;
1012
- if (config.bindings) backup.bindings = config.bindings;
1013
- const feishu = config.channels?.feishu;
1014
- if (feishu?.accounts) backup.channels = { feishu: { accounts: feishu.accounts } };
1015
- const backupDir = node_path.default.dirname(BACKUP_PATH);
1016
- if (!node_fs.default.existsSync(backupDir)) node_fs.default.mkdirSync(backupDir, { recursive: true });
1017
- const tmpPath = BACKUP_PATH + ".tmp";
1018
- node_fs.default.writeFileSync(tmpPath, JSON.stringify(backup, null, 2), "utf-8");
1019
- node_fs.default.renameSync(tmpPath, BACKUP_PATH);
1020
- return { success: true };
1561
+ node_fs.default.appendFileSync(logFile, line);
1562
+ } catch {}
1563
+ appendCliLog(line);
1564
+ };
1565
+ }
1566
+ //#endregion
1567
+ //#region src/fs-utils.ts
1568
+ /**
1569
+ * Rename src dst, falling back to `mv` (which handles cross-device copy)
1570
+ * when the kernel returns EXDEV.
1571
+ *
1572
+ * Sandbox filesystems can put sibling paths on different "devices" from
1573
+ * rename(2)'s point of view: bind mounts, overlayfs copy-up, and
1574
+ * mount-point children inside a single directory all trip EXDEV. Seen in
1575
+ * production when reset's atomic swap did
1576
+ * /home/gem/.npm-global/lib/node_modules/openclaw openclaw.bak
1577
+ * and the openclaw subdir was a bind-mounted volume.
1578
+ *
1579
+ * Behavior:
1580
+ * - Happy path hits rename(2) — atomic, single syscall, microseconds.
1581
+ * - EXDEV path shells out to `mv`, which does rename() then copy+unlink
1582
+ * on failure. Non-atomic but correct; callers already have rollback
1583
+ * logic (install-openclaw restores from .bak) so loss of atomicity
1584
+ * only matters if the process dies mid-copy, and that's survivable.
1585
+ * - Any other error (ENOENT, EACCES, EBUSY...) rethrows as-is so callers
1586
+ * see the real problem instead of a misleading `mv` fallback failure.
1587
+ */
1588
+ function moveSafe(src, dst) {
1589
+ try {
1590
+ node_fs.default.renameSync(src, dst);
1021
1591
  } catch (e) {
1022
- return {
1023
- success: false,
1024
- error: "backup failed: " + e.message
1025
- };
1592
+ if (e?.code !== "EXDEV") throw e;
1593
+ execCaptureErr(`mv ${shellQuote(src)} ${shellQuote(dst)}`);
1594
+ }
1595
+ }
1596
+ /**
1597
+ * Run a shell command, re-throwing with stderr attached on failure.
1598
+ *
1599
+ * Node's `execSync(..., { stdio: 'ignore' })` swallows stderr entirely —
1600
+ * callers only see "Command failed: <cmd>" with no hint of the real error
1601
+ * (ENOSPC, EROFS, "unrecognized option", etc.). Production debugging on
1602
+ * sandboxed boxes is painful without the underlying message, so we pipe
1603
+ * stderr, capture it, and embed it in the thrown Error. stdout stays
1604
+ * suppressed because the commands we run here (tar/mv) are silent on
1605
+ * success.
1606
+ */
1607
+ function execCaptureErr(cmd) {
1608
+ try {
1609
+ (0, node_child_process.execSync)(cmd, { stdio: [
1610
+ "ignore",
1611
+ "ignore",
1612
+ "pipe"
1613
+ ] });
1614
+ } catch (e) {
1615
+ const stderr = e?.stderr;
1616
+ const stderrStr = (typeof stderr === "string" ? stderr : stderr?.toString("utf8") ?? "").trim();
1617
+ const base = e?.message ?? "command failed";
1618
+ throw new Error(stderrStr ? `${base}\nstderr: ${stderrStr}` : base);
1619
+ }
1620
+ }
1621
+ /** POSIX single-quote shell escape. Paths with embedded quotes are rare but
1622
+ * the token-file path conventions in sandboxes don't guarantee cleanliness. */
1623
+ function shellQuote(s) {
1624
+ return `'${s.replace(/'/g, `'\\''`)}'`;
1625
+ }
1626
+ /**
1627
+ * Extract an npm-packed gzipped tarball.
1628
+ *
1629
+ * ## The problem this works around
1630
+ *
1631
+ * Some tarballs (openclaw's among them — they're not packed by vanilla
1632
+ * `npm pack`) include relative symlinks inside nested .bin/ dirs whose
1633
+ * targets contain `..`, e.g.
1634
+ * node_modules/<pkg>/node_modules/.bin/foo -> ../foo/bin/cli.js
1635
+ *
1636
+ * GNU tar classifies any symlink target with `..` or a leading `/` as
1637
+ * "dangerous" and defers its extraction to a post-files pass, while also
1638
+ * needing a post-files pass to restore directory permissions/mtimes. The
1639
+ * two passes race: the deferred-symlink handling mutates parent-dir inodes,
1640
+ * then the directory stat-restore pass does `fstatat()` and the recorded
1641
+ * inode doesn't match, firing
1642
+ *
1643
+ * tar: <path>: Directory renamed before its status could be extracted
1644
+ *
1645
+ * from `apply_nonancestor_delayed_set_stat()` in extract.c. This is an
1646
+ * `ERROR` (hard-fail, exit 2) — the `--warning=no-rename-directory`
1647
+ * keyword controls a different, incremental-archive code path and does
1648
+ * NOT silence this. Reference: Paul Eggert, bug-tar 2004-04:
1649
+ * https://lists.gnu.org/archive/html/bug-tar/2004-04/msg00021.html
1650
+ *
1651
+ * ## The fix
1652
+ *
1653
+ * Pass `--absolute-names` (aka `-P`). Per GNU tar docs, this disables the
1654
+ * "normalize dangerous names" logic — including the deferred-symlink pass
1655
+ * that's racing us. Also stops stripping leading `/`, but our tarballs
1656
+ * only contain relative (`./node_modules/...`) paths so there's nothing
1657
+ * to strip. Safe because:
1658
+ * - The tarball is sha512-verified upstream (downloadWithCache)
1659
+ * - All entry paths are relative, no absolute-path escape risk
1660
+ * - All dangerous symlink targets resolve within the extracted tree
1661
+ *
1662
+ * ## Belt-and-suspenders
1663
+ *
1664
+ * If some tar variant still emits the error despite -P, we fall through
1665
+ * to checking the stderr pattern: if every error line is the benign
1666
+ * "Directory renamed …" text (no real failures like ENOSPC/EACCES/gzip
1667
+ * CRC/etc.), swallow exit 2. Callers MUST still verify extraction
1668
+ * (e.g. `fs.existsSync(path.join(dest, 'package.json'))`) — tar's
1669
+ * `skip_this_one = 1` after the error means some dirs missed their
1670
+ * mtime/mode restore, but content was written.
1671
+ */
1672
+ function extractTarballTolerant(tarball, destDir, opts = {}) {
1673
+ const strip = opts.stripComponents ?? 0;
1674
+ const stripFlag = strip > 0 ? ` --strip-components=${strip}` : "";
1675
+ const cmd = `tar -xzf ${shellQuote(tarball)} -C ${shellQuote(destDir)}${stripFlag} -P`;
1676
+ try {
1677
+ execCaptureErr(cmd);
1678
+ return;
1679
+ } catch (e) {
1680
+ const msg = e?.message ?? "";
1681
+ const hasFalseAlarm = msg.includes("Directory renamed before its status could be extracted");
1682
+ const hasFatal = [
1683
+ /Cannot open/i,
1684
+ /Cannot mkdir/i,
1685
+ /Cannot create/i,
1686
+ /No space left on device/i,
1687
+ /Disk quota exceeded/i,
1688
+ /Permission denied/i,
1689
+ /Read-only file system/i,
1690
+ /unrecognized option/i,
1691
+ /gzip:/i,
1692
+ /Unexpected EOF/i,
1693
+ /Invalid argument/i
1694
+ ].some((r) => r.test(msg));
1695
+ if (!hasFalseAlarm || hasFatal) throw e;
1696
+ console.error(`[tar] -P did not suppress "Directory renamed" on ${tarball}; tolerating (content must be verified by caller)`);
1026
1697
  }
1027
1698
  }
1028
1699
  //#endregion
@@ -1034,7 +1705,9 @@ function runBackup(input) {
1034
1705
  */
1035
1706
  function startAsyncReset(ctxBase64) {
1036
1707
  const taskId = (0, node_crypto.randomUUID)();
1037
- const resultFile = `/tmp/openclaw-reset-${taskId}.json`;
1708
+ const resultFile = resetResultFile(taskId);
1709
+ const log = makeLogger(resetLogFile(taskId));
1710
+ log(`=== startAsyncReset spawning worker for taskId=${taskId} ===`);
1038
1711
  const initial = {
1039
1712
  status: "running",
1040
1713
  step: 0,
@@ -1046,7 +1719,7 @@ function startAsyncReset(ctxBase64) {
1046
1719
  const dir = node_path.default.dirname(resultFile);
1047
1720
  if (!node_fs.default.existsSync(dir)) node_fs.default.mkdirSync(dir, { recursive: true });
1048
1721
  node_fs.default.writeFileSync(tmpPath, JSON.stringify(initial), "utf-8");
1049
- node_fs.default.renameSync(tmpPath, resultFile);
1722
+ moveSafe(tmpPath, resultFile);
1050
1723
  const child = (0, node_child_process.spawn)(process.execPath, [
1051
1724
  process.argv[1],
1052
1725
  "reset",
@@ -1058,6 +1731,7 @@ function startAsyncReset(ctxBase64) {
1058
1731
  stdio: "ignore"
1059
1732
  });
1060
1733
  child.on("error", (err) => {
1734
+ log(`FATAL worker failed to start: ${err.message}`);
1061
1735
  const failResult = {
1062
1736
  status: "failed",
1063
1737
  step: 0,
@@ -1069,52 +1743,295 @@ function startAsyncReset(ctxBase64) {
1069
1743
  };
1070
1744
  const errTmpPath = resultFile + ".tmp";
1071
1745
  node_fs.default.writeFileSync(errTmpPath, JSON.stringify(failResult));
1072
- node_fs.default.renameSync(errTmpPath, resultFile);
1746
+ moveSafe(errTmpPath, resultFile);
1073
1747
  });
1074
1748
  child.unref();
1749
+ log(`spawned worker pid=${child.pid}`);
1075
1750
  return { taskId };
1076
1751
  }
1077
1752
  //#endregion
1753
+ //#region src/oss/fetchManifest.ts
1754
+ const MANIFEST_PREFIX = "builtin/manifests/openclaw/recommended/";
1755
+ const MANIFEST_SUFFIX = ".json";
1756
+ async function fetchManifest(ossFileMap, tag) {
1757
+ const key = `${MANIFEST_PREFIX}${tag}${MANIFEST_SUFFIX}`;
1758
+ const url = ossFileMap[key];
1759
+ if (!url) {
1760
+ const available = Object.keys(ossFileMap).filter((k) => k.startsWith(MANIFEST_PREFIX) && k.endsWith(MANIFEST_SUFFIX)).map((k) => k.slice(39, -5));
1761
+ const availStr = available.length ? available.join(", ") : "(none)";
1762
+ 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.`);
1763
+ }
1764
+ const res = await fetch(url);
1765
+ if (!res.ok) throw new Error(`fetch manifest failed: HTTP ${res.status} ${res.statusText}`);
1766
+ return await res.json();
1767
+ }
1768
+ async function downloadWithCache(pkg, ossFileMap, opts = {}) {
1769
+ const cacheRoot = opts.cacheRoot ?? "/tmp/openclaw-diagnose/resources";
1770
+ const shortHash = pkg.shasum.slice(0, 16);
1771
+ const destDir = node_path.default.join(cacheRoot, shortHash);
1772
+ const destFile = node_path.default.join(destDir, node_path.default.posix.basename(pkg.ossKey));
1773
+ node_fs.default.mkdirSync(destDir, { recursive: true });
1774
+ if (node_fs.default.existsSync(destFile)) return destFile;
1775
+ const url = ossFileMap[pkg.ossKey];
1776
+ if (!url) throw new Error(`signed URL missing for ${pkg.ossKey}`);
1777
+ if (!pkg.integrity.startsWith("sha512-")) throw new Error(`unsupported integrity format: ${pkg.integrity}`);
1778
+ const expected = pkg.integrity.slice(7);
1779
+ const tmpFile = node_path.default.join(destDir, `.tmp.${process.pid}.${node_crypto.default.randomBytes(4).toString("hex")}`);
1780
+ try {
1781
+ const res = await fetch(url);
1782
+ if (!res.ok) throw new Error(`download failed: HTTP ${res.status}`);
1783
+ if (!res.body) throw new Error(`download failed: empty body for ${pkg.ossKey}`);
1784
+ const hasher = node_crypto.default.createHash("sha512");
1785
+ const source = node_stream.Readable.fromWeb(res.body);
1786
+ async function* teeAndHash(src) {
1787
+ for await (const chunk of src) {
1788
+ hasher.update(chunk);
1789
+ yield chunk;
1790
+ }
1791
+ }
1792
+ await (0, node_stream_promises.pipeline)(source, teeAndHash, node_fs.default.createWriteStream(tmpFile));
1793
+ const actual = hasher.digest("base64");
1794
+ if (actual !== expected) {
1795
+ const envBypass = process.env.OPENCLAW_DEBUG_SKIP_INTEGRITY === "1";
1796
+ if (opts.skipIntegrity || envBypass) {
1797
+ const sourceLabel = opts.skipIntegrity ? "skipIntegrity=true" : "OPENCLAW_DEBUG_SKIP_INTEGRITY=1";
1798
+ console.error(`⚠ [downloadWithCache] INTEGRITY BYPASS for ${pkg.ossKey}: expected ${expected.slice(0, 12)}… got ${actual.slice(0, 12)}… — ${sourceLabel}. DO NOT use this flag in production.`);
1799
+ } else throw new Error(`integrity mismatch for ${pkg.ossKey}: expected ${expected} got ${actual}`);
1800
+ }
1801
+ moveSafe(tmpFile, destFile);
1802
+ return destFile;
1803
+ } catch (e) {
1804
+ try {
1805
+ node_fs.default.unlinkSync(tmpFile);
1806
+ } catch {}
1807
+ throw e;
1808
+ }
1809
+ }
1810
+ async function installOpenclaw(openclawTag, ossFileMap, opts = {}) {
1811
+ const homeBase = opts.homeBase ?? "/home/gem";
1812
+ const t0 = Date.now();
1813
+ const pkg = (await fetchManifest(ossFileMap, openclawTag)).packages.find((p) => p.role === "cli" && p.name === "openclaw");
1814
+ if (!pkg) throw new Error("install-openclaw: role=cli,name=openclaw not found in manifest");
1815
+ const targetDir = opts.targetDir ?? node_path.default.join(homeBase, pkg.installPath);
1816
+ const bakDir = targetDir + ".bak";
1817
+ const newDir = targetDir + ".new";
1818
+ const tarball = await downloadWithCache(pkg, ossFileMap, opts);
1819
+ console.error(`[install-openclaw] tag=${openclawTag} shasum=${pkg.shasum.slice(0, 12)}...`);
1820
+ if (node_fs.default.existsSync(newDir)) node_fs.default.rmSync(newDir, {
1821
+ recursive: true,
1822
+ force: true
1823
+ });
1824
+ if (node_fs.default.existsSync(bakDir)) node_fs.default.rmSync(bakDir, {
1825
+ recursive: true,
1826
+ force: true
1827
+ });
1828
+ node_fs.default.mkdirSync(node_path.default.dirname(targetDir), { recursive: true });
1829
+ const tmpStage = node_fs.default.mkdtempSync(node_path.default.join(opts.tmpRoot ?? node_os.default.tmpdir(), "openclaw-install-"));
1830
+ try {
1831
+ extractTarballTolerant(tarball, tmpStage, { stripComponents: 1 });
1832
+ if (!node_fs.default.existsSync(node_path.default.join(tmpStage, "package.json"))) throw new Error("extracted tarball missing package.json");
1833
+ moveSafe(tmpStage, newDir);
1834
+ const hadExisting = node_fs.default.existsSync(targetDir);
1835
+ try {
1836
+ if (hadExisting) moveSafe(targetDir, bakDir);
1837
+ moveSafe(newDir, targetDir);
1838
+ } catch (e) {
1839
+ if (hadExisting && !node_fs.default.existsSync(targetDir) && node_fs.default.existsSync(bakDir)) try {
1840
+ moveSafe(bakDir, targetDir);
1841
+ } catch {}
1842
+ try {
1843
+ node_fs.default.rmSync(newDir, {
1844
+ recursive: true,
1845
+ force: true
1846
+ });
1847
+ } catch {}
1848
+ throw e;
1849
+ }
1850
+ if (hadExisting && node_fs.default.existsSync(bakDir)) node_fs.default.rmSync(bakDir, {
1851
+ recursive: true,
1852
+ force: true
1853
+ });
1854
+ } finally {
1855
+ if (node_fs.default.existsSync(tmpStage)) try {
1856
+ node_fs.default.rmSync(tmpStage, {
1857
+ recursive: true,
1858
+ force: true
1859
+ });
1860
+ } catch {}
1861
+ }
1862
+ console.error(`[install-openclaw] done in ${Date.now() - t0}ms`);
1863
+ }
1864
+ async function installExtension(tag, ossFileMap, opts = {}) {
1865
+ const homeBase = opts.homeBase ?? "/home/gem";
1866
+ const hasAll = !!opts.all;
1867
+ const hasNames = (opts.names?.length ?? 0) > 0;
1868
+ if (hasAll && hasNames) throw new Error("install-extension: --all and --extension are mutually exclusive");
1869
+ if (!hasAll && !hasNames) throw new Error("install-extension: must provide --all or --extension=<name>");
1870
+ const allExts = (await fetchManifest(ossFileMap, tag)).packages.filter((p) => p.role === "extension");
1871
+ let targets;
1872
+ if (hasAll) targets = allExts;
1873
+ else {
1874
+ const wanted = new Set(opts.names);
1875
+ targets = allExts.filter((p) => wanted.has(p.name) || p.packageName != null && wanted.has(p.packageName));
1876
+ const foundKeys = /* @__PURE__ */ new Set();
1877
+ for (const t of targets) {
1878
+ foundKeys.add(t.name);
1879
+ if (t.packageName) foundKeys.add(t.packageName);
1880
+ }
1881
+ const missing = opts.names.filter((n) => !foundKeys.has(n));
1882
+ if (missing.length > 0) throw new Error(`install-extension: not found in manifest: ${missing.join(", ")}`);
1883
+ }
1884
+ console.error(`[install-extension] tag=${tag} targets=${targets.length}`);
1885
+ const t0 = Date.now();
1886
+ const tarballs = await Promise.all(targets.map(async (p) => {
1887
+ const tb = await downloadWithCache(p, ossFileMap, opts);
1888
+ console.error(`[install-extension] ${p.name}: downloaded`);
1889
+ return {
1890
+ pkg: p,
1891
+ tarball: tb
1892
+ };
1893
+ }));
1894
+ for (const { pkg, tarball } of tarballs) {
1895
+ installOne(pkg, tarball, homeBase);
1896
+ console.error(`[install-extension] ${pkg.name}: installed`);
1897
+ }
1898
+ if (!opts.skipConfigUpdate) updatePluginInstalls(opts.configPath ?? node_path.default.join(homeBase, "workspace/agent/openclaw.json"), targets);
1899
+ else console.error(`[install-extension] skipConfigUpdate=true — not touching openclaw.json`);
1900
+ console.error(`[install-extension] done ${targets.length}/${targets.length} in ${Date.now() - t0}ms`);
1901
+ }
1902
+ /**
1903
+ * Merge each installed extension's installMetadata into openclaw.json's
1904
+ * plugins.installs[<pkg.name>]. Atomic write via tmp + rename.
1905
+ *
1906
+ * - No openclaw.json → log + return (not an error; some install contexts don't have it yet)
1907
+ * - Extension without installMetadata in manifest → skip that entry (log)
1908
+ * - Existing plugins.installs entries for other extensions left untouched
1909
+ */
1910
+ function updatePluginInstalls(configPath, installedPkgs) {
1911
+ if (!node_fs.default.existsSync(configPath)) {
1912
+ console.error(`[install-extension] no config at ${configPath} — skip plugins.installs update`);
1913
+ return;
1914
+ }
1915
+ const JSON5 = loadJSON5();
1916
+ const raw = node_fs.default.readFileSync(configPath, "utf-8");
1917
+ const config = JSON5.parse(raw);
1918
+ if (!config.plugins || typeof config.plugins !== "object") config.plugins = {};
1919
+ const plugins = config.plugins;
1920
+ if (!plugins.installs || typeof plugins.installs !== "object") plugins.installs = {};
1921
+ const installs = plugins.installs;
1922
+ let updated = 0;
1923
+ let skipped = 0;
1924
+ for (const pkg of installedPkgs) if (pkg.installMetadata) {
1925
+ installs[pkg.name] = pkg.installMetadata;
1926
+ updated++;
1927
+ } else skipped++;
1928
+ const tmpPath = configPath + ".installs-tmp";
1929
+ node_fs.default.writeFileSync(tmpPath, JSON.stringify(config, null, 2), "utf-8");
1930
+ moveSafe(tmpPath, configPath);
1931
+ console.error(`[install-extension] plugins.installs updated: ${updated} entry(ies) in ${configPath}` + (skipped > 0 ? ` (${skipped} package(s) without installMetadata skipped)` : ""));
1932
+ }
1933
+ function installOne(pkg, tarball, homeBase) {
1934
+ const destDir = node_path.default.join(homeBase, pkg.installPath);
1935
+ const stagingDir = destDir + ".new";
1936
+ const oldDir = destDir + ".old";
1937
+ node_fs.default.mkdirSync(node_path.default.dirname(destDir), { recursive: true });
1938
+ if (node_fs.default.existsSync(stagingDir)) node_fs.default.rmSync(stagingDir, {
1939
+ recursive: true,
1940
+ force: true
1941
+ });
1942
+ node_fs.default.mkdirSync(stagingDir);
1943
+ try {
1944
+ extractTarballTolerant(tarball, stagingDir, { stripComponents: 1 });
1945
+ if (!node_fs.default.existsSync(node_path.default.join(stagingDir, "package.json"))) throw new Error(`extension tarball missing package.json: ${pkg.name}`);
1946
+ } catch (e) {
1947
+ try {
1948
+ node_fs.default.rmSync(stagingDir, {
1949
+ recursive: true,
1950
+ force: true
1951
+ });
1952
+ } catch {}
1953
+ throw e;
1954
+ }
1955
+ const hadOld = node_fs.default.existsSync(destDir);
1956
+ if (hadOld) moveSafe(destDir, oldDir);
1957
+ moveSafe(stagingDir, destDir);
1958
+ if (hadOld && node_fs.default.existsSync(oldDir)) node_fs.default.rmSync(oldDir, {
1959
+ recursive: true,
1960
+ force: true
1961
+ });
1962
+ }
1963
+ /**
1964
+ * Download + extract a config/template package to its install destination.
1965
+ *
1966
+ * Current manifest has all resources as format=tgz with content at the root
1967
+ * (config: openclaw.json file at root; template: scripts/ dir at root), so we
1968
+ * always `tar -xzf` without --strip-components into `dirname(fullInstallPath)`.
1969
+ * The final artefact ends up at exactly `homeBase + pkg.installPath`.
1970
+ */
1971
+ async function downloadResource(tag, ossFileMap, opts) {
1972
+ const homeBase = opts.homeBase ?? "/home/gem";
1973
+ const pkg = (await fetchManifest(ossFileMap, tag)).packages.find((p) => p.role === opts.role && p.name === opts.name);
1974
+ if (!pkg) throw new Error(`download-resource: not found in manifest: role=${opts.role} name=${opts.name}`);
1975
+ const file = await downloadWithCache(pkg, ossFileMap, opts);
1976
+ const fullInstallPath = node_path.default.join(homeBase, pkg.installPath);
1977
+ const extractDir = opts.dir ?? node_path.default.dirname(fullInstallPath);
1978
+ node_fs.default.mkdirSync(extractDir, { recursive: true });
1979
+ const format = (pkg.format ?? "").toLowerCase();
1980
+ const lower = pkg.ossKey.toLowerCase();
1981
+ if (format === "tgz" || lower.endsWith(".tgz") || lower.endsWith(".tar.gz")) {
1982
+ extractTarballTolerant(file, extractDir);
1983
+ console.error(`[download-resource] ${opts.role}/${opts.name}: extracted to ${extractDir}`);
1984
+ } else {
1985
+ const basename = node_path.default.posix.basename(pkg.ossKey);
1986
+ node_fs.default.copyFileSync(file, node_path.default.join(extractDir, basename));
1987
+ console.error(`[download-resource] ${opts.role}/${opts.name}: copied ${basename} to ${extractDir}`);
1988
+ }
1989
+ }
1990
+ //#endregion
1991
+ //#region src/oss/getOpenclawTag.ts
1992
+ /**
1993
+ * Extracts the openclaw tag from the manifest key present in ossFileMap.
1994
+ * Avoids passing an extra ctx field — we already know the tag from the
1995
+ * well-known manifest key studio_server included.
1996
+ *
1997
+ * Manifest key shape: builtin/manifests/openclaw/recommended/<tag>.json
1998
+ */
1999
+ function getOpenclawTagFromOssFileMap(ossFileMap) {
2000
+ const prefix = "builtin/manifests/openclaw/recommended/";
2001
+ const suffix = ".json";
2002
+ for (const key of Object.keys(ossFileMap)) if (key.startsWith(prefix) && key.endsWith(suffix)) return key.slice(39, -5);
2003
+ throw new Error("cannot resolve openclaw tag: ossFileMap missing manifest key");
2004
+ }
2005
+ //#endregion
1078
2006
  //#region src/reset.ts
1079
2007
  const STEPS = [
1080
2008
  "备份当前配置",
1081
- "下载技术栈模板",
1082
2009
  "生成默认配置",
1083
2010
  "杀掉 openclaw 进程",
1084
- "重装 openclaw",
2011
+ "等待沙箱初始化完成",
2012
+ "确认 openclaw 版本",
1085
2013
  "合并核心备份配置",
1086
- "复制启动脚本",
1087
- "重装内置插件",
2014
+ "检查启动脚本",
2015
+ "安装扩展",
1088
2016
  "启动并验证"
1089
2017
  ];
1090
2018
  const TOTAL_STEPS = STEPS.length;
1091
- const CORE_BACKUP_PATH = "/home/gem/workspace/.force/openclaw/core-backup.json";
1092
- const TMP_RESET_DIR = "/tmp/openclaw-reset";
1093
- /**
1094
- * Atomically write the result file (write .tmp + rename).
1095
- */
1096
2019
  function writeResultFile(resultFile, result) {
1097
2020
  const dir = node_path.default.dirname(resultFile);
1098
2021
  if (!node_fs.default.existsSync(dir)) node_fs.default.mkdirSync(dir, { recursive: true });
1099
2022
  const tmpPath = resultFile + ".tmp";
1100
2023
  node_fs.default.writeFileSync(tmpPath, JSON.stringify(result), "utf-8");
1101
- node_fs.default.renameSync(tmpPath, resultFile);
2024
+ moveSafe(tmpPath, resultFile);
1102
2025
  }
1103
- /**
1104
- * Update progress in the result file.
1105
- */
1106
- function updateProgress(resultFile, step, progress, startedAt) {
2026
+ function updateProgress(resultFile, step, startedAt) {
1107
2027
  writeResultFile(resultFile, {
1108
2028
  status: "running",
1109
2029
  step,
1110
2030
  totalSteps: TOTAL_STEPS,
1111
- progress,
2031
+ progress: STEPS[step - 1],
1112
2032
  startedAt
1113
2033
  });
1114
2034
  }
1115
- /**
1116
- * Mark the task as done.
1117
- */
1118
2035
  function markDone(resultFile, startedAt) {
1119
2036
  writeResultFile(resultFile, {
1120
2037
  status: "done",
@@ -1125,147 +2042,362 @@ function markDone(resultFile, startedAt) {
1125
2042
  completedAt: (/* @__PURE__ */ new Date()).toISOString()
1126
2043
  });
1127
2044
  }
1128
- /**
1129
- * Mark the task as failed.
1130
- */
1131
- function markFailed(resultFile, step, progress, error, startedAt) {
2045
+ function markFailed(resultFile, step, error, startedAt) {
1132
2046
  writeResultFile(resultFile, {
1133
2047
  status: "failed",
1134
2048
  step,
1135
2049
  totalSteps: TOTAL_STEPS,
1136
- progress,
2050
+ progress: step > 0 ? STEPS[step - 1] : "初始化",
1137
2051
  error,
1138
2052
  startedAt,
1139
2053
  completedAt: (/* @__PURE__ */ new Date()).toISOString()
1140
2054
  });
1141
2055
  }
1142
2056
  /**
2057
+ * Download the template assets (config/openclaw.json + template/scripts) from
2058
+ * OSS into a scratch directory so the existing step 2 (generateDefaultConfig)
2059
+ * and step 7 (copyStartupScripts) can consume them as local files — the rest
2060
+ * of the orchestrator code stays untouched.
2061
+ *
2062
+ * Called once before step 1. The caller is responsible for rm -rf'ing
2063
+ * stagedDir in a finally{} block after the reset completes (or fails).
2064
+ */
2065
+ async function stageTemplate(openclawTag, ossFileMap, stagedDir, configDir, log) {
2066
+ if (node_fs.default.existsSync(stagedDir)) node_fs.default.rmSync(stagedDir, {
2067
+ recursive: true,
2068
+ force: true
2069
+ });
2070
+ node_fs.default.mkdirSync(stagedDir, { recursive: true });
2071
+ await downloadResource(openclawTag, ossFileMap, {
2072
+ role: "config",
2073
+ name: "openclaw.json",
2074
+ dir: stagedDir
2075
+ });
2076
+ await downloadResource(openclawTag, ossFileMap, {
2077
+ role: "template",
2078
+ name: "scripts",
2079
+ dir: configDir
2080
+ });
2081
+ log(`staged openclaw.json to ${stagedDir}, scripts directly to ${configDir}/scripts`);
2082
+ }
2083
+ /** Step 1: Backup current config as openclaw.json.bak.N */
2084
+ function backupCurrentConfig(configPath, log) {
2085
+ if (!fileExists(configPath)) {
2086
+ log("no existing config, skip backup");
2087
+ return;
2088
+ }
2089
+ const dir = node_path.default.dirname(configPath);
2090
+ let maxN = 0;
2091
+ try {
2092
+ for (const f of node_fs.default.readdirSync(dir)) {
2093
+ const match = f.match(/^openclaw\.json\.bak\.(\d+)$/);
2094
+ if (match) {
2095
+ const n = parseInt(match[1], 10);
2096
+ if (n > maxN) maxN = n;
2097
+ }
2098
+ }
2099
+ } catch {}
2100
+ const bakPath = configPath + ".bak." + (maxN + 1);
2101
+ node_fs.default.copyFileSync(configPath, bakPath);
2102
+ log(`backed up to ${bakPath}`);
2103
+ }
2104
+ /** Step 2: Replace $$__XXX__ placeholders and write default config. */
2105
+ function generateDefaultConfig(srcDir, configPath, templateVars, log) {
2106
+ const srcConfigPath = node_path.default.join(srcDir, "openclaw.json");
2107
+ if (!fileExists(srcConfigPath)) throw new Error("staged openclaw.json not found at " + srcConfigPath);
2108
+ let content = node_fs.default.readFileSync(srcConfigPath, "utf-8");
2109
+ let replaced = 0;
2110
+ for (const [placeholder, value] of Object.entries(templateVars)) {
2111
+ const parts = content.split(placeholder);
2112
+ if (parts.length > 1) replaced += parts.length - 1;
2113
+ content = parts.join(value);
2114
+ }
2115
+ node_fs.default.writeFileSync(configPath, content, "utf-8");
2116
+ log(`wrote ${configPath} (${replaced} placeholder(s) replaced, ${Object.keys(templateVars).length} provided)`);
2117
+ }
2118
+ /** Step 3: Kill all openclaw processes. */
2119
+ function killOpenclawProcesses(log) {
2120
+ try {
2121
+ shell("pkill -f openclaw-gateway || true", 5e3);
2122
+ } catch {}
2123
+ shell("sleep 2", 5e3);
2124
+ log("killed openclaw-gateway processes");
2125
+ }
2126
+ /**
2127
+ * Step 4: Wait for the sandbox's own init (init_sandbox.sh / concurrent npm
2128
+ * install) to finish before we start our own work. Two processes sharing
2129
+ * ~/.npm cache + competing for disk/network just makes everything crawl;
2130
+ * letting init finish first is the cleanest way to get exclusive access.
2131
+ * Polls every 10s up to `maxWaitMs`. If the deadline is hit we fall through
2132
+ * anyway — better to try than to fail the reset outright.
2133
+ *
2134
+ * Kept even after we switched off `npm install` because the sandbox init
2135
+ * script still runs `npm install` for other packages and holds cache locks.
2136
+ */
2137
+ function waitForInitNpm(maxWaitMs, log) {
2138
+ const deadline = Date.now() + maxWaitMs;
2139
+ const ownPid = String(process.pid);
2140
+ let polls = 0;
2141
+ while (Date.now() < deadline) {
2142
+ polls++;
2143
+ let running = 0;
2144
+ try {
2145
+ const out = shell(`pgrep -af "init_sandbox.sh|npm install|npm i " | grep -v -- "${ownPid}" | wc -l`, 1e4);
2146
+ running = parseInt(out.trim(), 10) || 0;
2147
+ } catch {
2148
+ log(`poll ${polls}: no concurrent npm, proceeding`);
2149
+ return;
2150
+ }
2151
+ if (running === 0) {
2152
+ log(`poll ${polls}: no concurrent npm, proceeding`);
2153
+ return;
2154
+ }
2155
+ log(`poll ${polls}: ${running} concurrent npm/init process(es) still running, waiting 10s`);
2156
+ try {
2157
+ shell("sleep 10", 12e3);
2158
+ } catch {}
2159
+ }
2160
+ log(`deadline (${maxWaitMs}ms) hit after ${polls} poll(s), proceeding anyway`);
2161
+ }
2162
+ /**
2163
+ * Step 5: Install openclaw from the OSS-provided tarball at the target tag,
2164
+ * then verify `openclaw --version` output contains that tag. No npm involved.
2165
+ */
2166
+ async function step5InstallOpenclaw(openclawTag, ossFileMap, log) {
2167
+ log(`install-openclaw tag=${openclawTag}`);
2168
+ await installOpenclaw(openclawTag, ossFileMap);
2169
+ const out = shell("openclaw --version 2>&1 || true", 1e4).trim();
2170
+ if (!out.includes(openclawTag)) throw new Error(`openclaw version verify failed: got "${out}"`);
2171
+ log(`openclaw version verified: ${out}`);
2172
+ }
2173
+ /** Step 6: Merge coreBackup from resetData + ensure allowedOrigins. */
2174
+ function mergeCoreBackupAndOrigins(configPath, vars, resetData, log) {
2175
+ const JSON5 = loadJSON5();
2176
+ const backup = resetData.coreBackup;
2177
+ if (backup) {
2178
+ const config = JSON5.parse(node_fs.default.readFileSync(configPath, "utf-8"));
2179
+ const merged = [];
2180
+ if (backup.agents && backup.agents.length > 0) {
2181
+ if (!config.agents) config.agents = {};
2182
+ const agents = config.agents;
2183
+ if (!Array.isArray(agents.list)) agents.list = [];
2184
+ const configDir = node_path.default.dirname(configPath);
2185
+ for (const agent of backup.agents) {
2186
+ const enriched = {
2187
+ id: agent.id,
2188
+ name: agent.id,
2189
+ workspace: agent.workspace,
2190
+ agentDir: configDir + "/agents/" + agent.id + "/agent"
2191
+ };
2192
+ agents.list.push(enriched);
2193
+ }
2194
+ merged.push(`agents(+${backup.agents.length})`);
2195
+ const list = agents.list;
2196
+ let mainIdx = list.findIndex((a) => a.id === "main");
2197
+ if (mainIdx < 0) {
2198
+ list.unshift({ id: "main" });
2199
+ mainIdx = 0;
2200
+ }
2201
+ list[mainIdx].subagents = { allowAgents: ["*"] };
2202
+ list[mainIdx].default = true;
2203
+ merged.push("main-team-mode");
2204
+ const feishu = config.channels?.feishu;
2205
+ if (feishu) {
2206
+ if (!feishu.accounts) feishu.accounts = {};
2207
+ const accounts = feishu.accounts;
2208
+ const defaultAccount = {};
2209
+ for (const key of [
2210
+ "dmPolicy",
2211
+ "allowFrom",
2212
+ "groupPolicy",
2213
+ "groupAllowFrom"
2214
+ ]) if (feishu[key] !== void 0) defaultAccount[key] = feishu[key];
2215
+ if (Object.keys(defaultAccount).length > 0) {
2216
+ accounts.default = defaultAccount;
2217
+ merged.push("accounts.default");
2218
+ }
2219
+ }
2220
+ }
2221
+ if (backup.bindings && backup.bindings.length > 0) {
2222
+ config.bindings = backup.bindings;
2223
+ merged.push("bindings");
2224
+ }
2225
+ const backupAccounts = backup.channels?.feishu?.accounts;
2226
+ if (backupAccounts && Object.keys(backupAccounts).length > 0) {
2227
+ if (!config.channels) config.channels = {};
2228
+ const ch = config.channels;
2229
+ if (!ch.feishu) ch.feishu = {};
2230
+ const feishu = ch.feishu;
2231
+ if (!feishu.accounts) feishu.accounts = {};
2232
+ Object.assign(feishu.accounts, backupAccounts);
2233
+ merged.push("channels.feishu.accounts");
2234
+ }
2235
+ node_fs.default.writeFileSync(configPath, JSON.stringify(config, null, 2), "utf-8");
2236
+ log(`merged from coreBackup: [${merged.join(", ") || "nothing"}]`);
2237
+ } else log("no coreBackup in resetData, skip multi-agent merge");
2238
+ const expectedOrigins = Array.isArray(vars.expectedOrigins) ? vars.expectedOrigins : [];
2239
+ if (expectedOrigins.length === 0) {
2240
+ log("no expectedOrigins provided");
2241
+ return;
2242
+ }
2243
+ const config = JSON5.parse(node_fs.default.readFileSync(configPath, "utf-8"));
2244
+ if (!config.gateway) config.gateway = {};
2245
+ const gw = config.gateway;
2246
+ if (!gw.controlUi) gw.controlUi = {};
2247
+ const cui = gw.controlUi;
2248
+ const current = Array.isArray(cui.allowedOrigins) ? cui.allowedOrigins.filter((o) => typeof o === "string") : [];
2249
+ if (current.includes("*")) {
2250
+ log("allowedOrigins already contains \"*\", skip origin merge");
2251
+ return;
2252
+ }
2253
+ const seen = new Set(current);
2254
+ const added = [];
2255
+ const mergedOrigins = [...current];
2256
+ for (const o of expectedOrigins) if (!seen.has(o)) {
2257
+ mergedOrigins.push(o);
2258
+ seen.add(o);
2259
+ added.push(o);
2260
+ }
2261
+ cui.allowedOrigins = mergedOrigins;
2262
+ node_fs.default.writeFileSync(configPath, JSON.stringify(config, null, 2), "utf-8");
2263
+ log(`allowedOrigins: added ${added.length} (${JSON.stringify(added)}), total now ${mergedOrigins.length}`);
2264
+ }
2265
+ /**
2266
+ * Step 7: Verify startup scripts landed in configDir/scripts/.
2267
+ *
2268
+ * Scripts are extracted directly to configDir/scripts/ during stageTemplate —
2269
+ * there's no intermediate copy any more. This step is now a verification gate
2270
+ * (rather than a copy action) so the step count stays at 9 and we fail early
2271
+ * if the template tgz didn't carry a scripts/ dir.
2272
+ */
2273
+ function verifyStartupScripts(configDir, log) {
2274
+ const targetScriptsDir = node_path.default.join(configDir, "scripts");
2275
+ if (!node_fs.default.existsSync(targetScriptsDir)) throw new Error(`scripts dir missing at ${targetScriptsDir} — template download failed?`);
2276
+ log(`scripts dir present at ${targetScriptsDir}`);
2277
+ }
2278
+ /**
2279
+ * Step 8: Install all extensions listed in the OSS manifest at `openclawTag`.
2280
+ * Replaces the old `plugins update --all` / pre-packed tar.gz flow — the
2281
+ * manifest is now the single source of truth for which extensions ship.
2282
+ */
2283
+ async function step8InstallExtensions(openclawTag, ossFileMap, log) {
2284
+ log(`install-extension --all tag=${openclawTag}`);
2285
+ await installExtension(openclawTag, ossFileMap, {
2286
+ all: true,
2287
+ skipConfigUpdate: true
2288
+ });
2289
+ log("extensions installed");
2290
+ }
2291
+ /** Step 9: Write secrets/provider key files and restart openclaw. */
2292
+ function writeSecretsAndRestart(vars, resetData, configDir, log) {
2293
+ if (resetData.secretsContent && vars.secretsFilePath) {
2294
+ writeFile(vars.secretsFilePath, resetData.secretsContent);
2295
+ log(`wrote secrets to ${vars.secretsFilePath}`);
2296
+ }
2297
+ if (resetData.providerKeyContent && vars.providerFilePath) {
2298
+ writeFile(vars.providerFilePath, resetData.providerKeyContent);
2299
+ log(`wrote provider key to ${vars.providerFilePath}`);
2300
+ }
2301
+ const restartScript = node_path.default.join(configDir, "scripts", "restart.sh");
2302
+ if (fileExists(restartScript)) {
2303
+ const t = Date.now();
2304
+ shell(`bash '${restartScript}'`, 3e4);
2305
+ log(`restart.sh done in ${Date.now() - t}ms`);
2306
+ } else log(`no restart.sh at ${restartScript}, skip`);
2307
+ }
2308
+ /**
1143
2309
  * Run the 9-step reset process. Called from the worker entry point.
2310
+ *
2311
+ * Each step is an independent function. The orchestrator handles progress
2312
+ * reporting, error handling, and process-level exception guards.
2313
+ *
2314
+ * Template assets (openclaw.json + scripts/) are downloaded from OSS into a
2315
+ * scratch dir via `stageTemplate` before step 1 — there is no bundled
2316
+ * `template/` directory at runtime any more.
1144
2317
  */
1145
- function runReset(input, taskId, resultFile) {
2318
+ async function runReset(input, taskId, resultFile) {
1146
2319
  const startedAt = (/* @__PURE__ */ new Date()).toISOString();
1147
- const { configPath, resetData } = input;
2320
+ const { configPath, vars, resetData } = input;
1148
2321
  const configDir = node_path.default.dirname(configPath);
2322
+ const stagedDir = node_path.default.join(DIAGNOSE_DIR, `reset-${taskId}-template`);
1149
2323
  let currentStep = 0;
2324
+ let stepStartedAt = Date.now();
2325
+ const log = makeLogger(resetLogFile(taskId));
2326
+ log(`=== reset started, taskId=${taskId}, pid=${process.pid} ===`);
2327
+ log(`configPath=${configPath}, configDir=${configDir}, stagedDir=${stagedDir}`);
2328
+ const ossFileMap = resetData.ossFileMap;
2329
+ if (!ossFileMap || Object.keys(ossFileMap).length === 0) {
2330
+ const err = "resetData.ossFileMap missing or empty";
2331
+ log(`ERROR: ${err}`);
2332
+ markFailed(resultFile, 0, err, startedAt);
2333
+ process.exit(1);
2334
+ }
2335
+ let openclawTag;
2336
+ if (resetData.openclawTag) openclawTag = resetData.openclawTag;
2337
+ else try {
2338
+ openclawTag = getOpenclawTagFromOssFileMap(ossFileMap);
2339
+ } catch (e) {
2340
+ const err = e.message;
2341
+ log(`ERROR: ${err}`);
2342
+ markFailed(resultFile, 0, err, startedAt);
2343
+ process.exit(1);
2344
+ }
2345
+ log(`openclawTag=${openclawTag}`);
1150
2346
  process.on("uncaughtException", (err) => {
1151
- const stepName = currentStep > 0 ? STEPS[currentStep - 1] : "初始化";
1152
- markFailed(resultFile, currentStep, stepName, `uncaught exception: ${err.message}`, startedAt);
2347
+ log(`FATAL uncaughtException: ${err.message}\n${err.stack ?? ""}`);
2348
+ markFailed(resultFile, currentStep, `uncaught exception: ${err.message}`, startedAt);
1153
2349
  process.exit(1);
1154
2350
  });
1155
2351
  process.on("unhandledRejection", (reason) => {
1156
- const stepName = currentStep > 0 ? STEPS[currentStep - 1] : "初始化";
1157
- markFailed(resultFile, currentStep, stepName, `unhandled rejection: ${reason}`, startedAt);
2352
+ log(`FATAL unhandledRejection: ${String(reason)}`);
2353
+ markFailed(resultFile, currentStep, `unhandled rejection: ${reason}`, startedAt);
1158
2354
  process.exit(1);
1159
2355
  });
2356
+ /** Advance to the next step, updating the progress file and logging a boundary. */
2357
+ const step = (n) => {
2358
+ if (currentStep > 0) log(`step ${currentStep} "${STEPS[currentStep - 1]}" done in ${Date.now() - stepStartedAt}ms`);
2359
+ currentStep = n;
2360
+ stepStartedAt = Date.now();
2361
+ log(`--- step ${n}/${TOTAL_STEPS}: ${STEPS[n - 1]} ---`);
2362
+ updateProgress(resultFile, n, startedAt);
2363
+ };
1160
2364
  try {
1161
- currentStep = 1;
1162
- updateProgress(resultFile, currentStep, STEPS[0], startedAt);
1163
- if (fileExists(configPath)) {
1164
- let maxN = 0;
1165
- try {
1166
- const files = node_fs.default.readdirSync(configDir);
1167
- const bakPattern = /^openclaw\.json\.bak\.(\d+)$/;
1168
- for (const f of files) {
1169
- const match = f.match(bakPattern);
1170
- if (match) {
1171
- const n = parseInt(match[1], 10);
1172
- if (n > maxN) maxN = n;
1173
- }
1174
- }
1175
- } catch {}
1176
- const bakPath = configPath + ".bak." + (maxN + 1);
1177
- node_fs.default.copyFileSync(configPath, bakPath);
1178
- }
1179
- currentStep = 2;
1180
- updateProgress(resultFile, currentStep, STEPS[1], startedAt);
1181
- if (!node_fs.default.existsSync(TMP_RESET_DIR)) node_fs.default.mkdirSync(TMP_RESET_DIR, { recursive: true });
1182
- const zipPath = node_path.default.join(TMP_RESET_DIR, "template.zip");
1183
- const zipSource = resetData.zipDownloadURL;
1184
- if (zipSource.startsWith("http://") || zipSource.startsWith("https://")) shell(`curl -sSL -o '${zipPath}' '${zipSource}'`, 12e4);
1185
- else shell(`cp '${zipSource}' '${zipPath}'`, 1e4);
1186
- shell(`unzip -o '${zipPath}' -d '${TMP_RESET_DIR}'`, 6e4);
1187
- const srcDir = findSrcDir(TMP_RESET_DIR);
1188
- currentStep = 3;
1189
- updateProgress(resultFile, currentStep, STEPS[2], startedAt);
1190
- const srcConfigPath = node_path.default.join(srcDir, "openclaw.json");
1191
- if (!fileExists(srcConfigPath)) throw new Error("template openclaw.json not found in downloaded zip at " + srcConfigPath);
1192
- let configContent = node_fs.default.readFileSync(srcConfigPath, "utf-8");
1193
- for (const [placeholder, value] of Object.entries(resetData.templateVars)) configContent = configContent.split(placeholder).join(value);
1194
- node_fs.default.writeFileSync(configPath, configContent, "utf-8");
1195
- currentStep = 4;
1196
- updateProgress(resultFile, currentStep, STEPS[3], startedAt);
1197
- try {
1198
- shell("pkill -f openclaw-gateway || true", 5e3);
1199
- } catch {}
1200
- shell("sleep 2", 5e3);
1201
- currentStep = 5;
1202
- updateProgress(resultFile, currentStep, STEPS[4], startedAt);
1203
- const JSON5 = loadJSON5();
1204
- const version = JSON5.parse(node_fs.default.readFileSync(srcConfigPath, "utf-8")).meta?.lastTouchedVersion;
1205
- if (version) shell(`npm install -g @anthropic-ai/openclaw@${version}`, 3e5);
1206
- else shell("npm install -g @anthropic-ai/openclaw@latest", 3e5);
1207
- currentStep = 6;
1208
- updateProgress(resultFile, currentStep, STEPS[5], startedAt);
1209
- if (fileExists(CORE_BACKUP_PATH)) {
1210
- const backup = JSON.parse(node_fs.default.readFileSync(CORE_BACKUP_PATH, "utf-8"));
1211
- const mergeConfig = JSON5.parse(node_fs.default.readFileSync(configPath, "utf-8"));
1212
- if (backup.agents) mergeConfig.agents = backup.agents;
1213
- if (backup.bindings) mergeConfig.bindings = backup.bindings;
1214
- const backupFeishu = backup.channels?.feishu;
1215
- if (backupFeishu?.accounts) {
1216
- if (!mergeConfig.channels) mergeConfig.channels = {};
1217
- const ch = mergeConfig.channels;
1218
- if (!ch.feishu) ch.feishu = {};
1219
- const feishu = ch.feishu;
1220
- feishu.accounts = backupFeishu.accounts;
1221
- }
1222
- node_fs.default.writeFileSync(configPath, JSON.stringify(mergeConfig, null, 2), "utf-8");
1223
- }
1224
- currentStep = 7;
1225
- updateProgress(resultFile, currentStep, STEPS[6], startedAt);
1226
- const srcScriptsDir = node_path.default.join(srcDir, "scripts");
1227
- const targetScriptsDir = node_path.default.join(configDir, "scripts");
1228
- if (node_fs.default.existsSync(srcScriptsDir)) {
1229
- if (!node_fs.default.existsSync(targetScriptsDir)) node_fs.default.mkdirSync(targetScriptsDir, { recursive: true });
1230
- shell(`cp -r '${srcScriptsDir}'/* '${targetScriptsDir}/'`, 1e4);
1231
- }
1232
- currentStep = 8;
1233
- updateProgress(resultFile, currentStep, STEPS[7], startedAt);
1234
- try {
1235
- shell("openclaw plugins install --all", 3e5);
1236
- } catch (e) {}
1237
- currentStep = 9;
1238
- updateProgress(resultFile, currentStep, STEPS[8], startedAt);
1239
- if (resetData.secretsContent && input.vars.secretsFilePath) writeFile(input.vars.secretsFilePath, resetData.secretsContent);
1240
- if (resetData.providerKeyContent && input.vars.providerFilePath) writeFile(input.vars.providerFilePath, resetData.providerKeyContent);
1241
- const restartScript = node_path.default.join(configDir, "scripts", "restart.sh");
1242
- if (fileExists(restartScript)) shell(`bash '${restartScript}'`, 3e4);
2365
+ await stageTemplate(openclawTag, ossFileMap, stagedDir, configDir, log);
2366
+ step(1);
2367
+ backupCurrentConfig(configPath, log);
2368
+ step(2);
2369
+ generateDefaultConfig(stagedDir, configPath, resetData.templateVars, log);
2370
+ step(3);
2371
+ killOpenclawProcesses(log);
2372
+ step(4);
2373
+ waitForInitNpm(10 * 6e4, log);
2374
+ step(5);
2375
+ await step5InstallOpenclaw(openclawTag, ossFileMap, log);
2376
+ step(6);
2377
+ mergeCoreBackupAndOrigins(configPath, vars, resetData, log);
2378
+ step(7);
2379
+ verifyStartupScripts(configDir, log);
2380
+ step(8);
2381
+ await step8InstallExtensions(openclawTag, ossFileMap, log);
2382
+ step(9);
2383
+ writeSecretsAndRestart(vars, resetData, configDir, log);
2384
+ log(`step 9 "${STEPS[8]}" done in ${Date.now() - stepStartedAt}ms`);
2385
+ log("=== reset completed successfully ===");
2386
+ markDone(resultFile, startedAt);
2387
+ } catch (e) {
2388
+ const err = e.message;
2389
+ log(`ERROR in step ${currentStep} "${STEPS[currentStep - 1] ?? "init"}" after ${Date.now() - stepStartedAt}ms: ${err}\n${e.stack ?? ""}`);
2390
+ markFailed(resultFile, currentStep, err, startedAt);
2391
+ process.exit(1);
2392
+ } finally {
1243
2393
  try {
1244
- node_fs.default.rmSync(TMP_RESET_DIR, {
2394
+ node_fs.default.rmSync(stagedDir, {
1245
2395
  recursive: true,
1246
2396
  force: true
1247
2397
  });
1248
2398
  } catch {}
1249
- markDone(resultFile, startedAt);
1250
- } catch (e) {
1251
- const stepName = currentStep > 0 ? STEPS[currentStep - 1] : "初始化";
1252
- markFailed(resultFile, currentStep, stepName, e.message, startedAt);
1253
- process.exit(1);
1254
2399
  }
1255
2400
  }
1256
- /**
1257
- * Find the source directory within the extracted zip.
1258
- * Looks for openclaw.json in TMP_RESET_DIR or one level deep.
1259
- */
1260
- function findSrcDir(baseDir) {
1261
- if (node_fs.default.existsSync(node_path.default.join(baseDir, "openclaw.json"))) return baseDir;
1262
- const entries = node_fs.default.readdirSync(baseDir, { withFileTypes: true });
1263
- for (const entry of entries) if (entry.isDirectory()) {
1264
- const candidate = node_path.default.join(baseDir, entry.name);
1265
- if (node_fs.default.existsSync(node_path.default.join(candidate, "openclaw.json"))) return candidate;
1266
- }
1267
- return baseDir;
1268
- }
1269
2401
  //#endregion
1270
2402
  //#region src/get-reset-task.ts
1271
2403
  /**
@@ -1274,7 +2406,7 @@ function findSrcDir(baseDir) {
1274
2406
  * Returns immediately on terminal states (done/failed).
1275
2407
  */
1276
2408
  function getResetTask(taskId) {
1277
- const resultFile = `/tmp/openclaw-reset-${taskId}.json`;
2409
+ const resultFile = resetResultFile(taskId);
1278
2410
  const deadline = Date.now() + 3e4;
1279
2411
  while (Date.now() < deadline) {
1280
2412
  if (!node_fs.default.existsSync(resultFile)) {
@@ -1305,65 +2437,750 @@ function sleepSync(ms) {
1305
2437
  Atomics.wait(arr, 0, 0, ms);
1306
2438
  }
1307
2439
  //#endregion
2440
+ //#region src/oss/resolveOssFileMap.ts
2441
+ /**
2442
+ * Pick an OssFileMap in the order of decreasing specificity:
2443
+ * 1. `--oss_file_map=` flag — operator override (manual invocations, tests)
2444
+ * 2. `ctx.install.ossFileMap` — new shape (innerapi-driven DoctorCtx)
2445
+ * 3. `ctx.resetData.ossFileMap` — legacy shape (sandbox_console push path)
2446
+ *
2447
+ * Throws when none of the three yields a non-empty map. Empty maps are
2448
+ * treated as missing — an empty map is useless downstream and almost always
2449
+ * indicates a ctx wiring bug.
2450
+ */
2451
+ function resolveOssFileMap(args) {
2452
+ if (args.ossFileMapFlag) return JSON.parse(Buffer.from(args.ossFileMapFlag, "base64").toString("utf-8"));
2453
+ if (args.installOssFileMap && Object.keys(args.installOssFileMap).length > 0) return args.installOssFileMap;
2454
+ if (args.resetDataOssFileMap && Object.keys(args.resetDataOssFileMap).length > 0) return args.resetDataOssFileMap;
2455
+ throw new Error("ossFileMap missing: provide --oss_file_map flag, ctx.install.ossFileMap, or resetData.ossFileMap");
2456
+ }
2457
+ //#endregion
2458
+ //#region src/innerapi/fetchCtx.ts
2459
+ /**
2460
+ * CLI-side client for studio_server's `openclaw.get_doctor_ctx` inner API.
2461
+ *
2462
+ * Mirrors the proven pattern in
2463
+ * `packages/openclaw/extensions/miaoda/src/shared/innerapi-client.ts`:
2464
+ *
2465
+ * - `baseURL` from env `FORCE_AUTHN_INNERAPI_DOMAIN` (injected into every
2466
+ * openclaw sandbox).
2467
+ * - `platform: { enabled, tokenProvider: { type: 'file' } }` — the platform
2468
+ * plugin auto-attaches the sandbox's identity JWT loaded from the
2469
+ * rootfs token file. Same auth that the miaoda extension already uses.
2470
+ * - POST `/api/v1/studio/innerapi/integration_apis/call`
2471
+ * body = { apiName: 'openclaw.get_doctor_ctx', input: {}, bizType: 'openclaw' }
2472
+ * — the server-side APICall dispatches by `apiName` to
2473
+ * `GetDoctorCtxAPICall.Execute` whose `Name()` returns that string.
2474
+ * - Response envelope: { status_code, error_msg?, data: { success, output, ... } }.
2475
+ * `status_code` is a *string* ('0' = success).
2476
+ * Actual DoctorCtx lives in `data.output`.
2477
+ * - `x-tt-logid` header is logged on every failure path for cross-service
2478
+ * traceability.
2479
+ *
2480
+ * On HTTP 401 (sandbox identity token expired/invalid) we `process.exit(77)`
2481
+ * instead of throwing — the outer catch in `index.ts` cannot then mask auth
2482
+ * failure as a generic "Error: ...". Caller (e.g. sandbox_console) sees the
2483
+ * exit code and can refresh the token + retry.
2484
+ */
2485
+ const INNERAPI_CALL_PATH = "/api/v1/studio/innerapi/integration_apis/call";
2486
+ const API_NAME = "openclaw.get_doctor_ctx";
2487
+ const BIZ_TYPE = "openclaw";
2488
+ const API_TIMEOUT_MS = 3e4;
2489
+ const MAX_LOG_BODY = 500;
2490
+ let clientInstance = null;
2491
+ function getHttpClient() {
2492
+ if (!clientInstance) {
2493
+ const apiUrl = process.env.FORCE_AUTHN_INNERAPI_DOMAIN;
2494
+ (0, node_assert.default)(apiUrl, "missing env: FORCE_AUTHN_INNERAPI_DOMAIN (openclaw sandbox runtime must expose this)");
2495
+ clientInstance = new _lark_apaas_http_client.HttpClient({
2496
+ baseURL: apiUrl,
2497
+ timeout: API_TIMEOUT_MS,
2498
+ platform: {
2499
+ enabled: true,
2500
+ tokenProvider: { type: "file" }
2501
+ }
2502
+ });
2503
+ }
2504
+ return clientInstance;
2505
+ }
2506
+ /**
2507
+ * Fetch the sandbox's DoctorCtx by calling the innerapi's generic
2508
+ * `integration_apis/call` dispatcher with apiName=openclaw.get_doctor_ctx.
2509
+ *
2510
+ * Throws on HTTP (non-401) / decode / business errors. On 401 calls
2511
+ * `process.exit(77)` directly.
2512
+ */
2513
+ async function fetchCtxViaInnerApi() {
2514
+ const client = getHttpClient();
2515
+ const body = {
2516
+ apiName: API_NAME,
2517
+ input: {},
2518
+ bizType: BIZ_TYPE
2519
+ };
2520
+ const start = Date.now();
2521
+ const headers = { "Content-Type": "application/json" };
2522
+ const ttEnv = process.env.X_TT_ENV;
2523
+ if (ttEnv) headers["x-tt-env"] = ttEnv;
2524
+ let response;
2525
+ try {
2526
+ response = await client.post(INNERAPI_CALL_PATH, body, { headers });
2527
+ } catch (e) {
2528
+ const durationMs = Date.now() - start;
2529
+ if (e instanceof _lark_apaas_http_client.HttpError && e.response) {
2530
+ const status = e.response.status;
2531
+ const logId = e.response.headers.get("x-tt-logid") ?? "";
2532
+ if (status === 401) {
2533
+ console.error(`[CLI] innerapi 401 (logID: ${logId}) — sandbox identity token expired/invalid; exiting 77`);
2534
+ process.exit(77);
2535
+ }
2536
+ throw new Error(`fetchCtxViaInnerApi HTTP ${status} ${e.response.statusText} (logID: ${logId}, durationMs: ${durationMs})`);
2537
+ }
2538
+ const msg = e instanceof Error ? e.message : String(e);
2539
+ throw new Error(`fetchCtxViaInnerApi network error: ${msg} (durationMs: ${durationMs})`);
2540
+ }
2541
+ const logId = response.headers.get("x-tt-logid") ?? "";
2542
+ const durationMs = Date.now() - start;
2543
+ if (!response.ok) {
2544
+ if (response.status === 401) {
2545
+ console.error(`[CLI] innerapi 401 (logID: ${logId}) — sandbox identity token expired/invalid; exiting 77`);
2546
+ process.exit(77);
2547
+ }
2548
+ let preview = "";
2549
+ try {
2550
+ preview = (await response.text()).slice(0, MAX_LOG_BODY);
2551
+ } catch {}
2552
+ throw new Error(`fetchCtxViaInnerApi HTTP ${response.status} ${response.statusText} (logID: ${logId}, durationMs: ${durationMs})${preview ? ` body=${preview}` : ""}`);
2553
+ }
2554
+ let envelope;
2555
+ try {
2556
+ envelope = await response.json();
2557
+ } catch {
2558
+ throw new Error(`fetchCtxViaInnerApi decode error (logID: ${logId}, durationMs: ${durationMs})`);
2559
+ }
2560
+ if (envelope.status_code !== "0") throw new Error(`fetchCtxViaInnerApi API error (logID: ${logId}, durationMs: ${durationMs}): code=${envelope.status_code}, message=${envelope.error_msg ?? ""}`);
2561
+ if (envelope.data && envelope.data.success === false) throw new Error(`fetchCtxViaInnerApi business error (logID: ${logId}, durationMs: ${durationMs}): ${envelope.error_msg ?? JSON.stringify(envelope.data)}`);
2562
+ const output = envelope.data?.output;
2563
+ if (!output || typeof output !== "object") throw new Error(`fetchCtxViaInnerApi empty/invalid output (logID: ${logId}, durationMs: ${durationMs})`);
2564
+ return output;
2565
+ }
2566
+ //#endregion
2567
+ //#region src/ctx/normalize.ts
2568
+ /**
2569
+ * Accept raw ctx from any of these sources and produce a uniform view:
2570
+ * - New shape (DoctorCtx): `{ app, install, secrets, reset }` — from innerapi.
2571
+ * - Old shape (ResetInput): `{ configPath, vars, resetData }` — from
2572
+ * sandbox_console push path.
2573
+ * Detection is structural: if the top-level has all four new-shape groups we
2574
+ * pass through; otherwise we remap from the old shape.
2575
+ *
2576
+ * Missing fields fall back to safe empty defaults (empty strings / arrays /
2577
+ * maps) so every downstream consumer can read e.g. `ctx.app.feishuAppID`
2578
+ * without an extra nullish guard.
2579
+ */
2580
+ function normalizeCtx(raw) {
2581
+ const r = raw ?? {};
2582
+ 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 {
2583
+ app: fillApp(r.app),
2584
+ install: {
2585
+ openclawTag: r.install.openclawTag,
2586
+ ossFileMap: r.install.ossFileMap ?? {}
2587
+ },
2588
+ secrets: {
2589
+ secretsContent: r.secrets.secretsContent ?? "",
2590
+ providerKeyContent: r.secrets.providerKeyContent ?? ""
2591
+ },
2592
+ reset: {
2593
+ templateVars: r.reset.templateVars ?? {},
2594
+ coreBackup: r.reset.coreBackup
2595
+ }
2596
+ };
2597
+ const vars = r.vars ?? {};
2598
+ const resetData = r.resetData ?? {};
2599
+ const repairData = r.repairData ?? {};
2600
+ return {
2601
+ app: fillApp(vars),
2602
+ install: {
2603
+ openclawTag: r.install?.openclawTag ?? r.openclawTag,
2604
+ ossFileMap: r.install?.ossFileMap ?? resetData.ossFileMap ?? r.ossFileMap ?? {}
2605
+ },
2606
+ secrets: {
2607
+ secretsContent: resetData.secretsContent ?? repairData.secretsContent ?? "",
2608
+ providerKeyContent: resetData.providerKeyContent ?? repairData.providerKeyContent ?? ""
2609
+ },
2610
+ reset: {
2611
+ templateVars: resetData.templateVars ?? {},
2612
+ coreBackup: resetData.coreBackup
2613
+ }
2614
+ };
2615
+ }
2616
+ function fillApp(src) {
2617
+ return {
2618
+ feishuAppID: src.feishuAppID ?? "",
2619
+ feishuAppSecret: src.feishuAppSecret ?? "",
2620
+ teamChatID: typeof src.teamChatID === "string" && src.teamChatID !== "" ? src.teamChatID : void 0,
2621
+ feishuOpenID: src.feishuOpenID ?? "",
2622
+ openClawName: src.openClawName ?? "",
2623
+ gatewayToken: src.gatewayToken ?? "",
2624
+ innerAPIKey: src.innerAPIKey ?? "",
2625
+ baseURL: src.baseURL ?? "",
2626
+ miaodaDomain: src.miaodaDomain ?? "",
2627
+ miaodaOrigin: src.miaodaOrigin ?? "",
2628
+ expectedOrigins: Array.isArray(src.expectedOrigins) ? src.expectedOrigins : []
2629
+ };
2630
+ }
2631
+ //#endregion
2632
+ //#region src/ctx-input.ts
2633
+ /**
2634
+ * Build legacy Check/Repair/Reset input shapes from a raw ctx object. Shared
2635
+ * by both the top-level CLI dispatcher (`index.ts`) and the new `doctor`
2636
+ * subcommand (`doctor.ts`), which need identical input synthesis.
2637
+ *
2638
+ * Behavior:
2639
+ * - If `raw` already carries the legacy `configPath + vars` shape (the one
2640
+ * sandbox_console push emits), it's trusted and returned as-is. This
2641
+ * keeps the existing sandbox_console push contract working.
2642
+ * - Otherwise `raw` is treated as the new-shape DoctorCtx (or anything
2643
+ * structurally close — `normalizeCtx` fills the gaps with safe empties)
2644
+ * and the legacy Vars shape is synthesised using the hardcoded sandbox
2645
+ * path invariants from `paths.ts`.
2646
+ *
2647
+ * The optional `configPathOverride` lets unit tests point the builder at a
2648
+ * tmp file; production callers should leave it undefined so the sandbox
2649
+ * invariant from `paths.ts` is used.
2650
+ */
2651
+ function buildCheckInput(raw, configPathOverride) {
2652
+ const r = raw ?? {};
2653
+ if (r.configPath && r.vars) {
2654
+ if (configPathOverride) return {
2655
+ ...r,
2656
+ configPath: configPathOverride
2657
+ };
2658
+ return r;
2659
+ }
2660
+ const ctx = normalizeCtx(raw);
2661
+ return {
2662
+ configPath: configPathOverride ?? CONFIG_PATH,
2663
+ vars: {
2664
+ feishuAppID: ctx.app.feishuAppID,
2665
+ feishuAppSecret: ctx.app.feishuAppSecret,
2666
+ teamChatID: ctx.app.teamChatID,
2667
+ innerAPIKey: ctx.app.innerAPIKey,
2668
+ gatewayToken: ctx.app.gatewayToken,
2669
+ baseURL: ctx.app.baseURL,
2670
+ expectedOrigins: ctx.app.expectedOrigins,
2671
+ providerFilePath: PROVIDER_FILE_PATH,
2672
+ secretsFilePath: SECRETS_FILE_PATH
2673
+ },
2674
+ templateVars: ctx.reset.templateVars
2675
+ };
2676
+ }
2677
+ function buildRepairInput(raw, configPathOverride) {
2678
+ const r = raw ?? {};
2679
+ if (r.configPath && r.vars) {
2680
+ if (configPathOverride) return {
2681
+ ...r,
2682
+ configPath: configPathOverride
2683
+ };
2684
+ return r;
2685
+ }
2686
+ const ctx = normalizeCtx(raw);
2687
+ return {
2688
+ configPath: configPathOverride ?? CONFIG_PATH,
2689
+ vars: {
2690
+ feishuAppID: ctx.app.feishuAppID,
2691
+ feishuAppSecret: ctx.app.feishuAppSecret,
2692
+ teamChatID: ctx.app.teamChatID,
2693
+ innerAPIKey: ctx.app.innerAPIKey,
2694
+ gatewayToken: ctx.app.gatewayToken,
2695
+ baseURL: ctx.app.baseURL,
2696
+ expectedOrigins: ctx.app.expectedOrigins,
2697
+ providerFilePath: PROVIDER_FILE_PATH,
2698
+ secretsFilePath: SECRETS_FILE_PATH
2699
+ },
2700
+ repairData: {
2701
+ secretsContent: ctx.secrets.secretsContent,
2702
+ providerKeyContent: ctx.secrets.providerKeyContent
2703
+ },
2704
+ templateVars: ctx.reset.templateVars
2705
+ };
2706
+ }
2707
+ function buildResetInput(raw, configPathOverride) {
2708
+ const r = raw ?? {};
2709
+ if (r.configPath && r.vars && r.resetData) {
2710
+ if (configPathOverride) return {
2711
+ ...r,
2712
+ configPath: configPathOverride
2713
+ };
2714
+ return r;
2715
+ }
2716
+ const ctx = normalizeCtx(raw);
2717
+ return {
2718
+ configPath: configPathOverride ?? CONFIG_PATH,
2719
+ vars: {
2720
+ feishuAppID: ctx.app.feishuAppID,
2721
+ feishuAppSecret: ctx.app.feishuAppSecret,
2722
+ teamChatID: ctx.app.teamChatID,
2723
+ innerAPIKey: ctx.app.innerAPIKey,
2724
+ gatewayToken: ctx.app.gatewayToken,
2725
+ baseURL: ctx.app.baseURL,
2726
+ expectedOrigins: ctx.app.expectedOrigins,
2727
+ providerFilePath: PROVIDER_FILE_PATH,
2728
+ secretsFilePath: SECRETS_FILE_PATH
2729
+ },
2730
+ resetData: {
2731
+ templateVars: ctx.reset.templateVars,
2732
+ secretsContent: ctx.secrets.secretsContent,
2733
+ providerKeyContent: ctx.secrets.providerKeyContent,
2734
+ coreBackup: ctx.reset.coreBackup,
2735
+ ossFileMap: ctx.install.ossFileMap,
2736
+ openclawTag: ctx.install.openclawTag
2737
+ }
2738
+ };
2739
+ }
2740
+ //#endregion
2741
+ //#region src/doctor.ts
2742
+ async function runDoctor(rawCtx, opts) {
2743
+ if (opts.fix && opts.rules.length > 0) {
2744
+ const repairInput = buildRepairInput(rawCtx, opts.configPath);
2745
+ repairInput.failedRules = opts.rules;
2746
+ repairInput.repairData = {
2747
+ ...repairInput.repairData ?? {},
2748
+ restartCommand: ""
2749
+ };
2750
+ return { repair: runRepair(repairInput) };
2751
+ }
2752
+ const check = runCheck(buildCheckInput(rawCtx, opts.configPath));
2753
+ if (!opts.fix) return { failedRules: check.failedRules };
2754
+ const repairInput = buildRepairInput(rawCtx, opts.configPath);
2755
+ repairInput.failedRules = check.failedRules.standard;
2756
+ return {
2757
+ check,
2758
+ repair: runRepair(repairInput)
2759
+ };
2760
+ }
2761
+ //#endregion
2762
+ //#region src/help.ts
2763
+ const BIN = "mclaw-diagnose";
2764
+ function versionBanner() {
2765
+ return `v0.1.1-alpha.31`;
2766
+ }
2767
+ const COMMANDS = [
2768
+ {
2769
+ name: "doctor",
2770
+ hidden: false,
2771
+ summary: "Diagnose openclaw config; apply repairs with --fix",
2772
+ help: `USAGE
2773
+ ${BIN} doctor [--fix] [--rule=<key>]...
2774
+
2775
+ DESCRIPTION
2776
+ Fetches DoctorCtx via innerapi, then runs one of three modes depending
2777
+ on the flags. Output is a single JSON object on stdout.
2778
+
2779
+ MODES
2780
+ (no flags) Check-only. Runs the rule engine against the
2781
+ sandbox's current openclaw config and returns
2782
+ { failedRules: { standard, ai, reset } }
2783
+ No files are mutated. Use this when you just
2784
+ want to know what's wrong.
2785
+
2786
+ --fix Check + repair-all. First runs the rule engine,
2787
+ then repairs every failing standard-mode rule.
2788
+ Returns
2789
+ { check: {...}, repair: {...} }
2790
+ Use this as the default "fix everything" action.
2791
+
2792
+ --fix --rule=<key>... Targeted repair. Skips the check pass entirely
2793
+ and runs repair against the listed rule keys
2794
+ only. Unknown keys are silently ignored.
2795
+ Returns { repair: {...} } with only those
2796
+ rules' outcomes. Use this when you already
2797
+ know which rules need fixing.
2798
+
2799
+ OPTIONS
2800
+ --fix Enable repair. See MODES above.
2801
+ --rule=<key> Repair only this rule key. Repeatable. Only
2802
+ meaningful together with --fix.
2803
+
2804
+ EXAMPLES
2805
+ ${BIN} doctor # check only
2806
+ ${BIN} doctor --fix # check then repair all
2807
+ ${BIN} doctor --fix --rule=gateway # repair 'gateway' only
2808
+ ${BIN} doctor --fix --rule=gateway --rule=jwt_token # repair multiple
2809
+
2810
+ EXIT CODES
2811
+ 0 success
2812
+ 1 generic error
2813
+ 77 innerapi authentication failed (sandbox JWT expired/invalid)
2814
+ `
2815
+ },
2816
+ {
2817
+ name: "check",
2818
+ hidden: true,
2819
+ summary: "Run rule-engine check only",
2820
+ help: `USAGE
2821
+ ${BIN} check [--ctx=<base64>]
2822
+
2823
+ DESCRIPTION
2824
+ Runs the rule engine against the sandbox's current openclaw config and
2825
+ returns { failedRules }. Used by sandbox_console's push-style callers
2826
+ that already own the ctx — end-users should prefer \`doctor\`.
2827
+
2828
+ OPTIONS
2829
+ --ctx=<base64> Opaque ctx JSON (base64). When absent, fetched from
2830
+ innerapi (same path as doctor).
2831
+ `
2832
+ },
2833
+ {
2834
+ name: "repair",
2835
+ hidden: true,
2836
+ summary: "Apply standard-mode repairs",
2837
+ help: `USAGE
2838
+ ${BIN} repair [--ctx=<base64>]
2839
+
2840
+ DESCRIPTION
2841
+ Runs repair for the failing rules listed inside the ctx's repairData.
2842
+ Intended for sandbox_console's push path — end-users should use
2843
+ \`doctor --fix\` instead.
2844
+
2845
+ OPTIONS
2846
+ --ctx=<base64> Opaque ctx JSON (base64). When absent, fetched from
2847
+ innerapi.
2848
+ `
2849
+ },
2850
+ {
2851
+ name: "reset",
2852
+ hidden: true,
2853
+ summary: "Re-initialize sandbox via the 9-step reset pipeline",
2854
+ help: `USAGE
2855
+ ${BIN} reset --async [--ctx=<base64>]
2856
+ ${BIN} reset --worker --task-id=<id> [--ctx=<base64>]
2857
+
2858
+ DESCRIPTION
2859
+ Two-phase pipeline driven asynchronously: the --async invocation spawns
2860
+ a detached worker and returns { taskId } immediately; the --worker
2861
+ invocation (spawned by --async) runs the actual 9 steps and writes
2862
+ progress to /tmp/openclaw-diagnose/reset-<taskId>.json.
2863
+
2864
+ Poll progress with \`${BIN} get_reset_task --task-id=<id>\`.
2865
+
2866
+ OPTIONS
2867
+ --async Start a detached worker and return taskId on stdout.
2868
+ --worker Internal — run the 9-step pipeline (launched by --async).
2869
+ --task-id=<id> Required with --worker; identifies the progress file.
2870
+ --ctx=<base64> Opaque ctx JSON; fetched from innerapi when absent.
2871
+ `
2872
+ },
2873
+ {
2874
+ name: "get_reset_task",
2875
+ hidden: true,
2876
+ summary: "Poll progress of an async reset task",
2877
+ help: `USAGE
2878
+ ${BIN} get_reset_task --task-id=<id>
2879
+
2880
+ DESCRIPTION
2881
+ Reads /tmp/openclaw-diagnose/reset-<taskId>.json and prints its content
2882
+ as JSON on stdout. Safe to call repeatedly while reset is in progress.
2883
+
2884
+ OPTIONS
2885
+ --task-id=<id> Required. Matches the id returned by \`reset --async\`.
2886
+ `
2887
+ },
2888
+ {
2889
+ name: "install-openclaw",
2890
+ hidden: true,
2891
+ summary: "Download + install the openclaw tarball",
2892
+ help: `USAGE
2893
+ ${BIN} install-openclaw <tag> [--ctx=<base64> | --oss_file_map=<base64>]
2894
+
2895
+ DESCRIPTION
2896
+ Downloads the openclaw@<tag> tgz via the signed OSS URL found in the
2897
+ ctx's install.ossFileMap, extracts it into a tmpfs staging dir, and
2898
+ atomically swaps it into /home/gem/.npm-global/lib/node_modules/openclaw.
2899
+ Used by step 5 of reset.
2900
+
2901
+ ARGUMENTS
2902
+ <tag> Openclaw version tag, e.g. 2026.4.11.
2903
+
2904
+ OPTIONS
2905
+ --ctx=<base64> Opaque ctx; ossFileMap is extracted from it.
2906
+ --oss_file_map=... Pre-built OSS URL map (base64 JSON); skips innerapi
2907
+ entirely. Wins over --ctx when both provided.
2908
+ `
2909
+ },
2910
+ {
2911
+ name: "install-extension",
2912
+ hidden: true,
2913
+ summary: "Install openclaw extension package(s)",
2914
+ help: `USAGE
2915
+ ${BIN} install-extension <tag> (--all | --extension=<name>...) [options]
2916
+
2917
+ DESCRIPTION
2918
+ Downloads + installs one or more openclaw extension tarballs
2919
+ (feishu, miaoda, etc.) into <home_base>/workspace/agent/extensions/,
2920
+ then splices installMetadata into openclaw.json's plugins.installs
2921
+ unless --skip-config-update is passed.
2922
+
2923
+ ARGUMENTS
2924
+ <tag> Openclaw version tag; extension versions resolved
2925
+ against the matching manifest.
2926
+
2927
+ OPTIONS
2928
+ --all Install every extension in the manifest.
2929
+ --extension=<name> Install a specific extension (repeatable).
2930
+ --home_base=<dir> Override the /home/gem base (tests).
2931
+ --config_path=<p> Override the openclaw.json path (tests).
2932
+ --skip-config-update Leave plugins.installs in openclaw.json untouched.
2933
+ --ctx=<base64> Opaque ctx; see install-openclaw for semantics.
2934
+ --oss_file_map=... Pre-built OSS URL map (base64 JSON).
2935
+ `
2936
+ },
2937
+ {
2938
+ name: "download-resource",
2939
+ hidden: true,
2940
+ summary: "Download + extract a single OSS resource",
2941
+ help: `USAGE
2942
+ ${BIN} download-resource <tag> --role=<role> --name=<name> [--dir=<dir>] [options]
2943
+
2944
+ DESCRIPTION
2945
+ Downloads one resource (template, config asset, etc.) identified by
2946
+ (role, name) from the manifest and extracts/copies it to <dir>.
2947
+
2948
+ ARGUMENTS
2949
+ <tag> Openclaw version tag.
2950
+
2951
+ OPTIONS
2952
+ --role=<role> Package role (e.g. template, config).
2953
+ --name=<name> Package name within the role.
2954
+ --dir=<dir> Target dir (defaults to dirname(pkg.installPath)).
2955
+ --ctx=<base64> Opaque ctx; ossFileMap is extracted from it.
2956
+ --oss_file_map=... Pre-built OSS URL map (base64 JSON).
2957
+ `
2958
+ }
2959
+ ];
2960
+ function parseHelpFlags(args) {
2961
+ return {
2962
+ help: args.includes("--help") || args.includes("-h"),
2963
+ expert: args.includes("-x") || args.includes("--expert")
2964
+ };
2965
+ }
2966
+ /**
2967
+ * Render the top-level help to the given stream. When `expert` is true,
2968
+ * hidden commands are listed alongside the user-facing ones.
2969
+ */
2970
+ function formatTopLevelHelp(expert) {
2971
+ const visible = COMMANDS.filter((c) => !c.hidden);
2972
+ const hidden = COMMANDS.filter((c) => c.hidden);
2973
+ const pad = (s, w) => s + " ".repeat(Math.max(0, w - s.length));
2974
+ const w = Math.max(...COMMANDS.map((c) => c.name.length)) + 2;
2975
+ const lines = [];
2976
+ lines.push(`${BIN} — OpenClaw config diagnose / repair CLI`);
2977
+ lines.push(versionBanner());
2978
+ lines.push("");
2979
+ lines.push("USAGE");
2980
+ lines.push(` ${BIN} <command> [options]`);
2981
+ lines.push(` ${BIN} <command> --help per-command help`);
2982
+ lines.push(` ${BIN} --help this message`);
2983
+ lines.push("");
2984
+ lines.push("COMMANDS");
2985
+ for (const c of visible) lines.push(` ${pad(c.name, w)}${c.summary}`);
2986
+ if (expert && hidden.length > 0) {
2987
+ lines.push("");
2988
+ lines.push("INTERNAL COMMANDS (revealed by -x)");
2989
+ for (const c of hidden) lines.push(` ${pad(c.name, w)}${c.summary}`);
2990
+ }
2991
+ lines.push("");
2992
+ return lines.join("\n");
2993
+ }
2994
+ /** Render per-command help. Returns undefined when the name is unknown. */
2995
+ function formatCommandHelp(name) {
2996
+ const cmd = COMMANDS.find((c) => c.name === name);
2997
+ if (!cmd) return void 0;
2998
+ return cmd.help;
2999
+ }
3000
+ //#endregion
1308
3001
  //#region src/index.ts
1309
3002
  const args = node_process.default.argv.slice(2);
1310
- const mode = args.find((a) => !a.startsWith("--"));
1311
- switch (mode) {
1312
- case "check":
1313
- case "repair": {
1314
- const ctx = args.find((a) => a.startsWith("--ctx="))?.slice(6);
1315
- if (!ctx) {
1316
- console.error("Error: --ctx=<base64> is required");
1317
- node_process.default.exit(1);
3003
+ const mode = args.find((a) => !a.startsWith("-"));
3004
+ /**
3005
+ * Decode `--ctx=<base64>` into an opaque JSON object. Returns undefined when
3006
+ * the flag isn't present — the caller decides whether to fall back to the
3007
+ * innerapi or to error out.
3008
+ *
3009
+ * The object's shape is not enforced here; downstream code consumes it via
3010
+ * either `normalizeCtx()` (new path) or direct field access for the legacy
3011
+ * check/repair/reset contract still used by sandbox_console push.
3012
+ */
3013
+ function parseCtxFlag(args) {
3014
+ const ctxArg = args.find((a) => a.startsWith("--ctx="));
3015
+ if (!ctxArg) return void 0;
3016
+ const b64 = ctxArg.slice(6);
3017
+ return JSON.parse(Buffer.from(b64, "base64").toString("utf-8"));
3018
+ }
3019
+ /**
3020
+ * Pull the first non-flag positional after the mode name.
3021
+ * (The mode itself is args[0] in the filtered set, so we skip index 0.)
3022
+ */
3023
+ function getPositionalTag(args, modeName) {
3024
+ return args.find((a, i) => i > 0 && !a.startsWith("--") && a !== modeName);
3025
+ }
3026
+ function getFlag(args, name) {
3027
+ const prefix = `--${name}=`;
3028
+ return args.find((a) => a.startsWith(prefix))?.slice(prefix.length);
3029
+ }
3030
+ function getMultiFlag(args, name) {
3031
+ const prefix = `--${name}=`;
3032
+ return args.filter((a) => a.startsWith(prefix)).map((a) => a.slice(prefix.length));
3033
+ }
3034
+ async function main() {
3035
+ installStderrMirror();
3036
+ const helpFlags = parseHelpFlags(args);
3037
+ if (mode && helpFlags.help) {
3038
+ const body = formatCommandHelp(mode);
3039
+ if (body) {
3040
+ node_process.default.stdout.write(body);
3041
+ return;
1318
3042
  }
1319
- const input = JSON.parse(Buffer.from(ctx, "base64").toString("utf-8"));
1320
- if (mode === "check") console.log(JSON.stringify(runCheck(input)));
1321
- else console.log(JSON.stringify(runRepair(input)));
1322
- break;
1323
- }
1324
- case "backup": {
1325
- const ctx = args.find((a) => a.startsWith("--ctx="))?.slice(6);
1326
- if (!ctx) {
1327
- console.error("Error: --ctx=<base64> is required");
1328
- node_process.default.exit(1);
3043
+ node_process.default.stderr.write(`Unknown command: ${mode}\n\n`);
3044
+ node_process.default.stderr.write(formatTopLevelHelp(helpFlags.expert));
3045
+ node_process.default.exit(1);
3046
+ }
3047
+ if (!mode) {
3048
+ if (helpFlags.help) {
3049
+ node_process.default.stdout.write(formatTopLevelHelp(helpFlags.expert));
3050
+ return;
1329
3051
  }
1330
- const input = JSON.parse(Buffer.from(ctx, "base64").toString("utf-8"));
1331
- console.log(JSON.stringify(runBackup(input)));
1332
- break;
1333
- }
1334
- case "reset":
1335
- if (args.includes("--async")) {
1336
- const ctx = args.find((a) => a.startsWith("--ctx="))?.slice(6);
1337
- if (!ctx) {
1338
- console.error("Error: --ctx=<base64> is required");
3052
+ node_process.default.stderr.write(formatTopLevelHelp(helpFlags.expert));
3053
+ node_process.default.exit(1);
3054
+ }
3055
+ switch (mode) {
3056
+ case "check":
3057
+ case "repair": {
3058
+ const raw = parseCtxFlag(args) ?? await fetchCtxViaInnerApi();
3059
+ if (mode === "check") console.log(JSON.stringify(runCheck(buildCheckInput(raw))));
3060
+ else console.log(JSON.stringify(runRepair(buildRepairInput(raw))));
3061
+ break;
3062
+ }
3063
+ case "doctor": {
3064
+ const fix = args.includes("--fix");
3065
+ const rules = getMultiFlag(args, "rule");
3066
+ const result = await runDoctor(await fetchCtxViaInnerApi(), {
3067
+ fix,
3068
+ rules
3069
+ });
3070
+ console.log(JSON.stringify(result));
3071
+ break;
3072
+ }
3073
+ case "reset":
3074
+ if (args.includes("--async")) {
3075
+ const ctxArg = args.find((a) => a.startsWith("--ctx="));
3076
+ let ctxBase64;
3077
+ if (ctxArg) ctxBase64 = ctxArg.slice(6);
3078
+ else {
3079
+ const fetched = await fetchCtxViaInnerApi();
3080
+ ctxBase64 = Buffer.from(JSON.stringify(fetched), "utf-8").toString("base64");
3081
+ }
3082
+ console.log(JSON.stringify(startAsyncReset(ctxBase64)));
3083
+ } else if (args.includes("--worker")) {
3084
+ const taskId = args.find((a) => a.startsWith("--task-id="))?.slice(10);
3085
+ if (!taskId) {
3086
+ console.error("Error: --task-id=<id> is required for worker");
3087
+ node_process.default.exit(1);
3088
+ }
3089
+ const resultFile = resetResultFile(taskId);
3090
+ await runReset(buildResetInput(parseCtxFlag(args) ?? await fetchCtxViaInnerApi()), taskId, resultFile);
3091
+ } else {
3092
+ console.error("Usage: reset --async [--ctx=<base64>] | reset --worker --task-id=<id> [--ctx=<base64>]");
1339
3093
  node_process.default.exit(1);
1340
3094
  }
1341
- console.log(JSON.stringify(startAsyncReset(ctx)));
1342
- } else if (args.includes("--worker")) {
1343
- const ctx = args.find((a) => a.startsWith("--ctx="))?.slice(6);
3095
+ break;
3096
+ case "get_reset_task": {
1344
3097
  const taskId = args.find((a) => a.startsWith("--task-id="))?.slice(10);
1345
- if (!ctx || !taskId) {
1346
- console.error("Error: --ctx=<base64> and --task-id=<id> are required for worker");
3098
+ if (!taskId) {
3099
+ console.error("Error: --task-id=<id> is required");
1347
3100
  node_process.default.exit(1);
1348
3101
  }
1349
- const resultFile = `/tmp/openclaw-reset-${taskId}.json`;
1350
- runReset(JSON.parse(Buffer.from(ctx, "base64").toString("utf-8")), taskId, resultFile);
1351
- } else {
1352
- console.error("Usage: reset --async --ctx=<base64> | reset --worker --task-id=<id> --ctx=<base64>");
1353
- node_process.default.exit(1);
3102
+ console.log(JSON.stringify(getResetTask(taskId)));
3103
+ break;
1354
3104
  }
1355
- break;
1356
- case "get_reset_task": {
1357
- const taskId = args.find((a) => a.startsWith("--task-id="))?.slice(10);
1358
- if (!taskId) {
1359
- console.error("Error: --task-id=<id> is required");
1360
- node_process.default.exit(1);
3105
+ case "install-openclaw": {
3106
+ const tag = getPositionalTag(args, "install-openclaw");
3107
+ if (!tag) {
3108
+ console.error("Usage: install-openclaw <tag> [--ctx=<base64> | --oss_file_map=<base64>]");
3109
+ node_process.default.exit(1);
3110
+ }
3111
+ const ossFileMapFlag = getFlag(args, "oss_file_map");
3112
+ let installOssFileMap;
3113
+ if (!ossFileMapFlag) installOssFileMap = normalizeCtx(parseCtxFlag(args) ?? await fetchCtxViaInnerApi()).install.ossFileMap;
3114
+ await installOpenclaw(tag, resolveOssFileMap({
3115
+ ossFileMapFlag,
3116
+ installOssFileMap
3117
+ }));
3118
+ console.log(JSON.stringify({ ok: true }));
3119
+ break;
3120
+ }
3121
+ case "install-extension": {
3122
+ const tag = getPositionalTag(args, "install-extension");
3123
+ if (!tag) {
3124
+ console.error("Usage: install-extension <tag> (--all | --extension=<name>...) [--home_base=<dir>] [--config_path=<path>] [--skip-config-update] [--ctx=<base64> | --oss_file_map=<base64>]");
3125
+ node_process.default.exit(1);
3126
+ }
3127
+ const all = args.includes("--all");
3128
+ const names = getMultiFlag(args, "extension");
3129
+ const homeBase = getFlag(args, "home_base");
3130
+ const configPath = getFlag(args, "config_path");
3131
+ const skipConfigUpdate = args.includes("--skip-config-update");
3132
+ const ossFileMapFlag = getFlag(args, "oss_file_map");
3133
+ let installOssFileMap;
3134
+ if (!ossFileMapFlag) installOssFileMap = normalizeCtx(parseCtxFlag(args) ?? await fetchCtxViaInnerApi()).install.ossFileMap;
3135
+ await installExtension(tag, resolveOssFileMap({
3136
+ ossFileMapFlag,
3137
+ installOssFileMap
3138
+ }), {
3139
+ all,
3140
+ names: names.length > 0 ? names : void 0,
3141
+ homeBase,
3142
+ configPath,
3143
+ skipConfigUpdate
3144
+ });
3145
+ console.log(JSON.stringify({ ok: true }));
3146
+ break;
1361
3147
  }
1362
- console.log(JSON.stringify(getResetTask(taskId)));
1363
- break;
3148
+ case "download-resource": {
3149
+ const tag = getPositionalTag(args, "download-resource");
3150
+ if (!tag) {
3151
+ console.error("Usage: download-resource <tag> --role=<role> --name=<name> [--dir=<dir>] [--ctx=<base64> | --oss_file_map=<base64>]");
3152
+ node_process.default.exit(1);
3153
+ }
3154
+ const role = getFlag(args, "role");
3155
+ const name = getFlag(args, "name");
3156
+ const dir = getFlag(args, "dir");
3157
+ if (!role || !name) {
3158
+ console.error("Usage: download-resource <tag> --role=<role> --name=<name> [--dir=<dir>]");
3159
+ node_process.default.exit(1);
3160
+ }
3161
+ const ossFileMapFlag = getFlag(args, "oss_file_map");
3162
+ let installOssFileMap;
3163
+ if (!ossFileMapFlag) installOssFileMap = normalizeCtx(parseCtxFlag(args) ?? await fetchCtxViaInnerApi()).install.ossFileMap;
3164
+ await downloadResource(tag, resolveOssFileMap({
3165
+ ossFileMapFlag,
3166
+ installOssFileMap
3167
+ }), {
3168
+ role,
3169
+ name,
3170
+ dir
3171
+ });
3172
+ console.log(JSON.stringify({ ok: true }));
3173
+ break;
3174
+ }
3175
+ default:
3176
+ node_process.default.stderr.write(`Unknown command: ${mode}\n\n`);
3177
+ node_process.default.stderr.write(formatTopLevelHelp(helpFlags.expert));
3178
+ node_process.default.exit(1);
1364
3179
  }
1365
- default:
1366
- console.error("Usage: mclaw-diagnose <check|repair|backup|reset|get_reset_task> [options]");
1367
- node_process.default.exit(1);
1368
3180
  }
3181
+ main().catch((err) => {
3182
+ const msg = err instanceof Error ? err.message : String(err);
3183
+ console.error(`Error: ${msg}`);
3184
+ node_process.default.exit(1);
3185
+ });
1369
3186
  //#endregion