codex-overleaf-link 2.1.5 → 2.2.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 +27 -23
- package/extension/bootstrap/background.js +14 -1
- package/extension/bootstrap/manifest.template.json +1 -1
- package/extension/runtime-manifest.json +10 -0
- package/extension/src/background.js +507 -8
- package/extension/src/backgroundUpdateCoordinator.js +284 -19
- package/extension/src/content/activeTurnControl.js +179 -0
- package/extension/src/content/composerPanel.js +14 -5
- package/extension/src/content/contentRuntime.js +330 -309
- package/extension/src/content/diagnosticsPanel.js +132 -1
- package/extension/src/content/markdownDomRenderer.js +451 -0
- package/extension/src/content/markdownText.js +86 -7
- package/extension/src/content/mathText.js +49 -4
- package/extension/src/content/modelPicker.js +14 -5
- package/extension/src/content/nativeChannel.js +7 -1
- package/extension/src/content/panelRenderer.js +112 -12
- package/extension/src/content/pendingInputView.js +106 -0
- package/extension/src/content/projectProviderSelection.js +68 -0
- package/extension/src/content/projectSessionCleanup.js +4 -0
- package/extension/src/content/providerSettingsCoordinator.js +170 -22
- package/extension/src/content/providerSettingsDialog.js +2 -2
- package/extension/src/content/recentProjects.js +7 -2
- package/extension/src/content/runGuidanceController.js +70 -0
- package/extension/src/content/runGuidanceView.js +48 -0
- package/extension/src/content/runQueueScheduler.js +260 -0
- package/extension/src/content/runTimelineView.js +33 -80
- package/extension/src/content/sessionManager.js +2 -2
- package/extension/src/content/sessionPersistence.js +62 -0
- package/extension/src/content/settingsPanel.js +1 -1
- package/extension/src/content/updateNotice.js +80 -1
- package/extension/src/page/writebackRouter.js +3 -9
- package/extension/src/shared/compatibility.js +2 -1
- package/extension/src/shared/i18n.js +38 -0
- package/extension/src/shared/providerProfiles.js +3 -2
- package/extension/src/shared/runInputQueue.js +253 -0
- package/extension/src/shared/sessionState.js +45 -22
- package/extension/src/shared/storageDb.js +39 -44
- package/extension/src/shared/storageMigration.js +123 -3
- package/extension/src/shared/storageRunActions.js +34 -1
- package/extension/src/shared/updateConsent.js +15 -4
- package/extension/styles/panel.css +319 -20
- package/extension/vendor/katex/katex.min.css +1 -1
- package/extension/vendor/markdown-it/LICENSE +22 -0
- package/extension/vendor/markdown-it/SOURCE.json +7 -0
- package/extension/vendor/markdown-it/VERSION +1 -0
- package/extension/vendor/markdown-it/markdown-it.min.js +3 -0
- package/native-host/src/anthropicBridgeRequest.js +3 -3
- package/native-host/src/anthropicBridgeResponse.js +58 -17
- package/native-host/src/anthropicMessagesBridge.js +156 -23
- package/native-host/src/chatBridgeRequest.js +1 -1
- package/native-host/src/chatBridgeResponse.js +12 -8
- package/native-host/src/chatCompletionsBridge.js +22 -40
- package/native-host/src/codexPromptAssembly.js +3 -1
- package/native-host/src/codexProviderLaunch.js +6 -3
- package/native-host/src/codexSessionRunner.js +65 -65
- package/native-host/src/codexSessionTiming.js +45 -0
- package/native-host/src/index.js +58 -3
- package/native-host/src/managedInstall.js +45 -7
- package/native-host/src/providerProfile.js +2 -2
- package/native-host/src/providerStreamLifecycle.js +100 -0
- package/native-host/src/responsesPassthroughBridge.js +636 -0
- package/native-host/src/taskRunnerRuntime.js +81 -2
- package/native-host/src/updateArchive.js +4 -3
- package/native-host/src/updateManager.js +306 -19
- package/native-host/src/updateTrust.js +1 -1
- 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
|
-
|
|
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
|
|
179
|
+
|| /^extension-runtime\/vendor\/.+/.test(entryPath)
|
|
180
|
+
|| /^extension-runtime\/assets\/.+/.test(entryPath)
|
|
180
181
|
|| entryPath === 'native-runtime/package.json'
|
|
181
|
-
|| /^native-runtime\/native-host
|
|
182
|
+
|| /^native-runtime\/native-host\/.+/.test(entryPath)
|
|
182
183
|
|| /^native-runtime\/extension\/src\/shared\/.+/.test(entryPath)
|
|
183
|
-
|| /^native-runtime\/scripts
|
|
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
|
|
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:
|
|
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 (
|
|
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
|
|
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(
|
|
338
|
-
const stagedNative = path.join(
|
|
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, {
|
|
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 ===
|
|
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 =
|
|
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.
|
|
3
|
+
"version": "2.2.0",
|
|
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",
|