codex-overleaf-link 1.9.3 → 1.9.4

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.
@@ -19,20 +19,26 @@ const NATIVE_MARKER = '.codex-overleaf-managed-native.json';
19
19
  const JOURNAL_FILE = 'transaction.json';
20
20
  const CANDIDATE_FILE = 'candidate.json';
21
21
  const COOLDOWN_FILE = 'cooldowns.json';
22
+ const AUTHORIZATION_FILE = 'authorization.json';
23
+ const AUTHORIZATION_TTL_MS = 2 * 60 * 60 * 1000;
24
+ const CONSENT_MIGRATION_TARGET_VERSION = '1.9.4';
22
25
  const UPDATE_METHODS = new Set([
23
26
  'update.status',
24
27
  'update.check',
28
+ 'update.authorize',
25
29
  'update.stage',
26
30
  'update.canApply',
27
31
  'update.apply',
28
32
  'update.confirm',
29
- 'update.rollback'
33
+ 'update.rollback',
34
+ 'update.revoke'
30
35
  ]);
31
36
  const RELEASE_ASSET_HOSTS = new Set([
32
37
  'github.com',
33
38
  'objects.githubusercontent.com',
34
39
  'release-assets.githubusercontent.com'
35
40
  ]);
41
+ let updateMutationTail = Promise.resolve();
36
42
 
37
43
  function isUpdateMethod(method) {
38
44
  return UPDATE_METHODS.has(method);
@@ -49,19 +55,23 @@ async function handleUpdateRequest(request, options = {}) {
49
55
  }
50
56
  switch (request.method) {
51
57
  case 'update.status':
52
- return okResponse(request.id, recoverAndReadStatus(context));
58
+ return okResponse(request.id, await withUpdateMutex(() => recoverAndReadStatus(context)));
53
59
  case 'update.check':
54
- return okResponse(request.id, await checkForUpdate(context, request.params || {}, options));
60
+ return okResponse(request.id, await withUpdateMutex(() => checkForUpdate(context, request.params || {}, options)));
61
+ case 'update.authorize':
62
+ return okResponse(request.id, await withUpdateMutex(() => authorizeUpdate(context, request.params || {})));
55
63
  case 'update.stage':
56
- return okResponse(request.id, await stageCandidate(context, options));
64
+ return okResponse(request.id, await withUpdateMutex(() => stageCandidate(context, options)));
57
65
  case 'update.canApply':
58
66
  return okResponse(request.id, getApplyGate(options));
59
67
  case 'update.apply':
60
- return okResponse(request.id, applyStagedUpdate(context, request.params || {}, options));
68
+ return okResponse(request.id, await withUpdateMutex(() => applyStagedUpdate(context, request.params || {}, options)));
61
69
  case 'update.confirm':
62
- return okResponse(request.id, confirmUpdate(context, request.params || {}));
70
+ return okResponse(request.id, await withUpdateMutex(() => confirmUpdate(context, request.params || {})));
63
71
  case 'update.rollback':
64
- return okResponse(request.id, rollbackUpdate(context, request.params || {}));
72
+ return okResponse(request.id, await withUpdateMutex(() => rollbackUpdate(context, request.params || {})));
73
+ case 'update.revoke':
74
+ return okResponse(request.id, await withUpdateMutex(() => revokeUpdate(context, request.params || {})));
65
75
  default:
66
76
  throw updateError('unknown_update_method', 'Unknown managed update method.');
67
77
  }
@@ -70,6 +80,12 @@ async function handleUpdateRequest(request, options = {}) {
70
80
  }
71
81
  }
72
82
 
