create-principles-disciple 1.132.4 → 1.132.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/console/dist/server/routes/update.d.ts +7 -0
  2. package/console/dist/server/routes/update.js +200 -42
  3. package/console/dist/server/utils/installed-layout.d.ts +7 -0
  4. package/console/dist/server/utils/installed-layout.js +9 -0
  5. package/console/dist/types.d.ts +1 -0
  6. package/console/dist/ui/App.js +16 -0
  7. package/console/dist/ui/api.d.ts +11 -0
  8. package/console/dist/ui/api.js +18 -3
  9. package/console/dist/ui/pages/failed-tasks/FailedTasksPage.js +14 -56
  10. package/console/dist/web/assets/app.js +27 -50
  11. package/core/dist/runtime-v2/__tests__/evidence-sanitizer.test.js +86 -0
  12. package/core/dist/runtime-v2/__tests__/evidence-sanitizer.test.js.map +1 -1
  13. package/core/dist/runtime-v2/evidence-sanitizer.d.ts.map +1 -1
  14. package/core/dist/runtime-v2/evidence-sanitizer.js +87 -4
  15. package/core/dist/runtime-v2/evidence-sanitizer.js.map +1 -1
  16. package/core/package.json +1 -1
  17. package/dist/i18n.d.ts.map +1 -1
  18. package/dist/i18n.js +16 -0
  19. package/dist/i18n.js.map +1 -1
  20. package/dist/installer.d.ts +9 -0
  21. package/dist/installer.d.ts.map +1 -1
  22. package/dist/installer.js +148 -7
  23. package/dist/installer.js.map +1 -1
  24. package/dist/mvp-config.d.ts +7 -0
  25. package/dist/mvp-config.d.ts.map +1 -1
  26. package/dist/mvp-config.js +21 -7
  27. package/dist/mvp-config.js.map +1 -1
  28. package/dist/uninstaller.d.ts +15 -0
  29. package/dist/uninstaller.d.ts.map +1 -1
  30. package/dist/uninstaller.js +85 -0
  31. package/dist/uninstaller.js.map +1 -1
  32. package/package.json +1 -1
  33. package/pd-cli/dist/commands/console.d.ts.map +1 -1
  34. package/pd-cli/dist/commands/console.js +12 -2
  35. package/pd-cli/dist/commands/console.js.map +1 -1
  36. package/pd-cli/dist/services/console-launcher.d.ts.map +1 -1
  37. package/pd-cli/dist/services/console-launcher.js +8 -1
  38. package/pd-cli/dist/services/console-launcher.js.map +1 -1
  39. package/pd-cli/package.json +1 -1
  40. package/plugin/dist/bundle.js +327 -327
  41. package/release-manager/dist/i18n.d.ts.map +1 -1
  42. package/release-manager/dist/i18n.js +16 -0
  43. package/release-manager/dist/i18n.js.map +1 -1
  44. package/release-manager/dist/installer.d.ts +9 -0
  45. package/release-manager/dist/installer.d.ts.map +1 -1
  46. package/release-manager/dist/installer.js +148 -7
  47. package/release-manager/dist/installer.js.map +1 -1
  48. package/release-manager/dist/mvp-config.d.ts +7 -0
  49. package/release-manager/dist/mvp-config.d.ts.map +1 -1
  50. package/release-manager/dist/mvp-config.js +21 -7
  51. package/release-manager/dist/mvp-config.js.map +1 -1
  52. package/release-manager/dist/uninstaller.d.ts +15 -0
  53. package/release-manager/dist/uninstaller.d.ts.map +1 -1
  54. package/release-manager/dist/uninstaller.js +85 -0
  55. package/release-manager/dist/uninstaller.js.map +1 -1
  56. package/release-manager/package.json +1 -1
@@ -6,4 +6,11 @@
6
6
  * POST /rollback — Rollback an update
7
7
  */
8
8
  import type { IncomingMessage, ServerResponse } from 'node:http';
9
+ export declare function sweepStaleUpdateTempDirs(now?: number): {
10
+ swept: string[];
11
+ failed: {
12
+ dir: string;
13
+ error: string;
14
+ }[];
15
+ };
9
16
  export declare function handleUpdateRoute(req: IncomingMessage, res: ServerResponse, workspaceDir: string, subPath: string): Promise<void>;
