codex-overleaf-link 2.5.0 → 2.6.0

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 (189) hide show
  1. package/README.md +155 -392
  2. package/README.zh-CN.md +154 -393
  3. package/extension/bootstrap/background.js +52 -9
  4. package/extension/bootstrap/manifest.template.json +1 -1
  5. package/extension/bootstrap/popup.html +1 -0
  6. package/extension/bootstrap/popupFallback.js +55 -0
  7. package/extension/bootstrap/updateStatus.js +1 -0
  8. package/extension/src/background.js +283 -55
  9. package/extension/src/backgroundBootstrapMigration.js +181 -0
  10. package/extension/src/backgroundSharedSessions.js +129 -16
  11. package/extension/src/backgroundUpdateCoordinator.js +242 -29
  12. package/extension/src/backgroundUpdateSafety.js +173 -0
  13. package/extension/src/content/activeTurnControl.js +171 -8
  14. package/extension/src/content/applyResultFormatters.js +46 -73
  15. package/extension/src/content/assetTransferBroker.js +72 -14
  16. package/extension/src/content/composerAttachments.js +104 -47
  17. package/extension/src/content/composerPanel.js +110 -12
  18. package/extension/src/content/contentRuntime.js +1555 -926
  19. package/extension/src/content/contextTray.js +8 -2
  20. package/extension/src/content/contextUsageView.js +267 -0
  21. package/extension/src/content/diagnosticsController.js +122 -96
  22. package/extension/src/content/diagnosticsExport.js +19 -5
  23. package/extension/src/content/diagnosticsPanel.js +48 -0
  24. package/extension/src/content/directoryCleanupRecovery.js +54 -0
  25. package/extension/src/content/generated/content.bundle.js +11077 -3412
  26. package/extension/src/content/generated/content.bundle.meta.json +14 -3
  27. package/extension/src/content/globalPreferencesController.js +1 -1
  28. package/extension/src/content/legacyGlobalRegistry.js +10 -0
  29. package/extension/src/content/localSkillsPanel.js +104 -14
  30. package/extension/src/content/markdownDomRenderer.js +5 -3
  31. package/extension/src/content/markdownText.js +47 -11
  32. package/extension/src/content/modelPicker.js +8 -4
  33. package/extension/src/content/nativeCompatibilityController.js +74 -30
  34. package/extension/src/content/otWarmMirror.js +5 -6
  35. package/extension/src/content/otWarmMirrorController.js +17 -20
  36. package/extension/src/content/pageBridgeClient.js +17 -4
  37. package/extension/src/content/panelMaintenance.js +46 -14
  38. package/extension/src/content/panelRenderer.js +57 -27
  39. package/extension/src/content/pendingInputView.js +1 -1
  40. package/extension/src/content/projectProviderSelection.js +13 -4
  41. package/extension/src/content/projectSessionCleanup.js +109 -62
  42. package/extension/src/content/projectSettingsCoordinator.js +67 -6
  43. package/extension/src/content/providerSettingsCoordinator.js +46 -8
  44. package/extension/src/content/providerSettingsDialog.js +73 -14
  45. package/extension/src/content/recentProjects.js +15 -8
  46. package/extension/src/content/runActivitySummary.js +160 -18
  47. package/extension/src/content/runCompletionMeta.js +532 -0
  48. package/extension/src/content/runController.js +39 -15
  49. package/extension/src/content/runFailureNotice.js +29 -13
  50. package/extension/src/content/runGuidanceController.js +72 -27
  51. package/extension/src/content/runGuidanceView.js +59 -10
  52. package/extension/src/content/runPresence.js +18 -8
  53. package/extension/src/content/runQueueScheduler.js +157 -47
  54. package/extension/src/content/runRecoveryActions.js +147 -0
  55. package/extension/src/content/runResultActions.js +28 -143
  56. package/extension/src/content/runReviewController.js +633 -0
  57. package/extension/src/content/runScrollLayout.js +21 -2
  58. package/extension/src/content/runTimelineView.js +367 -175
  59. package/extension/src/content/scopedPersistencePanelState.js +134 -20
  60. package/extension/src/content/selectionContextView.js +1 -1
  61. package/extension/src/content/sensitivePreflight.js +153 -0
  62. package/extension/src/content/sessionForkController.js +80 -17
  63. package/extension/src/content/sessionManager.js +50 -4
  64. package/extension/src/content/sessionMenuView.js +54 -18
  65. package/extension/src/content/sessionPersistence.js +25 -5
  66. package/extension/src/content/settingsPanel.js +22 -5
  67. package/extension/src/content/settingsWorkbench.js +132 -89
  68. package/extension/src/content/sharedSessionViewSync.js +49 -11
  69. package/extension/src/content/subagentActivityView.js +23 -7
  70. package/extension/src/content/updateIdle.js +4 -1
  71. package/extension/src/content/updateNotice.js +118 -12
  72. package/extension/src/content/updateSafetyReceiver.js +58 -0
  73. package/extension/src/content/writebackController.js +53 -4
  74. package/extension/src/content/writebackOrchestrator.js +116 -94
  75. package/extension/src/content/writingStyleSettings.js +35 -11
  76. package/extension/src/page/binaryAssetUploader.js +136 -14
  77. package/extension/src/page/compileBridge.js +193 -180
  78. package/extension/src/page/fileMover.js +256 -0
  79. package/extension/src/page/overleafCapabilities.js +4 -1
  80. package/extension/src/page/ownedDirectoryCleanup.js +94 -0
  81. package/extension/src/page/referenceProjects.js +1 -0
  82. package/extension/src/page/reviewContext.js +113 -0
  83. package/extension/src/page/saveState.js +6 -4
  84. package/extension/src/page/snapshotRouter.js +84 -39
  85. package/extension/src/page/textCoordinates.js +17 -1
  86. package/extension/src/page/textFileCreator.js +114 -26
  87. package/extension/src/page/trackedChangeCapture.js +12 -2
  88. package/extension/src/page/trackedChangeOwnership.js +19 -1
  89. package/extension/src/page/trackedChangeReplay.js +41 -21
  90. package/extension/src/page/trackedChangesLifecycle.js +217 -199
  91. package/extension/src/page/treeOperations.js +7 -10
  92. package/extension/src/page/updateSafetyProbe.js +41 -0
  93. package/extension/src/page/writebackReceiptJournal.js +2 -0
  94. package/extension/src/page/writebackRouter.js +103 -155
  95. package/extension/src/pageBridge.js +126 -69
  96. package/extension/src/popup.js +62 -10
  97. package/extension/src/shared/agentTranscript.js +373 -17
  98. package/extension/src/shared/auditRecords.js +4 -0
  99. package/extension/src/shared/compatibility.js +22 -6
  100. package/extension/src/shared/compileAdapter.js +129 -66
  101. package/extension/src/shared/composerAttachmentDraft.js +156 -0
  102. package/extension/src/shared/contextUsage.js +125 -0
  103. package/extension/src/shared/directoryCleanupProof.js +43 -0
  104. package/extension/src/shared/failureReasons.js +349 -4
  105. package/extension/src/shared/globalPreferences.js +30 -13
  106. package/extension/src/shared/governanceRules.js +4 -37
  107. package/extension/src/shared/i18n.js +590 -24
  108. package/extension/src/shared/lineReferences.js +38 -31
  109. package/extension/src/shared/managedUpdateProjection.js +2 -2
  110. package/extension/src/shared/nativeRequestIdentity.js +1 -0
  111. package/extension/src/shared/pageRpcContract.js +5 -4
  112. package/extension/src/shared/panelIcons.js +39 -0
  113. package/extension/src/shared/projectFiles.js +21 -44
  114. package/extension/src/shared/providerProfiles.js +5 -3
  115. package/extension/src/shared/reviewing.js +130 -1
  116. package/extension/src/shared/runActivityModel.js +70 -4
  117. package/extension/src/shared/runExecutionSnapshot.js +13 -0
  118. package/extension/src/shared/runInputQueue.js +48 -5
  119. package/extension/src/shared/selectionContext.js +12 -3
  120. package/extension/src/shared/sessionState.js +132 -28
  121. package/extension/src/shared/sessionTitle.js +14 -1
  122. package/extension/src/shared/settlementFacts.js +10 -2
  123. package/extension/src/shared/sharedSessionBridge.js +118 -27
  124. package/extension/src/shared/sharedSessionTransfer.js +140 -0
  125. package/extension/src/shared/staleGuard.js +166 -52
  126. package/extension/src/shared/storageDb.js +90 -26
  127. package/extension/src/shared/storageMigration.js +78 -112
  128. package/extension/src/shared/storageRunActions.js +267 -30
  129. package/extension/src/shared/subagentActivityModel.js +1 -1
  130. package/extension/src/shared/undoOperations.js +253 -32
  131. package/extension/src/shared/updateConsent.js +6 -4
  132. package/extension/src/shared/updateRuntimeIdentity.js +17 -3
  133. package/extension/src/shared/writebackIntent.js +16 -0
  134. package/extension/src/shared/writebackOperationEvidence.js +4 -15
  135. package/extension/src/shared/writebackSettlement.js +58 -40
  136. package/extension/styles/panel.css +1123 -493
  137. package/native-host/src/anthropicBridgeRequest.js +4 -2
  138. package/native-host/src/anthropicBridgeResponse.js +6 -2
  139. package/native-host/src/anthropicMessagesBridge.js +24 -16
  140. package/native-host/src/bootstrapMigration.js +296 -0
  141. package/native-host/src/bootstrapMigrationContract.js +101 -0
  142. package/native-host/src/bootstrapUpdate.js +269 -0
  143. package/native-host/src/bootstrapUpdateWatchdog.js +44 -0
  144. package/native-host/src/chatBridgeRequest.js +60 -6
  145. package/native-host/src/chatBridgeResponse.js +20 -1
  146. package/native-host/src/chatCompletionsBridge.js +48 -15
  147. package/native-host/src/codexConfigPaths.js +106 -0
  148. package/native-host/src/codexHome.js +66 -73
  149. package/native-host/src/codexHomeScope.js +124 -0
  150. package/native-host/src/codexInheritedAuth.js +90 -0
  151. package/native-host/src/codexLocalSkills.js +301 -0
  152. package/native-host/src/codexPromptAssembly.js +114 -127
  153. package/native-host/src/codexProviderTest.js +24 -26
  154. package/native-host/src/codexSessionRunner.js +234 -198
  155. package/native-host/src/codexSessionTiming.js +18 -8
  156. package/native-host/src/codexSkillIsolation.js +170 -0
  157. package/native-host/src/codexThreadFork.js +19 -3
  158. package/native-host/src/index.js +19 -7
  159. package/native-host/src/managedInstall.js +110 -35
  160. package/native-host/src/managedInstallRecovery.js +140 -0
  161. package/native-host/src/managedRuntimeEvidence.js +57 -0
  162. package/native-host/src/mirrorWorkspace.js +92 -22
  163. package/native-host/src/nativeDoctor.js +15 -0
  164. package/native-host/src/nativeHelperLifecycle.js +21 -0
  165. package/native-host/src/nativeHostRegistration.js +33 -0
  166. package/native-host/src/nativeRunLifecycle.js +71 -0
  167. package/native-host/src/processStopBarrier.js +42 -7
  168. package/native-host/src/projectPathPlatform.js +30 -0
  169. package/native-host/src/providerContextError.js +20 -0
  170. package/native-host/src/providerHttp.js +80 -0
  171. package/native-host/src/providerProfile.js +4 -2
  172. package/native-host/src/providerReasoning.js +2 -7
  173. package/native-host/src/providerRuntime.js +1 -0
  174. package/native-host/src/providerStore.js +72 -14
  175. package/native-host/src/responsesPassthroughBridge.js +266 -52
  176. package/native-host/src/runtimeInstaller.js +21 -1
  177. package/native-host/src/selectionScope.js +36 -8
  178. package/native-host/src/subagentAdmission.js +26 -0
  179. package/native-host/src/subagentBroker.js +79 -66
  180. package/native-host/src/subagentTelemetry.js +19 -10
  181. package/native-host/src/subagentWorkspacePath.js +5 -2
  182. package/native-host/src/taskRunnerRuntime.js +92 -57
  183. package/native-host/src/textPatch.js +473 -377
  184. package/native-host/src/tomlStatements.js +140 -0
  185. package/native-host/src/updateManager.js +409 -46
  186. package/native-host/src/writingStyleRuntime.js +57 -29
  187. package/native-host/src/writingStyleStore.js +73 -0
  188. package/package.json +5 -8
  189. package/scripts/install-managed.mjs +1 -0