83
+ function withUpdateMutex(action) {
84
+ const result = updateMutationTail.then(action, action);
85
+ updateMutationTail = result.catch(() => {});
86
+ return result;
87
+ }
88
+
73
89
  async function checkForUpdate(context, params = {}, options = {}) {
74
90
  const fetchImpl = options.fetch || globalThis.fetch;
75
91
  if (typeof fetchImpl !== 'function') {
@@ -160,6 +176,53 @@ function buildReleaseAssetUrl(tag, assetName) {
160
176
  return GITHUB_RELEASE_DOWNLOAD_ROOT + '/' + tag + '/' + encodeURIComponent(assetName);
161
177
  }
162
178
 
179
+ function authorizeUpdate(context, params = {}) {
180
+ const authorizationId = String(params.authorizationId || '');
181
+ const targetVersion = String(params.targetVersion || '');
182
+ const requestedCurrentVersion = String(params.currentVersion || '');
183
+ if (!isUuid(authorizationId)) {
184
+ throw updateError('update_authorization_invalid', 'Update authorization id is invalid.');
185
+ }
186
+ const candidate = readJsonSafe(path.join(context.updatesRoot, CANDIDATE_FILE), null);
187
+ if (!candidate) {
188
+ throw updateError('update_candidate_missing', 'Check for an update before authorizing it.');
189
+ }
190
+ const manifest = verifySignedReleaseManifest(
191
+ Buffer.from(candidate.manifestBase64 || '', 'base64'),
192
+ Buffer.from(candidate.signatureBase64 || '', 'base64')
193
+ );
194
+ const activeVersion = readVersionPointer(context.nativeRoot, 'active-version');
195
+ if (requestedCurrentVersion !== activeVersion ||
196
+ targetVersion !== candidate.latestVersion ||
197
+ targetVersion !== manifest.version ||
198
+ !isNewerStableVersion(targetVersion, activeVersion)) {
199
+ throw updateError('update_consent_mismatch', 'Update authorization does not match the active signed candidate.');
200
+ }
201
+ const existing = readAuthorization(context);
202
+ if (existing?.id === authorizationId &&
203
+ existing.targetVersion === targetVersion &&
204
+ ['authorized', 'bound'].includes(existing.state) &&
205
+ (!existing.expiresAt || Date.parse(existing.expiresAt) > Date.now())) {
206
+ return publicAuthorization(existing);
207
+ }
208
+ if (existing && ['authorized', 'bound'].includes(existing.state)) {
209
+ throw updateError('update_authorization_in_progress', 'Another update authorization is already active.');
210
+ }
211
+ const grantedAt = new Date();
212
+ const authorization = {
213
+ schemaVersion: 1,
214
+ id: authorizationId,
215
+ targetVersion,
216
+ sourceVersion: activeVersion,
217
+ state: 'authorized',
218
+ grantedAt: grantedAt.toISOString(),
219
+ expiresAt: new Date(grantedAt.getTime() + AUTHORIZATION_TTL_MS).toISOString(),
220
+ transactionId: ''
221
+ };
222
+ writeAuthorization(context, authorization);
223
+ return publicAuthorization(authorization);
224
+ }
225
+
163
226
  async function stageCandidate(context, options = {}) {
164
227
  const candidate = readJsonSafe(path.join(context.updatesRoot, CANDIDATE_FILE), null);
165
228
  if (!candidate) {
@@ -172,10 +235,16 @@ async function stageCandidate(context, options = {}) {
172
235
  if (!isNewerStableVersion(manifest.version, activeVersion)) {
173
236
  throw updateError('update_candidate_stale', 'The staged update is no longer newer than the active version.');
174
237
  }
238
+ const authorization = requireAuthorization(context, manifest.version, ['authorized', 'bound']);
175
239
  const existingJournal = readJournal(context);
176
240
  if (existingJournal?.state === 'staged' && existingJournal.targetVersion === manifest.version) {
241
+ if (existingJournal.authorizationId !== authorization.id ||
242
+ (authorization.transactionId && authorization.transactionId !== existingJournal.id)) {
243
+ throw updateError('update_consent_mismatch', 'Staged transaction does not match the active authorization.');
244
+ }
177
245
  try {
178
246
  verifyStagedPair(existingJournal.payloadRoot, manifest.version);
247
+ bindAuthorization(context, authorization, existingJournal.id);
179
248
  return {
180
249
  transactionId: existingJournal.id,
181
250
  targetVersion: existingJournal.targetVersion,
@@ -189,38 +258,51 @@ async function stageCandidate(context, options = {}) {
189
258
  if (existingJournal && ['applying', 'awaiting_health'].includes(existingJournal.state)) {
190
259
  throw updateError('update_transaction_in_progress', 'A managed update transaction is already being applied.');
191
260
  }
261
+ if (authorization.state === 'bound') {
262
+ throw updateError('update_consent_mismatch', 'Bound authorization has no reusable staged transaction.');
263
+ }
192
264
  const transactionId = crypto.randomUUID();
193
265
  const stageRoot = path.join(context.updatesRoot, 'staging-' + transactionId);
194
266
  fs.mkdirSync(stageRoot, { recursive: true, mode: 0o700 });
195
- const archivePath = path.join(stageRoot, manifest.updateBundle.name);
196
- const fetchImpl = options.fetch || globalThis.fetch;
197
- const bundleBytes = await fetchReleaseAsset(fetchImpl, candidate.bundleUrl, manifest.updateBundle.size + 1);
198
- if (bundleBytes.length !== manifest.updateBundle.size) {
199
- throw updateError('update_bundle_size_mismatch', 'Downloaded update bundle size does not match the signed manifest.');
200
- }
201
- const hash = crypto.createHash('sha256').update(bundleBytes).digest('hex');
202
- if (hash !== manifest.updateBundle.sha256) {
203
- throw updateError('update_bundle_hash_mismatch', 'Downloaded update bundle hash does not match the signed manifest.');
204
- }
205
- fs.writeFileSync(archivePath, bundleBytes, { mode: 0o600 });
206
- const payloadRoot = path.join(stageRoot, 'payload');
207
- extractVerifiedUpdateBundle({ archivePath, destinationRoot: payloadRoot });
208
- verifyStagedPair(payloadRoot, manifest.version);
209
- const journal = {
210
- id: transactionId,
211
- state: 'staged',
212
- sourceVersion: activeVersion,
213
- sourcePreviousVersion: readVersionPointer(context.nativeRoot, 'previous-version'),
214
- targetVersion: manifest.version,
215
- stageRoot,
216
- payloadRoot,
217
- manifestSha256: crypto.createHash('sha256').update(manifestBytes).digest('hex'),
218
- createdAt: new Date().toISOString(),
219
- updatedAt: new Date().toISOString()
220
- };
221
- writeJournal(context, journal);
222
- cleanupOrphanStageRoots(context.updatesRoot, [stageRoot]);
223
- return { transactionId, targetVersion: manifest.version, state: journal.state };
267
+ try {
268
+ const archivePath = path.join(stageRoot, manifest.updateBundle.name);
269
+ const fetchImpl = options.fetch || globalThis.fetch;
270
+ const bundleBytes = await fetchReleaseAsset(fetchImpl, candidate.bundleUrl, manifest.updateBundle.size + 1);
271
+ if (bundleBytes.length !== manifest.updateBundle.size) {
272
+ throw updateError('update_bundle_size_mismatch', 'Downloaded update bundle size does not match the signed manifest.');
273
+ }
274
+ const hash = crypto.createHash('sha256').update(bundleBytes).digest('hex');
275
+ if (hash !== manifest.updateBundle.sha256) {
276
+ throw updateError('update_bundle_hash_mismatch', 'Downloaded update bundle hash does not match the signed manifest.');
277
+ }
278
+ fs.writeFileSync(archivePath, bundleBytes, { mode: 0o600 });
279
+ const payloadRoot = path.join(stageRoot, 'payload');
280
+ extractVerifiedUpdateBundle({ archivePath, destinationRoot: payloadRoot });
281
+ verifyStagedPair(payloadRoot, manifest.version);
282
+ const journal = {
283
+ id: transactionId,
284
+ authorizationId: authorization.id,
285
+ state: 'staged',
286
+ sourceVersion: activeVersion,
287
+ sourcePreviousVersion: readVersionPointer(context.nativeRoot, 'previous-version'),
288
+ targetVersion: manifest.version,
289
+ stageRoot,
290
+ payloadRoot,
291
+ manifestSha256: crypto.createHash('sha256').update(manifestBytes).digest('hex'),
292
+ createdAt: new Date().toISOString(),
293
+ updatedAt: new Date().toISOString()
294
+ };
295
+ writeJournal(context, journal);
296
+ bindAuthorization(context, authorization, transactionId);
297
+ cleanupOrphanStageRoots(context.updatesRoot, [stageRoot]);
298
+ return { transactionId, targetVersion: manifest.version, state: journal.state };
299
+ } catch (error) {
300
+ cleanupStage(stageRoot);
301
+ const journal = readJournal(context);
302
+ if (journal?.id === transactionId) removeJournal(context);
303
+ settleAuthorization(context, authorization.id, 'revoked');
304
+ throw error;
305
+ }
224
306
  }
225
307
 
226
308
  function getApplyGate(options = {}) {
@@ -245,6 +327,10 @@ function applyStagedUpdate(context, params = {}, options = {}) {
245
327
  if (params.transactionId && params.transactionId !== journal.id) {
246
328
  throw updateError('update_transaction_mismatch', 'Update transaction id does not match the staged update.');
247
329
  }
330
+ const authorization = requireAuthorization(context, journal.targetVersion, ['bound']);
331
+ if (journal.authorizationId !== authorization.id || authorization.transactionId !== journal.id) {
332
+ throw updateError('update_consent_mismatch', 'Apply transaction does not match the bound authorization.');
333
+ }
248
334
  assertManagedMarkers(context);
249
335
  const extensionRuntime = path.join(context.extensionRoot, 'runtime');
250
336
  const previousRuntime = path.join(context.extensionRoot, 'slots', 'previous', 'runtime');
@@ -268,6 +354,7 @@ function applyStagedUpdate(context, params = {}, options = {}) {
268
354
  syncManagedMarkerVersions(context, journal.targetVersion);
269
355
  } catch (error) {
270
356
  rollbackFiles(context, { ...journal, previousManifest });
357
+ settleAuthorization(context, journal.authorizationId, 'revoked');
271
358
  throw updateError('update_apply_failed', 'Managed update could not be applied atomically.', { cause: error });
272
359
  }
273
360
  const awaiting = {
@@ -289,6 +376,14 @@ function confirmUpdate(context, params = {}) {
289
376
  if (params.transactionId !== journal.id || params.extensionVersion !== journal.targetVersion || params.nativeVersion !== journal.targetVersion) {
290
377
  throw updateError('update_health_version_mismatch', 'Extension and native host did not confirm the same target version.');
291
378
  }
379
+ if (!isLegacyInboundConsentMigration(journal)) {
380
+ const authorization = requireAuthorization(context, journal.targetVersion, ['bound'], {
381
+ activeVersion: journal.sourceVersion
382
+ });
383
+ if (journal.authorizationId !== authorization.id || authorization.transactionId !== journal.id) {
384
+ throw updateError('update_consent_mismatch', 'Health confirmation does not match the bound authorization.');
385
+ }
386
+ }
292
387
  const manifest = readJsonSafe(path.join(context.extensionRoot, 'manifest.json'), null);
293
388
  if (readVersionPointer(context.nativeRoot, 'active-version') !== journal.targetVersion || manifest?.version !== journal.targetVersion) {
294
389
  throw updateError('update_health_version_mismatch', 'Managed files do not match the confirmed target version.');
@@ -299,6 +394,7 @@ function confirmUpdate(context, params = {}) {
299
394
  confirmedAt: new Date().toISOString(),
300
395
  updatedAt: new Date().toISOString()
301
396
  });
397
+ settleAuthorization(context, journal.authorizationId, 'consumed');
302
398
  pruneNativeVersions(context.nativeRoot, new Set([journal.targetVersion, journal.sourceVersion]));
303
399
  cleanupStage(journal.stageRoot);
304
400
  cleanupOrphanStageRoots(context.updatesRoot);
@@ -325,11 +421,50 @@ function rollbackUpdate(context, params = {}) {
325
421
  updatedAt: new Date().toISOString(),
326
422
  previousManifest: undefined
327
423
  });
424
+ settleAuthorization(context, journal.authorizationId, 'revoked');
328
425
  cleanupStage(journal.stageRoot);
329
426
  cleanupOrphanStageRoots(context.updatesRoot);
330
427
  return { version: journal.sourceVersion, state: 'rolled_back' };
331
428
  }
332
429
 
430
+ function revokeUpdate(context, params = {}) {
431
+ const authorizationId = String(params.authorizationId || '');
432
+ const targetVersion = String(params.targetVersion || '');
433
+ const transactionId = String(params.transactionId || '');
434
+ if (!isUuid(authorizationId) || !parseSemver(targetVersion)) {
435
+ throw updateError('update_authorization_invalid', 'Update revocation parameters are invalid.');
436
+ }
437
+ const journal = readJournal(context);
438
+ if (journal?.targetVersion === targetVersion &&
439
+ ['applying', 'awaiting_health', 'committed'].includes(journal.state)) {
440
+ throw updateError('update_revoke_too_late', 'The update is already being applied.');
441
+ }
442
+ const authorization = readAuthorization(context);
443
+ if (!authorization) {
444
+ if (!journal || journal.targetVersion !== targetVersion) {
445
+ return { state: 'already_revoked', targetVersion };
446
+ }
447
+ if (journal.authorizationId !== authorizationId) {
448
+ throw updateError('update_consent_mismatch', 'Revocation does not match the staged transaction.');
449
+ }
450
+ } else if (authorization.id !== authorizationId || authorization.targetVersion !== targetVersion) {
451
+ throw updateError('update_consent_mismatch', 'Revocation does not match the active authorization.');
452
+ }
453
+ if (transactionId && journal?.id !== transactionId) {
454
+ throw updateError('update_transaction_mismatch', 'Revocation transaction id does not match the staged update.');
455
+ }
456
+ if (authorization) {
457
+ writeAuthorization(context, { ...authorization, state: 'revoked', revokedAt: new Date().toISOString() });
458
+ }
459
+ if (journal?.state === 'staged') {
460
+ cleanupStage(journal.stageRoot);
461
+ removeJournal(context);
462
+ }
463
+ cleanupOrphanStageRoots(context.updatesRoot);
464
+ fs.rmSync(path.join(context.updatesRoot, AUTHORIZATION_FILE), { force: true });
465
+ return { state: 'revoked', targetVersion };
466
+ }
467
+
333
468
  function rollbackFiles(context, journal) {
334
469
  const runtime = path.join(context.extensionRoot, 'runtime');
335
470
  const previousRuntime = path.join(context.extensionRoot, 'slots', 'previous', 'runtime');
@@ -397,6 +532,7 @@ function recoverAndReadStatus(context) {
397
532
  rollbackFiles(context, journal);
398
533
  journal = { ...journal, state: 'rolled_back', reasonCode: 'update_interrupted', updatedAt: new Date().toISOString() };
399
534
  writeJournal(context, journal);
535
+ settleAuthorization(context, journal.authorizationId, 'revoked');
400
536
  }
401
537
  if (journal?.state === 'rolled_back') {
402
538
  pruneNativeVersions(context.nativeRoot, new Set([
@@ -408,7 +544,8 @@ function recoverAndReadStatus(context) {
408
544
  managed: true,
409
545
  activeVersion: readVersionPointer(context.nativeRoot, 'active-version'),
410
546
  previousVersion: readVersionPointer(context.nativeRoot, 'previous-version'),
411
- transaction: journal ? publicTransaction(journal) : null
547
+ transaction: journal ? publicTransaction(journal) : null,
548
+ authorization: publicAuthorization(readAuthorization(context))
412
549
  };
413
550
  }
414
551
 
@@ -542,6 +679,18 @@ function publicTransaction(journal) {
542
679
  };
543
680
  }
544
681
 
682
+ function publicAuthorization(authorization) {
683
+ if (!authorization) return null;
684
+ return {
685
+ state: authorization.state,
686
+ targetVersion: authorization.targetVersion,
687
+ sourceVersion: authorization.sourceVersion,
688
+ transactionId: authorization.transactionId || '',
689
+ grantedAt: authorization.grantedAt,
690
+ expiresAt: authorization.expiresAt
691
+ };
692
+ }
693
+
545
694
  function readJournal(context) {
546
695
  return readJsonSafe(path.join(context.updatesRoot, JOURNAL_FILE), null);
547
696
  }
@@ -550,6 +699,60 @@ function writeJournal(context, journal) {
550
699
  atomicWriteJson(path.join(context.updatesRoot, JOURNAL_FILE), journal);
551
700
  }
552
701
 
702
+ function removeJournal(context) {
703
+ fs.rmSync(path.join(context.updatesRoot, JOURNAL_FILE), { force: true });
704
+ }
705
+
706
+ function readAuthorization(context) {
707
+ return readJsonSafe(path.join(context.updatesRoot, AUTHORIZATION_FILE), null);
708
+ }
709
+
710
+ function writeAuthorization(context, authorization) {
711
+ atomicWriteJson(path.join(context.updatesRoot, AUTHORIZATION_FILE), authorization);
712
+ }
713
+
714
+ function requireAuthorization(context, targetVersion, allowedStates, options = {}) {
715
+ const authorization = readAuthorization(context);
716
+ if (!authorization) {
717
+ throw updateError('update_consent_required', 'Choose Update now before downloading or installing this update.');
718
+ }
719
+ const expectedActiveVersion = options.activeVersion || readVersionPointer(context.nativeRoot, 'active-version');
720
+ if (authorization.targetVersion !== targetVersion || authorization.sourceVersion !== expectedActiveVersion) {
721
+ throw updateError('update_consent_mismatch', 'Update authorization does not match this source and target version.');
722
+ }
723
+ if (!allowedStates.includes(authorization.state)) {
724
+ throw updateError('update_consent_required', 'Update authorization is not active.');
725
+ }
726
+ if (authorization.state === 'authorized' &&
727
+ (!authorization.expiresAt || Date.parse(authorization.expiresAt) <= Date.now())) {
728
+ settleAuthorization(context, authorization.id, 'revoked');
729
+ throw updateError('update_consent_required', 'Update authorization expired. Choose Update now again.');
730
+ }
731
+ return authorization;
732
+ }
733
+
734
+ function bindAuthorization(context, authorization, transactionId) {
735
+ const bound = {
736
+ ...authorization,
737
+ state: 'bound',
738
+ transactionId,
739
+ boundAt: authorization.boundAt || new Date().toISOString()
740
+ };
741
+ writeAuthorization(context, bound);
742
+ return bound;
743
+ }
744
+
745
+ function settleAuthorization(context, authorizationId, state) {
746
+ const authorization = readAuthorization(context);
747
+ if (!authorization || (authorizationId && authorization.id !== authorizationId)) return;
748
+ writeAuthorization(context, {
749
+ ...authorization,
750
+ state,
751
+ settledAt: new Date().toISOString()
752
+ });
753
+ fs.rmSync(path.join(context.updatesRoot, AUTHORIZATION_FILE), { force: true });
754
+ }
755
+
553
756
  function atomicWriteJson(target, value) {
554
757
  atomicWriteText(target, JSON.stringify(value, null, 2) + '\n');
555
758
  }
@@ -579,6 +782,21 @@ function normalizeReasonCode(value) {
579
782
  return /^[a-z0-9_]{1,80}$/.test(String(value || '')) ? String(value) : 'update_health_failed';
580
783
  }
581
784
 
785
+ function isUuid(value) {
786
+ return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(String(value || ''));
787
+ }
788
+
789
+ function isLegacyInboundConsentMigration(journal) {
790
+ return Boolean(
791
+ journal &&
792
+ !journal.authorizationId &&
793
+ journal.state === 'awaiting_health' &&
794
+ journal.targetVersion === CONSENT_MIGRATION_TARGET_VERSION &&
795
+ parseSemver(journal.sourceVersion) &&
796
+ compareSemver(journal.sourceVersion, CONSENT_MIGRATION_TARGET_VERSION) < 0
797
+ );
798
+ }
799
+
582
800
  function safeErrorCode(error) {
583
801
  return /^[a-z0-9_]{1,80}$/.test(String(error?.code || '')) ? error.code : 'update_internal_error';
584
802
  }
@@ -598,6 +816,7 @@ function errorResponse(id, code, message) {
598
816
  module.exports = {
599
817
  GITHUB_LATEST_URL,
600
818
  applyStagedUpdate,
819
+ authorizeUpdate,
601
820
  checkForUpdate,
602
821
  cleanupOrphanStageRoots,
603
822
  confirmUpdate,
@@ -605,6 +824,7 @@ module.exports = {
605
824
  getManagedContext,
606
825
  handleUpdateRequest,
607
826
  isUpdateMethod,
827
+ revokeUpdate,
608
828
  rollbackUpdate,
609
829
  stageCandidate,
610
830
  syncManagedMarkerVersions
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codex-overleaf-link",
3
- "version": "1.9.3",
3
+ "version": "1.9.4",
4
4
  "description": "Cross-platform Chrome bridge that connects Codex to the active Overleaf project.",
5
5
  "license": "MIT",
6
6
  "type": "commonjs",