@@ -7,7 +7,7 @@ import { sendSuccess, sendError, sendMethodNotAllowed, sendBadRequest, sendNotFo
7
7
  import { appendUpdateHistory } from './update-history.js';
8
8
  import { checkOpenClawGateway, stopOpenClawGateway, restartOpenClawGateway, } from '../utils/gateway.js';
9
9
  import { migrateLegacyExtensionBackups, reservePdBackupDestination, resolvePdBackupsRoot, } from '../utils/pd-backups.js';
10
- import { resolveExtensionsDir, resolveUpdateLayout, resolvePluginDir, readCurrentVersion, } from '../utils/installed-layout.js';
10
+ import { resolveExtensionsDir, resolveUpdateLayout, resolvePluginDir, resolveCanonicalRuntimeRoot, readCurrentVersion, } from '../utils/installed-layout.js';
11
11
  import { ActivationCompatibilityReadModel, isFeatureEnabled } from '@principles/core/runtime-v2';
12
12
  import { collectFileDepLinkSpecs } from '../utils/update-links.js';
13
13
  import { updateMutationController, LEGACY_MUTATION_AUTHORITY, RELEASE_MANAGER_AUTHORITY, MUTATION_KINDS, } from '../update/mutation-controller.js';
@@ -78,13 +78,17 @@ function validatePathInWorkspace(target, workspaceDir) {
78
78
  const resolved = path.resolve(target);
79
79
  const resolvedWorkspace = path.resolve(workspaceDir);
80
80
  // Allow paths within workspace, within the OpenClaw extensions directory,
81
- // or within the PD backups root. The extensions dir and backups root are
82
- // under the OpenClaw install home (~/.openclaw), which is NOT derived from
83
- // the workspace dir (the two may be on different drives).
81
+ // within the PD backups root, or within the canonical PD runtime root
82
+ // (~/.pd/runtime). The extensions dir and backups root are under the
83
+ // OpenClaw install home (~/.openclaw) and the runtime root under ~/.pd —
84
+ // none are derived from the workspace dir (the two may be on different
85
+ // drives). The runtime root must be allowed since the canonical layout
86
+ // (ADR-0020) keeps the governed plugin/console at ~/.pd/runtime/*.
84
87
  const extensionsDir = path.resolve(resolveExtensionsDir());
85
88
  const backupsRoot = path.resolve(resolvePdBackupsRoot());
89
+ const runtimeRoot = path.resolve(resolveCanonicalRuntimeRoot());
86
90
  const insideRoot = (root) => resolved.startsWith(root + path.sep) || resolved === root;
87
- return insideRoot(resolvedWorkspace) || insideRoot(extensionsDir) || insideRoot(backupsRoot);
91
+ return insideRoot(resolvedWorkspace) || insideRoot(extensionsDir) || insideRoot(backupsRoot) || insideRoot(runtimeRoot);
88
92
  }
89
93
  /**
90
94
  * Log the result of a legacy-backup migration (rc-9: failures are surfaced,
@@ -99,6 +103,52 @@ function logLegacyBackupMigration(source) {
99
103
  console.warn(`[${source}] Could not migrate legacy PD backup "${failure.name}" out of the extensions dir: ${failure.reason}. Move it out manually to silence the OpenClaw "duplicate plugin id" warning.`);
100
104
  }
101
105
  }
106
+ // CP-8 (2026-09-05 investigation): interrupted full updates leak their staging
107
+ // dir in os.tmpdir() (a crash between mkdtemp and the success/cleanup rmSync
108
+ // has no sweeper). ~140 orphaned pd-update-* dirs were observed on one
109
+ // machine over a week. Best-effort sweep of week-old staging dirs at the
110
+ // start of each full update. Concurrency window (review finding): a LIVE
111
+ // update's staging dir is at most minutes old — its top-level mtime updates
112
+ // when tar extraction creates direct child dirs (verified: creating a
113
+ // subdir bumps the parent mtime; grandchild file writes do not) — so a
114
+ // >7-day-old top-level mtime means no direct child was ever created, i.e.
115
+ // the update died before extraction started. Two concurrent console
116
+ // processes both running full updates >7 days apart would be needed to
117
+ // sweep a live dir; that residual risk is accepted (a process lock would be
118
+ // the follow-up).
119
+ const STALE_UPDATE_TEMP_DIR_AGE_MS = 7 * 24 * 60 * 60 * 1000;
120
+ const UPDATE_TEMP_DIR_PREFIXES = ['pd-update-', 'pd-full-update-'];
121
+ export function sweepStaleUpdateTempDirs(now = Date.now()) {
122
+ const swept = [];
123
+ const failed = [];
124
+ let entries;
125
+ try {
126
+ entries = fs.readdirSync(os.tmpdir(), { withFileTypes: true });
127
+ }
128
+ catch (error) {
129
+ console.warn(`[pd-update] Stale update temp sweep skipped (tmpdir unreadable): ${error instanceof Error ? error.message : String(error)}`);
130
+ return { swept, failed };
131
+ }
132
+ for (const entry of entries) {
133
+ if (!entry.isDirectory())
134
+ continue;
135
+ if (!UPDATE_TEMP_DIR_PREFIXES.some((prefix) => entry.name.startsWith(prefix)))
136
+ continue;
137
+ const dirPath = path.join(os.tmpdir(), entry.name);
138
+ try {
139
+ if (now - fs.statSync(dirPath).mtimeMs < STALE_UPDATE_TEMP_DIR_AGE_MS)
140
+ continue;
141
+ fs.rmSync(dirPath, { recursive: true, force: true });
142
+ swept.push(dirPath);
143
+ console.log(`[pd-update] Swept stale update staging dir: ${dirPath}`);
144
+ }
145
+ catch (error) {
146
+ failed.push({ dir: dirPath, error: error instanceof Error ? error.message : String(error) });
147
+ console.warn(`[pd-update] Could not sweep stale staging dir ${dirPath}: ${failed[failed.length - 1]?.error}`);
148
+ }
149
+ }
150
+ return { swept, failed };
151
+ }
102
152
  // ---------------------------------------------------------------------------
103
153
  // Core update operations (inline to avoid cross-package import)
104
154
  // ---------------------------------------------------------------------------
@@ -562,45 +612,73 @@ async function doApplyUpdate(options, workspaceDir) {
562
612
  // installed extension also has console/, core/, pd-cli/, node_modules/,
563
613
  // bin/, docs/. Deleting those would destroy the installation.
564
614
  appliedChanges = true;
565
- const diff = computeDiffLocal(targetDir, tempDir);
566
- const updatedFiles = [];
567
- for (const file of diff.modified) {
568
- if (isWorkspaceFile(file)) {
569
- switch (mergeStrategy) {
570
- case 'smart': {
571
- const content = fs.readFileSync(path.join(tempDir, file), 'utf-8');
572
- fs.writeFileSync(path.join(targetDir, `${file}.update`), content);
573
- break;
615
+ // Apply the update's file diff to a plugin tree with the update's merge
616
+ // strategy. Runs against targetDir AND the OpenClaw extension copy
617
+ // (below) so both physical plugin trees land on the same version —
618
+ // applying to only one tree is exactly the "update succeeded, gateway
619
+ // still runs old code" split-brain the 2026-09-05 investigation banned
620
+ // (CP-5 invariant; rollback and apply-full enforce the same sync).
621
+ // PR #1526 review recheck: the diff is computed PER TARGET TREE. A single
622
+ // diff computed against targetDir only describes targetDir's delta —
623
+ // reusing it for the extension copy skips files where a PREVIOUS drift
624
+ // already left targetDir at the new content while the extension copy
625
+ // still runs the old one, and the version stamp would then relabel stale
626
+ // files as updated. computeDiffLocal(current, new) diffs an existing tree
627
+ // against the staged release, so each tree gets its own honest delta.
628
+ const applyFileDiff = (destDir) => {
629
+ const treeDiff = computeDiffLocal(destDir, tempDir);
630
+ const files = [];
631
+ for (const file of treeDiff.modified) {
632
+ if (isWorkspaceFile(file)) {
633
+ switch (mergeStrategy) {
634
+ case 'smart': {
635
+ const content = fs.readFileSync(path.join(tempDir, file), 'utf-8');
636
+ fs.writeFileSync(path.join(destDir, `${file}.update`), content);
637
+ break;
638
+ }
639
+ case 'overwrite':
640
+ copyFileTo(path.join(tempDir, file), path.join(destDir, file));
641
+ files.push(file);
642
+ break;
643
+ case 'keep':
644
+ break;
574
645
  }
575
- case 'overwrite':
576
- copyFileTo(path.join(tempDir, file), path.join(targetDir, file));
577
- updatedFiles.push(file);
578
- break;
579
- case 'keep':
580
- break;
646
+ }
647
+ else {
648
+ copyFileTo(path.join(tempDir, file), path.join(destDir, file));
649
+ files.push(file);
581
650
  }
582
651
  }
583
- else {
584
- copyFileTo(path.join(tempDir, file), path.join(targetDir, file));
585
- updatedFiles.push(file);
652
+ for (const file of treeDiff.added) {
653
+ copyFileTo(path.join(tempDir, file), path.join(destDir, file));
654
+ files.push(file);
586
655
  }
587
- }
588
- for (const file of diff.added) {
589
- copyFileTo(path.join(tempDir, file), path.join(targetDir, file));
590
- updatedFiles.push(file);
591
- }
656
+ return files;
657
+ };
658
+ // Note: there is NO separate package.json version-stamp step. The staged
659
+ // tarball carries its own package.json (identity-verified by the
660
+ // staged-package guard above), and each tree's per-target diff copies it
661
+ // whenever it differs from the tree's current one — so after
662
+ // applyFileDiff both trees carry the release's package.json verbatim.
663
+ // The historic re-parse-and-restamp step was removed in the PR #1526
664
+ // recheck: it duplicated that copy and its parse→re-serialize→write chain
665
+ // was flagged by CodeQL as untrusted-data-to-file.
666
+ const updatedFiles = applyFileDiff(targetDir);
592
667
  // 4b. The incoming manifest declares the product-default skill language
593
668
  // (zh); restore the language this install was materialized with.
594
669
  reapplySkillLanguage(targetDir, skillLanguage);
595
670
  // Fix 3: deletions intentionally skipped — see comment above.
596
- // 5. Update package.json version
597
- const pkgPath = path.join(targetDir, 'package.json');
598
- if (fs.existsSync(pkgPath)) {
599
- const rawPkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
600
- if (typeof rawPkg === 'object' && rawPkg !== null) {
601
- const pkg = { ...rawPkg, version: toVersion };
602
- fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2));
603
- }
671
+ // 5b. CP-5 invariant: OpenClaw loads ~/.openclaw/extensions/principles-disciple,
672
+ // not this tree. When both physical plugin trees exist (canonical runtime +
673
+ // extension copy), the extension copy MUST move to the same version or the
674
+ // gateway keeps running the old code while history reports success. Same
675
+ // sync rollback (Fix 4 region) and apply-full perform.
676
+ const openClawPluginCopy = path.join(resolveExtensionsDir(), 'principles-disciple');
677
+ let extCopySynced = false;
678
+ if (path.resolve(openClawPluginCopy) !== path.resolve(targetDir) && fs.existsSync(openClawPluginCopy)) {
679
+ applyFileDiff(openClawPluginCopy);
680
+ reapplySkillLanguage(openClawPluginCopy, skillLanguage);
681
+ extCopySynced = true;
604
682
  }
605
683
  // 6. Cleanup temp dir
606
684
  if (fs.existsSync(tempDir))
@@ -615,7 +693,9 @@ async function doApplyUpdate(options, workspaceDir) {
615
693
  });
616
694
  return {
617
695
  success: true,
618
- message: 'Update applied successfully',
696
+ message: extCopySynced
697
+ ? 'Update applied successfully (OpenClaw extension copy synced too).'
698
+ : 'Update applied successfully',
619
699
  updatedFiles,
620
700
  backupPath,
621
701
  newVersion: toVersion,
@@ -635,11 +715,13 @@ async function doApplyUpdate(options, workspaceDir) {
635
715
  catch { /* best effort */ }
