create-principles-disciple 1.146.1 → 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.
Files changed (122) hide show
  1. package/_release/product-identity.json +2 -2
  2. package/console/dist/server/models/ActivationsConsoleModel.d.ts +3 -0
  3. package/console/dist/server/models/ActivationsConsoleModel.js +25 -1
  4. package/console/dist/server/models/ApprovalsConsoleModel.d.ts +5 -0
  5. package/console/dist/server/models/ApprovalsConsoleModel.js +40 -2
  6. package/console/dist/server/routes/activations.js +47 -0
  7. package/console/dist/server/routes/approvals.js +1 -1
  8. package/console/dist/server/routes/health.js +17 -0
  9. package/console/dist/server/routes/update-transaction.js +63 -3
  10. package/console/dist/shared/approved-rule-revision-contract.d.ts +8 -0
  11. package/console/dist/shared/approved-rule-revision-contract.js +48 -0
  12. package/console/dist/ui/api.d.ts +3 -0
  13. package/console/dist/ui/api.js +9 -0
  14. package/console/dist/ui/i18n/en.json +9 -0
  15. package/console/dist/ui/i18n/zh-CN.json +9 -0
  16. package/console/dist/ui/pages/activation/ActivationPage.js +69 -2
  17. package/console/dist/ui/pages/settings/UpdatePage.js +1 -1
  18. package/console/dist/ui/utils/validators.d.ts +10 -0
  19. package/console/dist/ui/utils/validators.js +11 -1
  20. package/console/dist/web/assets/app.css +3 -0
  21. package/console/dist/web/assets/app.js +31 -31
  22. package/core/dist/runtime-v2/activation/activation-dispatcher.d.ts +2 -0
  23. package/core/dist/runtime-v2/activation/activation-dispatcher.d.ts.map +1 -1
  24. package/core/dist/runtime-v2/activation/activation-dispatcher.js +60 -22
  25. package/core/dist/runtime-v2/activation/activation-dispatcher.js.map +1 -1
  26. package/core/dist/runtime-v2/activation/activation-types.d.ts +12 -2
  27. package/core/dist/runtime-v2/activation/activation-types.d.ts.map +1 -1
  28. package/core/dist/runtime-v2/activation/activation-types.js.map +1 -1
  29. package/core/dist/runtime-v2/activation/index.d.ts +2 -0
  30. package/core/dist/runtime-v2/activation/index.d.ts.map +1 -1
  31. package/core/dist/runtime-v2/activation/index.js +2 -0
  32. package/core/dist/runtime-v2/activation/index.js.map +1 -1
  33. package/core/dist/runtime-v2/activation/rule-replacement.d.ts +38 -0
  34. package/core/dist/runtime-v2/activation/rule-replacement.d.ts.map +1 -0
  35. package/core/dist/runtime-v2/activation/rule-replacement.js +44 -0
  36. package/core/dist/runtime-v2/activation/rule-replacement.js.map +1 -0
  37. package/core/dist/runtime-v2/activation/sqlite-activation-state-store.d.ts +9 -1
  38. package/core/dist/runtime-v2/activation/sqlite-activation-state-store.d.ts.map +1 -1
  39. package/core/dist/runtime-v2/activation/sqlite-activation-state-store.js +53 -23
  40. package/core/dist/runtime-v2/activation/sqlite-activation-state-store.js.map +1 -1
  41. package/core/dist/runtime-v2/activation/writers/rule-host-writer.d.ts.map +1 -1
  42. package/core/dist/runtime-v2/activation/writers/rule-host-writer.js +15 -2
  43. package/core/dist/runtime-v2/activation/writers/rule-host-writer.js.map +1 -1
  44. package/core/dist/runtime-v2/adapter/principle-tree-ledger-adapter.d.ts +2 -0
  45. package/core/dist/runtime-v2/adapter/principle-tree-ledger-adapter.d.ts.map +1 -1
  46. package/core/dist/runtime-v2/adapter/principle-tree-ledger-adapter.js +5 -1
  47. package/core/dist/runtime-v2/adapter/principle-tree-ledger-adapter.js.map +1 -1
  48. package/core/dist/runtime-v2/index.d.ts +3 -1
  49. package/core/dist/runtime-v2/index.d.ts.map +1 -1
  50. package/core/dist/runtime-v2/index.js +2 -1
  51. package/core/dist/runtime-v2/index.js.map +1 -1
  52. package/core/dist/runtime-v2/internalization/approved-rule-revision.d.ts +51 -0
  53. package/core/dist/runtime-v2/internalization/approved-rule-revision.d.ts.map +1 -0
  54. package/core/dist/runtime-v2/internalization/approved-rule-revision.js +211 -0
  55. package/core/dist/runtime-v2/internalization/approved-rule-revision.js.map +1 -0
  56. package/core/dist/runtime-v2/internalization/artificer-prompt-builder.d.ts +1 -1
  57. package/core/dist/runtime-v2/internalization/artificer-prompt-builder.d.ts.map +1 -1
  58. package/core/dist/runtime-v2/internalization/artificer-prompt-builder.js +2 -1
  59. package/core/dist/runtime-v2/internalization/artificer-prompt-builder.js.map +1 -1
  60. package/core/dist/runtime-v2/internalization/bash-risk.d.ts +6 -0
  61. package/core/dist/runtime-v2/internalization/bash-risk.d.ts.map +1 -1
  62. package/core/dist/runtime-v2/internalization/bash-risk.js +120 -0
  63. package/core/dist/runtime-v2/internalization/bash-risk.js.map +1 -1
  64. package/core/dist/runtime-v2/internalization/evaluator-runner.d.ts.map +1 -1
  65. package/core/dist/runtime-v2/internalization/evaluator-runner.js +4 -1
  66. package/core/dist/runtime-v2/internalization/evaluator-runner.js.map +1 -1
  67. package/core/dist/runtime-v2/internalization/peer-runner-contracts.d.ts +2 -1
  68. package/core/dist/runtime-v2/internalization/peer-runner-contracts.d.ts.map +1 -1
  69. package/core/dist/runtime-v2/internalization/peer-runner-contracts.js.map +1 -1
  70. package/core/dist/runtime-v2/internalization/pitask-metadata.d.ts +21 -0
  71. package/core/dist/runtime-v2/internalization/pitask-metadata.d.ts.map +1 -1
  72. package/core/dist/runtime-v2/internalization/pitask-metadata.js +30 -0
  73. package/core/dist/runtime-v2/internalization/pitask-metadata.js.map +1 -1
  74. package/core/dist/runtime-v2/internalization/rule-host-contracts.d.ts +2 -0
  75. package/core/dist/runtime-v2/internalization/rule-host-contracts.d.ts.map +1 -1
  76. package/core/dist/runtime-v2/internalization/rule-host-input-builder.d.ts.map +1 -1
  77. package/core/dist/runtime-v2/internalization/rule-host-input-builder.js +6 -0
  78. package/core/dist/runtime-v2/internalization/rule-host-input-builder.js.map +1 -1
  79. package/core/dist/runtime-v2/internalization/rulecode-verification-obligations.d.ts.map +1 -1
  80. package/core/dist/runtime-v2/internalization/rulecode-verification-obligations.js +32 -0
  81. package/core/dist/runtime-v2/internalization/rulecode-verification-obligations.js.map +1 -1
  82. package/core/dist/runtime-v2/proven-channel-baseline.js +1 -1
  83. package/core/dist/runtime-v2/proven-channel-baseline.js.map +1 -1
  84. package/core/dist/runtime-v2/store/task/sqlite-approved-rule-revision.d.ts +15 -0
  85. package/core/dist/runtime-v2/store/task/sqlite-approved-rule-revision.d.ts.map +1 -0
  86. package/core/dist/runtime-v2/store/task/sqlite-approved-rule-revision.js +39 -0
  87. package/core/dist/runtime-v2/store/task/sqlite-approved-rule-revision.js.map +1 -0
  88. package/core/package.json +1 -1
  89. package/dist/installer.d.ts +81 -0
  90. package/dist/installer.d.ts.map +1 -1
  91. package/dist/installer.js +480 -117
  92. package/dist/installer.js.map +1 -1
  93. package/dist/update/console-surface.d.ts +1 -1
  94. package/dist/update/console-surface.d.ts.map +1 -1
  95. package/dist/update/console-surface.js +1 -1
  96. package/dist/update/console-surface.js.map +1 -1
  97. package/dist/update/transaction-journal.d.ts +47 -0
  98. package/dist/update/transaction-journal.d.ts.map +1 -1
  99. package/dist/update/transaction-journal.js +87 -2
  100. package/dist/update/transaction-journal.js.map +1 -1
  101. package/host-runtime/dist/production-rulehost-gate.js +11 -2
  102. package/host-runtime/package.json +1 -1
  103. package/package.json +2 -2
  104. package/pd-cli/package.json +1 -1
  105. package/plugin/dist/bundle.js +477 -472
  106. package/plugin/dist/openclaw.plugin.json +1 -1
  107. package/plugin/dist/rulehost-evidence.js +52 -49
  108. package/plugin/openclaw.plugin.json +1 -1
  109. package/plugin/package.json +1 -1
  110. package/release-manager/dist/installer.d.ts +81 -0
  111. package/release-manager/dist/installer.d.ts.map +1 -1
  112. package/release-manager/dist/installer.js +480 -117
  113. package/release-manager/dist/installer.js.map +1 -1
  114. package/release-manager/dist/update/console-surface.d.ts +1 -1
  115. package/release-manager/dist/update/console-surface.d.ts.map +1 -1
  116. package/release-manager/dist/update/console-surface.js +1 -1
  117. package/release-manager/dist/update/console-surface.js.map +1 -1
  118. package/release-manager/dist/update/transaction-journal.d.ts +47 -0
  119. package/release-manager/dist/update/transaction-journal.d.ts.map +1 -1
  120. package/release-manager/dist/update/transaction-journal.js +87 -2
  121. package/release-manager/dist/update/transaction-journal.js.map +1 -1
  122. package/release-manager/package.json +2 -2
