codex-overleaf-link 2.1.5 → 2.2.1

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 (67) hide show
  1. package/README.md +27 -23
  2. package/extension/bootstrap/background.js +14 -1
  3. package/extension/bootstrap/manifest.template.json +1 -1
  4. package/extension/runtime-manifest.json +10 -0
  5. package/extension/src/background.js +507 -8
  6. package/extension/src/backgroundUpdateCoordinator.js +284 -19
  7. package/extension/src/content/activeTurnControl.js +179 -0
  8. package/extension/src/content/composerPanel.js +14 -5
  9. package/extension/src/content/contentRuntime.js +330 -309
  10. package/extension/src/content/diagnosticsPanel.js +132 -1
  11. package/extension/src/content/markdownDomRenderer.js +451 -0
  12. package/extension/src/content/markdownText.js +86 -7
  13. package/extension/src/content/mathText.js +49 -4
  14. package/extension/src/content/modelPicker.js +14 -5
  15. package/extension/src/content/nativeChannel.js +7 -1
  16. package/extension/src/content/panelRenderer.js +112 -12
  17. package/extension/src/content/pendingInputView.js +106 -0
  18. package/extension/src/content/projectProviderSelection.js +68 -0
  19. package/extension/src/content/projectSessionCleanup.js +4 -0
  20. package/extension/src/content/providerSettingsCoordinator.js +170 -22
  21. package/extension/src/content/providerSettingsDialog.js +2 -2
  22. package/extension/src/content/recentProjects.js +7 -2
  23. package/extension/src/content/runGuidanceController.js +70 -0
  24. package/extension/src/content/runGuidanceView.js +48 -0
  25. package/extension/src/content/runQueueScheduler.js +260 -0
  26. package/extension/src/content/runTimelineView.js +33 -80
  27. package/extension/src/content/sessionManager.js +2 -2
  28. package/extension/src/content/sessionPersistence.js +62 -0
  29. package/extension/src/content/settingsPanel.js +1 -1
  30. package/extension/src/content/updateNotice.js +80 -1
  31. package/extension/src/page/writebackRouter.js +3 -9
  32. package/extension/src/shared/compatibility.js +2 -1
  33. package/extension/src/shared/i18n.js +38 -0
  34. package/extension/src/shared/providerProfiles.js +3 -2
  35. package/extension/src/shared/runInputQueue.js +253 -0
  36. package/extension/src/shared/sessionState.js +45 -22
  37. package/extension/src/shared/storageDb.js +39 -44
  38. package/extension/src/shared/storageMigration.js +123 -3
  39. package/extension/src/shared/storageRunActions.js +34 -1
  40. package/extension/src/shared/updateConsent.js +15 -4
  41. package/extension/styles/panel.css +319 -20
  42. package/extension/vendor/katex/katex.min.css +1 -1
  43. package/extension/vendor/markdown-it/LICENSE +22 -0
  44. package/extension/vendor/markdown-it/SOURCE.json +7 -0
  45. package/extension/vendor/markdown-it/VERSION +1 -0
  46. package/extension/vendor/markdown-it/markdown-it.min.js +3 -0
  47. package/native-host/src/anthropicBridgeRequest.js +3 -3
  48. package/native-host/src/anthropicBridgeResponse.js +58 -17
  49. package/native-host/src/anthropicMessagesBridge.js +156 -23
  50. package/native-host/src/chatBridgeRequest.js +1 -1
  51. package/native-host/src/chatBridgeResponse.js +12 -8
  52. package/native-host/src/chatCompletionsBridge.js +22 -40
  53. package/native-host/src/codexPromptAssembly.js +3 -1
  54. package/native-host/src/codexProviderLaunch.js +6 -3
  55. package/native-host/src/codexSessionRunner.js +65 -65
  56. package/native-host/src/codexSessionTiming.js +45 -0
  57. package/native-host/src/index.js +58 -3
  58. package/native-host/src/managedInstall.js +45 -7
  59. package/native-host/src/mirrorWorkspace.js +15 -8
  60. package/native-host/src/providerProfile.js +2 -2
  61. package/native-host/src/providerStreamLifecycle.js +100 -0
  62. package/native-host/src/responsesPassthroughBridge.js +636 -0
  63. package/native-host/src/taskRunnerRuntime.js +81 -2
  64. package/native-host/src/updateArchive.js +4 -3
  65. package/native-host/src/updateManager.js +306 -19
  66. package/native-host/src/updateTrust.js +1 -1
  67. package/package.json +3 -1
@@ -34,6 +34,7 @@ const { version: PACKAGE_VERSION } = require('../../package.json');
34
34
 
35
35
  const activeProjectLocks = new Map();
36
36
  const activeRunControllers = new Map();
37
+ const activeRunEntries = new Map();
37
38
  // Parallel index of active runs by projectKey so codex.cancel can find a
38
39
  // controller even when the original request id is unknown (e.g. after the
39
40
  // Overleaf tab was reloaded — the requestId lived in content-side JS state
@@ -106,6 +107,10 @@ async function handleRequest(request, env = process.env, emit = () => {}) {
106
107
  return handleCodexRun(request, env, emit);
107
108
  }
