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.
- package/README.md +155 -392
- package/README.zh-CN.md +154 -393
- package/extension/bootstrap/background.js +52 -9
- package/extension/bootstrap/manifest.template.json +1 -1
- package/extension/bootstrap/popup.html +1 -0
- package/extension/bootstrap/popupFallback.js +55 -0
- package/extension/bootstrap/updateStatus.js +1 -0
- package/extension/src/background.js +283 -55
- package/extension/src/backgroundBootstrapMigration.js +181 -0
- package/extension/src/backgroundSharedSessions.js +129 -16
- package/extension/src/backgroundUpdateCoordinator.js +242 -29
- package/extension/src/backgroundUpdateSafety.js +173 -0
- package/extension/src/content/activeTurnControl.js +171 -8
- package/extension/src/content/applyResultFormatters.js +46 -73
- package/extension/src/content/assetTransferBroker.js +72 -14
- package/extension/src/content/composerAttachments.js +104 -47
- package/extension/src/content/composerPanel.js +110 -12
- package/extension/src/content/contentRuntime.js +1555 -926
- package/extension/src/content/contextTray.js +8 -2
- package/extension/src/content/contextUsageView.js +267 -0
- package/extension/src/content/diagnosticsController.js +122 -96
- package/extension/src/content/diagnosticsExport.js +19 -5
- package/extension/src/content/diagnosticsPanel.js +48 -0
- package/extension/src/content/directoryCleanupRecovery.js +54 -0
- package/extension/src/content/generated/content.bundle.js +11077 -3412
- package/extension/src/content/generated/content.bundle.meta.json +14 -3
- package/extension/src/content/globalPreferencesController.js +1 -1
- package/extension/src/content/legacyGlobalRegistry.js +10 -0
- package/extension/src/content/localSkillsPanel.js +104 -14
- package/extension/src/content/markdownDomRenderer.js +5 -3
- package/extension/src/content/markdownText.js +47 -11
- package/extension/src/content/modelPicker.js +8 -4
- package/extension/src/content/nativeCompatibilityController.js +74 -30
- package/extension/src/content/otWarmMirror.js +5 -6
- package/extension/src/content/otWarmMirrorController.js +17 -20
- package/extension/src/content/pageBridgeClient.js +17 -4
- package/extension/src/content/panelMaintenance.js +46 -14
- package/extension/src/content/panelRenderer.js +57 -27
- package/extension/src/content/pendingInputView.js +1 -1
- package/extension/src/content/projectProviderSelection.js +13 -4
- package/extension/src/content/projectSessionCleanup.js +109 -62
- package/extension/src/content/projectSettingsCoordinator.js +67 -6
- package/extension/src/content/providerSettingsCoordinator.js +46 -8
- package/extension/src/content/providerSettingsDialog.js +73 -14
- package/extension/src/content/recentProjects.js +15 -8
- package/extension/src/content/runActivitySummary.js +160 -18
- package/extension/src/content/runCompletionMeta.js +532 -0
- package/extension/src/content/runController.js +39 -15
- package/extension/src/content/runFailureNotice.js +29 -13
- package/extension/src/content/runGuidanceController.js +72 -27
- package/extension/src/content/runGuidanceView.js +59 -10
- package/extension/src/content/runPresence.js +18 -8
- package/extension/src/content/runQueueScheduler.js +157 -47
- package/extension/src/content/runRecoveryActions.js +147 -0
- package/extension/src/content/runResultActions.js +28 -143
- package/extension/src/content/runReviewController.js +633 -0
- package/extension/src/content/runScrollLayout.js +21 -2
- package/extension/src/content/runTimelineView.js +367 -175
- package/extension/src/content/scopedPersistencePanelState.js +134 -20
- package/extension/src/content/selectionContextView.js +1 -1
- package/extension/src/content/sensitivePreflight.js +153 -0
- package/extension/src/content/sessionForkController.js +80 -17
- package/extension/src/content/sessionManager.js +50 -4
- package/extension/src/content/sessionMenuView.js +54 -18
- package/extension/src/content/sessionPersistence.js +25 -5
- package/extension/src/content/settingsPanel.js +22 -5
- package/extension/src/content/settingsWorkbench.js +132 -89
- package/extension/src/content/sharedSessionViewSync.js +49 -11
- package/extension/src/content/subagentActivityView.js +23 -7
- package/extension/src/content/updateIdle.js +4 -1
- package/extension/src/content/updateNotice.js +118 -12
- package/extension/src/content/updateSafetyReceiver.js +58 -0
- package/extension/src/content/writebackController.js +53 -4
- package/extension/src/content/writebackOrchestrator.js +116 -94
- package/extension/src/content/writingStyleSettings.js +35 -11
- package/extension/src/page/binaryAssetUploader.js +136 -14
- package/extension/src/page/compileBridge.js +193 -180
- package/extension/src/page/fileMover.js +256 -0
- package/extension/src/page/overleafCapabilities.js +4 -1
- package/extension/src/page/ownedDirectoryCleanup.js +94 -0
- package/extension/src/page/referenceProjects.js +1 -0
- package/extension/src/page/reviewContext.js +113 -0
- package/extension/src/page/saveState.js +6 -4
- package/extension/src/page/snapshotRouter.js +84 -39
- package/extension/src/page/textCoordinates.js +17 -1
- package/extension/src/page/textFileCreator.js +114 -26
- package/extension/src/page/trackedChangeCapture.js +12 -2
- package/extension/src/page/trackedChangeOwnership.js +19 -1
- package/extension/src/page/trackedChangeReplay.js +41 -21
- package/extension/src/page/trackedChangesLifecycle.js +217 -199
- package/extension/src/page/treeOperations.js +7 -10
- package/extension/src/page/updateSafetyProbe.js +41 -0
- package/extension/src/page/writebackReceiptJournal.js +2 -0
- package/extension/src/page/writebackRouter.js +103 -155
- package/extension/src/pageBridge.js +126 -69
- package/extension/src/popup.js +62 -10
- package/extension/src/shared/agentTranscript.js +373 -17
- package/extension/src/shared/auditRecords.js +4 -0
- package/extension/src/shared/compatibility.js +22 -6
- package/extension/src/shared/compileAdapter.js +129 -66
- package/extension/src/shared/composerAttachmentDraft.js +156 -0
- package/extension/src/shared/contextUsage.js +125 -0
- package/extension/src/shared/directoryCleanupProof.js +43 -0
- package/extension/src/shared/failureReasons.js +349 -4
- package/extension/src/shared/globalPreferences.js +30 -13
- package/extension/src/shared/governanceRules.js +4 -37
- package/extension/src/shared/i18n.js +590 -24
- package/extension/src/shared/lineReferences.js +38 -31
- package/extension/src/shared/managedUpdateProjection.js +2 -2
- package/extension/src/shared/nativeRequestIdentity.js +1 -0
- package/extension/src/shared/pageRpcContract.js +5 -4
- package/extension/src/shared/panelIcons.js +39 -0
- package/extension/src/shared/projectFiles.js +21 -44
- package/extension/src/shared/providerProfiles.js +5 -3
- package/extension/src/shared/reviewing.js +130 -1
- package/extension/src/shared/runActivityModel.js +70 -4
- package/extension/src/shared/runExecutionSnapshot.js +13 -0
- package/extension/src/shared/runInputQueue.js +48 -5
- package/extension/src/shared/selectionContext.js +12 -3
- package/extension/src/shared/sessionState.js +132 -28
- package/extension/src/shared/sessionTitle.js +14 -1
- package/extension/src/shared/settlementFacts.js +10 -2
- package/extension/src/shared/sharedSessionBridge.js +118 -27
- package/extension/src/shared/sharedSessionTransfer.js +140 -0
- package/extension/src/shared/staleGuard.js +166 -52
- package/extension/src/shared/storageDb.js +90 -26
- package/extension/src/shared/storageMigration.js +78 -112
- package/extension/src/shared/storageRunActions.js +267 -30
- package/extension/src/shared/subagentActivityModel.js +1 -1
- package/extension/src/shared/undoOperations.js +253 -32
- package/extension/src/shared/updateConsent.js +6 -4
- package/extension/src/shared/updateRuntimeIdentity.js +17 -3
- package/extension/src/shared/writebackIntent.js +16 -0
- package/extension/src/shared/writebackOperationEvidence.js +4 -15
- package/extension/src/shared/writebackSettlement.js +58 -40
- package/extension/styles/panel.css +1123 -493
- package/native-host/src/anthropicBridgeRequest.js +4 -2
- package/native-host/src/anthropicBridgeResponse.js +6 -2
- package/native-host/src/anthropicMessagesBridge.js +24 -16
- package/native-host/src/bootstrapMigration.js +296 -0
- package/native-host/src/bootstrapMigrationContract.js +101 -0
- package/native-host/src/bootstrapUpdate.js +269 -0
- package/native-host/src/bootstrapUpdateWatchdog.js +44 -0
- package/native-host/src/chatBridgeRequest.js +60 -6
- package/native-host/src/chatBridgeResponse.js +20 -1
- package/native-host/src/chatCompletionsBridge.js +48 -15
- package/native-host/src/codexConfigPaths.js +106 -0
- package/native-host/src/codexHome.js +66 -73
- package/native-host/src/codexHomeScope.js +124 -0
- package/native-host/src/codexInheritedAuth.js +90 -0
- package/native-host/src/codexLocalSkills.js +301 -0
- package/native-host/src/codexPromptAssembly.js +114 -127
- package/native-host/src/codexProviderTest.js +24 -26
- package/native-host/src/codexSessionRunner.js +234 -198
- package/native-host/src/codexSessionTiming.js +18 -8
- package/native-host/src/codexSkillIsolation.js +170 -0
- package/native-host/src/codexThreadFork.js +19 -3
- package/native-host/src/index.js +19 -7
- package/native-host/src/managedInstall.js +110 -35
- package/native-host/src/managedInstallRecovery.js +140 -0
- package/native-host/src/managedRuntimeEvidence.js +57 -0
- package/native-host/src/mirrorWorkspace.js +92 -22
- package/native-host/src/nativeDoctor.js +15 -0
- package/native-host/src/nativeHelperLifecycle.js +21 -0
- package/native-host/src/nativeHostRegistration.js +33 -0
- package/native-host/src/nativeRunLifecycle.js +71 -0
- package/native-host/src/processStopBarrier.js +42 -7
- package/native-host/src/projectPathPlatform.js +30 -0
- package/native-host/src/providerContextError.js +20 -0
- package/native-host/src/providerHttp.js +80 -0
- package/native-host/src/providerProfile.js +4 -2
- package/native-host/src/providerReasoning.js +2 -7
- package/native-host/src/providerRuntime.js +1 -0
- package/native-host/src/providerStore.js +72 -14
- package/native-host/src/responsesPassthroughBridge.js +266 -52
- package/native-host/src/runtimeInstaller.js +21 -1
- package/native-host/src/selectionScope.js +36 -8
- package/native-host/src/subagentAdmission.js +26 -0
- package/native-host/src/subagentBroker.js +79 -66
- package/native-host/src/subagentTelemetry.js +19 -10
- package/native-host/src/subagentWorkspacePath.js +5 -2
- package/native-host/src/taskRunnerRuntime.js +92 -57
- package/native-host/src/textPatch.js +473 -377
- package/native-host/src/tomlStatements.js +140 -0
- package/native-host/src/updateManager.js +409 -46
- package/native-host/src/writingStyleRuntime.js +57 -29
- package/native-host/src/writingStyleStore.js +73 -0
- package/package.json +5 -8
- 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, () =>
|
|
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
|
-
|
|
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 &&
|
|
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
|
|
189
|
-
|
|
190
|
-
|
|
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
|
-
|
|
290
|
-
|
|
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), {
|
|
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?.
|
|
340
|
-
|| !
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
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 &&
|
|
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'
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
729
|
-
|
|
730
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
877
|
-
|
|
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 ||
|
|
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 + '-' +
|
|
1538
|
+
const temp = target + '.tmp-' + process.pid + '-' + crypto.randomBytes(6).toString('hex');
|
|
1193
1539
|
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
1194
|
-
fs.
|
|
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
|
};
|