@@ -14,6 +14,8 @@ const {
14
14
  const { extractVerifiedUpdateBundle } = require('./updateArchive');
15
15
  const { requestRelease } = require('./releaseTransport');
16
16
  const updateNetwork = require('./updateNetworkControl');
17
+ const { readManagedRuntimeEvidence, sameRuntimeEvidence } = require('./managedRuntimeEvidence');
18
+ const BootstrapUpdate = require('./bootstrapUpdate');
17
19
  const RUNNING_VERSION = require('../../package.json').version;
18
20
 
19
21
  const GITHUB_LATEST_URL = 'https://github.com/Ghqqqq/codex-overleaf-link/releases/latest';
@@ -30,9 +32,17 @@ const AUTHORIZATION_TTL_MS = 2 * 60 * 60 * 1000;
30
32
  const MUTATION_LOCK_WAIT_MS = 10 * 1000;
31
33
  const MUTATION_LOCK_STALE_MS = 15 * 60 * 1000;
32
34
  const ACTIVATION_DELAY_MS = 125;
35
+ const APPLYING_TRANSACTION_STATES = new Set(['activation_pending', 'applying', 'awaiting_health',
36
+ ...BootstrapUpdate.BOOTSTRAP_STATES]);
33
37
  const CONSENT_MIGRATION_TARGET_VERSION = '1.9.4';
38
+ // Pure reads of user-owned stores that do not depend on update files. They stay
39
+ // available while a migration or its recovery is unsettled.
40
+ const READ_ONLY_DURING_UPDATE = new Set(['codex.providers.list']);
41
+ // A user-requested return to the source version of an applied update.
42
+ const USER_ROLLBACK_REASON = 'update_user_rollback';
34
43
  const UPDATE_METHODS = new Set([
35
44
  'update.status',
45
+ 'update.bootstrap',
36
46
  'update.check',
37
47
  'update.authorize',
38
48
  'update.stage',
@@ -77,7 +87,14 @@ async function handleUpdateRequest(request, options = {}) {
77
87
  }
78
88
  switch (request.method) {
79
89
  case 'update.status':
80
- return okResponse(request.id, readStatusWithoutWaiting(context));
90
+ return okResponse(request.id, readStatusWithoutWaiting(context, request.params || {}));
91
+ case 'update.bootstrap':
92
+ return okResponse(request.id, await withUpdateMutex(context, () => {
93
+ const gate = getApplyGate(options);
94
+ if (!gate.idle) throw updateError('update_native_busy', 'Native host is still processing a Codex task.');
95
+ return BootstrapUpdate.advanceBootstrapMigration(context, readJournal(context),
96
+ request.params || {}, bootstrapDependencies(context), options.bootstrapMigration || {});
97
+ }));
81
98
  case 'update.check':
82
99
  return okResponse(request.id, await runNetworkUpdate(context, request, options,
83
100
  networkOptions => checkForUpdate(context, request.params || {}, networkOptions)));
@@ -95,7 +112,12 @@ async function handleUpdateRequest(request, options = {}) {
95
112
  case 'update.confirm':
96
113
  return okResponse(request.id, await withUpdateMutex(context, () => confirmUpdate(context, request.params || {})));
97
114
  case 'update.rollback':
98
- return okResponse(request.id, await withUpdateMutex(context, () => rollbackUpdate(context, request.params || {})));
115
+ return okResponse(request.id, await withUpdateMutex(context, () => {
116
+ if (request.params?.reasonCode === USER_ROLLBACK_REASON && !getApplyGate(options).idle) {
117
+ throw updateError('update_native_busy', 'Native host is still processing a Codex task.');
118
+ }
119
+ return rollbackUpdate(context, request.params || {});
120
+ }));
99
121
  case 'update.cancel':
100
122
  return okResponse(request.id, await cancelNetworkUpdate(context, request.params || {}));
101
123
  case 'update.revoke': {
@@ -112,7 +134,9 @@ async function handleUpdateRequest(request, options = {}) {
112
134
  throw updateError('unknown_update_method', 'Unknown managed update method.');
113
135
  }
114
136
  } catch (error) {
115
- return errorResponse(request.id, safeErrorCode(error), safeErrorMessage(error));
137
+ const code = safeErrorCode(error);
138
+ return errorResponse(request.id, request.method === 'update.bootstrap' && code === 'update_internal_error'
139
+ ? 'update_bootstrap_failed' : code, safeErrorMessage(error));
116
140
  }
117
141
  }
118
142
 
@@ -127,7 +151,7 @@ async function runNetworkUpdate(context, request, options, action) {
127
151
 
128
152
  async function cancelNetworkUpdate(context, params) {
129
153
  const journal = readJournal(context);
130
- if (journal && ['activation_pending', 'applying', 'awaiting_health'].includes(journal.state)) {
154
+ if (journal && (APPLYING_TRANSACTION_STATES.has(journal.state) || journal.sourceActivationPending)) {
131
155
  throw updateError('update_revoke_too_late', 'The update is already being installed.');
132
156
  }
133
157
  return updateNetwork.cancel(context, params.operationId);
@@ -185,9 +209,16 @@ async function acquireUpdateFileLock(context) {
185
209
 
186
210
  function removeStaleUpdateFileLock(lockPath) {
187
211
  const owner = readJsonSafe(lockPath, null);
188
- const expired = !owner || Number(owner.expiresAt || 0) <= Date.now();
189
- const abandoned = Number.isInteger(Number(owner?.pid)) && !isProcessAlive(Number(owner.pid));
190
- if (!expired && !abandoned) return false;
212
+ const pid = Number(owner?.pid);
213
+ if (Number.isInteger(pid) && pid > 0) {
214
+ // Expiry is not exit evidence. A slow filesystem must not let another
215
+ // process steal a live install/apply lock.
216
+ if (isProcessAlive(pid)) return false;
217
+ } else {
218
+ // The creator may be between exclusive open and writing its owner record.
219
+ try { if (Date.now() - fs.statSync(lockPath).mtimeMs < MUTATION_LOCK_STALE_MS) return false; }
220
+ catch (_error) { return false; }
221
+ }
191
222
  try {
192
223
  fs.rmSync(lockPath, { force: true });
193
224
  return true;
@@ -212,6 +243,47 @@ function isProcessAlive(pid) {
212
243
  }
213
244
  }
214
245
 
246
+ function bootstrapDependencies(context) {
247
+ return {
248
+ runningVersion: RUNNING_VERSION,
249
+ verifyManifest: verifySignedReleaseManifest,
250
+ verifyArchive: verifyStagedArchive,
251
+ writeJournal: value => writeJournal(context, value),
252
+ assertAuthorization(journal) {
253
+ const authorization = requireAuthorization(context, journal.targetVersion, ['bound'], { activeVersion: journal.sourceVersion });
254
+ if (authorization.id !== journal.authorizationId || authorization.transactionId !== journal.id) {
255
+ throw updateError('update_consent_mismatch', 'Bootstrap update does not match the authorized transaction.');
256
+ }
257
+ }
258
+ };
259
+ }
260
+
261
+ async function recoverBootstrapTransaction(options, transactionId, token) {
262
+ const context = getManagedContext(options);
263
+ if (!context.managed) throw updateError('update_not_managed', 'Managed installation markers are unavailable.');
264
+ return withUpdateMutex(context, () => {
265
+ const journal = readJournal(context);
266
+ if (journal?.id !== transactionId || journal.bootstrapMigration?.watchdogToken !== token) return { state: 'obsolete' };
267
+ if (journal.sourceActivationPending && journal.state === 'rolled_back') {
268
+ finishSourceActivation(context, journal);
269
+ return { state: 'rolled_back', version: journal.sourceVersion };
270
+ }
271
+ if (!BootstrapUpdate.bootstrapExpired(journal)) return { state: journal.state };
272
+ return rollbackUpdate(context, { transactionId, expectedState: journal.state, reasonCode: 'update_bootstrap_health_timeout' });
273
+ });
274
+ }
275
+
276
+ function assertBootstrapBusinessAvailable(request, options = {}) {
277
+ if (isUpdateMethod(request?.method) || ['bridge.ping', 'codex.cancel'].includes(request?.method)
278
+ || READ_ONLY_DURING_UPDATE.has(request?.method)) return;
279
+ const context = getManagedContext(options);
280
+ if (!context.managed) return;
281
+ const journal = readJournal(context);
282
+ if (BootstrapUpdate.migrationHint(context, journal) || journal?.sourceActivationPending) {
283
+ throw updateError('update_applying', 'The managed update is still restoring or loading its startup files.');
284
+ }
285
+ }
286
+
215
287
  function delay(milliseconds) {
216
288
  return new Promise(resolve => setTimeout(resolve, milliseconds));
217
289
  }
@@ -238,7 +310,8 @@ async function checkForUpdate(context, params = {}, options = {}) {
238
310
  });
239
311
  if (releaseResponse.status === 304) {
240
312
  const candidate = readJsonSafe(path.join(context.updatesRoot, CANDIDATE_FILE), null);
241
- if (candidate?.manifestBase64 && candidate?.signatureBase64) {
313
+ if (candidate?.manifestBase64 && candidate?.signatureBase64
314
+ && params.etag && candidate.etag === params.etag && candidate.currentVersion === currentVersion) {
242
315
  try {
243
316
  const manifest = verifySignedReleaseManifest(
244
317
  Buffer.from(candidate.manifestBase64, 'base64'),
@@ -258,7 +331,7 @@ async function checkForUpdate(context, params = {}, options = {}) {
258
331
  // Invalid cached candidates are treated as cache misses.
259
332
  }
260
333
  }
261
- const manual = readManualCandidate(context, currentVersion);
334
+ const manual = readManualCandidate(context, currentVersion, params.etag);
262
335
  if (manual) return { ...manual, etag: params.etag || manual.etag || '', cached: true };
263
336
  return { managed: true, available: false, reason: 'not_modified', currentVersion, etag: params.etag || '' };
264
337
  }
@@ -270,6 +343,10 @@ async function checkForUpdate(context, params = {}, options = {}) {
270
343
  throw updateError('update_github_response_invalid', 'GitHub latest release did not resolve to a stable semantic-version tag.');
271
344
  }
272
345
  const latestVersion = releaseTag.slice(1);
346
+ // A successful latest response replaces the previous discovery result,
347
+ // including when a release was withdrawn or is now in cooldown.
348
+ fs.rmSync(path.join(context.updatesRoot, MANUAL_CANDIDATE_FILE), { force: true });
349
+ fs.rmSync(path.join(context.updatesRoot, CANDIDATE_FILE), { force: true });
273
350
  if (!isNewerStableVersion(latestVersion, currentVersion)) {
274
351
  const activeMarker = readJsonSafe(path.join(context.extensionRoot, EXTENSION_MARKER), null);
275
352
  const equalVersionPrerelease = compareSemver(latestVersion, currentVersion) === 0
@@ -286,8 +363,14 @@ async function checkForUpdate(context, params = {}, options = {}) {
286
363
  };
287
364
  }
288
365
  const cooldowns = readJsonSafe(path.join(context.updatesRoot, COOLDOWN_FILE), {});
289
- if (Number(cooldowns[latestVersion]?.until || 0) > Date.now()) {
290
- return { managed: true, available: false, reason: 'cooldown', currentVersion, latestVersion, etag: releaseResponse.headers.get('etag') || '' };
366
+ const cooldown = cooldowns[latestVersion];
367
+ // Read legacy user-rollback cooldowns, allowing explicit checks to bypass
368
+ // them. New user rollbacks defer browser prompts without a Native cooldown.
369
+ if (Number(cooldown?.until || 0) > Date.now()
370
+ && !(cooldown.reasonCode === USER_ROLLBACK_REASON && params.manual === true)) {
371
+ return { managed: true, available: false, reason: 'cooldown', currentVersion, latestVersion,
372
+ cooldownUntil: Number(cooldown.until), cooldownReason: cooldown.reasonCode || '',
373
+ etag: releaseResponse.headers.get('etag') || '' };
291
374
  }
292
375
 
293
376
  const manifestBytes = await fetchReleaseAsset(
@@ -318,7 +401,25 @@ async function checkForUpdate(context, params = {}, options = {}) {
318
401
  manualInstall: manifest.manualInstall?.reason ? { reason: manifest.manualInstall.reason } : {},
319
402
  etag: releaseResponse.headers.get('etag') || ''
320
403
  };
321
- atomicWriteJson(path.join(context.updatesRoot, MANUAL_CANDIDATE_FILE), { ...manual, checkedAt: new Date().toISOString() });
404
+ atomicWriteJson(path.join(context.updatesRoot, MANUAL_CANDIDATE_FILE), {
405
+ ...manual,
406
+ checkedAt: new Date().toISOString(),
407
+ manifestBase64: manifestBytes.toString('base64'),
408
+ signatureBase64: signatureBytes.toString('base64')
409
+ });
410
+ return manual;
411
+ }
412
+ try {
413
+ BootstrapUpdate.assertMigrationSource(context, manifest, currentVersion);
414
+ } catch (error) {
415
+ if (error.code !== 'update_bootstrap_source_mismatch') throw error;
416
+ // Never cached as an installable candidate: only a reinstall can replace
417
+ // startup files this release cannot migrate.
418
+ const manual = { managed: true, available: false, reason: 'manual_install_required', code: error.code,
419
+ currentVersion, latestVersion, manualInstall: {}, etag: releaseResponse.headers.get('etag') || '' };
420
+ atomicWriteJson(path.join(context.updatesRoot, MANUAL_CANDIDATE_FILE), { ...manual,
421
+ checkedAt: new Date().toISOString(), manifestBase64: manifestBytes.toString('base64'),
422
+ signatureBase64: signatureBytes.toString('base64') });
322
423
  return manual;
323
424
  }
324
425
  const candidate = {
@@ -334,13 +435,36 @@ async function checkForUpdate(context, params = {}, options = {}) {
334
435
  return { managed: true, available: true, currentVersion, latestVersion, etag: candidate.etag };
335
436
  }
336
437
 
337
- function readManualCandidate(context, currentVersion) {
438
+ function readManualCandidate(context, currentVersion, etag) {
338
439
  const manual = readJsonSafe(path.join(context.updatesRoot, MANUAL_CANDIDATE_FILE), null);
339
- if (manual?.reason !== 'manual_install_required' || !parseSemver(manual.latestVersion)
340
- || !isNewerStableVersion(manual.latestVersion, currentVersion)
341
- || manual.requiredBootstrapProtocol === BOOTSTRAP_PROTOCOL) return null;
342
- const { checkedAt: _checkedAt, ...result } = manual;
343
- return { ...result, currentVersion };
440
+ if (!etag || manual?.etag !== etag || manual.currentVersion !== currentVersion
441
+ || !manual.manifestBase64 || !manual.signatureBase64) return null;
442
+ try {
443
+ const manifest = verifySignedReleaseManifest(
444
+ Buffer.from(manual.manifestBase64, 'base64'),
445
+ Buffer.from(manual.signatureBase64, 'base64'),
446
+ { allowBootstrapMismatch: true }
447
+ );
448
+ if (!isNewerStableVersion(manifest.version, currentVersion)) return null;
449
+ if (manifest.bootstrapProtocol === BOOTSTRAP_PROTOCOL) {
450
+ // Same protocol: a hint only while the installed startup files still
451
+ // cannot be migrated by this signed release.
452
+ try { BootstrapUpdate.assertMigrationSource(context, manifest, currentVersion); return null; }
453
+ catch (error) { if (error.code !== 'update_bootstrap_source_mismatch') return null; }
454
+ return { managed: true, available: false, reason: 'manual_install_required', code: 'update_bootstrap_source_mismatch',
455
+ currentVersion, latestVersion: manifest.version, manualInstall: {}, etag };
456
+ }
457
+ return {
458
+ managed: true, available: false, reason: 'manual_install_required',
459
+ currentVersion, latestVersion: manifest.version,
460
+ installedBootstrapProtocol: BOOTSTRAP_PROTOCOL,
461
+ requiredBootstrapProtocol: manifest.bootstrapProtocol,
462
+ manualInstall: manifest.manualInstall?.reason ? { reason: manifest.manualInstall.reason } : {},
463
+ etag
464
+ };
465
+ } catch (_error) {
466
+ return null;
467
+ }
344
468
  }
345
469
 
346
470
  function readAuthorizedCandidate(context, currentVersion) {
@@ -381,6 +505,10 @@ function buildReleaseAssetUrl(tag, assetName) {
381
505
  }
382
506
 
383
507
  function authorizeUpdate(context, params = {}) {
508
+ const activeJournal = readJournal(context);
509
+ if (BootstrapUpdate.migrationHint(context, activeJournal) || activeJournal?.sourceActivationPending) {
510
+ throw updateError('update_transaction_in_progress', 'The current Bootstrap update must finish before authorizing another update.');
511
+ }
384
512
  const authorizationId = String(params.authorizationId || '');
385
513
  const targetVersion = String(params.targetVersion || '');
386
514
  const requestedCurrentVersion = String(params.currentVersion || '');
@@ -402,6 +530,7 @@ function authorizeUpdate(context, params = {}) {
402
530
  !isNewerStableVersion(targetVersion, activeVersion)) {
403
531
  throw updateError('update_consent_mismatch', 'Update authorization does not match the active signed candidate.');
404
532
  }
533
+ BootstrapUpdate.assertMigrationSource(context, manifest, activeVersion);
405
534
  const existing = readAuthorization(context);
406
535
  if (existing?.id === authorizationId &&
407
536
  existing.targetVersion === targetVersion &&
@@ -428,6 +557,10 @@ function authorizeUpdate(context, params = {}) {
428
557
  }
429
558
 
430
559
  async function stageCandidate(context, options = {}) {
560
+ const activeJournal = readJournal(context);
561
+ if (BootstrapUpdate.migrationHint(context, activeJournal) || activeJournal?.sourceActivationPending) {
562
+ throw updateError('update_transaction_in_progress', 'The current Bootstrap update must finish before staging another update.');
563
+ }
431
564
  const candidate = readJsonSafe(path.join(context.updatesRoot, CANDIDATE_FILE), null);
432
565
  if (!candidate) {
433
566
  throw updateError('update_candidate_missing', 'Check for an update before staging it.');
@@ -440,6 +573,11 @@ async function stageCandidate(context, options = {}) {
440
573
  throw updateError('update_candidate_stale', 'The staged update is no longer newer than the active version.');
441
574
  }
442
575
  const authorization = requireAuthorization(context, manifest.version, ['authorized', 'bound']);
576
+ try { BootstrapUpdate.assertMigrationSource(context, manifest, activeVersion); }
577
+ catch (error) {
578
+ if (authorization.state === 'authorized') settleAuthorization(context, authorization.id, 'revoked');
579
+ throw error;
580
+ }
443
581
  const existingJournal = readJournal(context);
444
582
  if (existingJournal?.state === 'staged' && existingJournal.targetVersion === manifest.version) {
445
583
  if (existingJournal.authorizationId !== authorization.id ||
@@ -467,7 +605,7 @@ async function stageCandidate(context, options = {}) {
467
605
  );
468
606
  }
469
607
  }
470
- if (existingJournal && ['activation_pending', 'applying', 'awaiting_health'].includes(existingJournal.state)) {
608
+ if (existingJournal && APPLYING_TRANSACTION_STATES.has(existingJournal.state)) {
471
609
  throw updateError('update_transaction_in_progress', 'A managed update transaction is already being applied.');
472
610
  }
473
611
  if (authorization.state === 'bound') {
@@ -533,6 +671,7 @@ function getApplyGate(options = {}) {
533
671
  const blockers = [];
534
672
  if (Number(state.projectLocks || 0) > 0) blockers.push('native_project_locked');
535
673
  if (Number(state.runControllers || 0) > 0) blockers.push('native_run_active');
674
+ if (Number(state.processStopBarriers || 0) > 0) blockers.push('native_process_stop_unconfirmed');
536
675
  return { idle: blockers.length === 0, blockers, workState: state };
537
676
  }
538
677
 
@@ -577,7 +716,8 @@ function scheduleStagedActivation(context, params = {}, options = {}) {
577
716
 
578
717
  function recordScheduledActivationFailure(context, pending, error) {
579
718
  const current = readJournal(context);
580
- if (!current || current.id !== pending.id || ['awaiting_health', 'rolled_back'].includes(current.state)) return;
719
+ if (!current || current.id !== pending.id || ['awaiting_health', 'rolled_back', 'committed',
720
+ ...BootstrapUpdate.BOOTSTRAP_STATES].includes(current.state)) return;
581
721
  writeJournal(context, {
582
722
  ...current,
583
723
  state: 'rolled_back',
@@ -687,6 +827,7 @@ function confirmUpdate(context, params = {}) {
687
827
  if (params.transactionId !== journal.id || params.extensionVersion !== journal.targetVersion || params.nativeVersion !== journal.targetVersion) {
688
828
  throw updateError('update_health_version_mismatch', 'Extension and native host did not confirm the same target version.');
689
829
  }
830
+ BootstrapUpdate.assertBootstrapConfirmation(context, journal, params);
690
831
  if (!isLegacyInboundConsentMigration(journal)) {
691
832
  const authorization = requireAuthorization(context, journal.targetVersion, ['bound'], {
692
833
  activeVersion: journal.sourceVersion
@@ -699,9 +840,15 @@ function confirmUpdate(context, params = {}) {
699
840
  if (readVersionPointer(context.nativeRoot, 'active-version') !== journal.targetVersion || manifest?.version !== journal.targetVersion) {
700
841
  throw updateError('update_health_version_mismatch', 'Managed files do not match the confirmed target version.');
701
842
  }
843
+ let committedEvidence;
844
+ try { committedEvidence = readManagedRuntimeEvidence(context, journal.targetVersion); }
845
+ catch (cause) {
846
+ throw updateError('update_health_failed', 'The installed runtime files did not pass the update health check.', { cause });
847
+ }
702
848
  writeJournal(context, {
703
849
  ...journal,
704
850
  state: 'committed',
851
+ committedEvidence,
705
852
  confirmedAt: new Date().toISOString(),
706
853
  updatedAt: new Date().toISOString()
707
854
  });
@@ -713,35 +860,101 @@ function confirmUpdate(context, params = {}) {
713
860
  }
714
861
 
715
862
  function rollbackUpdate(context, params = {}) {
716
- const journal = readJournal(context);
863
+ let journal = readJournal(context);
717
864
  if (params.transactionId && journal?.id !== params.transactionId) {
718
865
  throw updateError('update_transaction_mismatch', 'Rollback transaction identity changed.');
719
866
  }
720
867
  if (params.expectedState && journal?.state !== params.expectedState) {
721
868
  throw updateError('update_transaction_state_changed', 'The update state changed before rollback.');
722
869
  }
723
- if (!journal || !['applying', 'awaiting_health', 'committed'].includes(journal.state)) {
870
+ journal = BootstrapUpdate.bindBootstrapMigration(context, journal, bootstrapDependencies(context));
871
+ if (!journal || !['applying', 'awaiting_health', 'committed', 'bootstrap_pending', 'bootstrap_rolling_back'].includes(journal.state)) {
724
872
  throw updateError('update_rollback_unavailable', 'No applied update is available for rollback.');
725
873
  }
726
- rollbackFiles(context, journal);
874
+ if (journal.bootstrapMigration && !params.transactionId && journal.bootstrapMigration.phase !== 'pending') {
875
+ throw updateError('update_transaction_mismatch', 'Bootstrap rollback must identify its update transaction.');
876
+ }
877
+ const userRequested = params.reasonCode === USER_ROLLBACK_REASON;
878
+ // Check every precondition before writing the rolling-back state, so an
879
+ // unavailable request never turns a working installation into a recovery.
880
+ if (userRequested && (!params.transactionId
881
+ || (journal.state === 'committed' && !userRollbackAvailable(context, journal)))) {
882
+ throw updateError('update_rollback_unavailable', 'The previous version is no longer available for rollback.');
883
+ }
884
+ const migration = Boolean(journal.bootstrapMigration);
885
+ if (migration) writeJournal(context, { ...journal, state: 'bootstrap_rolling_back' });
886
+ let rollbackEvidence;
887
+ try {
888
+ rollbackEvidence = rollbackFiles(context, journal, { deferSourceActivation: migration });
889
+ } catch (error) {
890
+ // The incomplete state remains. The failure record bounds automatic retries
891
+ // and lets status report the reason; explicit rollback requests still run.
892
+ const failed = readJournal(context);
893
+ if (failed?.id === journal.id) {
894
+ writeJournal(context, { ...failed, updatedAt: new Date().toISOString(), rollbackFailure: {
895
+ code: safeErrorCode(error), attempts: Number(failed.rollbackFailure?.attempts || 0) + 1,
896
+ at: new Date().toISOString() } });
897
+ }
898
+ throw error;
899
+ }
727
900
  const cooldowns = readJsonSafe(path.join(context.updatesRoot, COOLDOWN_FILE), {});
728
- cooldowns[journal.targetVersion] = {
729
- until: Date.now() + 24 * 60 * 60 * 1000,
730
- reasonCode: normalizeReasonCode(params.reasonCode)
731
- };
901
+ if (userRequested) {
902
+ // The restored Native may predate manual-check exemptions. Keep its
903
+ // original check usable; the coordinator snoozes automatic prompts.
904
+ delete cooldowns[journal.targetVersion];
905
+ } else {
906
+ cooldowns[journal.targetVersion] = {
907
+ until: Date.now() + 24 * 60 * 60 * 1000,
908
+ reasonCode: normalizeReasonCode(params.reasonCode)
909
+ };
910
+ }
732
911
  atomicWriteJson(path.join(context.updatesRoot, COOLDOWN_FILE), cooldowns);
733
- writeJournal(context, {
912
+ const restoredJournal = {
734
913
  ...journal,
735
914
  state: 'rolled_back',
736
915
  reasonCode: normalizeReasonCode(params.reasonCode),
737
916
  rolledBackAt: new Date().toISOString(),
738
917
  updatedAt: new Date().toISOString(),
739
- previousManifest: undefined
740
- });
918
+ previousManifest: undefined,
919
+ rollbackFailure: undefined,
920
+ rollbackEvidence,
921
+ ...(migration ? { bootstrapMigration: { ...journal.bootstrapMigration, phase: 'restored',
922
+ sourceUnchanged: rollbackEvidence.bootstrapSourceUnchanged === true },
923
+ sourceActivationPending: true } : {})
924
+ };
925
+ writeJournal(context, restoredJournal);
926
+ // The old launcher must not select a source process that cannot understand
927
+ // our recovery record until every file and the durable terminal record exist.
928
+ if (migration) {
929
+ settleAuthorization(context, journal.authorizationId, 'revoked');
930
+ atomicWriteText(path.join(context.nativeRoot, 'active-version'), journal.sourceVersion + '\n');
931
+ writeJournal(context, { ...restoredJournal, sourceActivationPending: false });
932
+ }
741
933
  settleAuthorization(context, journal.authorizationId, 'revoked');
742
934
  cleanupStage(journal.stageRoot);
743
935
  cleanupOrphanStageRoots(context.updatesRoot);
744
- return { version: journal.sourceVersion, state: 'rolled_back' };
936
+ return { transactionId: journal.id, version: journal.sourceVersion, state: 'rolled_back', restoredPair: rollbackEvidence };
937
+ }
938
+
939
+ // A committed update can be returned to its source pair only while every
940
+ // source layer is still on disk: the previous runtime slot, the source Native
941
+ // version, the saved manifest and, for a Bootstrap migration, its backups.
942
+ function userRollbackAvailable(context, journal) {
943
+ if (journal?.state !== 'committed' || !parseSemver(journal.sourceVersion) || !journal.previousManifest
944
+ || readVersionPointer(context.nativeRoot, 'active-version') !== journal.targetVersion) return false;
945
+ if (journal.bootstrapMigration
946
+ && !fs.existsSync(path.join(context.updatesRoot, 'bootstrap-migrations', journal.id, 'state.json'))) return false;
947
+ // Status runs often, so this reads only the version identities. rollbackFiles
948
+ // still verifies both full source trees before and after restoring them.
949
+ const previousRuntime = path.join(context.extensionRoot, 'slots', 'previous', 'runtime');
950
+ try {
951
+ const compatibility = fs.readFileSync(path.join(previousRuntime, 'src', 'shared', 'compatibility.js'), 'utf8');
952
+ const nativePackage = readJsonSafe(path.join(context.nativeRoot, 'versions', journal.sourceVersion, 'package.json'), null);
953
+ return compatibility.match(/BUILD_TARGET_VERSION\s*=\s*['"]([^'"]+)['"]/)?.[1] === journal.sourceVersion
954
+ && nativePackage?.version === journal.sourceVersion;
955
+ } catch (_) {
956
+ return false;
957
+ }
745
958
  }
746
959
 
747
960
  function revokeUpdate(context, params = {}) {
@@ -753,7 +966,7 @@ function revokeUpdate(context, params = {}) {
753
966
  }
754
967
  const journal = readJournal(context);
755
968
  if (journal?.targetVersion === targetVersion &&
756
- ['activation_pending', 'applying', 'awaiting_health', 'committed'].includes(journal.state)) {
969
+ (APPLYING_TRANSACTION_STATES.has(journal.state) || journal.state === 'committed' || journal.sourceActivationPending)) {
757
970
  throw updateError('update_revoke_too_late', 'The update is already being applied.');
758
971
  }
759
972
  const authorization = readAuthorization(context);
@@ -782,9 +995,15 @@ function revokeUpdate(context, params = {}) {
782
995
  return { state: 'revoked', targetVersion };
783
996
  }
784
997
 
785
- function rollbackFiles(context, journal) {
998
+ function rollbackFiles(context, journal, options = {}) {
786
999
  const runtime = path.join(context.extensionRoot, 'runtime');
787
1000
  const previousRuntime = path.join(context.extensionRoot, 'slots', 'previous', 'runtime');
1001
+ // An early interrupted apply may still have the source in the current slot.
1002
+ // A missing previous slot is recoverable only when these actual bytes match
1003
+ // the source pair; the source manifest and markers alone are insufficient.
1004
+ const source = readManagedRuntimeEvidence(context, journal.sourceVersion,
1005
+ fs.existsSync(previousRuntime) ? previousRuntime : runtime);
1006
+ if (journal.bootstrapMigration) BootstrapUpdate.restoreBootstrap(context, journal);
788
1007
  if (fs.existsSync(previousRuntime)) {
789
1008
  fs.rmSync(runtime, { recursive: true, force: true });
790
1009
  fs.renameSync(previousRuntime, runtime);
@@ -793,7 +1012,7 @@ function rollbackFiles(context, journal) {
793
1012
  if (journal.previousManifest) {
794
1013
  atomicWriteText(path.join(context.extensionRoot, 'manifest.json'), journal.previousManifest);
795
1014
  }
796
- if (parseSemver(journal.sourceVersion)) {
1015
+ if (parseSemver(journal.sourceVersion) && !options.deferSourceActivation) {
797
1016
  atomicWriteText(path.join(context.nativeRoot, 'active-version'), journal.sourceVersion + '\n');
798
1017
  }
799
1018
  const sourcePrevious = parseSemver(journal.sourcePreviousVersion) ? journal.sourcePreviousVersion : '';
@@ -805,6 +1024,18 @@ function rollbackFiles(context, journal) {
805
1024
  if (parseSemver(journal.sourceVersion)) {
806
1025
  syncManagedMarkerVersions(context, journal.sourceVersion);
807
1026
  }
1027
+ const restored = readManagedRuntimeEvidence(context, journal.sourceVersion);
1028
+ if (!sameRuntimeEvidence(source, restored)) {
1029
+ throw updateError('update_rollback_unconfirmed', 'The source runtime readback did not match the restored files.');
1030
+ }
1031
+ if (journal.bootstrapMigration) {
1032
+ const bootstrap = BootstrapUpdate.bootstrapEvidence(context, journal, 'source');
1033
+ if (bootstrap) {
1034
+ restored.bootstrapSha256 = bootstrap.digest;
1035
+ if (bootstrap.unmodified) restored.bootstrapSourceUnchanged = true;
1036
+ }
1037
+ }
1038
+ return restored;
808
1039
  }
809
1040
 
810
1041
  function syncManagedMarkerVersions(context, version) {
@@ -843,8 +1074,11 @@ function cleanupOrphanStageRoots(updatesRoot, retainedRoots = []) {
843
1074
  }
844
1075
  }
845
1076
 
846
- function recoverAndReadStatus(context) {
1077
+ function recoverAndReadStatus(context, params = {}) {
847
1078
  let journal = readJournal(context);
1079
+ if (journal?.sourceActivationPending && journal.state === 'rolled_back') {
1080
+ journal = finishSourceActivation(context, journal);
1081
+ }
848
1082
  const alignedVersion = readAlignedManagedVersion(context);
849
1083
  journal = supersedeObsoleteJournal(context, journal, alignedVersion);
850
1084
  if (journal?.state === 'activation_pending') {
@@ -852,33 +1086,134 @@ function recoverAndReadStatus(context) {
852
1086
  writeJournal(context, journal);
853
1087
  }
854
1088
  if (journal?.state === 'applying') {
855
- rollbackFiles(context, journal);
856
- journal = { ...journal, state: 'rolled_back', reasonCode: 'update_interrupted', updatedAt: new Date().toISOString() };
1089
+ const rollbackEvidence = rollbackFiles(context, journal);
1090
+ journal = { ...journal, rollbackEvidence, state: 'rolled_back', reasonCode: 'update_interrupted', updatedAt: new Date().toISOString() };
857
1091
  writeJournal(context, journal);
858
1092
  settleAuthorization(context, journal.authorizationId, 'revoked');
859
1093
  }
1094
+ journal = BootstrapUpdate.bindBootstrapMigration(context, journal, bootstrapDependencies(context));
1095
+ if (BootstrapUpdate.bootstrapExpired(journal, { recoveryRequested: params.bootstrapRecovery === true, context })) {
1096
+ try {
1097
+ rollbackUpdate(context, { transactionId: journal.id, expectedState: journal.state, reasonCode: 'update_bootstrap_health_timeout' });
1098
+ } catch (_error) {
1099
+ // Recorded as rollbackFailure on the journal; status still answers so
1100
+ // the browser can show the reason instead of an unreadable state.
1101
+ }
1102
+ journal = readJournal(context);
1103
+ }
860
1104
  if (journal?.state === 'rolled_back' || journal?.state === 'superseded') {
1105
+ if (journal.state === 'superseded') cleanupStage(journal.stageRoot);
861
1106
  pruneNativeVersions(context.nativeRoot, new Set([
862
1107
  readVersionPointer(context.nativeRoot, 'active-version'),
863
1108
  readVersionPointer(context.nativeRoot, 'previous-version')
864
1109
  ]));
865
1110
  }
1111
+ if (journal?.state === 'committed' && journal.committedEvidence && alignedVersion === journal.targetVersion) {
1112
+ const observed = readManagedRuntimeEvidence(context, journal.targetVersion);
1113
+ if (sameRuntimeEvidence(journal.committedEvidence, observed)) {
1114
+ if (journal.bootstrapMigration) BootstrapUpdate.assertBootstrapConfirmation(context, journal,
1115
+ { bootstrapRevision: journal.bootstrapMigration.targetRevision });
1116
+ const authorization = readAuthorization(context);
1117
+ if (['bound', 'consumed'].includes(authorization?.state) && authorization.id === journal.authorizationId
1118
+ && authorization.transactionId === journal.id && authorization.sourceVersion === journal.sourceVersion
1119
+ && authorization.targetVersion === journal.targetVersion) {
1120
+ settleAuthorization(context, journal.authorizationId, 'consumed');
1121
+ }
1122
+ cleanupStage(journal.stageRoot);
1123
+ }
1124
+ }
866
1125
  return currentStatus(context, journal);
867
1126
  }
868
1127
 
1128
+ function finishSourceActivation(context, journal) {
1129
+ const actual = readManagedRuntimeEvidence(context, journal.sourceVersion);
1130
+ if (!sameRuntimeEvidence(actual, journal.rollbackEvidence)) {
1131
+ throw updateError('update_rollback_unconfirmed', 'The recovered source files changed before activation.');
1132
+ }
1133
+ const bootstrap = BootstrapUpdate.bootstrapEvidence(context, journal, 'source');
1134
+ if (journal.bootstrapMigration
1135
+ && (!bootstrap?.digest || bootstrap.digest !== journal.rollbackEvidence?.bootstrapSha256)) {
1136
+ throw updateError('update_bootstrap_source_mismatch', 'The recovered Bootstrap bytes changed before activation.');
1137
+ }
1138
+ settleAuthorization(context, journal.authorizationId, 'revoked');
1139
+ atomicWriteText(path.join(context.nativeRoot, 'active-version'), journal.sourceVersion + '\n');
1140
+ const finished = { ...journal, sourceActivationPending: false };
1141
+ writeJournal(context, finished);
1142
+ cleanupStage(journal.stageRoot);
1143
+ return finished;
1144
+ }
1145
+
1146
+ // Called by the full installer while holding the same mutation lock, only
1147
+ // after both runtime trees and browser registration have succeeded.
1148
+ function settleReinstalledUpdate(context, version, expected) {
1149
+ if (!expected?.id || !parseSemver(expected.sourceVersion) || !parseSemver(expected.targetVersion)
1150
+ || compareSemver(version, expected.targetVersion) < 0
1151
+ || compareSemver(version, expected.sourceVersion) <= 0) return;
1152
+ const journal = readJournal(context);
1153
+ if (!journal || ['id', 'state', 'sourceVersion', 'targetVersion'].some(key => journal[key] !== expected[key])) {
1154
+ throw updateError('update_transaction_mismatch', 'The update transaction changed during full installation.');
1155
+ }
1156
+ if (assertManagedLayout(context) !== version) {
1157
+ throw updateError('update_managed_layout_invalid', 'Full installation did not produce the requested pair.');
1158
+ }
1159
+ const reinstalledPair = readManagedRuntimeEvidence(context, version);
1160
+ const timestamp = new Date().toISOString();
1161
+ // The installer already activated `version`; a pending switch back to the
1162
+ // source would block business requests and could repoint the launcher.
1163
+ writeJournal(context, {
1164
+ ...journal, state: 'superseded', supersededFromState: journal.state,
1165
+ supersededByVersion: version, supersededAt: timestamp,
1166
+ updatedAt: timestamp, reasonCode: 'update_reinstalled', reinstalledPair,
1167
+ sourceActivationPending: false
1168
+ });
1169
+ const authorization = readAuthorization(context);
1170
+ if (authorization?.state === 'bound' && authorization.id === journal.authorizationId
1171
+ && authorization.transactionId === journal.id && authorization.sourceVersion === journal.sourceVersion
1172
+ && authorization.targetVersion === journal.targetVersion) {
1173
+ settleAuthorization(context, authorization.id, 'revoked');
1174
+ }
1175
+ }
1176
+
869
1177
  function currentStatus(context, journal = readJournal(context)) {
1178
+ let restoredPair = null;
1179
+ let reinstalledPair = null;
1180
+ if (journal?.state === 'rolled_back') {
1181
+ try {
1182
+ const observed = readManagedRuntimeEvidence(context, journal.sourceVersion);
1183
+ if (!journal.rollbackEvidence || sameRuntimeEvidence(journal.rollbackEvidence, observed)) {
1184
+ if (journal.bootstrapMigration) {
1185
+ const bootstrap = BootstrapUpdate.bootstrapEvidence(context, journal, 'source');
1186
+ if (bootstrap) observed.bootstrapSha256 = bootstrap.digest;
1187
+ if (journal.rollbackEvidence?.bootstrapSha256 !== observed.bootstrapSha256) throw new Error('Bootstrap restoration changed.');
1188
+ }
1189
+ restoredPair = observed;
1190
+ }
1191
+ } catch (_) { /* Missing or changed bytes cannot confirm a recovered pair. */ }
1192
+ }
1193
+ if (journal?.state === 'superseded' && journal.reasonCode === 'update_reinstalled' && journal.reinstalledPair) {
1194
+ try {
1195
+ const observed = readManagedRuntimeEvidence(context, journal.supersededByVersion);
1196
+ if (sameRuntimeEvidence(journal.reinstalledPair, observed)) reinstalledPair = observed;
1197
+ } catch (_) { /* A reinstall receipt cannot authenticate later changed files. */ }
1198
+ }
870
1199
  return {
871
1200
  managed: true,
872
1201
  activeVersion: readVersionPointer(context.nativeRoot, 'active-version'),
873
1202
  runtimeVersion: context.env?.CODEX_OVERLEAF_ACTIVE_VERSION || RUNNING_VERSION,
874
1203
  installedAligned: Boolean(readAlignedManagedVersion(context)),
875
1204
  previousVersion: readVersionPointer(context.nativeRoot, 'previous-version'),
876
- transaction: journal ? publicTransaction(journal) : null,
877
- authorization: publicAuthorization(readAuthorization(context))
1205
+ transaction: journal ? { ...publicTransaction(journal),
1206
+ ...(journal.state === 'awaiting_health' && BootstrapUpdate.migrationHint(context, journal)
1207
+ && journal.bootstrapMigration?.phase !== 'loaded' ? { state: 'bootstrap_pending' } : {}) } : null,
1208
+ authorization: publicAuthorization(readAuthorization(context)),
1209
+ rollbackAvailable: userRollbackAvailable(context, journal)
1210
+ ? { transactionId: journal.id, sourceVersion: journal.sourceVersion, targetVersion: journal.targetVersion } : null,
1211
+ restoredPair,
1212
+ reinstalledPair
878
1213
  };
879
1214
  }
880
1215
 
881
- function readStatusWithoutWaiting(context) {
1216
+ function readStatusWithoutWaiting(context, params = {}) {
882
1217
  const lockPath = path.join(context.updatesRoot, MUTATION_LOCK_FILE);
883
1218
  const token = crypto.randomUUID();
884
1219
  let descriptor;
@@ -888,7 +1223,7 @@ function readStatusWithoutWaiting(context) {
888
1223
  if (error.code !== 'EEXIST') throw error;
889
1224
  const owner = readJsonSafe(lockPath, null);
890
1225
  if (Number.isInteger(owner?.pid) && !isProcessAlive(owner.pid) && removeStaleUpdateFileLock(lockPath)) {
891
- return readStatusWithoutWaiting(context);
1226
+ return readStatusWithoutWaiting(context, params);
892
1227
  }
893
1228
  // A live mutation owns recovery; report an atomic-file snapshot without
894
1229
  // queueing behind its network transfer or changing its journal.
@@ -897,7 +1232,7 @@ function readStatusWithoutWaiting(context) {
897
1232
  try {
898
1233
  fs.writeFileSync(descriptor, JSON.stringify({ token, pid: process.pid,
899
1234
  createdAt: Date.now(), expiresAt: Date.now() + MUTATION_LOCK_STALE_MS }));
900
- return recoverAndReadStatus(context);
1235
+ return recoverAndReadStatus(context, params);
901
1236
  } finally {
902
1237
  fs.closeSync(descriptor);
903
1238
  releaseUpdateFileLock({ lockPath, token });
@@ -913,12 +1248,21 @@ function readAlignedManagedVersion(context) {
913
1248
  }
914
1249
 
915
1250
  function supersedeObsoleteJournal(context, journal, activeVersion) {
916
- if (!journal || journal.state === 'superseded' || !parseSemver(activeVersion) ||
1251
+ if (!journal || !parseSemver(activeVersion) ||
917
1252
  !parseSemver(journal.sourceVersion) || !parseSemver(journal.targetVersion) ||
918
1253
  compareSemver(activeVersion, journal.sourceVersion) <= 0 ||
919
1254
  compareSemver(activeVersion, journal.targetVersion) <= 0) {
920
1255
  return journal;
921
1256
  }
1257
+ // A previous runtime may already have retired the journal without settling
1258
+ // its bound authorization. Never consume an authorization owned elsewhere.
1259
+ const authorization = readAuthorization(context);
1260
+ if (authorization?.state === 'bound' && journal.authorizationId
1261
+ && authorization.id === journal.authorizationId && authorization.transactionId === journal.id
1262
+ && authorization.sourceVersion === journal.sourceVersion && authorization.targetVersion === journal.targetVersion) {
1263
+ settleAuthorization(context, authorization.id, 'revoked');
1264
+ }
1265
+ if (journal.state === 'superseded') return journal;
922
1266
  const timestamp = new Date().toISOString();
923
1267
  const superseded = {
924
1268
  ...journal,
@@ -1098,6 +1442,7 @@ function publicTransaction(journal) {
1098
1442
  state: journal.state,
1099
1443
  sourceVersion: journal.sourceVersion,
1100
1444
  targetVersion: journal.targetVersion,
1445
+ ...(journal.bootstrapMigration ? { bootstrapRevision: journal.bootstrapMigration.targetRevision } : {}),
1101
1446
  createdAt: journal.createdAt,
1102
1447
  activationRequestedAt: journal.activationRequestedAt,
1103
1448
  appliedAt: journal.appliedAt,
@@ -1106,7 +1451,8 @@ function publicTransaction(journal) {
1106
1451
  supersededAt: journal.supersededAt,
1107
1452
  supersededByVersion: journal.supersededByVersion,
1108
1453
  supersededFromState: journal.supersededFromState,
1109
- reasonCode: journal.reasonCode || ''
1454
+ reasonCode: journal.reasonCode || '',
1455
+ rollbackFailureCode: journal.rollbackFailure?.code || ''
1110
1456
  };
1111
1457
  }
1112
1458
 
@@ -1189,10 +1535,22 @@ function atomicWriteJson(target, value) {
1189
1535
  }
1190
1536
 
1191
1537
  function atomicWriteText(target, value) {
1192
- const temp = target + '.tmp-' + process.pid + '-' + Date.now();
1538
+ const temp = target + '.tmp-' + process.pid + '-' + crypto.randomBytes(6).toString('hex');
1193
1539
  fs.mkdirSync(path.dirname(target), { recursive: true });
1194
- fs.writeFileSync(temp, value, { mode: 0o600 });
1540
+ let descriptor = fs.openSync(temp, 'wx', 0o600);
1541
+ try {
1542
+ // Windows requires a writable handle for FlushFileBuffers (Node fsync).
1543
+ fs.writeFileSync(descriptor, value);
1544
+ fs.fsyncSync(descriptor);
1545
+ } finally { if (descriptor !== undefined) fs.closeSync(descriptor); }
1195
1546
  fs.renameSync(temp, target);
1547
+ descriptor = undefined;
1548
+ try {
1549
+ descriptor = fs.openSync(path.dirname(target), 'r');
1550
+ fs.fsyncSync(descriptor);
1551
+ } catch (error) {
1552
+ if (process.platform !== 'win32' || !['EPERM', 'EISDIR', 'EINVAL', 'EBADF', 'EACCES'].includes(error.code)) throw error;
1553
+ } finally { if (descriptor !== undefined) fs.closeSync(descriptor); }
1196
1554
  }
1197
1555
 
1198
1556
  function readJsonSafe(target, fallback) {
@@ -1246,8 +1604,10 @@ function errorResponse(id, code, message) {
1246
1604
 
1247
1605
  module.exports = {
1248
1606
  GITHUB_LATEST_URL,
1607
+ MUTATION_LOCK_STALE_MS,
1249
1608
  applyStagedUpdate,
1250
1609
  assertManagedLayout,
1610
+ assertBootstrapBusinessAvailable,
1251
1611
  authorizeUpdate,
1252
1612
  checkForUpdate,
1253
1613
  cleanupOrphanStageRoots,
@@ -1257,8 +1617,11 @@ module.exports = {
1257
1617
  handleUpdateRequest,
1258
1618
  isUpdateMethod,
1259
1619
  revokeUpdate,
1620
+ removeStaleUpdateFileLock,
1621
+ recoverBootstrapTransaction,
1260
1622
  rollbackUpdate,
1261
1623
  scheduleStagedActivation,
1624
+ settleReinstalledUpdate,
1262
1625
  stageCandidate,
1263
1626
  syncManagedMarkerVersions
1264
1627
  };