codex-overleaf-link 1.8.6 → 1.9.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 (72) hide show
  1. package/README.md +45 -21
  2. package/bin/codex-overleaf-link.mjs +58 -0
  3. package/extension/assets/icons/codex-overleaf-dialog-icon.png +0 -0
  4. package/extension/assets/icons/codex-overleaf-icon.png +0 -0
  5. package/extension/assets/icons/icon128.png +0 -0
  6. package/extension/assets/icons/icon16.png +0 -0
  7. package/extension/assets/icons/icon32.png +0 -0
  8. package/extension/assets/icons/icon48.png +0 -0
  9. package/extension/bootstrap/background.js +404 -0
  10. package/extension/bootstrap/manifest.template.json +51 -0
  11. package/extension/bootstrap/popup.css +26 -0
  12. package/extension/bootstrap/popup.html +38 -0
  13. package/extension/bootstrap/popup.js +44 -0
  14. package/extension/bootstrap/runtimeContext.js +2 -0
  15. package/extension/runtime-manifest.json +60 -0
  16. package/extension/src/background.js +532 -0
  17. package/extension/src/content/applyResultFormatters.js +387 -0
  18. package/extension/src/content/composerAttachments.js +522 -0
  19. package/extension/src/content/composerPanel.js +254 -0
  20. package/extension/src/content/contentRuntime.js +8895 -0
  21. package/extension/src/content/contextTray.js +686 -0
  22. package/extension/src/content/diagnosticsController.js +659 -0
  23. package/extension/src/content/diagnosticsPanel.js +342 -0
  24. package/extension/src/content/diffReviewPanel.js +797 -0
  25. package/extension/src/content/localSkillsPanel.js +280 -0
  26. package/extension/src/content/markdownText.js +606 -0
  27. package/extension/src/content/mirrorHealth.js +92 -0
  28. package/extension/src/content/modelPicker.js +489 -0
  29. package/extension/src/content/nativeChannel.js +62 -0
  30. package/extension/src/content/otWarmMirror.js +964 -0
  31. package/extension/src/content/otWarmMirrorController.js +174 -0
  32. package/extension/src/content/panelMaintenance.js +367 -0
  33. package/extension/src/content/panelRenderer.js +253 -0
  34. package/extension/src/content/recentProjects.js +1063 -0
  35. package/extension/src/content/reviewHunks.js +247 -0
  36. package/extension/src/content/runController.js +242 -0
  37. package/extension/src/content/runTimelineView.js +1126 -0
  38. package/extension/src/content/sessionManager.js +487 -0
  39. package/extension/src/content/sessionPanel.js +263 -0
  40. package/extension/src/content/settingsPanel.js +527 -0
  41. package/extension/src/content/themeController.js +91 -0
  42. package/extension/src/content/updateIdle.js +67 -0
  43. package/extension/src/content/writebackController.js +300 -0
  44. package/extension/src/content/writebackOrchestrator.js +850 -0
  45. package/extension/src/contentScript.js +8 -0
  46. package/extension/src/page/compileBridge.js +460 -0
  47. package/extension/src/page/overleafCapabilities.js +100 -0
  48. package/extension/src/page/overleafEditor.js +282 -0
  49. package/extension/src/page/overleafProjectSnapshot.js +234 -0
  50. package/extension/src/page/overleafRealtimeObserver.js +491 -0
  51. package/extension/src/page/pageBridgeCapability.js +71 -0
  52. package/extension/src/page/snapshotRouter.js +948 -0
  53. package/extension/src/page/trackedChangesLifecycle.js +1506 -0
  54. package/extension/src/page/treeOperations.js +1584 -0
  55. package/extension/src/page/writeGuard.js +142 -0
  56. package/extension/src/page/writebackRouter.js +1934 -0
  57. package/extension/src/pageBridge.js +2224 -0
  58. package/extension/src/popup.js +247 -0
  59. package/extension/src/shared/compatibility.js +1 -1
  60. package/extension/styles/panel.css +4048 -0
  61. package/native-host/src/index.js +9 -2
  62. package/native-host/src/managedInstall.js +393 -0
  63. package/native-host/src/managedLauncherRuntime.js +71 -0
  64. package/native-host/src/nativeHostPlatform.js +16 -0
  65. package/native-host/src/runtimeInstaller.js +6 -1
  66. package/native-host/src/taskRunnerRuntime.js +8 -0
  67. package/native-host/src/updateArchive.js +253 -0
  68. package/native-host/src/updateManager.js +548 -0
  69. package/native-host/src/updateTrust.js +156 -0
  70. package/package.json +11 -2
  71. package/scripts/install-managed.mjs +64 -0
  72. package/scripts/uninstall-managed.mjs +48 -0