108
109
 
110
+ if (request.method === 'codex.steer') {
111
+ return handleCodexSteer(request);
112
+ }
113
+
109
114
  if (request.method === 'codex.cancel') {
110
115
  return handleCodexCancel(request);
111
116
  }
@@ -170,9 +175,16 @@ async function handleCodexRun(request, env, emit) {
170
175
  return errorResponse(request.id, 'project_locked', `Project ${projectKey} is currently in use by codex.run`);
171
176
  }
172
177
  const abortController = new AbortController();
178
+ const activeEntry = {
179
+ id: request.id || '',
180
+ projectKey,
181
+ controller: abortController,
182
+ control: null
183
+ };
173
184
  if (request.id) {
174
185
  activeRunControllers.set(request.id, abortController);
175
- activeRunByProject.set(projectKey, { id: request.id, controller: abortController });
186
+ activeRunEntries.set(request.id, activeEntry);
187
+ activeRunByProject.set(projectKey, activeEntry);
176
188
  }
177
189
  let providerResolution;
178
190
  try {
@@ -232,6 +244,9 @@ async function handleCodexRun(request, env, emit) {
232
244
  emit,
233
245
  rootDir: env.CODEX_OVERLEAF_MIRROR_ROOT,
234
246
  providerLaunch: providerResolution.providerLaunch,
247
+ onControlReady: control => {
248
+ activeEntry.control = control;
249
+ },
235
250
  signal: abortController.signal
236
251
  });
237
252
  const syncChanges = Array.isArray(result.syncChanges) ? result.syncChanges : [];
@@ -265,6 +280,9 @@ async function handleCodexRun(request, env, emit) {
265
280
  if (request.id && activeRunControllers.get(request.id) === abortController) {
266
281
  activeRunControllers.delete(request.id);
267
282
  }
283
+ if (request.id && activeRunEntries.get(request.id) === activeEntry) {
284
+ activeRunEntries.delete(request.id);
285
+ }
268
286
  if (activeRunByProject.get(projectKey)?.controller === abortController) {
269
287
  activeRunByProject.delete(projectKey);
270
288
  }
@@ -272,6 +290,55 @@ async function handleCodexRun(request, env, emit) {
272
290
  }
273
291
  }
274
292
 
293
+ async function handleCodexSteer(request) {
294
+ const params = request.params || {};
295
+ const entry = findActiveRunEntry(params);
296
+ if (!entry?.control) {
297
+ return errorResponse(request.id, 'codex_turn_not_ready', 'The active Codex turn is not ready for guidance.');
298
+ }
299
+ const expectedThreadId = String(params.threadId || '');
300
+ const expectedTurnId = String(params.expectedTurnId || params.turnId || '');
301
+ if ((expectedThreadId && expectedThreadId !== entry.control.threadId)
302
+ || (expectedTurnId && expectedTurnId !== entry.control.turnId)) {
303
+ return errorResponse(request.id, 'codex_turn_mismatch', 'The active Codex turn changed before the guidance was delivered.');
304
+ }
305
+ const inputText = String(
306
+ params.text
307
+ || (Array.isArray(params.input)
308
+ ? params.input.filter(item => item?.type === 'text').map(item => item.text || '').join('\n')
309
+ : '')
310
+ ).trim();
311
+ if (!inputText) {
312
+ return errorResponse(request.id, 'codex_steer_empty', 'Guidance input is empty.');
313
+ }
314
+ if (inputText.length > 12000) {
315
+ return errorResponse(request.id, 'codex_steer_too_large', 'Guidance input exceeds the 12000 character limit.');
316
+ }
317
+ try {
318
+ await entry.control.steer({
319
+ input: [{ type: 'text', text: inputText }],
320
+ clientUserMessageId: String(params.clientUserMessageId || '').slice(0, 160) || undefined
321
+ });
322
+ return okResponse(request.id, {
323
+ accepted: true,
324
+ requestId: entry.id,
325
+ threadId: entry.control.threadId,
326
+ turnId: entry.control.turnId
327
+ });
328
+ } catch (error) {
329
+ return errorResponse(request.id, 'codex_steer_failed', truncateText(error.message, 4000));
330
+ }
331
+ }
332
+
333
+ function findActiveRunEntry(params = {}) {
334
+ const targetId = String(params.requestId || params.id || '');
335
+ if (targetId && activeRunEntries.has(targetId)) {
336
+ return activeRunEntries.get(targetId);
337
+ }
338
+ const projectKey = String(params.projectKey || '');
339
+ return projectKey ? activeRunByProject.get(projectKey) : null;
340
+ }
341
+
275
342
  // Cancel paths, in priority order:
276
343
  // 1. By requestId (legacy + primary, when the caller still has it)
277
344
  // 2. By projectKey (after page refresh — requestId is lost but projectKey