package/dist/installer.js CHANGED
@@ -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
- function restoreBackup(backupDir, runtimeBackupDir) {
982
- if ((!backupDir || !existsSync(backupDir)) && (!runtimeBackupDir || !existsSync(runtimeBackupDir)))
983
- return { restored: true };
984
- const extDir = getPluginExtDir();
985
- const runtimeDir = getPdRuntimeDir();
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 (backupDir && existsSync(extDir)) {
988
- rmSync(extDir, { recursive: true, force: true });
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
- if (backupDir && existsSync(backupDir))
991
- renameSync(backupDir, extDir);
992
- if (runtimeBackupDir && existsSync(runtimeBackupDir)) {
993
- if (existsSync(runtimeDir))
994
- rmSync(runtimeDir, { recursive: true, force: true });
995
- renameSync(runtimeBackupDir, runtimeDir);
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
- logger.info('Restored previous install from backup');
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 = `Failed to restore backup: ${e instanceof Error ? e.message : String(e)}`;
1002
- logger.error(msg);
1003
- return { restored: false, error: msg };
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
- async function verifyConsole(workspaceDir) {
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, reason: 'Console server entry not found' };
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, reason: 'Console web UI (dist/web/index.html) not found — bundle may be corrupted or npm install removed dist/. Re-run: npx create-principles-disciple' };
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 = Math.floor(Math.random() * (i + 1));
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
- let lastReason = '';
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
- let childStdout = '';
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 += chunk.toString();
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
- lastReason = `Console process exited prematurely with code ${childExitCode} on port ${port}. Stderr: ${childStderr.slice(0, 500)}`;
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 url = `http://127.0.0.1:${port}`;
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(`${url}/api/health`, (res) => {
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
- reason = `Console /api/health returned HTTP ${result.statusCode} (expected 200)`;
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(0, 300)}. Stdout: ${childStdout.slice(0, 300)}`;
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
- try {
2581
- child.kill('SIGTERM');
2582
- }
2583
- catch { /* ignore */ }
2584
- try {
2585
- if (!childExited) {
2586
- child.kill('SIGKILL');
2587
- }
2588
- }
2589
- catch { /* ignore */ }
2590
- lastReason = reason;
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
- return { ok: true, url, process: child };
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, reason: `All ${CONSOLE_PORT_MAX_RETRIES} port attempts failed. Last: ${lastReason}` };
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
- const killConsoleChild = () => {
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
- try {
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
- throw new Error(`Console verification failed: ${consoleVerify.reason ?? 'unknown'}. Installation rolled back — plugin and CLI are not activated.`);
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
- throw new Error(`Demo verification failed: ${e instanceof Error ? e.message : String(e)}. Installation rolled back — plugin and CLI are not activated.`, { cause: e });
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
- retainSupersededBackup(backupDir, runtimeBackupDir);
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
- killConsoleChild();
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
- killConsoleChild();
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) or restore failed → `failed`
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 restoreResult = restoreBackup(backupDir, runtimeBackupDir);
3824
- if (journal) {
3825
- const restoreFailed = Boolean(backupDir || runtimeBackupDir) && !restoreResult.restored;
3826
- const detail = restoreFailed
3827
- ? `${errorMsgRaw}; backup restore FAILED: ${restoreResult.error ?? 'unknown'}`
3828
- : errorMsgRaw;
3829
- if ((backupDir || runtimeBackupDir) && restoreResult.restored) {
3830
- journalInstallerTransitionDegrading(journal, journal.lastState, 'rolled_back', detail);
3831
- }
3832
- else {
3833
- journalInstallerTransitionDegrading(journal, journal.lastState, 'failed', detail);
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
- const hasBackup = Boolean(backupDir || runtimeBackupDir);
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
- const freshCleanup = !hasBackup && mutationStarted
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