@@ -0,0 +1,548 @@
1
+ 'use strict';
2
+
3
+ const crypto = require('node:crypto');
4
+ const fs = require('node:fs');
5
+ const path = require('node:path');
6
+ const {
7
+ compareSemver,
8
+ isNewerStableVersion,
9
+ parseSemver,
10
+ updateError,
11
+ verifySignedReleaseManifest
12
+ } = require('./updateTrust');
13
+ const { extractVerifiedUpdateBundle } = require('./updateArchive');
14
+
15
+ const GITHUB_LATEST_URL = 'https://github.com/Ghqqqq/codex-overleaf-link/releases/latest';
16
+ const GITHUB_RELEASE_DOWNLOAD_ROOT = 'https://github.com/Ghqqqq/codex-overleaf-link/releases/download';
17
+ const EXTENSION_MARKER = '.codex-overleaf-managed-extension.json';
18
+ const NATIVE_MARKER = '.codex-overleaf-managed-native.json';
19
+ const JOURNAL_FILE = 'transaction.json';
20
+ const CANDIDATE_FILE = 'candidate.json';
21
+ const COOLDOWN_FILE = 'cooldowns.json';
22
+ const UPDATE_METHODS = new Set([
23
+ 'update.status',
24
+ 'update.check',
25
+ 'update.stage',
26
+ 'update.canApply',
27
+ 'update.apply',
28
+ 'update.confirm',
29
+ 'update.rollback'
30
+ ]);
31
+ const RELEASE_ASSET_HOSTS = new Set([
32
+ 'github.com',
33
+ 'objects.githubusercontent.com',
34
+ 'release-assets.githubusercontent.com'
35
+ ]);
36
+
37
+ function isUpdateMethod(method) {
38
+ return UPDATE_METHODS.has(method);
39
+ }
40
+
41
+ async function handleUpdateRequest(request, options = {}) {
42
+ if (!request || !isUpdateMethod(request.method)) {
43
+ return errorResponse(request?.id, 'unknown_update_method', 'Unknown managed update method.');
44
+ }
45
+ try {
46
+ const context = getManagedContext(options);
47
+ if (!context.managed) {
48
+ throw updateError('update_not_managed', 'Run install-managed once to enable coordinated automatic updates.');
49
+ }
50
+ switch (request.method) {
51
+ case 'update.status':
52
+ return okResponse(request.id, recoverAndReadStatus(context));
53
+ case 'update.check':
54
+ return okResponse(request.id, await checkForUpdate(context, request.params || {}, options));
55
+ case 'update.stage':
56
+ return okResponse(request.id, await stageCandidate(context, options));
57
+ case 'update.canApply':
58
+ return okResponse(request.id, getApplyGate(options));
59
+ case 'update.apply':
60
+ return okResponse(request.id, applyStagedUpdate(context, request.params || {}, options));
61
+ case 'update.confirm':
62
+ return okResponse(request.id, confirmUpdate(context, request.params || {}));
63
+ case 'update.rollback':
64
+ return okResponse(request.id, rollbackUpdate(context, request.params || {}));
65
+ default:
66
+ throw updateError('unknown_update_method', 'Unknown managed update method.');
67
+ }
68
+ } catch (error) {
69
+ return errorResponse(request.id, safeErrorCode(error), safeErrorMessage(error));
70
+ }
71
+ }
72
+
73
+ async function checkForUpdate(context, params = {}, options = {}) {
74
+ const fetchImpl = options.fetch || globalThis.fetch;
75
+ if (typeof fetchImpl !== 'function') {
76
+ throw updateError('update_network_unavailable', 'This Node runtime cannot check GitHub Releases.');
77
+ }
78
+ const currentVersion = normalizeCurrentVersion(params.currentVersion, context);
79
+ const headers = {
80
+ Accept: 'text/html,application/xhtml+xml',
81
+ 'User-Agent': 'codex-overleaf-link-updater'
82
+ };
83
+ if (params.etag && typeof params.etag === 'string' && params.etag.length < 300) {
84
+ headers['If-None-Match'] = params.etag;
85
+ }
86
+ const releaseResponse = await fetchWithTimeout(fetchImpl, GITHUB_LATEST_URL, {
87
+ headers,
88
+ method: 'HEAD',
89
+ redirect: 'follow'
90
+ }, 12000);
91
+ if (releaseResponse.status === 304) {
92
+ return { managed: true, available: false, reason: 'not_modified', currentVersion, etag: params.etag || '' };
93
+ }
94
+ if (!releaseResponse.ok) {
95
+ throw updateError('update_github_http_error', 'GitHub update check failed with HTTP ' + releaseResponse.status + '.');
96
+ }
97
+ const releaseTag = parseStableReleaseTag(releaseResponse.url);
98
+ if (!releaseTag) {
99
+ throw updateError('update_github_response_invalid', 'GitHub latest release did not resolve to a stable semantic-version tag.');
100
+ }
101
+ const latestVersion = releaseTag.slice(1);
102
+ if (!isNewerStableVersion(latestVersion, currentVersion)) {
103
+ return {
104
+ managed: true,
105
+ available: false,
106
+ reason: compareSemver(latestVersion, currentVersion) === 0 ? 'up_to_date' : 'downgrade_rejected',
107
+ currentVersion,
108
+ latestVersion,
109
+ etag: releaseResponse.headers.get('etag') || ''
110
+ };
111
+ }
112
+ const cooldowns = readJsonSafe(path.join(context.updatesRoot, COOLDOWN_FILE), {});
113
+ if (Number(cooldowns[latestVersion]?.until || 0) > Date.now()) {
114
+ return { managed: true, available: false, reason: 'cooldown', currentVersion, latestVersion, etag: releaseResponse.headers.get('etag') || '' };
115
+ }
116
+
117
+ const manifestBytes = await fetchReleaseAsset(
118
+ fetchImpl,
119
+ buildReleaseAssetUrl(releaseTag, 'release-manifest.json'),
120
+ 256 * 1024
121
+ );
122
+ const signatureBytes = await fetchReleaseAsset(
123
+ fetchImpl,
124
+ buildReleaseAssetUrl(releaseTag, 'release-manifest.sig'),
125
+ 16 * 1024
126
+ );
127
+ const manifest = verifySignedReleaseManifest(manifestBytes, signatureBytes);
128
+ if (manifest.version !== latestVersion || manifest.tag !== releaseTag) {
129
+ throw updateError('update_release_manifest_mismatch', 'GitHub release tag does not match the signed update manifest.');
130
+ }
131
+ const candidate = {
132
+ checkedAt: new Date().toISOString(),
133
+ currentVersion,
134
+ latestVersion,
135
+ etag: releaseResponse.headers.get('etag') || '',
136
+ manifestBase64: manifestBytes.toString('base64'),
137
+ signatureBase64: signatureBytes.toString('base64'),
138
+ bundleUrl: buildReleaseAssetUrl(releaseTag, manifest.updateBundle.name)
139
+ };
140
+ atomicWriteJson(path.join(context.updatesRoot, CANDIDATE_FILE), candidate);
141
+ return { managed: true, available: true, currentVersion, latestVersion, etag: candidate.etag };
142
+ }
143
+
144
+ function parseStableReleaseTag(value) {
145
+ try {
146
+ const url = new URL(String(value || ''));
147
+ if (url.protocol !== 'https:' || url.hostname !== 'github.com') return '';
148
+ const match = url.pathname.match(/^\/Ghqqqq\/codex-overleaf-link\/releases\/tag\/(v\d+\.\d+\.\d+)\/?$/i);
149
+ return match?.[1] || '';
150
+ } catch (_error) {
151
+ return '';
152
+ }
153
+ }
154
+
155
+ function buildReleaseAssetUrl(tag, assetName) {
156
+ if (!/^v\d+\.\d+\.\d+$/.test(String(tag || '')) ||
157
+ !/^[A-Za-z0-9][A-Za-z0-9._-]{0,199}$/.test(String(assetName || ''))) {
158
+ throw updateError('update_release_asset_invalid', 'Stable release asset metadata is invalid.');
159
+ }
160
+ return GITHUB_RELEASE_DOWNLOAD_ROOT + '/' + tag + '/' + encodeURIComponent(assetName);
161
+ }
162
+
163
+ async function stageCandidate(context, options = {}) {
164
+ const candidate = readJsonSafe(path.join(context.updatesRoot, CANDIDATE_FILE), null);
165
+ if (!candidate) {
166
+ throw updateError('update_candidate_missing', 'Check for an update before staging it.');
167
+ }
168
+ const manifestBytes = Buffer.from(candidate.manifestBase64 || '', 'base64');
169
+ const signatureBytes = Buffer.from(candidate.signatureBase64 || '', 'base64');
170
+ const manifest = verifySignedReleaseManifest(manifestBytes, signatureBytes);
171
+ const activeVersion = readVersionPointer(context.nativeRoot, 'active-version');
172
+ if (!isNewerStableVersion(manifest.version, activeVersion)) {
173
+ throw updateError('update_candidate_stale', 'The staged update is no longer newer than the active version.');
174
+ }
175
+ const transactionId = crypto.randomUUID();
176
+ const stageRoot = path.join(context.updatesRoot, 'staging-' + transactionId);
177
+ fs.mkdirSync(stageRoot, { recursive: true, mode: 0o700 });
178
+ const archivePath = path.join(stageRoot, manifest.updateBundle.name);
179
+ const fetchImpl = options.fetch || globalThis.fetch;
180
+ const bundleBytes = await fetchReleaseAsset(fetchImpl, candidate.bundleUrl, manifest.updateBundle.size + 1);
181
+ if (bundleBytes.length !== manifest.updateBundle.size) {
182
+ throw updateError('update_bundle_size_mismatch', 'Downloaded update bundle size does not match the signed manifest.');
183
+ }
184
+ const hash = crypto.createHash('sha256').update(bundleBytes).digest('hex');
185
+ if (hash !== manifest.updateBundle.sha256) {
186
+ throw updateError('update_bundle_hash_mismatch', 'Downloaded update bundle hash does not match the signed manifest.');
187
+ }
188
+ fs.writeFileSync(archivePath, bundleBytes, { mode: 0o600 });
189
+ const payloadRoot = path.join(stageRoot, 'payload');
190
+ extractVerifiedUpdateBundle({ archivePath, destinationRoot: payloadRoot });
191
+ verifyStagedPair(payloadRoot, manifest.version);
192
+ const journal = {
193
+ id: transactionId,
194
+ state: 'staged',
195
+ sourceVersion: activeVersion,
196
+ sourcePreviousVersion: readVersionPointer(context.nativeRoot, 'previous-version'),
197
+ targetVersion: manifest.version,
198
+ stageRoot,
199
+ payloadRoot,
200
+ manifestSha256: crypto.createHash('sha256').update(manifestBytes).digest('hex'),
201
+ createdAt: new Date().toISOString(),
202
+ updatedAt: new Date().toISOString()
203
+ };
204
+ writeJournal(context, journal);
205
+ return { transactionId, targetVersion: manifest.version, state: journal.state };
206
+ }
207
+
208
+ function getApplyGate(options = {}) {
209
+ const state = typeof options.getWorkState === 'function'
210
+ ? options.getWorkState()
211
+ : { projectLocks: 0, runControllers: 0 };
212
+ const blockers = [];
213
+ if (Number(state.projectLocks || 0) > 0) blockers.push('native_project_locked');
214
+ if (Number(state.runControllers || 0) > 0) blockers.push('native_run_active');
215
+ return { idle: blockers.length === 0, blockers, workState: state };
216
+ }
217
+
218
+ function applyStagedUpdate(context, params = {}, options = {}) {
219
+ const gate = getApplyGate(options);
220
+ if (!gate.idle) {
221
+ throw updateError('update_native_busy', 'Native host is still processing a Codex task.');
222
+ }
223
+ const journal = readJournal(context);
224
+ if (!journal || journal.state !== 'staged') {
225
+ throw updateError('update_not_staged', 'No verified update is ready to apply.');
226
+ }
227
+ if (params.transactionId && params.transactionId !== journal.id) {
228
+ throw updateError('update_transaction_mismatch', 'Update transaction id does not match the staged update.');
229
+ }
230
+ assertManagedMarkers(context);
231
+ const extensionRuntime = path.join(context.extensionRoot, 'runtime');
232
+ const previousRuntime = path.join(context.extensionRoot, 'slots', 'previous', 'runtime');
233
+ const stagedExtension = path.join(journal.payloadRoot, 'extension-runtime');
234
+ const stagedNative = path.join(journal.payloadRoot, 'native-runtime');
235
+ const targetNative = path.join(context.nativeRoot, 'versions', journal.targetVersion);
236
+ const manifestPath = path.join(context.extensionRoot, 'manifest.json');
237
+ const previousManifest = fs.readFileSync(manifestPath, 'utf8');
238
+ writeJournal(context, { ...journal, state: 'applying', previousManifest, updatedAt: new Date().toISOString() });
239
+
240
+ try {
241
+ fs.rmSync(path.dirname(previousRuntime), { recursive: true, force: true });
242
+ fs.mkdirSync(path.dirname(previousRuntime), { recursive: true });
243
+ fs.renameSync(extensionRuntime, previousRuntime);
244
+ fs.renameSync(stagedExtension, extensionRuntime);
245
+ fs.rmSync(targetNative, { recursive: true, force: true });
246
+ fs.renameSync(stagedNative, targetNative);
247
+ atomicWriteText(path.join(context.nativeRoot, 'previous-version'), journal.sourceVersion + '\n');
248
+ atomicWriteText(path.join(context.nativeRoot, 'active-version'), journal.targetVersion + '\n');
249
+ rewriteManagedManifestVersion(manifestPath, journal.targetVersion);
250
+ } catch (error) {
251
+ rollbackFiles(context, { ...journal, previousManifest });
252
+ throw updateError('update_apply_failed', 'Managed update could not be applied atomically.', { cause: error });
253
+ }
254
+ const awaiting = {
255
+ ...journal,
256
+ previousManifest,
257
+ state: 'awaiting_health',
258
+ appliedAt: new Date().toISOString(),
259
+ updatedAt: new Date().toISOString()
260
+ };
261
+ writeJournal(context, awaiting);
262
+ return { transactionId: journal.id, targetVersion: journal.targetVersion, state: awaiting.state };
263
+ }
264
+
265
+ function confirmUpdate(context, params = {}) {
266
+ const journal = readJournal(context);
267
+ if (!journal || journal.state !== 'awaiting_health') {
268
+ throw updateError('update_confirmation_unexpected', 'No update is waiting for health confirmation.');
269
+ }
270
+ if (params.transactionId !== journal.id || params.extensionVersion !== journal.targetVersion || params.nativeVersion !== journal.targetVersion) {
271
+ throw updateError('update_health_version_mismatch', 'Extension and native host did not confirm the same target version.');
272
+ }
273
+ const manifest = readJsonSafe(path.join(context.extensionRoot, 'manifest.json'), null);
274
+ if (readVersionPointer(context.nativeRoot, 'active-version') !== journal.targetVersion || manifest?.version !== journal.targetVersion) {
275
+ throw updateError('update_health_version_mismatch', 'Managed files do not match the confirmed target version.');
276
+ }
277
+ writeJournal(context, {
278
+ ...journal,
279
+ state: 'committed',
280
+ confirmedAt: new Date().toISOString(),
281
+ updatedAt: new Date().toISOString()
282
+ });
283
+ pruneNativeVersions(context.nativeRoot, new Set([journal.targetVersion, journal.sourceVersion]));
284
+ cleanupStage(journal.stageRoot);
285
+ return { version: journal.targetVersion, state: 'committed' };
286
+ }
287
+
288
+ function rollbackUpdate(context, params = {}) {
289
+ const journal = readJournal(context);
290
+ if (!journal || !['applying', 'awaiting_health', 'committed'].includes(journal.state)) {
291
+ throw updateError('update_rollback_unavailable', 'No applied update is available for rollback.');
292
+ }
293
+ rollbackFiles(context, journal);
294
+ const cooldowns = readJsonSafe(path.join(context.updatesRoot, COOLDOWN_FILE), {});
295
+ cooldowns[journal.targetVersion] = {
296
+ until: Date.now() + 24 * 60 * 60 * 1000,
297
+ reasonCode: normalizeReasonCode(params.reasonCode)
298
+ };
299
+ atomicWriteJson(path.join(context.updatesRoot, COOLDOWN_FILE), cooldowns);
300
+ writeJournal(context, {
301
+ ...journal,
302
+ state: 'rolled_back',
303
+ reasonCode: normalizeReasonCode(params.reasonCode),
304
+ rolledBackAt: new Date().toISOString(),
305
+ updatedAt: new Date().toISOString(),
306
+ previousManifest: undefined
307
+ });
308
+ cleanupStage(journal.stageRoot);
309
+ return { version: journal.sourceVersion, state: 'rolled_back' };
310
+ }
311
+
312
+ function rollbackFiles(context, journal) {
313
+ const runtime = path.join(context.extensionRoot, 'runtime');
314
+ const previousRuntime = path.join(context.extensionRoot, 'slots', 'previous', 'runtime');
315
+ if (fs.existsSync(previousRuntime)) {
316
+ fs.rmSync(runtime, { recursive: true, force: true });
317
+ fs.renameSync(previousRuntime, runtime);
318
+ fs.rmSync(path.dirname(previousRuntime), { recursive: true, force: true });
319
+ }
320
+ if (journal.previousManifest) {
321
+ atomicWriteText(path.join(context.extensionRoot, 'manifest.json'), journal.previousManifest);
322
+ }
323
+ if (parseSemver(journal.sourceVersion)) {
324
+ atomicWriteText(path.join(context.nativeRoot, 'active-version'), journal.sourceVersion + '\n');
325
+ }
326
+ const sourcePrevious = parseSemver(journal.sourcePreviousVersion) ? journal.sourcePreviousVersion : '';
327
+ if (sourcePrevious && fs.existsSync(path.join(context.nativeRoot, 'versions', sourcePrevious))) {
328
+ atomicWriteText(path.join(context.nativeRoot, 'previous-version'), sourcePrevious + '\n');
329
+ } else {
330
+ fs.rmSync(path.join(context.nativeRoot, 'previous-version'), { force: true });
331
+ }
332
+ }
333
+
334
+ function recoverAndReadStatus(context) {
335
+ let journal = readJournal(context);
336
+ if (journal?.state === 'applying') {
337
+ rollbackFiles(context, journal);
338
+ journal = { ...journal, state: 'rolled_back', reasonCode: 'update_interrupted', updatedAt: new Date().toISOString() };
339
+ writeJournal(context, journal);
340
+ }
341
+ if (journal?.state === 'rolled_back') {
342
+ pruneNativeVersions(context.nativeRoot, new Set([
343
+ readVersionPointer(context.nativeRoot, 'active-version'),
344
+ readVersionPointer(context.nativeRoot, 'previous-version')
345
+ ]));
346
+ }
347
+ return {
348
+ managed: true,
349
+ activeVersion: readVersionPointer(context.nativeRoot, 'active-version'),
350
+ previousVersion: readVersionPointer(context.nativeRoot, 'previous-version'),
351
+ transaction: journal ? publicTransaction(journal) : null
352
+ };
353
+ }
354
+
355
+ function pruneNativeVersions(nativeRoot, retainedVersions) {
356
+ const versionsRoot = path.join(nativeRoot, 'versions');
357
+ let entries = [];
358
+ try {
359
+ entries = fs.readdirSync(versionsRoot, { withFileTypes: true });
360
+ } catch (_error) {
361
+ return;
362
+ }
363
+ for (const entry of entries) {
364
+ if (entry.isDirectory() && parseSemver(entry.name) && !retainedVersions.has(entry.name)) {
365
+ fs.rmSync(path.join(versionsRoot, entry.name), { recursive: true, force: true });
366
+ }
367
+ }
368
+ }
369
+
370
+ function getManagedContext(options = {}) {
371
+ const env = options.env || process.env;
372
+ const nativeRoot = path.resolve(options.nativeRoot || env.CODEX_OVERLEAF_MANAGED_NATIVE_ROOT || '');
373
+ const extensionRoot = path.resolve(options.extensionRoot || env.CODEX_OVERLEAF_MANAGED_EXTENSION_ROOT || '');
374
+ const managed = env.CODEX_OVERLEAF_MANAGED === '1'
375
+ && Boolean(nativeRoot)
376
+ && Boolean(extensionRoot)
377
+ && isManagedMarker(readJsonSafe(path.join(nativeRoot, NATIVE_MARKER), null), 'native')
378
+ && isManagedMarker(readJsonSafe(path.join(extensionRoot, EXTENSION_MARKER), null), 'extension');
379
+ const updatesRoot = managed ? path.join(nativeRoot, 'updates') : '';
380
+ if (managed) fs.mkdirSync(updatesRoot, { recursive: true, mode: 0o700 });
381
+ return { env, managed, nativeRoot, extensionRoot, updatesRoot };
382
+ }
383
+
384
+ function assertManagedMarkers(context) {
385
+ if (!context.managed) throw updateError('update_not_managed', 'Managed installation markers are missing.');
386
+ }
387
+
388
+ function isManagedMarker(marker, kind) {
389
+ return marker?.managedBy === 'codex-overleaf-link' && marker?.kind === kind && marker?.bootstrapProtocol === 1;
390
+ }
391
+
392
+ function verifyStagedPair(payloadRoot, targetVersion) {
393
+ const nativePackage = readJsonSafe(path.join(payloadRoot, 'native-runtime', 'package.json'), null);
394
+ if (nativePackage?.version !== targetVersion) {
395
+ throw updateError('update_native_version_mismatch', 'Staged native runtime version does not match the signed release.');
396
+ }
397
+ const compatibilityPath = path.join(payloadRoot, 'extension-runtime', 'src', 'shared', 'compatibility.js');
398
+ const compatibility = fs.readFileSync(compatibilityPath, 'utf8');
399
+ const match = compatibility.match(/BUILD_TARGET_VERSION\s*=\s*['"]([^'"]+)['"]/);
400
+ if (match?.[1] !== targetVersion) {
401
+ throw updateError('update_extension_version_mismatch', 'Staged extension runtime version does not match the signed release.');
402
+ }
403
+ }
404
+
405
+ function normalizeCurrentVersion(value, context) {
406
+ const requested = String(value || '');
407
+ const active = readVersionPointer(context.nativeRoot, 'active-version');
408
+ if (parseSemver(requested) && requested === active) return requested;
409
+ if (parseSemver(active)) return active;
410
+ throw updateError('update_current_version_invalid', 'Managed installation has no valid active version.');
411
+ }
412
+
413
+ function readVersionPointer(root, name) {
414
+ try {
415
+ const value = fs.readFileSync(path.join(root, name), 'utf8').trim();
416
+ return parseSemver(value) ? value : '';
417
+ } catch (_error) {
418
+ return '';
419
+ }
420
+ }
421
+
422
+ function rewriteManagedManifestVersion(manifestPath, version) {
423
+ const manifest = readJsonSafe(manifestPath, null);
424
+ if (!manifest || manifest.manifest_version !== 3 || !manifest.background?.service_worker?.startsWith('bootstrap/')) {
425
+ throw updateError('update_bootstrap_manifest_invalid', 'Managed Bootstrap manifest cannot be rewritten safely.');
426
+ }
427
+ manifest.version = version;
428
+ atomicWriteText(manifestPath, JSON.stringify(manifest, null, 2) + '\n');
429
+ }
430
+
431
+ async function fetchReleaseAsset(fetchImpl, url, limit) {
432
+ const parsed = new URL(String(url || ''));
433
+ if (parsed.protocol !== 'https:' || parsed.hostname !== 'github.com' || !parsed.pathname.startsWith('/Ghqqqq/codex-overleaf-link/releases/download/')) {
434
+ throw updateError('update_asset_url_forbidden', 'Release asset URL is outside the trusted repository.');
435
+ }
436
+ const response = await fetchWithTimeout(fetchImpl, parsed.href, { redirect: 'follow' }, 20000);
437
+ if (!response.ok) throw updateError('update_asset_http_error', 'Release asset download failed with HTTP ' + response.status + '.');
438
+ const finalUrl = new URL(response.url || parsed.href);
439
+ if (finalUrl.protocol !== 'https:' || !RELEASE_ASSET_HOSTS.has(finalUrl.hostname)) {
440
+ throw updateError('update_asset_redirect_forbidden', 'Release asset redirected to an untrusted host.');
441
+ }
442
+ return readResponseBytes(response, limit);
443
+ }
444
+
445
+ async function fetchWithTimeout(fetchImpl, url, options, timeoutMs) {
446
+ const controller = new AbortController();
447
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
448
+ try {
449
+ return await fetchImpl(url, { ...options, signal: controller.signal });
450
+ } catch (error) {
451
+ throw updateError('update_network_failed', 'Update network request failed.', { cause: error });
452
+ } finally {
453
+ clearTimeout(timer);
454
+ }
455
+ }
456
+
457
+ async function readResponseBytes(response, limit) {
458
+ const declared = Number(response.headers?.get?.('content-length') || 0);
459
+ if (declared > limit) throw updateError('update_download_limit', 'Update response exceeds its size limit.');
460
+ const buffer = Buffer.from(await response.arrayBuffer());
461
+ if (!buffer.length || buffer.length > limit) throw updateError('update_download_limit', 'Update response exceeds its size limit.');
462
+ return buffer;
463
+ }
464
+
465
+ function findReleaseAsset(assets, name) {
466
+ const matches = assets.filter(asset => asset?.name === name && typeof asset.browser_download_url === 'string');
467
+ if (matches.length !== 1) throw updateError('update_asset_missing', 'Release must contain exactly one ' + name + ' asset.');
468
+ return matches[0];
469
+ }
470
+
471
+ function publicTransaction(journal) {
472
+ return {
473
+ id: journal.id,
474
+ state: journal.state,
475
+ sourceVersion: journal.sourceVersion,
476
+ targetVersion: journal.targetVersion,
477
+ createdAt: journal.createdAt,
478
+ appliedAt: journal.appliedAt,
479
+ confirmedAt: journal.confirmedAt,
480
+ rolledBackAt: journal.rolledBackAt,
481
+ reasonCode: journal.reasonCode || ''
482
+ };
483
+ }
484
+
485
+ function readJournal(context) {
486
+ return readJsonSafe(path.join(context.updatesRoot, JOURNAL_FILE), null);
487
+ }
488
+
489
+ function writeJournal(context, journal) {
490
+ atomicWriteJson(path.join(context.updatesRoot, JOURNAL_FILE), journal);
491
+ }
492
+
493
+ function atomicWriteJson(target, value) {
494
+ atomicWriteText(target, JSON.stringify(value, null, 2) + '\n');
495
+ }
496
+
497
+ function atomicWriteText(target, value) {
498
+ const temp = target + '.tmp-' + process.pid + '-' + Date.now();
499
+ fs.mkdirSync(path.dirname(target), { recursive: true });
500
+ fs.writeFileSync(temp, value, { mode: 0o600 });
501
+ fs.renameSync(temp, target);
502
+ }
503
+
504
+ function readJsonSafe(target, fallback) {
505
+ try {
506
+ return JSON.parse(fs.readFileSync(target, 'utf8'));
507
+ } catch (_error) {
508
+ return fallback;
509
+ }
510
+ }
511
+
512
+ function cleanupStage(stageRoot) {
513
+ if (stageRoot && path.basename(stageRoot).startsWith('staging-')) {
514
+ fs.rmSync(stageRoot, { recursive: true, force: true });
515
+ }
516
+ }
517
+
518
+ function normalizeReasonCode(value) {
519
+ return /^[a-z0-9_]{1,80}$/.test(String(value || '')) ? String(value) : 'update_health_failed';
520
+ }
521
+
522
+ function safeErrorCode(error) {
523
+ return /^[a-z0-9_]{1,80}$/.test(String(error?.code || '')) ? error.code : 'update_internal_error';
524
+ }
525
+
526
+ function safeErrorMessage(error) {
527
+ return String(error?.message || 'Managed update failed.').replace(/(?:file:\/\/)?(?:[A-Za-z]:[\\/]|\/Users\/|\/home\/)[^\s]*/g, '[local path]').slice(0, 500);
528
+ }
529
+
530
+ function okResponse(id, result) {
531
+ return { id, ok: true, result };
532
+ }
533
+
534
+ function errorResponse(id, code, message) {
535
+ return { id, ok: false, error: { code, message } };
536
+ }
537
+
538
+ module.exports = {
539
+ GITHUB_LATEST_URL,
540
+ applyStagedUpdate,
541
+ checkForUpdate,
542
+ confirmUpdate,
543
+ getApplyGate,
544
+ getManagedContext,
545
+ handleUpdateRequest,
546
+ isUpdateMethod,
547
+ rollbackUpdate
548
+ };