create-principles-disciple 1.132.4 → 1.132.5
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/console/dist/server/routes/update.d.ts +7 -0
- package/console/dist/server/routes/update.js +200 -42
- package/console/dist/server/utils/installed-layout.d.ts +7 -0
- package/console/dist/server/utils/installed-layout.js +9 -0
- package/console/dist/types.d.ts +1 -0
- package/console/dist/ui/App.js +16 -0
- package/console/dist/ui/api.d.ts +11 -0
- package/console/dist/ui/api.js +18 -3
- package/console/dist/ui/pages/failed-tasks/FailedTasksPage.js +14 -56
- package/console/dist/web/assets/app.js +27 -50
- package/core/dist/runtime-v2/__tests__/evidence-sanitizer.test.js +55 -0
- package/core/dist/runtime-v2/__tests__/evidence-sanitizer.test.js.map +1 -1
- package/core/dist/runtime-v2/evidence-sanitizer.d.ts.map +1 -1
- package/core/dist/runtime-v2/evidence-sanitizer.js +17 -3
- package/core/dist/runtime-v2/evidence-sanitizer.js.map +1 -1
- package/dist/i18n.d.ts.map +1 -1
- package/dist/i18n.js +12 -0
- package/dist/i18n.js.map +1 -1
- package/dist/installer.d.ts.map +1 -1
- package/dist/installer.js +86 -6
- package/dist/installer.js.map +1 -1
- package/package.json +1 -1
- package/plugin/dist/bundle.js +327 -327
- package/release-manager/dist/i18n.d.ts.map +1 -1
- package/release-manager/dist/i18n.js +12 -0
- package/release-manager/dist/i18n.js.map +1 -1
- package/release-manager/dist/installer.d.ts.map +1 -1
- package/release-manager/dist/installer.js +86 -6
- package/release-manager/dist/installer.js.map +1 -1
- 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
|
-
//
|
|
82
|
-
//
|
|
83
|
-
//
|
|
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
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
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
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
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
|
-
|
|
584
|
-
copyFileTo(path.join(tempDir, file), path.join(
|
|
585
|
-
|
|
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
|
-
|
|
589
|
-
|
|
590
|
-
|
|
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
|
-
//
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
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:
|
|
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
|
|
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 {
|
|
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.
|
|
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: '
|
|
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);
|
package/console/dist/types.d.ts
CHANGED
package/console/dist/ui/App.js
CHANGED
|
@@ -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;
|
package/console/dist/ui/api.d.ts
CHANGED
|
@@ -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
|
*
|
package/console/dist/ui/api.js
CHANGED
|
@@ -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
|
-
|
|
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 (
|
|
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
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
};
|
|
199
|
-
|
|
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
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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
|
-
|
|
206
|
+
setState({ status: "error", message: result.error, nextAction: result.nextAction });
|
|
207
|
+
}, []);
|
|
250
208
|
useEffect(() => {
|
|
251
209
|
loadData();
|
|
252
210
|
}, [loadData]);
|