636
716
  }
637
717
  if (isLockError) {
718
+ // CP-10: same honest lock attribution as the full-update catch — the
719
+ // holder may be this Console process or an indexer, not just the gateway.
638
720
  return {
639
721
  success: false,
640
- message: 'Update blocked by file lock (OpenClaw gateway may still be running)',
722
+ message: 'Update blocked by a file lock (OpenClaw gateway, this Console process, or antivirus/file indexing may hold it)',
641
723
  reason: 'file_locked',
642
- nextAction: '请重启电脑后再次尝试更新。',
724
+ nextAction: 'Stop the OpenClaw gateway and retry the update; restart the computer only if it fails again.',
643
725
  };
644
726
  }
645
727
  return {
@@ -667,6 +749,18 @@ async function doRollbackUpdate(options, workspaceDir) {
667
749
  // package.json, etc.) get the backup version, while node_modules/console/
668
750
  // core/ (not in the backup) are left untouched.
669
751
  copyDirRecursive(backupDir, targetDir);
752
+ // Keep the OpenClaw extension copy in sync with the restored canonical
753
+ // plugin (CP-5 invariant from the 2026-09-05 investigation): OpenClaw
754
+ // loads ~/.openclaw/extensions/principles-disciple, so a rollback that
755
+ // only restores ~/.pd/runtime/plugin would leave the gateway running the
756
+ // version the upgrade just broke. Only synced when the copy exists and
757
+ // differs from the restore target (canonical mode).
758
+ const openClawPluginCopy = path.join(resolveExtensionsDir(), 'principles-disciple');
759
+ let extCopySynced = false;
760
+ if (path.resolve(openClawPluginCopy) !== path.resolve(targetDir) && fs.existsSync(openClawPluginCopy)) {
761
+ copyDirRecursive(backupDir, openClawPluginCopy);
762
+ extCopySynced = true;
763
+ }
670
764
  // Record rollback history
671
765
  appendUpdateHistory(workspaceDir, {
672
766
  fromVersion: 'rolled-back',
@@ -675,7 +769,12 @@ async function doRollbackUpdate(options, workspaceDir) {
675
769
  kind: 'rollback',
676
770
  backupPath: backupDir,
677
771
  });
678
- return { success: true, message: 'Rollback completed successfully' };
772
+ return {
773
+ success: true,
774
+ message: extCopySynced
775
+ ? 'Rollback completed successfully (OpenClaw extension copy restored too). Note: only the plugin was rolled back; console/core/host-runtime/pd-cli keep the upgraded version. For a full step-back, re-run the official installer pinned to the older release.'
776
+ : 'Rollback completed successfully. Note: only the plugin was rolled back; console/core/host-runtime/pd-cli keep the upgraded version. For a full step-back, re-run the official installer pinned to the older release.',
777
+ };
679
778
  }
680
779
  catch (error) {
681
780
  return {
@@ -975,6 +1074,9 @@ async function doInlineFullUpdate(workspaceDir) {
975
1074
  };
976
1075
  }
977
1076
  const extDir = layout.pluginDir;
1077
+ // CP-8: reclaim staging dirs orphaned by previously interrupted updates
1078
+ // before staging this update's own temp dir (best-effort, never fatal).
1079
+ sweepStaleUpdateTempDirs();
978
1080
  // Legacy-contract preflight BEFORE stopping the gateway or touching files:
979
1081
  // refuse while an active rule still uses a removed RuleHost contract
980
1082
  // symbol — the running installation stays exactly as it was.
@@ -998,6 +1100,10 @@ async function doInlineFullUpdate(workspaceDir) {
998
1100
  // overwrites it (PR #1332 companion — see reapplySkillLanguage).
999
1101
  const skillLanguage = detectInstalledSkillLanguage(extDir);
1000
1102
  let tempDir;
1103
+ // CP-4: pre-swap backup of the canonical plugin tree; recorded in
1104
+ // update-history (success and failure) so /rollback can find it. Declared
1105
+ // at function scope: the failure history in the catch block references it.
1106
+ let pluginBackupDir;
1001
1107
  // Dependency entries quarantined during resolution-link reconciliation;
1002
1108
  // restored on failure, discarded on success (PRI-665).
1003
1109
  let reconciledQuarantine = [];
@@ -1150,6 +1256,36 @@ async function doInlineFullUpdate(workspaceDir) {
1150
1256
  // them. On fresh installs this is an overlay refresh + link no-op.
1151
1257
  const hostRuntimeSrc = path.join(tempDir, 'host-runtime');
1152
1258
  const hostRuntimeDest = layout.hostRuntimeDir;
1259
+ // Backup the canonical plugin tree BEFORE the first mutation so a full
1260
+ // update is recoverable via /rollback (the diff /apply path has always
1261
+ // had this via createBackup; the full path never did — CP-4 of the
1262
+ // 2026-09-05 install/upgrade investigation). The backup excludes
1263
+ // node_modules (same SKIP_DIRS contract as /apply), and the backup dir
1264
+ // is recorded in update-history (success AND failure) so the rollback
1265
+ // handler can find it. Console self-update owns the console dir backup;
1266
+ // core/host-runtime/pd-cli are plain dist overlays re-fetched on any
1267
+ // next update, so only the version authority (plugin) is backed up.
1268
+ // R5 (review): reservePdBackupDestination creates the directory BEFORE
1269
+ // the copy — a mid-copy failure must not leave backupPath pointing at a
1270
+ // partial backup (a later /rollback would happily restore the fragment).
1271
+ // Copy into the reserved dir inside its own try: on failure remove the
1272
+ // fragment and let the thrown error flow into the outer catch with
1273
+ // pluginBackupDir still undefined (no backupPath recorded).
1274
+ if (fs.existsSync(extDir)) {
1275
+ const backupDest = reservePdBackupDestination(path.basename(extDir));
1276
+ try {
1277
+ copyDirRecursive(extDir, backupDest, SKIP_DIRS);
1278
+ }
1279
+ catch (backupError) {
1280
+ try {
1281
+ fs.rmSync(backupDest, { recursive: true, force: true });
1282
+ }
1283
+ catch { /* best effort */ }
1284
+ throw backupError;
1285
+ }
1286
+ pluginBackupDir = backupDest;
1287
+ console.log(`[pd-update-full] Backed up ${extDir} → ${backupDest}`);
1288
+ }
1153
1289
  if (fs.existsSync(hostRuntimeSrc) &&
1154
1290
  fs.existsSync(path.join(hostRuntimeSrc, 'package.json')) &&
1155
1291
  fs.existsSync(path.join(hostRuntimeSrc, 'dist'))) {
@@ -1178,6 +1314,7 @@ async function doInlineFullUpdate(workspaceDir) {
1178
1314
  toVersion: 'failed',
1179
1315
  success: false,
1180
1316
  kind: 'failure',
1317
+ ...(pluginBackupDir ? { backupPath: pluginBackupDir } : {}),
1181
1318
  });
1182
1319
  return {
1183
1320
  success: false,
@@ -1195,6 +1332,20 @@ async function doInlineFullUpdate(workspaceDir) {
1195
1332
  // The incoming manifest declares the product-default skill language
1196
1333
  // (zh); restore the language this install was materialized with.
1197
1334
  reapplySkillLanguage(extDir, skillLanguage);
1335
+ // CP-5 (2026-09-05 investigation): under the canonical layout extDir is
1336
+ // ~/.pd/runtime/plugin, but OpenClaw still LOADS the plugin copy in
1337
+ // ~/.openclaw/extensions/principles-disciple — the installer registers
1338
+ // that path and OpenClaw discovers plugins by scanning extensions/.
1339
+ // Leaving it stale made every "successful" upgrade half-effective (the
1340
+ // gateway kept running the install-time code and `pd --version` kept
1341
+ // reporting the old version). Refresh the copy whenever it exists; do
1342
+ // NOT create one for installs that never had it (e.g. Codex-only).
1343
+ const openClawPluginCopy = path.join(resolveExtensionsDir(), 'principles-disciple');
1344
+ if (path.resolve(openClawPluginCopy) !== path.resolve(extDir) && fs.existsSync(openClawPluginCopy)) {
1345
+ copyDirRecursive(pluginSrc, openClawPluginCopy, SKIP_DIRS);
1346
+ reapplySkillLanguage(openClawPluginCopy, skillLanguage);
1347
+ console.log(`[pd-update-full] Refreshed OpenClaw extension copy: ${openClawPluginCopy}`);
1348
+ }
1198
1349
  }
1199
1350
  // b. console/ → extDir/console/ (overwrite dist/ files; do NOT rmSync —
1200
1351
  // console/node_modules/ may contain locked native modules like
@@ -1256,6 +1407,7 @@ async function doInlineFullUpdate(workspaceDir) {
1256
1407
  toVersion: newVersion,
1257
1408
  success: false,
1258
1409
  kind: 'failure',
1410
+ ...(pluginBackupDir ? { backupPath: pluginBackupDir } : {}),
1259
1411
  });
1260
1412
  return {
1261
1413
  success: false,
@@ -1271,6 +1423,7 @@ async function doInlineFullUpdate(workspaceDir) {
1271
1423
  toVersion: newVersion,
1272
1424
  success: true,
1273
1425
  kind: 'update',
1426
+ ...(pluginBackupDir ? { backupPath: pluginBackupDir } : {}),
1274
1427
  });
1275
1428
  return {
1276
1429
  success: true,
@@ -1303,13 +1456,18 @@ async function doInlineFullUpdate(workspaceDir) {
1303
1456
  toVersion: 'failed',
1304
1457
  success: false,
1305
1458
  kind: 'failure',
1459
+ ...(pluginBackupDir ? { backupPath: pluginBackupDir } : {}),
1306
1460
  });
1307
1461
  if (isLockError) {
1462
+ // CP-10 (2026-09-05 investigation): the lock holder is NOT necessarily
1463
+ // the gateway — the console process performing this very update and
1464
+ // file indexers/antivirus hold the same class of locks. Name the real
1465
+ // candidates instead of prescribing a reboot first.
1308
1466
  return {
1309
1467
  success: false,
1310
- message: 'Update blocked by a file lock. The OpenClaw gateway may still be running.',
1468
+ message: 'Update blocked by a file lock. Locks may be held by the OpenClaw gateway, this Console process itself (self-update), or antivirus/file indexing.',
1311
1469
  reason: 'file_locked',
1312
- nextAction: 'Please restart your computer, then try the update again.',
1470
+ nextAction: 'Stop the OpenClaw gateway, wait a few seconds, and retry the update. If it fails again, restart the computer.',
1313
1471
  requiresRestart: false,
1314
1472
  };
1315
1473
  }
@@ -12,6 +12,13 @@ export interface UpdateLayout {
12
12
  }
13
13
  export declare function resolveUpdateLayout(): UpdateLayout | undefined;
14
14
  export declare function resolvePluginDir(_workspaceDir: string): string;
15
+ /**
16
+ * The canonical runtime root (~/.pd/runtime) — the PD-owned directory the
17
+ * canonical layout keeps plugin/console/core/host-runtime/pd-cli under.
18
+ * Pure path computation (no manifest read), so it is safe to use as a path
19
+ * guard allow-root even when the install resolves to legacy mode.
20
+ */
21
+ export declare function resolveCanonicalRuntimeRoot(): string;
15
22
  export declare function readCurrentVersion(pluginDir: string): string | undefined;
16
23
  /**
17
24
  * The PD version this console governs — the installed plugin's package.json
@@ -63,6 +63,15 @@ export function resolveUpdateLayout() {
63
63
  export function resolvePluginDir(_workspaceDir) {
64
64
  return resolveUpdateLayout()?.pluginDir ?? path.join(resolveExtensionsDir(), 'principles-disciple');
65
65
  }
66
+ /**
67
+ * The canonical runtime root (~/.pd/runtime) — the PD-owned directory the
68
+ * canonical layout keeps plugin/console/core/host-runtime/pd-cli under.
69
+ * Pure path computation (no manifest read), so it is safe to use as a path
70
+ * guard allow-root even when the install resolves to legacy mode.
71
+ */
72
+ export function resolveCanonicalRuntimeRoot() {
73
+ return getInstallLayoutPaths(os.homedir()).runtimeDir;
74
+ }
66
75
  // rc-2: type-guard the parsed JSON instead of an `as` cast (ERR-001).
67
76
  function isRecord(value) {
68
77
  return typeof value === 'object' && value !== null && !Array.isArray(value);
@@ -84,4 +84,5 @@ export type ApiResponse<T> = {
84
84
  error: string;
85
85
  reason?: string;
86
86
  nextAction?: string;
87
+ status?: number;
87
88
  };
@@ -51,6 +51,22 @@ function AuthRoutes() {
51
51
  useEffect(() => {
52
52
  verifyAuth();
53
53
  }, [verifyAuth]);
54
+ // PRI-643: verifyAuth otherwise runs only on mount. If the server switches
55
+ // auth mode (or the token expires) while the page stays open, re-verify
56
+ // when the tab becomes visible or the window regains focus; a 401 from the
57
+ // probe also triggers api.ts's global redirect to the login route.
58
+ useEffect(() => {
59
+ const recheck = () => {
60
+ if (document.visibilityState === "visible")
61
+ verifyAuth();
62
+ };
63
+ window.addEventListener("focus", recheck);
64
+ document.addEventListener("visibilitychange", recheck);
65
+ return () => {
66
+ window.removeEventListener("focus", recheck);
67
+ document.removeEventListener("visibilitychange", recheck);
68
+ };
69
+ }, [verifyAuth]);
54
70
  useEffect(() => {
55
71
  if (!showSplash)
56
72
  return;
@@ -7,6 +7,17 @@ import type { FeedbackReportData, FeedbackDraftSummaryData, FeedbackDraftEnvelop
7
7
  declare function getToken(): string | null;
8
8
  declare function setToken(token: string): void;
9
9
  declare function clearToken(): void;
10
+ /**
11
+ * True when an unauthorized response should route the UI to the login route.
12
+ *
13
+ * The only registered login route is exactly "/login" (App.tsx); a hash like
14
+ * "#/login-help" is a different (unmatched) route and must still redirect.
15
+ * Already on the login route → false, so a failed login attempt cannot
16
+ * redirect-loop. The redirect target itself is the hardcoded
17
+ * "#/login?session_expired=true" literal at the assignment site — never
18
+ * derived from request data.
19
+ */
20
+ export declare function shouldRedirectToLoginOnUnauthorized(currentHash: string): boolean;
10
21
  /**
11
22
  * Send an authenticated request to the Console API.
12
23
  *
@@ -10,6 +10,19 @@ function setToken(token) {
10
10
  function clearToken() {
11
11
  sessionStorage.removeItem("pd_token");
12
12
  }
13
+ /**
14
+ * True when an unauthorized response should route the UI to the login route.
15
+ *
16
+ * The only registered login route is exactly "/login" (App.tsx); a hash like
17
+ * "#/login-help" is a different (unmatched) route and must still redirect.
18
+ * Already on the login route → false, so a failed login attempt cannot
19
+ * redirect-loop. The redirect target itself is the hardcoded
20
+ * "#/login?session_expired=true" literal at the assignment site — never
21
+ * derived from request data.
22
+ */
23
+ export function shouldRedirectToLoginOnUnauthorized(currentHash) {
24
+ return !(currentHash === "#/login" || currentHash.startsWith("#/login?"));
25
+ }
13
26
  async function request(path, options, validate) {
14
27
  const token = getToken();
15
28
  const headers = {
@@ -28,10 +41,12 @@ async function request(path, options, validate) {
28
41
  });
29
42
  if (!response.ok) {
30
43
  if (response.status === 401) {
31
- const hadToken = getToken() !== null;
44
+ // Any 401 invalidates the session: clear the token even when none was
45
+ // attached (page loaded before the server switched to token mode —
46
+ // PRI-643) and route to login so a token can be entered.
32
47
  clearToken();
33
48
  // eslint-disable-next-line no-undef
34
- if (hadToken && !window.location.hash.startsWith("#/login")) {
49
+ if (shouldRedirectToLoginOnUnauthorized(window.location.hash)) {
35
50
  // eslint-disable-next-line no-undef
36
51
  window.location.hash = "#/login?session_expired=true";
37
52
  }
@@ -60,7 +75,7 @@ async function request(path, options, validate) {
60
75
  catch {
61
76
  // ignore parse errors
62
77
  }
63
- return { success: false, error: errorMessage, reason, nextAction };
78
+ return { success: false, error: errorMessage, reason, nextAction, status: response.status };
64
79
  }
65
80
  const raw = await response.json();
66
81
  const apiData = (raw && typeof raw === "object" && Object.hasOwn(raw, "success") && Object.hasOwn(raw, "data"))
@@ -36,7 +36,7 @@ import { Button } from "../../components/ui/button.js";
36
36
  import { AlertDialog, AlertDialogContent, AlertDialogHeader, AlertDialogFooter, AlertDialogTitle, AlertDialogDescription, AlertDialogAction, AlertDialogCancel, } from "../../components/ui/alert-dialog.js";
37
37
  import { ShinyText } from "../../components/ui/shiny-text.js";
38
38
  import { formatDate } from "../../utils/format.js";
39
- import { recoverFailedTask, fetchConfigSummary } from "../../api.js";
39
+ import { recoverFailedTask, fetchConfigSummary, request } from "../../api.js";
40
40
  // ── Runtime validators (rc-1, rc-2, rc-5) ───────────────────────────────────
41
41
  //
42
42
  // The API response is `unknown` until these guards run. No `as` casts.
@@ -190,63 +190,21 @@ export function FailedTasksPage() {
190
190
  }, []);
191
191
  const loadData = useCallback(async () => {
192
192
  setState({ status: "loading" });
193
- try {
194
- // Auth header — read from sessionStorage (same storage as api.ts)
195
- const token = sessionStorage.getItem("pd_token");
196
- const headers = {
197
- "Content-Type": "application/json",
198
- };
199
- if (token) {
200
- headers.Authorization = `Bearer ${token}`;
201
- }
202
- const response = await fetch("/api/v1/failed-tasks", { headers });
203
- // 403 = feature flag disabled — show disabled state (rc-9: surface reason)
204
- if (response.status === 403) {
205
- setState({ status: "disabled" });
206
- return;
207
- }
208
- if (!response.ok) {
209
- let errorMessage = `HTTP ${response.status}`;
210
- let nextAction;
211
- try {
212
- const raw = await response.json();
213
- if (isObject(raw)) {
214
- const msg = readString(raw, "message") ?? readString(raw, "error");
215
- if (msg)
216
- errorMessage = msg;
217
- const na = readNullableString(raw, "nextAction");
218
- if (na)
219
- nextAction = na;
220
- }
221
- }
222
- catch {
223
- // ignore parse errors — fall back to HTTP status message
224
- }
225
- setState({ status: "error", message: errorMessage, nextAction });
226
- return;
227
- }
228
- const raw = await response.json();
229
- // Unwrap { success: true, data: {...} } envelope
230
- const dataRaw = isObject(raw) && Object.hasOwn(raw, "success") && Object.hasOwn(raw, "data")
231
- ? raw.data
232
- : raw;
233
- const validated = validateFailedTasksData(dataRaw);
234
- if (validated === null) {
235
- setState({
236
- status: "error",
237
- message: t("pages.failedTasks.error"),
238
- });
239
- return;
240
- }
241
- setState({ status: "loaded", data: validated });
193
+ // Shared request(): same auth header and global 401 session-expiry
194
+ // handling as every other page (PRI-643 — a manual fetch here bypassed
195
+ // session-expiry routing entirely).
196
+ const result = await request("/api/v1/failed-tasks", undefined, validateFailedTasksData);
197
+ if (result.success) {
198
+ setState({ status: "loaded", data: result.data });
199
+ return;
242
200
  }
243
- catch (err) {
244
- setState({
245
- status: "error",
246
- message: err instanceof Error ? err.message : "Network error",
247
- });
201
+ // 403 = feature flag disabled — show disabled state (rc-9: surface reason)
202
+ if (result.status === 403) {
203
+ setState({ status: "disabled" });
204
+ return;
248
205
  }
249
- }, [t]);
206
+ setState({ status: "error", message: result.error, nextAction: result.nextAction });
207
+ }, []);
250
208
  useEffect(() => {
251
209
  loadData();
252
210
  }, [loadData]);