create-principles-disciple 1.146.2 → 1.146.3
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/_release/product-identity.json +2 -2
- package/console/dist/server/routes/health.js +17 -0
- package/console/dist/server/routes/update-transaction.js +63 -3
- package/console/dist/ui/i18n/en.json +2 -0
- package/console/dist/ui/i18n/zh-CN.json +2 -0
- package/console/dist/ui/pages/settings/UpdatePage.js +1 -1
- package/console/dist/ui/utils/validators.d.ts +10 -0
- package/console/dist/ui/utils/validators.js +11 -1
- package/console/dist/web/assets/app.js +16 -16
- package/core/dist/runtime-v2/internalization/bash-risk.d.ts +2 -0
- package/core/dist/runtime-v2/internalization/bash-risk.d.ts.map +1 -1
- package/core/dist/runtime-v2/internalization/bash-risk.js +78 -0
- package/core/dist/runtime-v2/internalization/bash-risk.js.map +1 -1
- package/core/package.json +1 -1
- package/dist/installer.d.ts +81 -0
- package/dist/installer.d.ts.map +1 -1
- package/dist/installer.js +480 -117
- package/dist/installer.js.map +1 -1
- package/dist/update/console-surface.d.ts +1 -1
- package/dist/update/console-surface.d.ts.map +1 -1
- package/dist/update/console-surface.js +1 -1
- package/dist/update/console-surface.js.map +1 -1
- package/dist/update/transaction-journal.d.ts +47 -0
- package/dist/update/transaction-journal.d.ts.map +1 -1
- package/dist/update/transaction-journal.js +87 -2
- package/dist/update/transaction-journal.js.map +1 -1
- package/host-runtime/package.json +1 -1
- package/package.json +2 -2
- package/plugin/dist/bundle.js +385 -385
- package/plugin/dist/rulehost-evidence.js +16 -16
- package/plugin/openclaw.plugin.json +1 -1
- package/plugin/package.json +1 -1
- package/release-manager/dist/installer.d.ts +81 -0
- package/release-manager/dist/installer.d.ts.map +1 -1
- package/release-manager/dist/installer.js +480 -117
- package/release-manager/dist/installer.js.map +1 -1
- package/release-manager/dist/update/console-surface.d.ts +1 -1
- package/release-manager/dist/update/console-surface.d.ts.map +1 -1
- package/release-manager/dist/update/console-surface.js +1 -1
- package/release-manager/dist/update/console-surface.js.map +1 -1
- package/release-manager/dist/update/transaction-journal.d.ts +47 -0
- package/release-manager/dist/update/transaction-journal.d.ts.map +1 -1
- package/release-manager/dist/update/transaction-journal.js +87 -2
- package/release-manager/dist/update/transaction-journal.js.map +1 -1
- package/release-manager/package.json +2 -2
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { existsSync, lstatSync, mkdtempSync, readdirSync, realpathSync, statSync, readFileSync, writeFileSync, mkdirSync, rmSync, copyFileSync, cpSync, renameSync, chmodSync, symlinkSync } from 'fs';
|
|
2
2
|
import { tmpdir } from 'os';
|
|
3
|
-
import { createHash, randomUUID } from 'node:crypto';
|
|
3
|
+
import { createHash, randomInt, randomUUID } from 'node:crypto';
|
|
4
4
|
import { pathToFileURL, fileURLToPath } from 'node:url';
|
|
5
5
|
import fse from 'fs-extra';
|
|
6
6
|
import * as path from 'path';
|
|
@@ -22,7 +22,7 @@ import { parseEmbeddedProductIdentity, ProductIdentityError } from './update/pro
|
|
|
22
22
|
import { digestDirectory, ensurePdHomeLayout, mergeIntoInstallJson, readBootstrapManifest, readInstallConfig, readInstallJsonRecord, resolvePdHomePaths, } from './update/install-layout.js';
|
|
23
23
|
import { RELEASE_METADATA_URL_ENV, normalizeReleaseMetadataUrl } from './update/release-metadata-source.js';
|
|
24
24
|
import { provisionBootstrapTrustRoot, TrustRootValidationError } from './update/trust-root-provisioning.js';
|
|
25
|
-
import { appendJournalTransition, readActiveRecord, writeActiveRecord, } from './update/transaction-journal.js';
|
|
25
|
+
import { appendJournalTransition, findRecoveryPendingTransactionIds, readActiveRecord, writeActiveRecord, writeRecoveryResolution, } from './update/transaction-journal.js';
|
|
26
26
|
/** PRI-343: Keep in sync with @principles/core CONVERSATION_ACCESS_CONFIG_KEY */
|
|
27
27
|
export const CONVERSATION_ACCESS_CONFIG_KEY = 'allowConversationAccess';
|
|
28
28
|
/** PRI-853: the maintained official signed-release metadata repository.
|
|
@@ -89,6 +89,12 @@ const PD_CLI_VERIFICATION_TIMEOUT_MS = 30_000;
|
|
|
89
89
|
const STORY_A_VERIFICATION_TIMEOUT_MS = 30_000;
|
|
90
90
|
const CONSOLE_HEALTH_CHECK_TIMEOUT_MS = 8_000;
|
|
91
91
|
const CONSOLE_WARMUP_TIME_MS = 6_000;
|
|
92
|
+
// P0-2 (SPEC v1.2 §5 step 6): bounded exit-join windows for the verification
|
|
93
|
+
// child — SIGTERM wait, then SIGKILL wait. Bounded so a wedged child can never
|
|
94
|
+
// hang the install forever; the join RESULT (not a guess) decides whether
|
|
95
|
+
// destructive directory operations may proceed.
|
|
96
|
+
const CONSOLE_CHILD_GRACEFUL_EXIT_TIMEOUT_MS = 5_000;
|
|
97
|
+
const CONSOLE_CHILD_KILL_EXIT_TIMEOUT_MS = 5_000;
|
|
92
98
|
// 端口范围常量。PD_CONSOLE_PORT_BASE 允许在操作系统保留了大段端口
|
|
93
99
|
// (如 Windows excludedportrange) 的机器上整体平移探测窗口;未设置时
|
|
94
100
|
// 保持历史默认 3100–3199。resolveConsolePortBase() 是唯一 resolved
|
|
@@ -946,6 +952,22 @@ export function migrateLegacyPdBackups() {
|
|
|
946
952
|
logger.info(`Migrated ${moved} legacy PD backup dir(s) out of the extensions dir to ${backupsDir}`);
|
|
947
953
|
}
|
|
948
954
|
}
|
|
955
|
+
/**
|
|
956
|
+
* P0-3 (SPEC v1.2 §17 / PRI-1070 comment §2): backup step failed midway and
|
|
957
|
+
* the compensation (undo of the already-moved extension tree) ALSO failed —
|
|
958
|
+
* the installer is holding the moved backup paths so the failure handler can
|
|
959
|
+
* still restore them. Never silently dropped.
|
|
960
|
+
*/
|
|
961
|
+
export class BackupIntegrityError extends Error {
|
|
962
|
+
extensionBackupDir;
|
|
963
|
+
runtimeBackupDir;
|
|
964
|
+
constructor(message, extensionBackupDir, runtimeBackupDir) {
|
|
965
|
+
super(message);
|
|
966
|
+
this.name = 'BackupIntegrityError';
|
|
967
|
+
this.extensionBackupDir = extensionBackupDir;
|
|
968
|
+
this.runtimeBackupDir = runtimeBackupDir;
|
|
969
|
+
}
|
|
970
|
+
}
|
|
949
971
|
/**
|
|
950
972
|
* Exported for real-filesystem tests (tests/backup-location.test.ts).
|
|
951
973
|
*/
|
|
@@ -963,10 +985,13 @@ export function backupExistingInstall(host = 'openclaw') {
|
|
|
963
985
|
const timestamp = Date.now();
|
|
964
986
|
const backupDir = hasExt ? path.join(backupsDir, `principles-disciple.backup.${timestamp}`) : null;
|
|
965
987
|
const runtimeBackupDir = hasRuntime ? path.join(getPdRuntimeBackupsDir(), `runtime.backup.${timestamp}`) : null;
|
|
988
|
+
let extMoved = false;
|
|
966
989
|
try {
|
|
967
990
|
mkdirSync(backupsDir, { recursive: true });
|
|
968
|
-
if (backupDir)
|
|
991
|
+
if (backupDir) {
|
|
969
992
|
renameSync(extDir, backupDir);
|
|
993
|
+
extMoved = true;
|
|
994
|
+
}
|
|
970
995
|
if (runtimeBackupDir) {
|
|
971
996
|
mkdirSync(getPdRuntimeBackupsDir(), { recursive: true });
|
|
972
997
|
renameSync(runtimeDir, runtimeBackupDir);
|
|
@@ -975,34 +1000,125 @@ export function backupExistingInstall(host = 'openclaw') {
|
|
|
975
1000
|
return { type: 'backed_up', backupDir, runtimeBackupDir };
|
|
976
1001
|
}
|
|
977
1002
|
catch (e) {
|
|
1003
|
+
// P0-3 (PRI-1070 comment §2): a PARTIAL backup (extension moved, runtime
|
|
1004
|
+
// move failed) used to escape with no path information — the caller then
|
|
1005
|
+
// ran restoreBackup(null, null), a no-op that silently stranded the moved
|
|
1006
|
+
// extension tree. Compensate first: put the moved tree back. Only when
|
|
1007
|
+
// even the compensation fails does the BackupIntegrityError carry the
|
|
1008
|
+
// moved paths out for the failure handler.
|
|
1009
|
+
if (extMoved && backupDir && existsSync(backupDir) && !existsSync(extDir)) {
|
|
1010
|
+
try {
|
|
1011
|
+
renameSync(backupDir, extDir);
|
|
1012
|
+
}
|
|
1013
|
+
catch (undoError) {
|
|
1014
|
+
throw new BackupIntegrityError(`Could not backup existing install at ${extDir}: ${e instanceof Error ? e.message : String(e)}; compensation ALSO failed: ${undoError instanceof Error ? undoError.message : String(undoError)}. The moved extension backup is preserved at ${backupDir} — re-running the installer will restore it.`, backupDir, null);
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
978
1017
|
throw new Error(`Could not backup existing install at ${extDir}: ${e instanceof Error ? e.message : String(e)}. Aborting to prevent data loss — resolve the lock or rename manually and re-run.`, { cause: e });
|
|
979
1018
|
}
|
|
980
1019
|
}
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
1020
|
+
/**
|
|
1021
|
+
* Restore ONE tree rename-aside (P0-3, PRI-1070; SPEC v1.2 §17):
|
|
1022
|
+
*
|
|
1023
|
+
* 1. live target exists → rename it aside to `<target>.restore-quarantine.<ts>`
|
|
1024
|
+
* (same-volume rename; NEVER a recursive delete — the R8 experiment
|
|
1025
|
+
* proved a live process's mapped .node makes rmSync die with EPERM
|
|
1026
|
+
* halfway through, while a directory rename succeeds).
|
|
1027
|
+
* 2. rename the backup onto the target.
|
|
1028
|
+
* 3. success → best-effort delete of the quarantine (residue is reported,
|
|
1029
|
+
* never fatal).
|
|
1030
|
+
* step 2 failure → try to rename the quarantine back (undo the aside);
|
|
1031
|
+
* if even that fails, BOTH the backup and the quarantine are reported in
|
|
1032
|
+
* `preservedDirs` and nothing was destroyed (I-2).
|
|
1033
|
+
*
|
|
1034
|
+
* Idempotent: once the backup has landed, a re-run sees `no_backup` and the
|
|
1035
|
+
* already-restored tree stays in place.
|
|
1036
|
+
*/
|
|
1037
|
+
function restoreTreeAside(tree, backupDir, targetDir) {
|
|
1038
|
+
if (!backupDir || !existsSync(backupDir)) {
|
|
1039
|
+
return { tree, status: 'no_backup', preservedDirs: [] };
|
|
1040
|
+
}
|
|
1041
|
+
let quarantine = null;
|
|
986
1042
|
try {
|
|
987
|
-
if (
|
|
988
|
-
|
|
1043
|
+
if (existsSync(targetDir)) {
|
|
1044
|
+
try {
|
|
1045
|
+
quarantine = `${targetDir}.restore-quarantine.${Date.now()}`;
|
|
1046
|
+
renameSync(targetDir, quarantine);
|
|
1047
|
+
}
|
|
1048
|
+
catch (asideError) {
|
|
1049
|
+
// TOCTOU guard: if the aside rename reports ENOENT the target is in
|
|
1050
|
+
// fact already gone (e.g. handled concurrently) — there is nothing to
|
|
1051
|
+
// set aside, and the restore itself below remains correct.
|
|
1052
|
+
if (asideError.code !== 'ENOENT')
|
|
1053
|
+
throw asideError;
|
|
1054
|
+
quarantine = null;
|
|
1055
|
+
}
|
|
989
1056
|
}
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
if (
|
|
993
|
-
|
|
994
|
-
rmSync(
|
|
995
|
-
|
|
1057
|
+
renameSync(backupDir, targetDir);
|
|
1058
|
+
let residue = '';
|
|
1059
|
+
if (quarantine !== null) {
|
|
1060
|
+
try {
|
|
1061
|
+
rmSync(quarantine, { recursive: true, force: true });
|
|
1062
|
+
}
|
|
1063
|
+
catch (e) {
|
|
1064
|
+
residue = `; quarantine residue left at ${quarantine} (${e instanceof Error ? e.message : String(e)})`;
|
|
1065
|
+
}
|
|
996
1066
|
}
|
|
997
|
-
|
|
998
|
-
return { restored: true };
|
|
1067
|
+
return { tree, status: 'restored', detail: residue.length > 0 ? `restored${residue}` : undefined, preservedDirs: [] };
|
|
999
1068
|
}
|
|
1000
1069
|
catch (e) {
|
|
1001
|
-
const msg =
|
|
1002
|
-
|
|
1003
|
-
|
|
1070
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
1071
|
+
let undo = false;
|
|
1072
|
+
if (quarantine !== null && existsSync(quarantine) && !existsSync(targetDir)) {
|
|
1073
|
+
try {
|
|
1074
|
+
renameSync(quarantine, targetDir);
|
|
1075
|
+
undo = true;
|
|
1076
|
+
}
|
|
1077
|
+
catch {
|
|
1078
|
+
undo = false;
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
const preserved = [backupDir];
|
|
1082
|
+
if (quarantine !== null && !undo)
|
|
1083
|
+
preserved.push(quarantine);
|
|
1084
|
+
return {
|
|
1085
|
+
tree,
|
|
1086
|
+
status: 'failed',
|
|
1087
|
+
detail: `restore failed: ${msg}; aside-undo=${undo ? 'ok (live tree back in place)' : 'failed'}`,
|
|
1088
|
+
preservedDirs: preserved,
|
|
1089
|
+
};
|
|
1004
1090
|
}
|
|
1005
1091
|
}
|
|
1092
|
+
/**
|
|
1093
|
+
* P0-3/P0-4: restore both trees with per-tree outcomes. `restored: true` only
|
|
1094
|
+
* when every tree is genuinely back (or had no backup to apply). A partial
|
|
1095
|
+
* restore is reported as such — never as success, never as rolled_back.
|
|
1096
|
+
*/
|
|
1097
|
+
export function restoreBackup(backupDir, runtimeBackupDir) {
|
|
1098
|
+
const extDir = getPluginExtDir();
|
|
1099
|
+
const runtimeDir = getPdRuntimeDir();
|
|
1100
|
+
const extension = backupDir !== null || runtimeBackupDir !== null
|
|
1101
|
+
? restoreTreeAside('extension', backupDir, extDir)
|
|
1102
|
+
: { tree: 'extension', status: 'no_backup', preservedDirs: [] };
|
|
1103
|
+
const runtime = backupDir !== null || runtimeBackupDir !== null
|
|
1104
|
+
? restoreTreeAside('runtime', runtimeBackupDir, runtimeDir)
|
|
1105
|
+
: { tree: 'runtime', status: 'no_backup', preservedDirs: [] };
|
|
1106
|
+
const restored = extension.status !== 'failed' && runtime.status !== 'failed';
|
|
1107
|
+
const partial = !restored;
|
|
1108
|
+
if (restored && (extension.status === 'restored' || runtime.status === 'restored')) {
|
|
1109
|
+
logger.info('Restored previous install from backup');
|
|
1110
|
+
}
|
|
1111
|
+
else if (partial) {
|
|
1112
|
+
logger.error(`Partial restore: extension=${extension.status} runtime=${runtime.status}`);
|
|
1113
|
+
}
|
|
1114
|
+
return {
|
|
1115
|
+
restored,
|
|
1116
|
+
partial,
|
|
1117
|
+
extension,
|
|
1118
|
+
runtime,
|
|
1119
|
+
...(partial ? { error: `extension=${extension.status}${extension.detail ? ` (${extension.detail})` : ''}; runtime=${runtime.status}${runtime.detail ? ` (${runtime.detail})` : ''}; preserved=${[...extension.preservedDirs, ...runtime.preservedDirs].join(', ') || 'none'}` } : {}),
|
|
1120
|
+
};
|
|
1121
|
+
}
|
|
1006
1122
|
/**
|
|
1007
1123
|
* CP-6: remove the runtime trees a failed FRESH install deployed. Only called
|
|
1008
1124
|
* when there is no pre-existing install (no backup) and a deployment step
|
|
@@ -2483,11 +2599,122 @@ async function installConsoleDependencies() {
|
|
|
2483
2599
|
await prepareComponentDependencies(consoleDest, 'Console');
|
|
2484
2600
|
}
|
|
2485
2601
|
const CONSOLE_PORT_MAX_RETRIES = 3;
|
|
2486
|
-
|
|
2602
|
+
/**
|
|
2603
|
+
* P0-2 (SPEC v1.2 §5 step 6 / invariant I-4): terminate a child and WAIT for
|
|
2604
|
+
* its real exit event before anyone touches the directories it may hold.
|
|
2605
|
+
*
|
|
2606
|
+
* Discipline proven by the R8 Windows experiment (2026-11-02, real
|
|
2607
|
+
* better-sqlite3 .node): subscribing to 'exit' AFTER the kill signal loses
|
|
2608
|
+
* the event — the listener must be attached BEFORE the signal is sent.
|
|
2609
|
+
* Graceful SIGTERM first, bounded wait, then SIGKILL, then a second bounded
|
|
2610
|
+
* wait. If even that does not confirm exit, `exited: false` is returned and
|
|
2611
|
+
* the caller MUST NOT perform destructive directory operations (defer to a
|
|
2612
|
+
* recovery state instead).
|
|
2613
|
+
*/
|
|
2614
|
+
export async function terminateAndJoinChild(child, options = {}) {
|
|
2615
|
+
const gracefulTimeoutMs = options.gracefulTimeoutMs ?? CONSOLE_CHILD_GRACEFUL_EXIT_TIMEOUT_MS;
|
|
2616
|
+
const killTimeoutMs = options.killTimeoutMs ?? CONSOLE_CHILD_KILL_EXIT_TIMEOUT_MS;
|
|
2617
|
+
if (child.exitCode !== null || child.signalCode !== null) {
|
|
2618
|
+
return { exited: true, exitCode: child.exitCode, signal: child.signalCode, timedOut: false, escalation: 'already_exited' };
|
|
2619
|
+
}
|
|
2620
|
+
const joinExit = (ms) => new Promise((resolve) => {
|
|
2621
|
+
const timer = setTimeout(() => resolve('timeout'), ms);
|
|
2622
|
+
child.once('exit', (code, signal) => {
|
|
2623
|
+
clearTimeout(timer);
|
|
2624
|
+
resolve({ code, signal });
|
|
2625
|
+
});
|
|
2626
|
+
});
|
|
2627
|
+
try {
|
|
2628
|
+
child.kill('SIGTERM');
|
|
2629
|
+
}
|
|
2630
|
+
catch { /* already gone — the join decides */ }
|
|
2631
|
+
let joined = await joinExit(gracefulTimeoutMs);
|
|
2632
|
+
if (joined !== 'timeout') {
|
|
2633
|
+
return { exited: true, exitCode: joined.code, signal: joined.signal, timedOut: false, escalation: 'sigterm' };
|
|
2634
|
+
}
|
|
2635
|
+
try {
|
|
2636
|
+
child.kill('SIGKILL');
|
|
2637
|
+
}
|
|
2638
|
+
catch { /* already gone — the join decides */ }
|
|
2639
|
+
joined = await joinExit(killTimeoutMs);
|
|
2640
|
+
if (joined !== 'timeout') {
|
|
2641
|
+
return { exited: true, exitCode: joined.code, signal: joined.signal, timedOut: true, escalation: 'sigkill' };
|
|
2642
|
+
}
|
|
2643
|
+
return { exited: false, exitCode: null, signal: null, timedOut: true, escalation: 'sigkill' };
|
|
2644
|
+
}
|
|
2645
|
+
export class ConsoleVerifyError extends Error {
|
|
2646
|
+
attempts;
|
|
2647
|
+
hadJoinFailure;
|
|
2648
|
+
constructor(message, attempts) {
|
|
2649
|
+
super(message);
|
|
2650
|
+
this.name = 'ConsoleVerifyError';
|
|
2651
|
+
this.attempts = attempts;
|
|
2652
|
+
this.hadJoinFailure = attempts.some((attempt) => attempt.outcome === 'join_failed');
|
|
2653
|
+
}
|
|
2654
|
+
}
|
|
2655
|
+
/**
|
|
2656
|
+
* PD Console health shape, bound to the posture we launched (rc-5: Object.hasOwn
|
|
2657
|
+
* for untrusted keys).
|
|
2658
|
+
*
|
|
2659
|
+
* P1-B: `data.overall` is the PD-specific discriminative field — the generic
|
|
2660
|
+
* { success: true } envelope alone matches any service. PRI-1054 review P2:
|
|
2661
|
+
* `data.authenticationMode` must equal the launched posture, because that is the
|
|
2662
|
+
* same fact `pd console open` refuses on (`console_authentication_mode_mismatch`).
|
|
2663
|
+
* Without it the installer certified ANY healthy PD console on 3100..3119 as
|
|
2664
|
+
* "the console I just launched", including a pre-existing no-auth one that
|
|
2665
|
+
* `pd console open` had already refused to adopt.
|
|
2666
|
+
*
|
|
2667
|
+
* (Hoisted above the G1 P0-1 identity verifier — its first caller in file
|
|
2668
|
+
* order — for no-use-before-define discipline; the autolaunch probe below
|
|
2669
|
+
* shares this same shape contract.)
|
|
2670
|
+
*/
|
|
2671
|
+
function isLaunchedConsoleHealth(payload, expectedMode) {
|
|
2672
|
+
if (typeof payload !== 'object' || payload === null)
|
|
2673
|
+
return false;
|
|
2674
|
+
if (!Object.hasOwn(payload, 'success') || Reflect.get(payload, 'success') !== true)
|
|
2675
|
+
return false;
|
|
2676
|
+
if (!Object.hasOwn(payload, 'data'))
|
|
2677
|
+
return false;
|
|
2678
|
+
const data = Reflect.get(payload, 'data');
|
|
2679
|
+
if (typeof data !== 'object' || data === null)
|
|
2680
|
+
return false;
|
|
2681
|
+
if (!Object.hasOwn(data, 'overall'))
|
|
2682
|
+
return false;
|
|
2683
|
+
const overall = Reflect.get(data, 'overall');
|
|
2684
|
+
if (overall !== 'healthy' && overall !== 'degraded' && overall !== 'error')
|
|
2685
|
+
return false;
|
|
2686
|
+
if (!Object.hasOwn(data, 'authenticationMode'))
|
|
2687
|
+
return false;
|
|
2688
|
+
return Reflect.get(data, 'authenticationMode') === expectedMode;
|
|
2689
|
+
}
|
|
2690
|
+
/** sendUnauthorized's envelope — the only responder worth offering the token to. */
|
|
2691
|
+
function isConsoleUnauthorizedEnvelope(payload) {
|
|
2692
|
+
if (typeof payload !== 'object' || payload === null)
|
|
2693
|
+
return false;
|
|
2694
|
+
return Object.hasOwn(payload, 'success') && Reflect.get(payload, 'success') === false
|
|
2695
|
+
&& Object.hasOwn(payload, 'error') && Reflect.get(payload, 'error') === 'unauthorized';
|
|
2696
|
+
}
|
|
2697
|
+
/** P0-1 (SPEC v1.2 §7 / invariant I-3): the response must prove it came from
|
|
2698
|
+
* THIS child — a one-shot nonce injected through the child env and echoed by
|
|
2699
|
+
* /api/health. An old console, another local service, or a proxy returning
|
|
2700
|
+
* 200+JSON cannot know the value, so they all fail closed here. The nonce
|
|
2701
|
+
* value never enters logs or diagnostics. */
|
|
2702
|
+
function isVerifiedConsoleIdentity(payload, expectedNonce) {
|
|
2703
|
+
if (!isLaunchedConsoleHealth(payload, 'no_auth'))
|
|
2704
|
+
return false;
|
|
2705
|
+
const data = Reflect.get(payload, 'data');
|
|
2706
|
+
if (typeof data !== 'object' || data === null || Array.isArray(data))
|
|
2707
|
+
return false;
|
|
2708
|
+
if (!Object.hasOwn(data, 'verifyNonce'))
|
|
2709
|
+
return false;
|
|
2710
|
+
return Reflect.get(data, 'verifyNonce') === expectedNonce;
|
|
2711
|
+
}
|
|
2712
|
+
/** Exported for real-subprocess tests (tests/console-verify-identity.test.ts). */
|
|
2713
|
+
export async function verifyConsole(workspaceDir) {
|
|
2487
2714
|
const consoleDest = getInstalledConsoleDir();
|
|
2488
2715
|
const serverEntry = path.join(consoleDest, 'dist', 'server.js');
|
|
2489
2716
|
if (!existsSync(serverEntry)) {
|
|
2490
|
-
return { ok: false, url: '', process: null,
|
|
2717
|
+
return { ok: false, url: '', process: null, attempts: [{ port: 0, pid: null, outcome: 'child_exited_early', detail: 'Console server entry not found' }], unconfirmedChildren: [] };
|
|
2491
2718
|
}
|
|
2492
2719
|
// EP-06 regression guard (PR #1169): verify the web UI bundle exists BEFORE
|
|
2493
2720
|
// spawning the server. Without dist/web/index.html the server returns 404
|
|
@@ -2495,14 +2722,14 @@ async function verifyConsole(workspaceDir) {
|
|
|
2495
2722
|
// for new users. npm install --ignore-scripts can remove dist/ in edge cases.
|
|
2496
2723
|
const webIndex = path.join(consoleDest, 'dist', 'web', 'index.html');
|
|
2497
2724
|
if (!existsSync(webIndex)) {
|
|
2498
|
-
return { ok: false, url: '', process: null,
|
|
2725
|
+
return { ok: false, url: '', process: null, attempts: [{ port: 0, pid: null, outcome: 'child_exited_early', detail: 'Console web UI (dist/web/index.html) not found — bundle may be corrupted or npm install removed dist/.' }], unconfirmedChildren: [] };
|
|
2499
2726
|
}
|
|
2500
2727
|
// Build a shuffled port list to avoid retrying the same port
|
|
2501
2728
|
const portRange = [];
|
|
2502
2729
|
for (let p = CONSOLE_PORT_RANGE_MIN; p <= CONSOLE_PORT_RANGE_MAX; p++)
|
|
2503
2730
|
portRange.push(p);
|
|
2504
2731
|
for (let i = portRange.length - 1; i > 0; i--) {
|
|
2505
|
-
const j =
|
|
2732
|
+
const j = randomInt(i + 1);
|
|
2506
2733
|
const a = portRange[i];
|
|
2507
2734
|
const b = portRange[j];
|
|
2508
2735
|
if (a === undefined || b === undefined)
|
|
@@ -2511,8 +2738,19 @@ async function verifyConsole(workspaceDir) {
|
|
|
2511
2738
|
portRange[j] = a;
|
|
2512
2739
|
}
|
|
2513
2740
|
const portsToTry = portRange.slice(0, CONSOLE_PORT_MAX_RETRIES);
|
|
2514
|
-
|
|
2741
|
+
const attempts = [];
|
|
2742
|
+
// Probe children whose exit could NOT be confirmed (join_failed). They stay
|
|
2743
|
+
// owned by the caller even when a later port attempt verifies — an orphan
|
|
2744
|
+
// probe child holding runtime-tree handles must never be dropped on the
|
|
2745
|
+
// floor (review 2026-11-02, Major: verifyConsole success path).
|
|
2746
|
+
const unconfirmedChildren = [];
|
|
2515
2747
|
for (const port of portsToTry) {
|
|
2748
|
+
// P0-1: fresh one-shot nonce per attempt (SPEC v1.2 §14), injected via
|
|
2749
|
+
// the parent env so the spawn below (env: { ...process.env }) passes it
|
|
2750
|
+
// to the child. Never argv (visible in process listings), never logged.
|
|
2751
|
+
// Cleared at every attempt exit below; attempts are strictly serial.
|
|
2752
|
+
const nonce = randomUUID();
|
|
2753
|
+
process.env.PD_CONSOLE_VERIFY_NONCE = nonce;
|
|
2516
2754
|
const child = spawn(process.execPath, [serverEntry, '--workspace', workspaceDir, '--port', String(port), '--no-auth', '--host', '127.0.0.1'], {
|
|
2517
2755
|
stdio: 'pipe',
|
|
2518
2756
|
env: { ...process.env },
|
|
@@ -2520,29 +2758,31 @@ async function verifyConsole(workspaceDir) {
|
|
|
2520
2758
|
});
|
|
2521
2759
|
let childExited = false;
|
|
2522
2760
|
let childExitCode = null;
|
|
2761
|
+
let childExitSignal = null;
|
|
2523
2762
|
let childStderr = '';
|
|
2524
|
-
|
|
2525
|
-
child.on('exit', (code) => {
|
|
2763
|
+
child.on('exit', (code, signal) => {
|
|
2526
2764
|
childExited = true;
|
|
2527
2765
|
childExitCode = code;
|
|
2766
|
+
childExitSignal = signal;
|
|
2528
2767
|
});
|
|
2529
2768
|
child.stderr?.on('data', (chunk) => {
|
|
2530
|
-
childStderr
|
|
2531
|
-
|
|
2532
|
-
child.stdout?.on('data', (chunk) => {
|
|
2533
|
-
childStdout += chunk.toString();
|
|
2769
|
+
if (childStderr.length < 4000)
|
|
2770
|
+
childStderr += chunk.toString();
|
|
2534
2771
|
});
|
|
2535
2772
|
await new Promise((resolve) => { setTimeout(resolve, CONSOLE_WARMUP_TIME_MS); });
|
|
2536
2773
|
if (childExited) {
|
|
2537
|
-
|
|
2774
|
+
delete process.env.PD_CONSOLE_VERIFY_NONCE;
|
|
2775
|
+
attempts.push({ port, pid: child.pid ?? null, outcome: 'child_exited_early', exitCode: childExitCode, signal: childExitSignal, stderrTail: childStderr.slice(-300) });
|
|
2538
2776
|
continue; // Try next port
|
|
2539
2777
|
}
|
|
2540
|
-
const
|
|
2778
|
+
const healthUrl = new URL('/api/health', `http://127.0.0.1:${port}`);
|
|
2541
2779
|
let ok = false;
|
|
2542
2780
|
let reason = '';
|
|
2781
|
+
let reasonKind = 'bad_body';
|
|
2782
|
+
let httpStatus;
|
|
2543
2783
|
try {
|
|
2544
2784
|
const result = await new Promise((resolve, reject) => {
|
|
2545
|
-
const req = http.get(
|
|
2785
|
+
const req = http.get(healthUrl, (res) => {
|
|
2546
2786
|
const chunks = [];
|
|
2547
2787
|
res.on('data', (chunk) => chunks.push(chunk));
|
|
2548
2788
|
res.on('end', () => {
|
|
@@ -2552,17 +2792,31 @@ async function verifyConsole(workspaceDir) {
|
|
|
2552
2792
|
req.on('error', reject);
|
|
2553
2793
|
req.setTimeout(CONSOLE_HEALTH_CHECK_TIMEOUT_MS, () => { req.destroy(); reject(new Error('health check timeout')); });
|
|
2554
2794
|
});
|
|
2795
|
+
httpStatus = result.statusCode;
|
|
2555
2796
|
if (result.statusCode !== 200) {
|
|
2556
|
-
|
|
2797
|
+
// SPEC v1.2 §14: a 502 (or any non-200) is recorded as an observed
|
|
2798
|
+
// fact with UNKNOWN attribution — we do NOT guess whether the
|
|
2799
|
+
// responder was PD, a proxy, or another local service (PRI-1069).
|
|
2800
|
+
reason = `Console /api/health returned HTTP ${result.statusCode} (expected 200); responder identity unknown`;
|
|
2801
|
+
reasonKind = 'http_error_status';
|
|
2557
2802
|
}
|
|
2558
2803
|
else if (!result.body || result.body.trim().length === 0) {
|
|
2559
2804
|
reason = 'Console /api/health returned empty body';
|
|
2805
|
+
reasonKind = 'bad_body';
|
|
2560
2806
|
}
|
|
2561
2807
|
else {
|
|
2562
2808
|
try {
|
|
2563
2809
|
const parsed = JSON.parse(result.body);
|
|
2564
2810
|
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
2565
2811
|
reason = 'Console /api/health returned non-object JSON';
|
|
2812
|
+
reasonKind = 'bad_body';
|
|
2813
|
+
}
|
|
2814
|
+
else if (!isVerifiedConsoleIdentity(parsed, nonce)) {
|
|
2815
|
+
// P0-1 fail-closed: a 200 JSON object that cannot echo THIS
|
|
2816
|
+
// attempt's nonce is some other responder (old console, foreign
|
|
2817
|
+
// local service, proxy) — never "verified".
|
|
2818
|
+
reason = 'Health responder did not prove this launch instance (nonce/shape mismatch) — rejected';
|
|
2819
|
+
reasonKind = 'identity_mismatch';
|
|
2566
2820
|
}
|
|
2567
2821
|
else {
|
|
2568
2822
|
ok = true;
|
|
@@ -2570,29 +2824,47 @@ async function verifyConsole(workspaceDir) {
|
|
|
2570
2824
|
}
|
|
2571
2825
|
catch {
|
|
2572
2826
|
reason = 'Console /api/health returned malformed JSON';
|
|
2827
|
+
reasonKind = 'bad_body';
|
|
2573
2828
|
}
|
|
2574
2829
|
}
|
|
2575
2830
|
}
|
|
2576
2831
|
catch (e) {
|
|
2577
|
-
reason = `Console health check failed on port ${port}: ${e instanceof Error ? e.message : String(e)}. Stderr: ${childStderr.slice(
|
|
2832
|
+
reason = `Console health check failed on port ${port}: ${e instanceof Error ? e.message : String(e)}. Stderr: ${childStderr.slice(-300)}`;
|
|
2833
|
+
reasonKind = 'request_error';
|
|
2578
2834
|
}
|
|
2579
2835
|
if (!ok) {
|
|
2580
|
-
|
|
2581
|
-
|
|
2582
|
-
|
|
2583
|
-
|
|
2584
|
-
|
|
2585
|
-
|
|
2586
|
-
|
|
2587
|
-
|
|
2588
|
-
|
|
2589
|
-
|
|
2590
|
-
|
|
2836
|
+
// P0-2: bounded terminate + REAL exit join before moving on; the child
|
|
2837
|
+
// may hold native-module handles inside the runtime tree a later
|
|
2838
|
+
// restore would rename/delete (R8: a mapped .node kills rmSync with
|
|
2839
|
+
// EPERM, while a same-volume directory rename survives).
|
|
2840
|
+
const join = await terminateAndJoinChild(child);
|
|
2841
|
+
delete process.env.PD_CONSOLE_VERIFY_NONCE;
|
|
2842
|
+
const outcome = join.exited ? reasonKind : 'join_failed';
|
|
2843
|
+
if (!join.exited)
|
|
2844
|
+
unconfirmedChildren.push(child);
|
|
2845
|
+
attempts.push({
|
|
2846
|
+
port,
|
|
2847
|
+
pid: child.pid ?? null,
|
|
2848
|
+
outcome,
|
|
2849
|
+
httpStatus,
|
|
2850
|
+
exitCode: join.exitCode,
|
|
2851
|
+
signal: join.signal,
|
|
2852
|
+
stderrTail: childStderr.slice(-300),
|
|
2853
|
+
...(join.exited
|
|
2854
|
+
? (reason.length > 0 ? { detail: reason } : {})
|
|
2855
|
+
: { detail: `${reason}; child NOT confirmed exited after SIGKILL — destructive operations must be deferred` }),
|
|
2856
|
+
});
|
|
2857
|
+
// A join_failed attempt does NOT fail the whole probe — the next port
|
|
2858
|
+
// may still verify — but the unconfirmed child must stay owned: it is
|
|
2859
|
+
// carried out in `unconfirmedChildren` so the caller terminates (and
|
|
2860
|
+
// waits on) every probe child before any destructive directory op.
|
|
2591
2861
|
continue; // Try next port
|
|
2592
2862
|
}
|
|
2593
|
-
|
|
2863
|
+
delete process.env.PD_CONSOLE_VERIFY_NONCE;
|
|
2864
|
+
attempts.push({ port, pid: child.pid ?? null, outcome: 'verified', httpStatus: 200 });
|
|
2865
|
+
return { ok: true, url: healthUrl.toString(), process: child, attempts, unconfirmedChildren };
|
|
2594
2866
|
}
|
|
2595
|
-
return { ok: false, url: '', process: null,
|
|
2867
|
+
return { ok: false, url: '', process: null, attempts, unconfirmedChildren };
|
|
2596
2868
|
}
|
|
2597
2869
|
// ─── Task 8: auto-launch console at end of successful install ────────────────
|
|
2598
2870
|
/**
|
|
@@ -2651,44 +2923,6 @@ function requestConsoleHealth(port, token, timeoutMs) {
|
|
|
2651
2923
|
req.setTimeout(timeoutMs, () => { req.destroy(); resolve(null); });
|
|
2652
2924
|
});
|
|
2653
2925
|
}
|
|
2654
|
-
/**
|
|
2655
|
-
* PD Console health shape, bound to the posture we launched (rc-5: Object.hasOwn
|
|
2656
|
-
* for untrusted keys).
|
|
2657
|
-
*
|
|
2658
|
-
* P1-B: `data.overall` is the PD-specific discriminative field — the generic
|
|
2659
|
-
* { success: true } envelope alone matches any service. PRI-1054 review P2:
|
|
2660
|
-
* `data.authenticationMode` must equal the launched posture, because that is the
|
|
2661
|
-
* same fact `pd console open` refuses on (`console_authentication_mode_mismatch`).
|
|
2662
|
-
* Without it the installer certified ANY healthy PD console on 3100..3119 as
|
|
2663
|
-
* "the console I just launched", including a pre-existing no-auth one that
|
|
2664
|
-
* `pd console open` had already refused to adopt.
|
|
2665
|
-
*/
|
|
2666
|
-
function isLaunchedConsoleHealth(payload, expectedMode) {
|
|
2667
|
-
if (typeof payload !== 'object' || payload === null)
|
|
2668
|
-
return false;
|
|
2669
|
-
if (!Object.hasOwn(payload, 'success') || Reflect.get(payload, 'success') !== true)
|
|
2670
|
-
return false;
|
|
2671
|
-
if (!Object.hasOwn(payload, 'data'))
|
|
2672
|
-
return false;
|
|
2673
|
-
const data = Reflect.get(payload, 'data');
|
|
2674
|
-
if (typeof data !== 'object' || data === null)
|
|
2675
|
-
return false;
|
|
2676
|
-
if (!Object.hasOwn(data, 'overall'))
|
|
2677
|
-
return false;
|
|
2678
|
-
const overall = Reflect.get(data, 'overall');
|
|
2679
|
-
if (overall !== 'healthy' && overall !== 'degraded' && overall !== 'error')
|
|
2680
|
-
return false;
|
|
2681
|
-
if (!Object.hasOwn(data, 'authenticationMode'))
|
|
2682
|
-
return false;
|
|
2683
|
-
return Reflect.get(data, 'authenticationMode') === expectedMode;
|
|
2684
|
-
}
|
|
2685
|
-
/** sendUnauthorized's envelope — the only responder worth offering the token to. */
|
|
2686
|
-
function isConsoleUnauthorizedEnvelope(payload) {
|
|
2687
|
-
if (typeof payload !== 'object' || payload === null)
|
|
2688
|
-
return false;
|
|
2689
|
-
return Object.hasOwn(payload, 'success') && Reflect.get(payload, 'success') === false
|
|
2690
|
-
&& Object.hasOwn(payload, 'error') && Reflect.get(payload, 'error') === 'unauthorized';
|
|
2691
|
-
}
|
|
2692
2926
|
/**
|
|
2693
2927
|
* Is the console READY on this port — meaning the one launched in `posture`?
|
|
2694
2928
|
*
|
|
@@ -3337,6 +3571,10 @@ transaction) {
|
|
|
3337
3571
|
const components = { plugin: 'skipped', cli: 'skipped', console: 'skipped' };
|
|
3338
3572
|
const verification = { features: 'skipped', storyA: 'skipped' };
|
|
3339
3573
|
let consoleProcess = null;
|
|
3574
|
+
// P0-2 (review 2026-11-02 Major): probe children from earlier port attempts
|
|
3575
|
+
// whose exit could not be confirmed. They ride along until the terminal
|
|
3576
|
+
// join below — a later verified port never orphans them.
|
|
3577
|
+
let unconfirmedProbeChildren = [];
|
|
3340
3578
|
let stepIndex = 0;
|
|
3341
3579
|
// CP-6: true once the run has started deploying runtime components (after
|
|
3342
3580
|
// the backup step). With no pre-existing install (no backup), a failure
|
|
@@ -3350,18 +3588,32 @@ transaction) {
|
|
|
3350
3588
|
// finally block can attach the gateway restart notice to the SAME object
|
|
3351
3589
|
// the caller receives (a finally block cannot rewrite an inline return).
|
|
3352
3590
|
let pendingResult;
|
|
3353
|
-
|
|
3591
|
+
// P0-2: bounded terminate + exit join over EVERY probe child we still own
|
|
3592
|
+
// (the verified child plus any earlier unconfirmed ones). The aggregated
|
|
3593
|
+
// join result decides whether destructive directory operations may proceed
|
|
3594
|
+
// — an unconfirmed child may hold native-module handles inside the runtime
|
|
3595
|
+
// tree (R8 experiment).
|
|
3596
|
+
const killConsoleChild = async () => {
|
|
3597
|
+
const targets = [...unconfirmedProbeChildren];
|
|
3598
|
+
unconfirmedProbeChildren = [];
|
|
3354
3599
|
if (consoleProcess) {
|
|
3355
|
-
|
|
3356
|
-
consoleProcess.kill('SIGTERM');
|
|
3357
|
-
}
|
|
3358
|
-
catch { /* ignore */ }
|
|
3359
|
-
try {
|
|
3360
|
-
consoleProcess.kill('SIGKILL');
|
|
3361
|
-
}
|
|
3362
|
-
catch { /* ignore */ }
|
|
3600
|
+
targets.push(consoleProcess);
|
|
3363
3601
|
consoleProcess = null;
|
|
3364
3602
|
}
|
|
3603
|
+
if (targets.length === 0)
|
|
3604
|
+
return null;
|
|
3605
|
+
let aggregated = null;
|
|
3606
|
+
for (const child of targets) {
|
|
3607
|
+
const join = await terminateAndJoinChild(child);
|
|
3608
|
+
if (!join.exited) {
|
|
3609
|
+
logger.error(`Console child (pid=${child.pid ?? '?'}) could not be confirmed exited after SIGKILL (graceful ${CONSOLE_CHILD_GRACEFUL_EXIT_TIMEOUT_MS}ms + kill ${CONSOLE_CHILD_KILL_EXIT_TIMEOUT_MS}ms)`);
|
|
3610
|
+
}
|
|
3611
|
+
// Worst result wins: one unconfirmed child must gate destructive
|
|
3612
|
+
// operations even when every other child exited cleanly.
|
|
3613
|
+
if (aggregated === null || !join.exited)
|
|
3614
|
+
aggregated = join;
|
|
3615
|
+
}
|
|
3616
|
+
return aggregated;
|
|
3365
3617
|
};
|
|
3366
3618
|
try {
|
|
3367
3619
|
// CP-9 (2026-09-05 investigation): the workspace must exist before any
|
|
@@ -3550,13 +3802,22 @@ transaction) {
|
|
|
3550
3802
|
if (spinner)
|
|
3551
3803
|
updateProgress(spinner, stepIndex, 'Verifying pd-console...');
|
|
3552
3804
|
const consoleVerify = await verifyConsole(options.workspaceDir);
|
|
3805
|
+
unconfirmedProbeChildren.push(...consoleVerify.unconfirmedChildren);
|
|
3553
3806
|
if (consoleVerify.ok) {
|
|
3554
3807
|
components.console = 'configured';
|
|
3555
3808
|
components.consoleEntrypoint = consoleVerify.url;
|
|
3556
3809
|
consoleProcess = consoleVerify.process;
|
|
3557
3810
|
}
|
|
3558
3811
|
else {
|
|
3559
|
-
|
|
3812
|
+
// SPEC v1.2 §14: attempt-level diagnostics travel with the failure so
|
|
3813
|
+
// the journal records every port/PID/status/identity outcome. The
|
|
3814
|
+
// message states ONLY the probe fact — whether anything was rolled back
|
|
3815
|
+
// is decided by the actual restore below and reported there (review
|
|
3816
|
+
// 2026-11-02: never claim "rolled back" before the restore ran).
|
|
3817
|
+
const attemptsSummary = consoleVerify.attempts
|
|
3818
|
+
.map((a) => `port=${a.port} pid=${a.pid ?? '?'} outcome=${a.outcome}${a.httpStatus !== undefined ? ` http=${a.httpStatus}` : ''}${a.stderrTail ? ` stderr="${a.stderrTail.slice(-160)}"` : ''}`)
|
|
3819
|
+
.join(' | ');
|
|
3820
|
+
throw new ConsoleVerifyError(`Console verification failed: all ${CONSOLE_PORT_MAX_RETRIES} port attempts failed (${attemptsSummary}). Installation was not activated.`, consoleVerify.attempts);
|
|
3560
3821
|
}
|
|
3561
3822
|
stepIndex++;
|
|
3562
3823
|
// ADR-0024 D-2: the console probe passed — the staged installation is live.
|
|
@@ -3648,7 +3909,10 @@ transaction) {
|
|
|
3648
3909
|
verification.storyA = 'passed';
|
|
3649
3910
|
}
|
|
3650
3911
|
catch (e) {
|
|
3651
|
-
|
|
3912
|
+
// Same discipline as ConsoleVerifyError (review 2026-11-02): state the
|
|
3913
|
+
// probe fact only — the actual rollback outcome is journaled and
|
|
3914
|
+
// reported by the failure handler below, never pre-claimed here.
|
|
3915
|
+
throw new Error(`Demo verification failed: ${e instanceof Error ? e.message : String(e)}. Installation was not activated.`, { cause: e });
|
|
3652
3916
|
}
|
|
3653
3917
|
stepIndex++;
|
|
3654
3918
|
if (spinner)
|
|
@@ -3745,17 +4009,52 @@ transaction) {
|
|
|
3745
4009
|
// runtime set is RETAINED as the supported previous release (rollback /
|
|
3746
4010
|
// data-compat window material); only OLDER retained sets beyond the newest
|
|
3747
4011
|
// are pruned. Deletion of the freshest backup would strand code rollback.
|
|
3748
|
-
|
|
4012
|
+
// Review 2026-11-02 (Major: data integrity): while a PRIOR failed
|
|
4013
|
+
// transaction is still recovery-pending (restore_incomplete/deferred/
|
|
4014
|
+
// install_residue marker, no resolution record), pruning OLDER backup
|
|
4015
|
+
// sets could destroy the last good version's backup — the newest set here
|
|
4016
|
+
// is this run's just-superseded (possibly half-deployed) tree, and the
|
|
4017
|
+
// old "current + one previous" window cannot protect the original. Skip
|
|
4018
|
+
// pruning for this run; it resumes once the pending set is resolved
|
|
4019
|
+
// (written below, at confirm time) on a subsequent install.
|
|
4020
|
+
const { transactionsDir } = resolvePdHomePaths(getPdDir());
|
|
4021
|
+
const recoveryPendingBeforePrune = findRecoveryPendingTransactionIds(transactionsDir);
|
|
4022
|
+
if (recoveryPendingBeforePrune.length > 0) {
|
|
4023
|
+
logger.warn(`Recovery pending for ${recoveryPendingBeforePrune.length} prior failed transaction(s) (${recoveryPendingBeforePrune.join(', ')}) — keeping ALL backup sets this run (no pruning); normal retention resumes on the next install after this one confirms.`);
|
|
4024
|
+
}
|
|
4025
|
+
else {
|
|
4026
|
+
retainSupersededBackup(backupDir, runtimeBackupDir);
|
|
4027
|
+
}
|
|
3749
4028
|
// ADR-0024 D-2: retention/pruning of OLDER sets is the commit point of the
|
|
3750
4029
|
// transaction.
|
|
3751
4030
|
journalInstallerTransitionDegrading(journal, journal.lastState, 'confirmed', 'previous release retained; older sets pruned; install complete');
|
|
3752
4031
|
// PRI-709 P0-2: active.json is the deployment identity source, written
|
|
3753
4032
|
// journal-first — after `confirmed`, never before it.
|
|
3754
4033
|
commitInstallerActiveRecord(journal);
|
|
4034
|
+
// G1 (review 2026-11-02 Minor: recovery alarm never clears): now that this
|
|
4035
|
+
// install physically completed (confirmed), any prior recovery-pending
|
|
4036
|
+
// failed transaction it just superseded IS resolved — record that fact so
|
|
4037
|
+
// recovery surfaces stop alarming about it. Fail-safe: resolution writing
|
|
4038
|
+
// is best-effort; an unwritable record keeps the alarm (conservative).
|
|
4039
|
+
for (const pendingId of recoveryPendingBeforePrune) {
|
|
4040
|
+
try {
|
|
4041
|
+
writeRecoveryResolution(transactionsDir, {
|
|
4042
|
+
resolvedTransactionId: pendingId,
|
|
4043
|
+
resolvedByTransactionId: journal.transactionId,
|
|
4044
|
+
});
|
|
4045
|
+
logger.info(`Recorded recovery resolution: failed transaction ${pendingId} superseded by ${journal.transactionId}`);
|
|
4046
|
+
}
|
|
4047
|
+
catch (resolutionError) {
|
|
4048
|
+
logger.warn(`Could not record recovery resolution for ${pendingId} (alarm stays): ${resolutionError instanceof Error ? resolutionError.message : String(resolutionError)}`);
|
|
4049
|
+
}
|
|
4050
|
+
}
|
|
3755
4051
|
if (spinner) {
|
|
3756
4052
|
spinner.succeed('Install complete!');
|
|
3757
4053
|
}
|
|
3758
|
-
|
|
4054
|
+
// P0-2: terminate + join the verified child before any later step that
|
|
4055
|
+
// may touch the runtime tree (here: none destructive, but a wedged child
|
|
4056
|
+
// must not leak past the install either).
|
|
4057
|
+
await killConsoleChild();
|
|
3759
4058
|
// Task 8: auto-launch console via `pd console open` (reuses PRI-300
|
|
3760
4059
|
// handleConsoleOpen — no new launcher subsystem). Opens the browser to
|
|
3761
4060
|
// /welcome for onboarding. Detached so the console survives installer exit.
|
|
@@ -3811,27 +4110,42 @@ transaction) {
|
|
|
3811
4110
|
catch (error) {
|
|
3812
4111
|
if (spinner)
|
|
3813
4112
|
spinner.fail('Install failed');
|
|
3814
|
-
|
|
4113
|
+
// P0-2: terminate + join the child BEFORE any restore attempt. An
|
|
4114
|
+
// unconfirmed child may hold native-module handles inside the runtime
|
|
4115
|
+
// tree; restoring under it is exactly the PRI-1069 failure mode (EPERM
|
|
4116
|
+
// mid-delete, half-restored runtime).
|
|
4117
|
+
const childJoin = await killConsoleChild();
|
|
3815
4118
|
// ADR-0024 D-2: record the outcome (journal may be null when the throw
|
|
3816
4119
|
// happened before the transaction was planned — zero side effects then).
|
|
3817
4120
|
// `failed` and `rolled_back` are BOTH terminal states and one journal
|
|
3818
4121
|
// file must end at exactly one terminal state (the strict reader rejects
|
|
3819
4122
|
// any transition after a terminal one), so the outcome is either-or:
|
|
3820
4123
|
// - backup restored (a real rollback happened) → `rolled_back`
|
|
3821
|
-
// - no backup existed (mutation never started)
|
|
4124
|
+
// - no backup existed (mutation never started), restore failed, or
|
|
4125
|
+
// restore deferred (child unconfirmed) → `failed`
|
|
3822
4126
|
const errorMsgRaw = error instanceof Error ? error.message : String(error);
|
|
3823
|
-
const
|
|
3824
|
-
|
|
3825
|
-
|
|
3826
|
-
|
|
3827
|
-
|
|
3828
|
-
|
|
3829
|
-
|
|
3830
|
-
|
|
3831
|
-
|
|
3832
|
-
|
|
3833
|
-
|
|
3834
|
-
|
|
4127
|
+
const childUnconfirmed = childJoin !== null && !childJoin.exited;
|
|
4128
|
+
const probeHadJoinFailure = error instanceof ConsoleVerifyError && error.hadJoinFailure;
|
|
4129
|
+
let restoreResult;
|
|
4130
|
+
let restoreDeferred = false;
|
|
4131
|
+
if (childUnconfirmed || probeHadJoinFailure) {
|
|
4132
|
+
// I-4 (SPEC v1.2 §17): an unconfirmed probe child forbids destructive
|
|
4133
|
+
// directory operations. Keep everything on disk; surface a machine-
|
|
4134
|
+
// readable marker the recovery scan (P0-4) will NOT skip.
|
|
4135
|
+
restoreDeferred = true;
|
|
4136
|
+
restoreResult = {
|
|
4137
|
+
restored: false,
|
|
4138
|
+
partial: true,
|
|
4139
|
+
error: `child (pid=${childJoin && !childJoin.exited ? 'unconfirmed' : 'probe-child'}) not confirmed exited; destructive restore deferred`,
|
|
4140
|
+
};
|
|
4141
|
+
}
|
|
4142
|
+
else if (error instanceof BackupIntegrityError) {
|
|
4143
|
+
// P0-3: the backup step itself failed midway and its compensation also
|
|
4144
|
+
// failed — restore from the paths the error carries.
|
|
4145
|
+
restoreResult = restoreBackup(error.extensionBackupDir ?? backupDir, error.runtimeBackupDir ?? runtimeBackupDir);
|
|
4146
|
+
}
|
|
4147
|
+
else {
|
|
4148
|
+
restoreResult = restoreBackup(backupDir, runtimeBackupDir);
|
|
3835
4149
|
}
|
|
3836
4150
|
const errorMsg = errorMsgRaw;
|
|
3837
4151
|
// ERR-046 / rc-9: never claim a restore that didn't happen. When backupDir
|
|
@@ -3842,7 +4156,9 @@ transaction) {
|
|
|
3842
4156
|
const isLockError = /EPERM|EBUSY|EACCES|operation not permitted/i.test(errorMsg);
|
|
3843
4157
|
const extDir = getPluginExtDir();
|
|
3844
4158
|
// EP-11: all operator-visible failure/rollback text goes through t().
|
|
3845
|
-
|
|
4159
|
+
// P0-3: a BackupIntegrityError means a tree IS displaced into a backup
|
|
4160
|
+
// location even though the local variables are still null — count it.
|
|
4161
|
+
const hasBackup = Boolean(backupDir || runtimeBackupDir) || error instanceof BackupIntegrityError;
|
|
3846
4162
|
// CP-6 (2026-09-05 investigation, E-003/E-014): a FRESH install (no
|
|
3847
4163
|
// pre-existing install → no backup) that failed mid-deployment used to
|
|
3848
4164
|
// leave ~/.pd/runtime and the extension copy fully populated while the
|
|
@@ -3858,9 +4174,56 @@ transaction) {
|
|
|
3858
4174
|
// permission-restricted, freshCleanup.failed names the residue (rc-9).
|
|
3859
4175
|
// hasBackup=true keeps its restore path, and a lock error before
|
|
3860
4176
|
// mutationStarted=true correctly keeps the no-changes messages below.
|
|
3861
|
-
|
|
4177
|
+
// Review 2026-11-02 (Major/Minor): the cleanup is computed BEFORE the
|
|
4178
|
+
// journal write so PARTIAL-clean residue can be journaled with a machine-
|
|
4179
|
+
// readable marker (`install_residue:`) instead of vanishing behind a
|
|
4180
|
+
// terminal `failed`; and restore_deferred FORBIDS it — the probe child may
|
|
4181
|
+
// still hold runtime-tree handles, and rmSync under it is the exact
|
|
4182
|
+
// PRI-1069 failure mode.
|
|
4183
|
+
const freshCleanup = !hasBackup && mutationStarted && !restoreDeferred
|
|
3862
4184
|
? cleanUnactivatedFreshInstall()
|
|
3863
4185
|
: undefined;
|
|
4186
|
+
if (journal) {
|
|
4187
|
+
const restoreFailed = hasBackup && !restoreResult.restored;
|
|
4188
|
+
const freshResidue = !hasBackup && freshCleanup !== undefined && freshCleanup.failed.length > 0;
|
|
4189
|
+
// SPEC v1.2 §14: attempt-level diagnostics ride along in the recovery
|
|
4190
|
+
// markers too — the recovery list reads journal details, not thrown
|
|
4191
|
+
// error objects (review 2026-11-02 Minor).
|
|
4192
|
+
const attemptsNote = error instanceof ConsoleVerifyError && error.attempts.length > 0
|
|
4193
|
+
? ` attempts=[${error.attempts.map((a) => `port=${a.port} pid=${a.pid ?? '?'} outcome=${a.outcome}${a.httpStatus !== undefined ? ` http=${a.httpStatus}` : ''}${a.exitCode !== undefined ? ` exit=${a.exitCode}` : ''}`).join(', ')}];`
|
|
4194
|
+
: '';
|
|
4195
|
+
let detail;
|
|
4196
|
+
if (restoreDeferred) {
|
|
4197
|
+
detail = `restore_deferred:${attemptsNote} ${restoreResult.error ?? 'child not confirmed exited'}; backups and live trees preserved at extension=${backupDir ?? '-'} runtime=${runtimeBackupDir ?? '-'}; close the reported processes and re-run the installer to retry recovery`;
|
|
4198
|
+
}
|
|
4199
|
+
else if (restoreFailed) {
|
|
4200
|
+
// P0-4: machine-readable marker prefix — the recovery list (Console
|
|
4201
|
+
// recoveryStatus) must surface terminal-failed transactions whose
|
|
4202
|
+
// PHYSICAL install was left inconsistent instead of skipping them.
|
|
4203
|
+
const preserved = [...(restoreResult.extension?.preservedDirs ?? []), ...(restoreResult.runtime?.preservedDirs ?? [])];
|
|
4204
|
+
detail = `restore_incomplete:${attemptsNote} ${restoreResult.error ?? 'unknown'}; preserved=[${preserved.join(', ')}]; extension=${restoreResult.extension?.status ?? 'n/a'} runtime=${restoreResult.runtime?.status ?? 'n/a'}; re-run the installer to retry recovery`;
|
|
4205
|
+
}
|
|
4206
|
+
else if (freshResidue && freshCleanup !== undefined) {
|
|
4207
|
+
detail = `install_residue: fresh-install cleanup left residue: ${freshCleanup.failed.map((f) => `${f.dir} (${f.error})`).join(', ')}; ${errorMsgRaw}`;
|
|
4208
|
+
}
|
|
4209
|
+
else if (hasBackup && restoreResult.restored) {
|
|
4210
|
+
// A successful rollback that still left quarantine residue must name
|
|
4211
|
+
// it in the journal (review 2026-11-02 Minor: residue invisible in
|
|
4212
|
+
// the rolled_back path).
|
|
4213
|
+
const residueNotes = [restoreResult.extension?.detail, restoreResult.runtime?.detail]
|
|
4214
|
+
.filter((note) => typeof note === 'string' && note.length > 0 && note.includes('residue'));
|
|
4215
|
+
detail = residueNotes.length > 0 ? `${errorMsgRaw}; restore notes: ${residueNotes.join('; ')}` : errorMsgRaw;
|
|
4216
|
+
}
|
|
4217
|
+
else {
|
|
4218
|
+
detail = errorMsgRaw;
|
|
4219
|
+
}
|
|
4220
|
+
if (!restoreDeferred && !restoreFailed && hasBackup && restoreResult.restored) {
|
|
4221
|
+
journalInstallerTransitionDegrading(journal, journal.lastState, 'rolled_back', detail);
|
|
4222
|
+
}
|
|
4223
|
+
else {
|
|
4224
|
+
journalInstallerTransitionDegrading(journal, journal.lastState, 'failed', detail);
|
|
4225
|
+
}
|
|
4226
|
+
}
|
|
3864
4227
|
// PRI-697 review P1: the global pd shim is a side effect OUTSIDE the
|
|
3865
4228
|
// runtime/extension trees both cleanup paths above address. Whatever
|
|
3866
4229
|
// the backup state, files THIS run created in the npm global bin dir
|