@@ -1207,12 +1274,24 @@ function errorResponse(id, code, message, details = {}) {
1207
1274
  function getActiveNativeWorkState() {
1208
1275
  return {
1209
1276
  projectLocks: activeProjectLocks.size,
1210
- runControllers: activeRunControllers.size
1277
+ runControllers: activeRunControllers.size,
1278
+ activeTurnControls: Array.from(activeRunEntries.values()).filter(entry => entry.control).length
1211
1279
  };
1212
1280
  }
1213
1281
 
1282
+ function abortAllActiveOperations(reason = 'Native messaging transport disconnected.') {
1283
+ const error = new Error(String(reason || 'Native messaging transport disconnected.'));
1284
+ error.code = 'native_transport_closed';
1285
+ for (const controller of activeRunControllers.values()) {
1286
+ if (controller && !controller.signal?.aborted) {
1287
+ controller.abort(error);
1288
+ }
1289
+ }
1290
+ }
1291
+
1214
1292
  module.exports = {
1215
1293
  NATIVE_REQUEST_QUOTAS,
1294
+ abortAllActiveOperations,
1216
1295
  buildDefaultTaskResult,
1217
1296
  getActiveNativeWorkState,
1218
1297
  handleRequest,
@@ -176,11 +176,12 @@ function isAllowedUpdatePath(value) {
176
176
  return entryPath === 'extension-runtime/runtime-manifest.json'
177
177
  || /^extension-runtime\/src\/.+/.test(entryPath)
178
178
  || /^extension-runtime\/styles\/.+/.test(entryPath)
179
- || /^extension-runtime\/vendor\/katex\/(?:LICENSE|VERSION|katex\.min\.(?:css|js)|fonts\/[A-Za-z0-9_.-]+\.(?:ttf|woff2?))$/.test(entryPath)
179
+ || /^extension-runtime\/vendor\/.+/.test(entryPath)
180
+ || /^extension-runtime\/assets\/.+/.test(entryPath)
180
181
  || entryPath === 'native-runtime/package.json'
181
- || /^native-runtime\/native-host\/src\/.+/.test(entryPath)
182
+ || /^native-runtime\/native-host\/.+/.test(entryPath)
182
183
  || /^native-runtime\/extension\/src\/shared\/.+/.test(entryPath)
183
- || /^native-runtime\/scripts\/(?:codex-json-agent|install-native-host|uninstall-native-host)\.mjs$/.test(entryPath);
184
+ || /^native-runtime\/scripts\/.+/.test(entryPath);
184
185
  }
185
186
 
186
187
  function safeArchiveTarget(root, entryPath) {
@@ -20,7 +20,11 @@ const JOURNAL_FILE = 'transaction.json';
20
20
  const CANDIDATE_FILE = 'candidate.json';
21
21
  const COOLDOWN_FILE = 'cooldowns.json';
22
22
  const AUTHORIZATION_FILE = 'authorization.json';
23
+ const MUTATION_LOCK_FILE = 'mutation.lock';
23
24
  const AUTHORIZATION_TTL_MS = 2 * 60 * 60 * 1000;
25
+ const MUTATION_LOCK_WAIT_MS = 10 * 1000;
26
+ const MUTATION_LOCK_STALE_MS = 15 * 60 * 1000;
27
+ const ACTIVATION_DELAY_MS = 125;
24
28
  const CONSENT_MIGRATION_TARGET_VERSION = '1.9.4';
25
29
  const UPDATE_METHODS = new Set([
26
30
  'update.status',
@@ -29,10 +33,19 @@ const UPDATE_METHODS = new Set([
29
33
  'update.stage',
30
34
  'update.canApply',
31
35
  'update.apply',
36
+ 'update.activate',
32
37
  'update.confirm',
33
38
  'update.rollback',
34
39
  'update.revoke'
35
40
  ]);
41
+ const LAYOUT_GATED_METHODS = new Set([
42
+ 'update.check',
43
+ 'update.authorize',
44
+ 'update.stage',
45
+ 'update.apply',
46
+ 'update.activate',
47
+ 'update.confirm'
48
+ ]);
36
49
  const RELEASE_ASSET_HOSTS = new Set([
37
50
  'github.com',
38
51
  'objects.githubusercontent.com',
@@ -53,25 +66,30 @@ async function handleUpdateRequest(request, options = {}) {
53
66
  if (!context.managed) {
54
67
  throw updateError('update_not_managed', 'Run install-managed once to enable coordinated automatic updates.');
55
68
  }
69
+ if (LAYOUT_GATED_METHODS.has(request.method)) {
70
+ assertManagedLayout(context);
71
+ }
56
72
  switch (request.method) {
57
73
  case 'update.status':
58
- return okResponse(request.id, await withUpdateMutex(() => recoverAndReadStatus(context)));
74
+ return okResponse(request.id, await withUpdateMutex(context, () => recoverAndReadStatus(context)));
59
75
  case 'update.check':
60
- return okResponse(request.id, await withUpdateMutex(() => checkForUpdate(context, request.params || {}, options)));
76
+ return okResponse(request.id, await withUpdateMutex(context, () => checkForUpdate(context, request.params || {}, options)));
61
77
  case 'update.authorize':
62
- return okResponse(request.id, await withUpdateMutex(() => authorizeUpdate(context, request.params || {})));
78
+ return okResponse(request.id, await withUpdateMutex(context, () => authorizeUpdate(context, request.params || {})));
63
79
  case 'update.stage':
64
- return okResponse(request.id, await withUpdateMutex(() => stageCandidate(context, options)));
80
+ return okResponse(request.id, await withUpdateMutex(context, () => stageCandidate(context, options)));
65
81
  case 'update.canApply':
66
82
  return okResponse(request.id, getApplyGate(options));
67
83
  case 'update.apply':
68
- return okResponse(request.id, await withUpdateMutex(() => applyStagedUpdate(context, request.params || {}, options)));
84
+ return okResponse(request.id, await withUpdateMutex(context, () => applyStagedUpdate(context, request.params || {}, options)));
85
+ case 'update.activate':
86
+ return okResponse(request.id, await withUpdateMutex(context, () => scheduleStagedActivation(context, request.params || {}, options)));
69
87
  case 'update.confirm':
70
- return okResponse(request.id, await withUpdateMutex(() => confirmUpdate(context, request.params || {})));
88
+ return okResponse(request.id, await withUpdateMutex(context, () => confirmUpdate(context, request.params || {})));
71
89
  case 'update.rollback':
72
- return okResponse(request.id, await withUpdateMutex(() => rollbackUpdate(context, request.params || {})));
90
+ return okResponse(request.id, await withUpdateMutex(context, () => rollbackUpdate(context, request.params || {})));
73
91
  case 'update.revoke':
74
- return okResponse(request.id, await withUpdateMutex(() => revokeUpdate(context, request.params || {})));
92
+ return okResponse(request.id, await withUpdateMutex(context, () => revokeUpdate(context, request.params || {})));
75
93
  default:
76
94
  throw updateError('unknown_update_method', 'Unknown managed update method.');
77
95
  }
@@ -80,12 +98,82 @@ async function handleUpdateRequest(request, options = {}) {
80
98
  }
81
99
  }
82
100
 
83
- function withUpdateMutex(action) {
84
- const result = updateMutationTail.then(action, action);
101
+ function withUpdateMutex(context, action) {
102
+ const guardedAction = () => withUpdateFileLock(context, action);
103
+ const result = updateMutationTail.then(guardedAction, guardedAction);
85
104
  updateMutationTail = result.catch(() => {});
86
105
  return result;
87
106
  }
88
107
 
108
+ async function withUpdateFileLock(context, action) {
109
+ const lock = await acquireUpdateFileLock(context);
110
+ try {
111
+ return await action();
112
+ } finally {
113
+ releaseUpdateFileLock(lock);
114
+ }
115
+ }
116
+
117
+ async function acquireUpdateFileLock(context) {
118
+ const lockPath = path.join(context.updatesRoot, MUTATION_LOCK_FILE);
119
+ const deadline = Date.now() + MUTATION_LOCK_WAIT_MS;
120
+ const token = crypto.randomUUID();
121
+ while (Date.now() <= deadline) {
122
+ try {
123
+ const descriptor = fs.openSync(lockPath, 'wx', 0o600);
124
+ try {
125
+ fs.writeFileSync(descriptor, JSON.stringify({
126
+ token,
127
+ pid: process.pid,
128
+ createdAt: Date.now(),
129
+ expiresAt: Date.now() + MUTATION_LOCK_STALE_MS
130
+ }) + '\n');
131
+ } finally {
132
+ fs.closeSync(descriptor);
133
+ }
134
+ return { lockPath, token };
135
+ } catch (error) {
136
+ if (error?.code !== 'EEXIST') throw error;
137
+ if (removeStaleUpdateFileLock(lockPath)) continue;
138
+ await delay(75);
139
+ }
140
+ }
141
+ throw updateError('update_transaction_locked', 'Another Native Host process is already changing the managed update state.');
142
+ }
143
+
144
+ function removeStaleUpdateFileLock(lockPath) {
145
+ const owner = readJsonSafe(lockPath, null);
146
+ const expired = !owner || Number(owner.expiresAt || 0) <= Date.now();
147
+ const abandoned = Number.isInteger(Number(owner?.pid)) && !isProcessAlive(Number(owner.pid));
148
+ if (!expired && !abandoned) return false;
149
+ try {
150
+ fs.rmSync(lockPath, { force: true });
151
+ return true;
152
+ } catch (_error) {
153
+ return false;
154
+ }
155
+ }
156
+
157
+ function releaseUpdateFileLock(lock) {
158
+ if (!lock?.lockPath || !lock.token) return;
159
+ const owner = readJsonSafe(lock.lockPath, null);
160
+ if (owner?.token === lock.token) fs.rmSync(lock.lockPath, { force: true });
161
+ }
162
+
163
+ function isProcessAlive(pid) {
164
+ if (!Number.isInteger(pid) || pid <= 0) return false;
165
+ try {
166
+ process.kill(pid, 0);
167
+ return true;
168
+ } catch (error) {
169
+ return error?.code === 'EPERM';
170
+ }
171
+ }
172
+
173
+ function delay(milliseconds) {
174
+ return new Promise(resolve => setTimeout(resolve, milliseconds));
175
+ }
176
+
89
177
  async function checkForUpdate(context, params = {}, options = {}) {
90
178
  const fetchImpl = options.fetch || globalThis.fetch;
91
179
  if (typeof fetchImpl !== 'function') {
@@ -105,6 +193,27 @@ async function checkForUpdate(context, params = {}, options = {}) {
105
193
  redirect: 'follow'
106
194
  }, 12000);
107
195
  if (releaseResponse.status === 304) {
196
+ const candidate = readJsonSafe(path.join(context.updatesRoot, CANDIDATE_FILE), null);
197
+ if (candidate?.manifestBase64 && candidate?.signatureBase64) {
198
+ try {
199
+ const manifest = verifySignedReleaseManifest(
200
+ Buffer.from(candidate.manifestBase64, 'base64'),
201
+ Buffer.from(candidate.signatureBase64, 'base64')
202
+ );
203
+ if (isNewerStableVersion(manifest.version, currentVersion)) {
204
+ return {
205
+ managed: true,
206
+ available: true,
207
+ currentVersion,
208
+ latestVersion: manifest.version,
209
+ etag: params.etag || candidate.etag || '',
210
+ cached: true
211
+ };
212
+ }
213
+ } catch (_error) {
214
+ // Invalid cached candidates are treated as cache misses.
215
+ }
216
+ }
108
217
  return { managed: true, available: false, reason: 'not_modified', currentVersion, etag: params.etag || '' };
109
218
  }
110
219
  if (!releaseResponse.ok) {
@@ -116,10 +225,15 @@ async function checkForUpdate(context, params = {}, options = {}) {
116
225
  }
117
226
  const latestVersion = releaseTag.slice(1);
118
227
  if (!isNewerStableVersion(latestVersion, currentVersion)) {
228
+ const activeMarker = readJsonSafe(path.join(context.extensionRoot, EXTENSION_MARKER), null);
229
+ const equalVersionPrerelease = compareSemver(latestVersion, currentVersion) === 0
230
+ && activeMarker?.releaseChannel === 'prerelease';
119
231
  return {
120
232
  managed: true,
121
233
  available: false,
122
- reason: compareSemver(latestVersion, currentVersion) === 0 ? 'up_to_date' : 'downgrade_rejected',
234
+ reason: equalVersionPrerelease
235
+ ? 'prerelease_promotion_requires_manual_update'
236
+ : (compareSemver(latestVersion, currentVersion) === 0 ? 'up_to_date' : 'downgrade_rejected'),
123
237
  currentVersion,
124
238
  latestVersion,
125
239
  etag: releaseResponse.headers.get('etag') || ''
@@ -243,6 +357,7 @@ async function stageCandidate(context, options = {}) {
243
357
  throw updateError('update_consent_mismatch', 'Staged transaction does not match the active authorization.');
244
358
  }
245
359
  try {
360
+ verifyStagedArchive(existingJournal);
246
361
  verifyStagedPair(existingJournal.payloadRoot, manifest.version);
247
362
  bindAuthorization(context, authorization, existingJournal.id);
248
363
  return {
@@ -251,11 +366,18 @@ async function stageCandidate(context, options = {}) {
251
366
  state: existingJournal.state,
252
367
  reused: true
253
368
  };
254
- } catch (_error) {
369
+ } catch (error) {
255
370
  cleanupStage(existingJournal.stageRoot);
371
+ removeJournal(context);
372
+ settleAuthorization(context, authorization.id, 'revoked');
373
+ throw updateError(
374
+ safeErrorCode(error) || 'update_staged_payload_invalid',
375
+ 'The previously staged update is no longer valid. Choose Update now to download it again.',
376
+ { cause: error }
377
+ );
256
378
  }
257
379
  }
258
- if (existingJournal && ['applying', 'awaiting_health'].includes(existingJournal.state)) {
380
+ if (existingJournal && ['activation_pending', 'applying', 'awaiting_health'].includes(existingJournal.state)) {
259
381
  throw updateError('update_transaction_in_progress', 'A managed update transaction is already being applied.');
260
382
  }
261
383
  if (authorization.state === 'bound') {
@@ -288,6 +410,9 @@ async function stageCandidate(context, options = {}) {
288
410
  targetVersion: manifest.version,
289
411
  stageRoot,
290
412
  payloadRoot,
413
+ archivePath,
414
+ bundleSize: manifest.updateBundle.size,
415
+ bundleSha256: manifest.updateBundle.sha256,
291
416
  manifestSha256: crypto.createHash('sha256').update(manifestBytes).digest('hex'),
292
417
  createdAt: new Date().toISOString(),
293
418
  updatedAt: new Date().toISOString()
@@ -315,13 +440,67 @@ function getApplyGate(options = {}) {
315
440
  return { idle: blockers.length === 0, blockers, workState: state };
316
441
  }
317
442
 
318
- function applyStagedUpdate(context, params = {}, options = {}) {
443
+ function scheduleStagedActivation(context, params = {}, options = {}) {
319
444
  const gate = getApplyGate(options);
320
445
  if (!gate.idle) {
321
446
  throw updateError('update_native_busy', 'Native host is still processing a Codex task.');
322
447
  }
448
+ assertManagedLayout(context);
323
449
  const journal = readJournal(context);
324
450
  if (!journal || journal.state !== 'staged') {
451
+ throw updateError('update_not_staged', 'No verified update is ready to activate.');
452
+ }
453
+ if (params.transactionId && params.transactionId !== journal.id) {
454
+ throw updateError('update_transaction_mismatch', 'Update transaction id does not match the staged update.');
455
+ }
456
+ const authorization = requireAuthorization(context, journal.targetVersion, ['bound']);
457
+ if (journal.authorizationId !== authorization.id || authorization.transactionId !== journal.id) {
458
+ throw updateError('update_consent_mismatch', 'Activation transaction does not match the bound authorization.');
459
+ }
460
+ const pending = {
461
+ ...journal,
462
+ state: 'activation_pending',
463
+ activationRequestedAt: new Date().toISOString(),
464
+ updatedAt: new Date().toISOString()
465
+ };
466
+ writeJournal(context, pending);
467
+ setTimeout(() => {
468
+ void withUpdateMutex(context, () => applyStagedUpdate(context, {
469
+ transactionId: pending.id
470
+ }, {
471
+ ...options,
472
+ allowActivationPending: true
473
+ })).catch(error => recordScheduledActivationFailure(context, pending, error));
474
+ }, ACTIVATION_DELAY_MS);
475
+ return {
476
+ transactionId: pending.id,
477
+ targetVersion: pending.targetVersion,
478
+ state: pending.state
479
+ };
480
+ }
481
+
482
+ function recordScheduledActivationFailure(context, pending, error) {
483
+ const current = readJournal(context);
484
+ if (!current || current.id !== pending.id || ['awaiting_health', 'rolled_back'].includes(current.state)) return;
485
+ writeJournal(context, {
486
+ ...current,
487
+ state: 'rolled_back',
488
+ reasonCode: safeErrorCode(error),
489
+ rolledBackAt: new Date().toISOString(),
490
+ updatedAt: new Date().toISOString()
491
+ });
492
+ settleAuthorization(context, pending.authorizationId, 'revoked');
493
+ }
494
+
495
+ function applyStagedUpdate(context, params = {}, options = {}) {
496
+ const gate = getApplyGate(options);
497
+ if (!gate.idle) {
498
+ throw updateError('update_native_busy', 'Native host is still processing a Codex task.');
499
+ }
500
+ assertManagedLayout(context);
501
+ const journal = readJournal(context);
502
+ const allowedStates = options.allowActivationPending ? ['activation_pending'] : ['staged'];
503
+ if (!journal || !allowedStates.includes(journal.state)) {
325
504
  throw updateError('update_not_staged', 'No verified update is ready to apply.');
326
505
  }
327
506
  if (params.transactionId && params.transactionId !== journal.id) {
@@ -331,15 +510,42 @@ function applyStagedUpdate(context, params = {}, options = {}) {
331
510
  if (journal.authorizationId !== authorization.id || authorization.transactionId !== journal.id) {
332
511
  throw updateError('update_consent_mismatch', 'Apply transaction does not match the bound authorization.');
333
512
  }
513
+ const verifiedPayloadRoot = path.join(journal.stageRoot, 'payload-apply');
514
+ try {
515
+ verifyStagedArchive(journal);
516
+ fs.rmSync(verifiedPayloadRoot, { recursive: true, force: true });
517
+ extractVerifiedUpdateBundle({
518
+ archivePath: journal.archivePath,
519
+ destinationRoot: verifiedPayloadRoot
520
+ });
521
+ verifyStagedPair(verifiedPayloadRoot, journal.targetVersion);
522
+ } catch (error) {
523
+ cleanupStage(journal.stageRoot);
524
+ writeJournal(context, {
525
+ ...journal,
526
+ state: 'rolled_back',
527
+ reasonCode: safeErrorCode(error) || 'update_staged_payload_invalid',
528
+ rolledBackAt: new Date().toISOString(),
529
+ updatedAt: new Date().toISOString()
530
+ });
531
+ settleAuthorization(context, journal.authorizationId, 'revoked');
532
+ throw error;
533
+ }
334
534
  assertManagedMarkers(context);
335
535
  const extensionRuntime = path.join(context.extensionRoot, 'runtime');
336
536
  const previousRuntime = path.join(context.extensionRoot, 'slots', 'previous', 'runtime');
337
- const stagedExtension = path.join(journal.payloadRoot, 'extension-runtime');
338
- const stagedNative = path.join(journal.payloadRoot, 'native-runtime');
537
+ const stagedExtension = path.join(verifiedPayloadRoot, 'extension-runtime');
538
+ const stagedNative = path.join(verifiedPayloadRoot, 'native-runtime');
339
539
  const targetNative = path.join(context.nativeRoot, 'versions', journal.targetVersion);
340
540
  const manifestPath = path.join(context.extensionRoot, 'manifest.json');
341
541
  const previousManifest = fs.readFileSync(manifestPath, 'utf8');
342
- writeJournal(context, { ...journal, state: 'applying', previousManifest, updatedAt: new Date().toISOString() });
542
+ writeJournal(context, {
543
+ ...journal,
544
+ payloadRoot: verifiedPayloadRoot,
545
+ state: 'applying',
546
+ previousManifest,
547
+ updatedAt: new Date().toISOString()
548
+ });
343
549
 
344
550
  try {
345
551
  fs.rmSync(path.dirname(previousRuntime), { recursive: true, force: true });
@@ -354,11 +560,20 @@ function applyStagedUpdate(context, params = {}, options = {}) {
354
560
  syncManagedMarkerVersions(context, journal.targetVersion);
355
561
  } catch (error) {
356
562
  rollbackFiles(context, { ...journal, previousManifest });
563
+ writeJournal(context, {
564
+ ...journal,
565
+ previousManifest,
566
+ state: 'rolled_back',
567
+ reasonCode: 'update_apply_failed',
568
+ rolledBackAt: new Date().toISOString(),
569
+ updatedAt: new Date().toISOString()
570
+ });
357
571
  settleAuthorization(context, journal.authorizationId, 'revoked');
358
572
  throw updateError('update_apply_failed', 'Managed update could not be applied atomically.', { cause: error });
359
573
  }
360
574
  const awaiting = {
361
575
  ...journal,
576
+ payloadRoot: verifiedPayloadRoot,
362
577
  previousManifest,
363
578
  state: 'awaiting_health',
364
579
  appliedAt: new Date().toISOString(),
@@ -436,7 +651,7 @@ function revokeUpdate(context, params = {}) {
436
651
  }
437
652
  const journal = readJournal(context);
438
653
  if (journal?.targetVersion === targetVersion &&
439
- ['applying', 'awaiting_health', 'committed'].includes(journal.state)) {
654
+ ['activation_pending', 'applying', 'awaiting_health', 'committed'].includes(journal.state)) {
440
655
  throw updateError('update_revoke_too_late', 'The update is already being applied.');
441
656
  }
442
657
  const authorization = readAuthorization(context);
@@ -528,6 +743,10 @@ function cleanupOrphanStageRoots(updatesRoot, retainedRoots = []) {
528
743
 
529
744
  function recoverAndReadStatus(context) {
530
745
  let journal = readJournal(context);
746
+ if (journal?.state === 'activation_pending') {
747
+ journal = { ...journal, state: 'staged', reasonCode: 'update_activation_interrupted', updatedAt: new Date().toISOString() };
748
+ writeJournal(context, journal);
749
+ }
531
750
  if (journal?.state === 'applying') {
532
751
  rollbackFiles(context, journal);
533
752
  journal = { ...journal, state: 'rolled_back', reasonCode: 'update_interrupted', updatedAt: new Date().toISOString() };
@@ -582,8 +801,36 @@ function assertManagedMarkers(context) {
582
801
  if (!context.managed) throw updateError('update_not_managed', 'Managed installation markers are missing.');
583
802
  }
584
803
 
804
+ function assertManagedLayout(context) {
805
+ assertManagedMarkers(context);
806
+ const activeVersion = readVersionPointer(context.nativeRoot, 'active-version');
807
+ const nativeMarker = readJsonSafe(path.join(context.nativeRoot, NATIVE_MARKER), null);
808
+ const extensionMarker = readJsonSafe(path.join(context.extensionRoot, EXTENSION_MARKER), null);
809
+ const manifest = readJsonSafe(path.join(context.extensionRoot, 'manifest.json'), null);
810
+ const requiredFiles = [
811
+ path.join(context.extensionRoot, 'bootstrap', 'background.js'),
812
+ path.join(context.extensionRoot, 'runtime', 'runtime-manifest.json')
813
+ ];
814
+ const valid = Boolean(
815
+ parseSemver(activeVersion) &&
816
+ nativeMarker?.version === activeVersion &&
817
+ extensionMarker?.version === activeVersion &&
818
+ manifest?.version === activeVersion &&
819
+ manifest?.manifest_version === 3 &&
820
+ manifest?.background?.service_worker === 'bootstrap/background.js' &&
821
+ requiredFiles.every(filePath => fs.existsSync(filePath) && fs.statSync(filePath).isFile())
822
+ );
823
+ if (!valid) {
824
+ throw updateError(
825
+ 'update_managed_layout_invalid',
826
+ 'Managed extension files do not match the active Native Host. Run install-managed for the latest stable version, then reload the Chrome extension and Overleaf.'
827
+ );
828
+ }
829
+ return activeVersion;
830
+ }
831
+
585
832
  function isManagedMarker(marker, kind) {
586
- return marker?.managedBy === 'codex-overleaf-link' && marker?.kind === kind && marker?.bootstrapProtocol === 1;
833
+ return marker?.managedBy === 'codex-overleaf-link' && marker?.kind === kind && marker?.bootstrapProtocol === 2;
587
834
  }
588
835
 
589
836
  function verifyStagedPair(payloadRoot, targetVersion) {
@@ -597,6 +844,43 @@ function verifyStagedPair(payloadRoot, targetVersion) {
597
844
  if (match?.[1] !== targetVersion) {
598
845
  throw updateError('update_extension_version_mismatch', 'Staged extension runtime version does not match the signed release.');
599
846
  }
847
+ const extensionRuntimeRoot = path.join(payloadRoot, 'extension-runtime');
848
+ const runtimeManifest = readJsonSafe(path.join(extensionRuntimeRoot, 'runtime-manifest.json'), null);
849
+ const runtimeFiles = [
850
+ ...(Array.isArray(runtimeManifest?.js) ? runtimeManifest.js : []),
851
+ ...(Array.isArray(runtimeManifest?.css) ? runtimeManifest.css : [])
852
+ ];
853
+ if (!runtimeFiles.length) {
854
+ throw updateError('update_runtime_manifest_invalid', 'Staged runtime manifest does not declare any runtime files.');
855
+ }
856
+ for (const relativePath of runtimeFiles) {
857
+ const normalized = String(relativePath || '').replace(/\\/g, '/');
858
+ if (!normalized || normalized.startsWith('/') ||
859
+ normalized.split('/').some(part => !part || part === '.' || part === '..')) {
860
+ throw updateError('update_runtime_manifest_invalid', 'Staged runtime manifest contains an invalid file path.');
861
+ }
862
+ const filePath = path.resolve(extensionRuntimeRoot, ...normalized.split('/'));
863
+ const relative = path.relative(extensionRuntimeRoot, filePath);
864
+ if (!relative || relative.startsWith('..') || path.isAbsolute(relative) ||
865
+ !fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) {
866
+ throw updateError('update_runtime_asset_missing', 'Staged runtime manifest references a missing file: ' + normalized);
867
+ }
868
+ }
869
+ }
870
+
871
+ function verifyStagedArchive(journal) {
872
+ if (!journal?.archivePath || !Number.isSafeInteger(journal.bundleSize) ||
873
+ !/^[0-9a-f]{64}$/.test(String(journal.bundleSha256 || ''))) {
874
+ throw updateError('update_staged_archive_invalid', 'Staged update archive metadata is incomplete. Download the update again.');
875
+ }
876
+ const bytes = fs.readFileSync(journal.archivePath);
877
+ if (bytes.length !== journal.bundleSize) {
878
+ throw updateError('update_bundle_size_mismatch', 'Staged update bundle size changed after verification.');
879
+ }
880
+ const hash = crypto.createHash('sha256').update(bytes).digest('hex');
881
+ if (hash !== journal.bundleSha256) {
882
+ throw updateError('update_bundle_hash_mismatch', 'Staged update bundle changed after verification.');
883
+ }
600
884
  }
601
885
 
602
886
  function normalizeCurrentVersion(value, context) {
@@ -672,6 +956,7 @@ function publicTransaction(journal) {
672
956
  sourceVersion: journal.sourceVersion,
673
957
  targetVersion: journal.targetVersion,
674
958
  createdAt: journal.createdAt,
959
+ activationRequestedAt: journal.activationRequestedAt,
675
960
  appliedAt: journal.appliedAt,
676
961
  confirmedAt: journal.confirmedAt,
677
962
  rolledBackAt: journal.rolledBackAt,
@@ -816,6 +1101,7 @@ function errorResponse(id, code, message) {
816
1101
  module.exports = {
817
1102
  GITHUB_LATEST_URL,
818
1103
  applyStagedUpdate,
1104
+ assertManagedLayout,
819
1105
  authorizeUpdate,
820
1106
  checkForUpdate,
821
1107
  cleanupOrphanStageRoots,
@@ -826,6 +1112,7 @@ module.exports = {
826
1112
  isUpdateMethod,
827
1113
  revokeUpdate,
828
1114
  rollbackUpdate,
1115
+ scheduleStagedActivation,
829
1116
  stageCandidate,
830
1117
  syncManagedMarkerVersions
831
1118
  };
@@ -5,7 +5,7 @@ const crypto = require('node:crypto');
5
5
  const UPDATE_REPOSITORY = 'Ghqqqq/codex-overleaf-link';
6
6
  const UPDATE_CHANNEL = 'stable';
7
7
  const UPDATE_MANIFEST_SCHEMA = 2;
8
- const BOOTSTRAP_PROTOCOL = 1;
8
+ const BOOTSTRAP_PROTOCOL = 2;
9
9
  const TRUSTED_UPDATE_KEYS = Object.freeze({
10
10
  'release-2026-01': [
11
11
  '-----BEGIN PUBLIC KEY-----',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codex-overleaf-link",
3
- "version": "2.1.5",
3
+ "version": "2.2.1",
4
4
  "description": "Cross-platform Chrome bridge that connects Codex to the active Overleaf project.",
5
5
  "license": "MIT",
6
6
  "type": "commonjs",
@@ -20,6 +20,8 @@
20
20
  "smoke:extension": "node scripts/smoke-extension.mjs",
21
21
  "verify:release": "node scripts/verify-release.mjs",
22
22
  "verify:release-artifacts": "node scripts/verify-release-artifacts.mjs",
23
+ "verify:update-boundary": "node scripts/verify-update-boundary.mjs",
24
+ "verify:update-hop": "node scripts/verify-update-hop.mjs",
23
25
  "build:release": "node scripts/build-release.mjs",
24
26
  "sign:release": "node scripts/sign-release-manifest.mjs",
25
27
  "install:managed": "node scripts/install-managed.mjs",