codex-overleaf-link 2.5.0 → 2.5.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 (168) hide show
  1. package/README.md +38 -26
  2. package/README.zh-CN.md +39 -27
  3. package/extension/bootstrap/background.js +52 -9
  4. package/extension/bootstrap/manifest.template.json +1 -1
  5. package/extension/bootstrap/popup.html +1 -0
  6. package/extension/bootstrap/popupFallback.js +55 -0
  7. package/extension/bootstrap/updateStatus.js +1 -0
  8. package/extension/src/background.js +206 -53
  9. package/extension/src/backgroundBootstrapMigration.js +181 -0
  10. package/extension/src/backgroundSharedSessions.js +74 -14
  11. package/extension/src/backgroundUpdateCoordinator.js +118 -21
  12. package/extension/src/backgroundUpdateSafety.js +173 -0
  13. package/extension/src/content/activeTurnControl.js +8 -2
  14. package/extension/src/content/applyResultFormatters.js +46 -73
  15. package/extension/src/content/assetTransferBroker.js +72 -14
  16. package/extension/src/content/composerAttachments.js +93 -45
  17. package/extension/src/content/composerPanel.js +18 -0
  18. package/extension/src/content/contentRuntime.js +661 -676
  19. package/extension/src/content/diagnosticsController.js +122 -96
  20. package/extension/src/content/diagnosticsExport.js +19 -5
  21. package/extension/src/content/diagnosticsPanel.js +30 -0
  22. package/extension/src/content/directoryCleanupRecovery.js +54 -0
  23. package/extension/src/content/generated/content.bundle.js +6879 -2786
  24. package/extension/src/content/generated/content.bundle.meta.json +11 -3
  25. package/extension/src/content/globalPreferencesController.js +1 -1
  26. package/extension/src/content/legacyGlobalRegistry.js +6 -0
  27. package/extension/src/content/localSkillsPanel.js +102 -12
  28. package/extension/src/content/markdownText.js +5 -6
  29. package/extension/src/content/modelPicker.js +7 -4
  30. package/extension/src/content/nativeCompatibilityController.js +45 -20
  31. package/extension/src/content/otWarmMirror.js +4 -5
  32. package/extension/src/content/otWarmMirrorController.js +17 -20
  33. package/extension/src/content/pageBridgeClient.js +15 -2
  34. package/extension/src/content/panelMaintenance.js +40 -12
  35. package/extension/src/content/panelRenderer.js +6 -0
  36. package/extension/src/content/projectProviderSelection.js +13 -4
  37. package/extension/src/content/projectSessionCleanup.js +109 -62
  38. package/extension/src/content/projectSettingsCoordinator.js +39 -2
  39. package/extension/src/content/providerSettingsCoordinator.js +46 -8
  40. package/extension/src/content/providerSettingsDialog.js +71 -13
  41. package/extension/src/content/recentProjects.js +11 -5
  42. package/extension/src/content/runActivitySummary.js +40 -6
  43. package/extension/src/content/runCompletionMeta.js +344 -0
  44. package/extension/src/content/runController.js +37 -14
  45. package/extension/src/content/runFailureNotice.js +21 -12
  46. package/extension/src/content/runGuidanceController.js +72 -27
  47. package/extension/src/content/runGuidanceView.js +59 -10
  48. package/extension/src/content/runQueueScheduler.js +148 -42
  49. package/extension/src/content/runRecoveryActions.js +146 -0
  50. package/extension/src/content/runResultActions.js +8 -125
  51. package/extension/src/content/runReviewController.js +574 -0
  52. package/extension/src/content/runScrollLayout.js +21 -2
  53. package/extension/src/content/runTimelineView.js +146 -153
  54. package/extension/src/content/scopedPersistencePanelState.js +51 -6
  55. package/extension/src/content/selectionContextView.js +1 -1
  56. package/extension/src/content/sensitivePreflight.js +140 -0
  57. package/extension/src/content/sessionForkController.js +80 -17
  58. package/extension/src/content/sessionManager.js +1 -1
  59. package/extension/src/content/settingsPanel.js +7 -3
  60. package/extension/src/content/settingsWorkbench.js +5 -0
  61. package/extension/src/content/sharedSessionViewSync.js +45 -8
  62. package/extension/src/content/updateIdle.js +4 -1
  63. package/extension/src/content/updateNotice.js +60 -8
  64. package/extension/src/content/updateSafetyReceiver.js +58 -0
  65. package/extension/src/content/writebackController.js +53 -4
  66. package/extension/src/content/writebackOrchestrator.js +82 -73
  67. package/extension/src/content/writingStyleSettings.js +35 -11
  68. package/extension/src/page/binaryAssetUploader.js +136 -14
  69. package/extension/src/page/fileMover.js +256 -0
  70. package/extension/src/page/ownedDirectoryCleanup.js +94 -0
  71. package/extension/src/page/referenceProjects.js +1 -0
  72. package/extension/src/page/reviewContext.js +113 -0
  73. package/extension/src/page/saveState.js +6 -4
  74. package/extension/src/page/snapshotRouter.js +84 -39
  75. package/extension/src/page/textFileCreator.js +105 -25
  76. package/extension/src/page/trackedChangeCapture.js +12 -2
  77. package/extension/src/page/trackedChangeOwnership.js +19 -1
  78. package/extension/src/page/trackedChangeReplay.js +41 -21
  79. package/extension/src/page/trackedChangesLifecycle.js +217 -199
  80. package/extension/src/page/treeOperations.js +7 -10
  81. package/extension/src/page/updateSafetyProbe.js +41 -0
  82. package/extension/src/page/writebackReceiptJournal.js +2 -0
  83. package/extension/src/page/writebackRouter.js +80 -79
  84. package/extension/src/pageBridge.js +19 -3
  85. package/extension/src/popup.js +62 -10
  86. package/extension/src/shared/agentTranscript.js +23 -0
  87. package/extension/src/shared/auditRecords.js +2 -0
  88. package/extension/src/shared/compatibility.js +22 -6
  89. package/extension/src/shared/compileAdapter.js +79 -57
  90. package/extension/src/shared/composerAttachmentDraft.js +151 -0
  91. package/extension/src/shared/directoryCleanupProof.js +43 -0
  92. package/extension/src/shared/failureReasons.js +309 -3
  93. package/extension/src/shared/globalPreferences.js +30 -13
  94. package/extension/src/shared/governanceRules.js +4 -37
  95. package/extension/src/shared/i18n.js +298 -2
  96. package/extension/src/shared/managedUpdateProjection.js +2 -2
  97. package/extension/src/shared/nativeRequestIdentity.js +1 -0
  98. package/extension/src/shared/pageRpcContract.js +5 -4
  99. package/extension/src/shared/projectFiles.js +21 -44
  100. package/extension/src/shared/providerProfiles.js +5 -3
  101. package/extension/src/shared/runInputQueue.js +48 -5
  102. package/extension/src/shared/selectionContext.js +12 -3
  103. package/extension/src/shared/sessionState.js +72 -27
  104. package/extension/src/shared/settlementFacts.js +10 -2
  105. package/extension/src/shared/sharedSessionBridge.js +37 -14
  106. package/extension/src/shared/sharedSessionTransfer.js +140 -0
  107. package/extension/src/shared/staleGuard.js +118 -13
  108. package/extension/src/shared/storageDb.js +58 -26
  109. package/extension/src/shared/storageMigration.js +71 -111
  110. package/extension/src/shared/storageRunActions.js +209 -26
  111. package/extension/src/shared/subagentActivityModel.js +1 -1
  112. package/extension/src/shared/undoOperations.js +136 -13
  113. package/extension/src/shared/updateConsent.js +6 -4
  114. package/extension/src/shared/updateRuntimeIdentity.js +1 -1
  115. package/extension/src/shared/writebackIntent.js +16 -0
  116. package/extension/src/shared/writebackOperationEvidence.js +4 -15
  117. package/extension/src/shared/writebackSettlement.js +38 -38
  118. package/extension/styles/panel.css +19 -5
  119. package/native-host/src/anthropicBridgeRequest.js +4 -2
  120. package/native-host/src/anthropicBridgeResponse.js +6 -2
  121. package/native-host/src/anthropicMessagesBridge.js +24 -16
  122. package/native-host/src/bootstrapMigration.js +296 -0
  123. package/native-host/src/bootstrapMigrationContract.js +101 -0
  124. package/native-host/src/bootstrapUpdate.js +233 -0
  125. package/native-host/src/bootstrapUpdateWatchdog.js +44 -0
  126. package/native-host/src/chatBridgeResponse.js +10 -0
  127. package/native-host/src/chatCompletionsBridge.js +21 -15
  128. package/native-host/src/codexConfigPaths.js +106 -0
  129. package/native-host/src/codexHome.js +66 -73
  130. package/native-host/src/codexHomeScope.js +124 -0
  131. package/native-host/src/codexInheritedAuth.js +90 -0
  132. package/native-host/src/codexLocalSkills.js +301 -0
  133. package/native-host/src/codexPromptAssembly.js +31 -12
  134. package/native-host/src/codexProviderTest.js +24 -26
  135. package/native-host/src/codexSessionRunner.js +110 -183
  136. package/native-host/src/codexSessionTiming.js +18 -8
  137. package/native-host/src/codexSkillIsolation.js +170 -0
  138. package/native-host/src/codexThreadFork.js +19 -3
  139. package/native-host/src/index.js +19 -7
  140. package/native-host/src/managedInstall.js +110 -35
  141. package/native-host/src/managedInstallRecovery.js +140 -0
  142. package/native-host/src/managedRuntimeEvidence.js +57 -0
  143. package/native-host/src/mirrorWorkspace.js +92 -22
  144. package/native-host/src/nativeDoctor.js +15 -0
  145. package/native-host/src/nativeHelperLifecycle.js +21 -0
  146. package/native-host/src/nativeHostRegistration.js +33 -0
  147. package/native-host/src/nativeRunLifecycle.js +71 -0
  148. package/native-host/src/processStopBarrier.js +42 -7
  149. package/native-host/src/projectPathPlatform.js +30 -0
  150. package/native-host/src/providerHttp.js +80 -0
  151. package/native-host/src/providerProfile.js +4 -2
  152. package/native-host/src/providerReasoning.js +2 -7
  153. package/native-host/src/providerRuntime.js +1 -0
  154. package/native-host/src/providerStore.js +72 -14
  155. package/native-host/src/responsesPassthroughBridge.js +266 -52
  156. package/native-host/src/runtimeInstaller.js +21 -1
  157. package/native-host/src/selectionScope.js +36 -8
  158. package/native-host/src/subagentAdmission.js +26 -0
  159. package/native-host/src/subagentBroker.js +79 -66
  160. package/native-host/src/subagentTelemetry.js +19 -10
  161. package/native-host/src/subagentWorkspacePath.js +5 -2
  162. package/native-host/src/taskRunnerRuntime.js +77 -54
  163. package/native-host/src/tomlStatements.js +140 -0
  164. package/native-host/src/updateManager.js +297 -39
  165. package/native-host/src/writingStyleRuntime.js +57 -29
  166. package/native-host/src/writingStyleStore.js +73 -0
  167. package/package.json +1 -1
  168. package/scripts/install-managed.mjs +1 -0
@@ -0,0 +1,140 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+ const crypto = require('node:crypto');
6
+ const { updateError } = require('./updateTrust');
7
+ const { MUTATION_LOCK_STALE_MS, removeStaleUpdateFileLock } = require('./updateManager');
8
+
9
+ // A full install changes one Native version and a small fixed set of files.
10
+ // Retain just those destinations until registration and journal settlement
11
+ // succeed; this is in-process compensation, not crash recovery for the disk.
12
+ function createInstallRestorePoint({ nativeRoot, version, files }) {
13
+ const rootWasAbsent = !fs.existsSync(nativeRoot);
14
+ let existed = false;
15
+ const backup = path.join(path.dirname(nativeRoot), '.native.rollback-' + crypto.randomUUID());
16
+ const target = path.join(nativeRoot, 'versions', version);
17
+ let targetExisted = false;
18
+ let snapshots = [];
19
+ let lock = null;
20
+ let restored = false;
21
+ try {
22
+ lock = acquireInstallLock(nativeRoot);
23
+ existed = fs.existsSync(path.join(nativeRoot, '.codex-overleaf-managed-native.json'));
24
+ if (!existed && (fs.readdirSync(nativeRoot).some(name => name !== 'updates')
25
+ || fs.readdirSync(path.join(nativeRoot, 'updates')).some(name => name !== 'mutation.lock'))) {
26
+ throw new Error('Refusing to replace an unmarked managed native root: ' + nativeRoot);
27
+ }
28
+ targetExisted = fs.existsSync(target);
29
+ snapshots = files.map(filename => {
30
+ try {
31
+ const stat = fs.statSync(filename);
32
+ return { filename, bytes: fs.readFileSync(filename), mode: stat.mode & 0o777 };
33
+ } catch (error) {
34
+ if (error.code !== 'ENOENT') throw error;
35
+ return { filename, bytes: null };
36
+ }
37
+ });
38
+ if (targetExisted) fs.cpSync(target, backup, { recursive: true });
39
+ } catch (error) {
40
+ if (lock) releaseInstallLock(lock);
41
+ fs.rmSync(backup, { recursive: true, force: true });
42
+ if (rootWasAbsent) {
43
+ // Only remove directories still empty after our failed initialization;
44
+ // never remove another creator's lock, marker or runtime files.
45
+ try { fs.rmdirSync(path.join(nativeRoot, 'updates')); fs.rmdirSync(nativeRoot); } catch (_) { /* retained */ }
46
+ }
47
+ throw error;
48
+ }
49
+ return {
50
+ nativeExisted: existed,
51
+ restore() {
52
+ assertInstallLock(lock);
53
+ const errors = [];
54
+ const attempt = action => { try { action(); } catch (error) { errors.push(error); } };
55
+ attempt(() => {
56
+ fs.rmSync(target, { recursive: true, force: true });
57
+ if (targetExisted) fs.renameSync(backup, target);
58
+ });
59
+ for (const snapshot of snapshots) attempt(() => {
60
+ if (snapshot.bytes === null) fs.rmSync(snapshot.filename, { force: true });
61
+ else {
62
+ fs.mkdirSync(path.dirname(snapshot.filename), { recursive: true });
63
+ fs.writeFileSync(snapshot.filename, snapshot.bytes, { mode: snapshot.mode });
64
+ fs.chmodSync(snapshot.filename, snapshot.mode);
65
+ }
66
+ });
67
+ if (errors.length) throw updateError('managed_install_restore_failed',
68
+ 'Managed installation failed and the previous installation could not be fully restored.', { cause: errors[0] });
69
+ restored = true;
70
+ },
71
+ finish() {
72
+ let removeEmptyRoot = false;
73
+ try {
74
+ fs.rmSync(backup, { recursive: true, force: true });
75
+ if (restored && !existed) {
76
+ assertInstallLock(lock);
77
+ // The root was created by this install. Keep its mutation lock while
78
+ // removing our remaining staging/directories, including after all
79
+ // filesystem and browser-registration compensation has completed.
80
+ for (const name of fs.readdirSync(nativeRoot)) {
81
+ if (name !== 'updates') fs.rmSync(path.join(nativeRoot, name), { recursive: true, force: true });
82
+ }
83
+ for (const name of fs.readdirSync(path.join(nativeRoot, 'updates'))) {
84
+ if (name !== 'mutation.lock') fs.rmSync(path.join(nativeRoot, 'updates', name), { recursive: true, force: true });
85
+ }
86
+ removeEmptyRoot = true;
87
+ }
88
+ }
89
+ finally { if (lock) releaseInstallLock(lock); }
90
+ if (removeEmptyRoot) {
91
+ // After release another installer may own these paths. Empty-only
92
+ // removal cannot delete its new lock, metadata, runtime or bridge.
93
+ for (const directory of [path.join(nativeRoot, 'updates'), nativeRoot]) {
94
+ try { fs.rmdirSync(directory); }
95
+ catch (error) { if (!['ENOENT', 'ENOTEMPTY', 'EEXIST'].includes(error.code)) throw error; }
96
+ }
97
+ }
98
+ },
99
+ release() { if (lock) releaseInstallLock(lock); }
100
+ };
101
+ }
102
+
103
+ function assertInstallLock(lock) {
104
+ let owner;
105
+ try { owner = JSON.parse(fs.readFileSync(lock.filename, 'utf8')); } catch (_) { /* unknown */ }
106
+ if (owner?.token !== lock?.token) {
107
+ throw updateError('managed_install_restore_failed', 'The installation no longer owns its recovery lock.');
108
+ }
109
+ }
110
+
111
+ function acquireInstallLock(nativeRoot) {
112
+ const filename = path.join(nativeRoot, 'updates', 'mutation.lock');
113
+ fs.mkdirSync(path.dirname(filename), { recursive: true });
114
+ const token = crypto.randomUUID();
115
+ for (let attempt = 0; attempt < 2; attempt += 1) {
116
+ let descriptor;
117
+ try {
118
+ descriptor = fs.openSync(filename, 'wx', 0o600);
119
+ fs.writeFileSync(descriptor, JSON.stringify({ token, pid: process.pid, operationId: '',
120
+ createdAt: Date.now(), expiresAt: Date.now() + MUTATION_LOCK_STALE_MS }));
121
+ fs.closeSync(descriptor);
122
+ return { filename, token };
123
+ } catch (error) {
124
+ if (descriptor !== undefined) { fs.closeSync(descriptor); fs.rmSync(filename, { force: true }); throw error; }
125
+ if (error.code !== 'EEXIST') throw error;
126
+ if (!removeStaleUpdateFileLock(filename)) {
127
+ throw updateError('update_busy', 'A managed update is in progress. Retry installation after it settles.');
128
+ }
129
+ }
130
+ }
131
+ throw updateError('update_busy', 'A managed update is in progress. Retry installation after it settles.');
132
+ }
133
+
134
+ function releaseInstallLock(lock) {
135
+ try {
136
+ if (JSON.parse(fs.readFileSync(lock.filename, 'utf8')).token === lock.token) fs.rmSync(lock.filename);
137
+ } catch (error) { if (error.code !== 'ENOENT') throw error; }
138
+ }
139
+
140
+ module.exports = { createInstallRestorePoint };
@@ -0,0 +1,57 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+ const crypto = require('node:crypto');
6
+ const { parseSemver, updateError } = require('./updateTrust');
7
+
8
+ function readManagedRuntimeEvidence(context, version, runtimeRoot = path.join(context.extensionRoot, 'runtime')) {
9
+ try {
10
+ if (!parseSemver(version)) throw new Error('Invalid source version');
11
+ const compatibility = fs.readFileSync(path.join(runtimeRoot, 'src/shared/compatibility.js'), 'utf8');
12
+ const declared = compatibility.match(/BUILD_TARGET_VERSION\s*=\s*['"]([^'"]+)['"]/)?.[1];
13
+ if (declared !== version) throw new Error('Extension source version mismatch');
14
+ const manifest = JSON.parse(fs.readFileSync(path.join(runtimeRoot, 'runtime-manifest.json'), 'utf8'));
15
+ const assets = [...(Array.isArray(manifest.js) ? manifest.js : []), ...(Array.isArray(manifest.css) ? manifest.css : [])];
16
+ if (!assets.length) throw new Error('Missing runtime assets');
17
+ for (const asset of assets) {
18
+ if (typeof asset !== 'string' || asset.startsWith('/') || asset.includes('\\')
19
+ || asset.split('/').some(part => !part || part === '.' || part === '..')
20
+ || !fs.statSync(path.join(runtimeRoot, asset)).isFile()) throw new Error('Invalid runtime asset');
21
+ }
22
+ const nativeRoot = path.join(context.nativeRoot, 'versions', version);
23
+ if (JSON.parse(fs.readFileSync(path.join(nativeRoot, 'package.json'), 'utf8')).version !== version
24
+ || !fs.statSync(path.join(nativeRoot, 'native-host/src/index.js')).isFile()) throw new Error('Native source unavailable');
25
+ return { extensionVersion: declared, nativeVersion: version,
26
+ extensionSha256: hashTree(runtimeRoot), nativeSha256: hashTree(nativeRoot) };
27
+ } catch (cause) {
28
+ throw updateError('update_rollback_source_unavailable',
29
+ 'The source runtime files could not be verified. Run install-managed to restore the matched installation.', { cause });
30
+ }
31
+ }
32
+
33
+ function hashTree(root) {
34
+ const hash = crypto.createHash('sha256');
35
+ function visit(directory, prefix) {
36
+ // Native processes can start under different LANG/ICU defaults. Evidence
37
+ // ordering must be a fixed code-unit order, never locale collation.
38
+ for (const entry of fs.readdirSync(directory, { withFileTypes: true })
39
+ .sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0)) {
40
+ const filename = path.join(directory, entry.name), relative = prefix + entry.name;
41
+ if (entry.isDirectory()) visit(filename, relative + '/');
42
+ else if (entry.isFile()) {
43
+ const bytes = fs.readFileSync(filename);
44
+ hash.update(JSON.stringify([relative, bytes.length])); hash.update(bytes);
45
+ } else throw new Error('Runtime entry is not a regular file or directory');
46
+ }
47
+ }
48
+ visit(root, '');
49
+ return hash.digest('hex');
50
+ }
51
+
52
+ function sameRuntimeEvidence(left, right) {
53
+ return Boolean(left && right && ['extensionVersion', 'nativeVersion', 'extensionSha256', 'nativeSha256']
54
+ .every(key => left[key] && left[key] === right[key]));
55
+ }
56
+
57
+ module.exports = { readManagedRuntimeEvidence, sameRuntimeEvidence };
@@ -4,6 +4,7 @@ const crypto = require('node:crypto');
4
4
  const fs = require('node:fs');
5
5
  const path = require('node:path');
6
6
  const { getHomeDir } = require('./nativeHostPlatform');
7
+ const { assertProjectPathsForPlatform } = require('./projectPathPlatform');
7
8
 
8
9
  const BASELINE_FILE = 'baseline.json';
9
10
  const MAX_BINARY_FILE_BYTES = 10 * 1024 * 1024;
@@ -37,10 +38,14 @@ async function syncOverleafToMirror({ projectId, project, rootDir }) {
37
38
  const mirror = getProjectMirror(projectId, { rootDir });
38
39
  const normalized = normalizeProjectFilesDetailed(project?.files || []);
39
40
  const files = normalized.files;
40
- const skippedFiles = normalized.skippedFiles;
41
- const nextPaths = new Set(files.map(file => file.path));
42
41
  const previous = readBaseline(mirror.baselinePath);
43
42
  const fullProjectSnapshot = project?.capabilities?.fullProjectSnapshot !== false;
43
+ const skippedFiles = resolveUnreadSnapshotFiles(project, normalized, previous, fullProjectSnapshot);
44
+ // Runtime platform is authoritative; request fields cannot opt out of this check.
45
+ assertProjectPathsForPlatform([...files, ...skippedFiles].map(file => file.path), process.platform);
46
+ const unreadPaths = new Set(skippedFiles.map(file => file.path));
47
+ const nextPaths = new Set([...files.map(file => file.path), ...unreadPaths]);
48
+ const completeContents = fullProjectSnapshot && !unreadPaths.size;
44
49
 
45
50
  const mirrorRoot = path.dirname(mirror.projectRoot);
46
51
  assertSafeMirrorPathBeforeCreate(mirror.workspacePath, mirrorRoot);
@@ -82,20 +87,21 @@ async function syncOverleafToMirror({ projectId, project, rootDir }) {
82
87
 
83
88
  const now = new Date().toISOString();
84
89
  const nextBaselineFiles = fullProjectSnapshot
85
- ? files.map(file => buildBaselineFile(file))
90
+ ? [...files.map(file => buildBaselineFile(file)), ...(previous.files || []).filter(file => unreadPaths.has(file.path))]
86
91
  : mergePartialBaselineFiles(previous.files || [], files);
87
92
 
88
93
  writeBaseline(mirror.baselinePath, {
89
94
  ...previous,
90
95
  projectKey: mirror.projectKey,
91
- capturedAt: fullProjectSnapshot ? now : (previous.capturedAt || ''),
92
- lastFullSyncAt: fullProjectSnapshot ? now : previous.lastFullSyncAt,
93
- lastPartialSyncAt: fullProjectSnapshot ? previous.lastPartialSyncAt : now,
96
+ capturedAt: completeContents ? now : (previous.capturedAt || ''),
97
+ lastFullSyncAt: completeContents ? now : previous.lastFullSyncAt,
98
+ lastPartialSyncAt: completeContents ? previous.lastPartialSyncAt : now,
94
99
  lastSyncSource: fullProjectSnapshot ? (project?.capabilities?.method || 'snapshot') : previous.lastSyncSource,
95
100
  lastFileCount: files.length,
96
- dirty: fullProjectSnapshot ? false : previous.dirty === true,
97
- dirtyReason: fullProjectSnapshot ? '' : previous.dirtyReason || '',
98
- dirtyAt: fullProjectSnapshot ? '' : previous.dirtyAt || '',
101
+ dirty: unreadPaths.size > 0 || (!completeContents && previous.dirty === true),
102
+ dirtyReason: unreadPaths.size ? 'snapshot_content_unavailable' : completeContents ? '' : previous.dirtyReason || '',
103
+ dirtyAt: unreadPaths.size ? now : completeContents ? '' : previous.dirtyAt || '',
104
+ unreadFiles: skippedFiles,
99
105
  files: nextBaselineFiles
100
106
  });
101
107
 
@@ -104,10 +110,30 @@ async function syncOverleafToMirror({ projectId, project, rootDir }) {
104
110
  fileCount: files.length,
105
111
  writtenCount,
106
112
  skippedFiles,
113
+ contentComplete: completeContents,
107
114
  partialSnapshot: !fullProjectSnapshot
108
115
  };
109
116
  }
110
117
 
118
+ // A ZIP skip establishes that a path exists, without establishing its current
119
+ // bytes. Keep its last known bytes/baseline, but never promote them to fresh.
120
+ function resolveUnreadSnapshotFiles(project, normalized, previous, fullProjectSnapshot) {
121
+ const filesByPath = new Map();
122
+ const candidates = [
123
+ ...(fullProjectSnapshot ? [] : previous.unreadFiles || []),
124
+ ...(Array.isArray(project?.capabilities?.skipped) ? project.capabilities.skipped : []),
125
+ ...normalized.skippedFiles
126
+ ];
127
+ for (const file of candidates) {
128
+ if (!['binary_file_too_large', 'binary_project_too_large'].includes(file?.reason) || typeof file.path !== 'string') continue;
129
+ const filePath = normalizeRelativePath(file.path);
130
+ filesByPath.set(filePath, { path: filePath, kind: 'binary', reason: file.reason,
131
+ ...(Number.isFinite(Number(file.size)) && Number(file.size) >= 0 ? { size: Number(file.size) } : {}) });
132
+ }
133
+ for (const file of normalized.files) filesByPath.delete(file.path);
134
+ return Array.from(filesByPath.values());
135
+ }
136
+
111
137
  async function collectMirrorChanges({ projectId, rootDir }) {
112
138
  return (await collectMirrorChangesDetailed({ projectId, rootDir })).changes;
113
139
  }
@@ -116,6 +142,7 @@ async function collectMirrorChangesDetailed({ projectId, rootDir }) {
116
142
  const mirror = getProjectMirror(projectId, { rootDir });
117
143
  const baseline = readBaseline(mirror.baselinePath);
118
144
  const baselineByPath = new Map((baseline.files || []).map(file => [file.path, file]));
145
+ const unreadPaths = new Set((baseline.unreadFiles || []).map(file => file.path));
119
146
  const currentPaths = listWorkspaceFiles(mirror.workspacePath);
120
147
  const currentByPath = new Map();
121
148
  const unsupportedChanges = [];
@@ -124,6 +151,14 @@ async function collectMirrorChangesDetailed({ projectId, rootDir }) {
124
151
  const previous = baselineByPath.get(filePath);
125
152
  const target = resolveWorkspacePath(mirror.workspacePath, filePath);
126
153
  const stat = fs.statSync(target);
154
+ if (unreadPaths.has(filePath)) {
155
+ if (!previous || !workspaceFileMatchesBaseline(target, previous)) {
156
+ unsupportedChanges.push({ type: 'unsupported-local-file', path: filePath,
157
+ reason: 'snapshot_content_unavailable', size: stat.size, previousExists: true,
158
+ previousKind: previous?.kind || 'binary' });
159
+ }
160
+ continue;
161
+ }
127
162
  if (isGeneratedArtifactPath(filePath, baselineByPath)) {
128
163
  if (!previous || !workspaceFileMatchesBaseline(target, previous)) {
129
164
  unsupportedChanges.push({
@@ -208,6 +243,7 @@ async function collectMirrorChangesDetailed({ projectId, rootDir }) {
208
243
  previousExists: Boolean(previous),
209
244
  previousKind: previous?.kind || '',
210
245
  previousSize: previous?.size,
246
+ previousSha256: previous?.kind === 'binary' ? previous.hash : undefined,
211
247
  size: contentOrBinary.size
212
248
  });
213
249
  continue;
@@ -225,6 +261,13 @@ async function collectMirrorChangesDetailed({ projectId, rootDir }) {
225
261
  }
226
262
 
227
263
  for (const [filePath, previous] of baselineByPath) {
264
+ if (unreadPaths.has(filePath)) {
265
+ if (!currentPaths.includes(filePath)) {
266
+ unsupportedChanges.push({ type: 'unsupported-local-file', path: filePath,
267
+ reason: 'snapshot_content_unavailable', previousExists: true, previousKind: previous.kind });
268
+ }
269
+ continue;
270
+ }
228
271
  if (previous.kind === 'binary') {
229
272
  if (!currentByPath.has(filePath) && !currentPaths.includes(filePath)) {
230
273
  unsupportedChanges.push({
@@ -622,6 +665,7 @@ function hashBytes(bytes) {
622
665
  function getMirrorStatus(projectId, options = {}) {
623
666
  const mirror = getProjectMirror(projectId, options);
624
667
  const baseline = readBaseline(mirror.baselinePath);
668
+ const unreadContents = (baseline.unreadFiles || []).length > 0;
625
669
  if (!baseline.lastFullSyncAt) {
626
670
  return {
627
671
  exists: false,
@@ -633,13 +677,13 @@ function getMirrorStatus(projectId, options = {}) {
633
677
  lastPartialSyncAt: baseline.lastPartialSyncAt || '',
634
678
  lastSyncSource: baseline.lastSyncSource || '',
635
679
  lastFileCount: Number.isFinite(Number(baseline.lastFileCount)) ? Number(baseline.lastFileCount) : (baseline.files || []).length,
636
- dirty: baseline.dirty === true,
637
- dirtyReason: baseline.dirtyReason || '',
680
+ dirty: unreadContents || baseline.dirty === true,
681
+ dirtyReason: unreadContents ? 'snapshot_content_unavailable' : baseline.dirtyReason || '',
638
682
  workspacePath: mirror.workspacePath,
639
683
  ...buildOtStatusFields(baseline, false)
640
684
  };
641
685
  }
642
- if (baseline.dirty === true) {
686
+ if (unreadContents || baseline.dirty === true) {
643
687
  return {
644
688
  exists: false,
645
689
  projectKey: mirror.projectKey,
@@ -651,7 +695,7 @@ function getMirrorStatus(projectId, options = {}) {
651
695
  lastSyncSource: baseline.lastSyncSource || '',
652
696
  lastFileCount: Number.isFinite(Number(baseline.lastFileCount)) ? Number(baseline.lastFileCount) : (baseline.files || []).length,
653
697
  dirty: true,
654
- dirtyReason: baseline.dirtyReason || 'dirty_mirror',
698
+ dirtyReason: unreadContents ? 'snapshot_content_unavailable' : baseline.dirtyReason || 'dirty_mirror',
655
699
  workspacePath: mirror.workspacePath,
656
700
  ...buildOtStatusFields(baseline, false)
657
701
  };
@@ -747,6 +791,7 @@ function buildOtStatusFields(baseline = {}, trusted) {
747
791
  && isFreshOtObservation(file.freshness))
748
792
  .map(file => ({
749
793
  path: file.path,
794
+ sha256: file.hash,
750
795
  source: file.freshness.source,
751
796
  state: file.freshness.state,
752
797
  lastFullSyncAt: file.freshness.lastFullSyncAt || '',
@@ -767,12 +812,14 @@ async function applyFileOverlays({ projectId, overlays, rootDir }) {
767
812
  const mirror = getProjectMirror(projectId, { rootDir });
768
813
  const baseline = readBaseline(mirror.baselinePath);
769
814
  const filesByPath = new Map((baseline.files || []).map(f => [f.path, f]));
815
+ const unreadPaths = new Set((baseline.unreadFiles || []).map(file => file.path));
770
816
 
771
817
  for (const overlay of overlays || []) {
772
818
  if (!overlay?.path || typeof overlay.content !== 'string') {
773
819
  continue;
774
820
  }
775
821
  const normalizedPath = normalizeRelativePath(overlay.path);
822
+ if (unreadPaths.has(normalizedPath)) continue;
776
823
  const target = resolveWorkspacePath(mirror.workspacePath, normalizedPath);
777
824
  assertSafeWorkspaceTarget(mirror.workspacePath, target, normalizedPath);
778
825
  fs.mkdirSync(path.dirname(target), { recursive: true });
@@ -794,14 +841,15 @@ async function applyFileOverlays({ projectId, overlays, rootDir }) {
794
841
  }
795
842
 
796
843
  // v1.8.0: after a VERIFIED writeback, the workspace already holds the exact
797
- // content that landed in Overleaf (the writeback reads from this workspace).
798
- // Instead of re-downloading the whole project only to rewrite what we just
799
- // uploaded, re-hash the written workspace files in place and refresh the
800
- // baseline freshness metadata. Any doubt -> ok:false, and the caller falls
801
- // back to a full syncOverleafToMirror.
802
- async function confirmWritebackFiles({ projectId, paths, rootDir }) {
844
+ // content that landed in Overleaf only while its hashes match the page's
845
+ // verified post-images. Check every target before updating any baseline.
846
+ // Missing or mismatched evidence falls back to a full syncOverleafToMirror.
847
+ async function confirmWritebackFiles({ projectId, paths, expectedFiles, rootDir }) {
803
848
  const mirror = getProjectMirror(projectId, { rootDir });
804
849
  const baseline = readBaseline(mirror.baselinePath);
850
+ if ((baseline.unreadFiles || []).length) {
851
+ return { ok: false, reason: 'snapshot_content_unavailable' };
852
+ }
805
853
  if (!baseline.lastFullSyncAt) {
806
854
  return { ok: false, reason: 'no_baseline' };
807
855
  }
@@ -810,8 +858,23 @@ async function confirmWritebackFiles({ projectId, paths, rootDir }) {
810
858
  }
811
859
  const filesByPath = new Map((baseline.files || []).map(file => [file.path, file]));
812
860
  const confirmed = [];
813
- for (const rawPath of Array.isArray(paths) ? paths : []) {
861
+ const requestedPaths = Array.isArray(paths) ? paths : [];
862
+ const seenPaths = new Set();
863
+ const expectedByPath = new Map();
864
+ if (Array.isArray(expectedFiles) && expectedFiles.length === requestedPaths.length) {
865
+ for (const file of expectedFiles) {
866
+ if (typeof file?.path !== 'string' || !/^[a-f0-9]{64}$/i.test(file?.sha256 || '')) {
867
+ return { ok: false, reason: 'postimage_proof_invalid' };
868
+ }
869
+ const normalizedPath = normalizeRelativePath(file.path);
870
+ if (expectedByPath.has(normalizedPath)) return { ok: false, reason: 'postimage_proof_invalid' };
871
+ expectedByPath.set(normalizedPath, file.sha256.toLowerCase());
872
+ }
873
+ }
874
+ for (const rawPath of requestedPaths) {
814
875
  const normalizedPath = normalizeRelativePath(rawPath);
876
+ if (seenPaths.has(normalizedPath)) return { ok: false, reason: 'postimage_proof_invalid' };
877
+ seenPaths.add(normalizedPath);
815
878
  const baselineFile = filesByPath.get(normalizedPath);
816
879
  if (!baselineFile) {
817
880
  return { ok: false, reason: 'missing_baseline_file', path: normalizedPath };
@@ -819,6 +882,9 @@ async function confirmWritebackFiles({ projectId, paths, rootDir }) {
819
882
  if (baselineFile.kind !== 'text') {
820
883
  return { ok: false, reason: 'not_text', path: normalizedPath };
821
884
  }
885
+ if (!expectedByPath.has(normalizedPath)) {
886
+ return { ok: false, reason: 'postimage_proof_required', path: normalizedPath };
887
+ }
822
888
  let target;
823
889
  try {
824
890
  target = resolveWorkspacePath(mirror.workspacePath, normalizedPath);
@@ -835,6 +901,9 @@ async function confirmWritebackFiles({ projectId, paths, rootDir }) {
835
901
  return { ok: false, reason: 'workspace_read_failed', path: normalizedPath };
836
902
  }
837
903
  const hash = hashText(content);
904
+ if (hash !== expectedByPath.get(normalizedPath)) {
905
+ return { ok: false, reason: 'postimage_mismatch', path: normalizedPath };
906
+ }
838
907
  filesByPath.set(normalizedPath, { ...baselineFile, hash, content });
839
908
  confirmed.push({ path: normalizedPath, hash });
840
909
  }
@@ -854,6 +923,7 @@ async function confirmWritebackFiles({ projectId, paths, rootDir }) {
854
923
  async function patchMirrorFiles({ projectId, files, rootDir, source = 'ot' }) {
855
924
  const mirror = getProjectMirror(projectId, { rootDir });
856
925
  const baseline = readBaseline(mirror.baselinePath);
926
+ const unreadContents = (baseline.unreadFiles || []).length > 0;
857
927
  const baselineByPath = new Map((baseline.files || []).map(file => [file.path, file]));
858
928
  const patchedFilesByPath = new Map();
859
929
  const appliedFiles = [];
@@ -895,7 +965,7 @@ async function patchMirrorFiles({ projectId, files, rootDir, source = 'ot' }) {
895
965
  const nextHash = hashText(patch.nextContent);
896
966
  if (patch.baseHash !== baselineFile.hash) {
897
967
  if (nextHash === baselineFile.hash) {
898
- if (baseline.dirty === true) {
968
+ if (unreadContents || baseline.dirty === true) {
899
969
  skippedFiles.push({ path: normalized.path, reason: 'dirty_mirror' });
900
970
  lastOtErrorCode = 'dirty_mirror';
901
971
  continue;
@@ -922,7 +992,7 @@ async function patchMirrorFiles({ projectId, files, rootDir, source = 'ot' }) {
922
992
  lastOtErrorCode = 'base_hash_mismatch';
923
993
  continue;
924
994
  }
925
- if (baseline.dirty === true) {
995
+ if (unreadContents || baseline.dirty === true) {
926
996
  skippedFiles.push({ path: normalized.path, reason: 'dirty_mirror' });
927
997
  lastOtErrorCode = 'dirty_mirror';
928
998
  continue;
@@ -10,6 +10,7 @@ const {
10
10
  getNativeHostPlatform,
11
11
  getNativeHostRegistrationTarget
12
12
  } = require('./nativeHostPlatform');
13
+ const { queryWindowsNativeHostRegistration } = require('./nativeHostRegistration');
13
14
  const { decodeFrames, encodeMessage } = require('./nativeMessaging');
14
15
  const {
15
16
  buildBridgePingParams,
@@ -68,6 +69,12 @@ async function runDoctor(options = {}) {
68
69
  }
69
70
  };
70
71
 
72
+ if (registrationTarget.kind === 'registry') {
73
+ const observed = queryWindowsNativeHostRegistration(registrationTarget, { env, spawnSync: options.spawnSync });
74
+ body.registration = { ...body.registration, ...observed,
75
+ ...(observed.registeredManifestPath ? { registeredManifestPath: normalizeDiagnosticPath(observed.registeredManifestPath, pathOptions) } : {}) };
76
+ }
77
+
71
78
  if (runtimeRoot) {
72
79
  body.runtimeRoot = runtimeRoot;
73
80
  body.diagnostics.runtimeRoot = runtimeRoot;
@@ -160,6 +167,10 @@ function classifyDoctorResult(body = {}) {
160
167
  };
161
168
  }
162
169
 
170
+ if (body.registration?.kind === 'registry' && body.registration.verified !== true) {
171
+ return { exitCode: 2, ok: false, status: 'registration_unverified' };
172
+ }
173
+
163
174
  if (body.compatibility?.classification === 'compatible') {
164
175
  return {
165
176
  exitCode: 0,
@@ -198,6 +209,10 @@ function formatDoctorHuman(body = {}) {
198
209
  if (body.registration?.registryKey) {
199
210
  lines.push(`Registry key: ${body.registration.registryKey}`);
200
211
  }
212
+ if (body.registration?.kind === 'registry') {
213
+ lines.push(`Registry discovery: ${body.registration.status || 'unverified'}`);
214
+ if (body.registration.registeredManifestPath) lines.push(`Registered manifest: ${body.registration.registeredManifestPath}`);
215
+ }
201
216
  if (body.bridge?.path) {
202
217
  lines.push(`Bridge: ${body.bridge.path}`);
203
218
  }
@@ -0,0 +1,21 @@
1
+ 'use strict';
2
+
3
+ const { register } = require('./nativeRunLifecycle');
4
+
5
+ // Helpers own no project mirror lock, but their preparation and process-stop
6
+ // promises must finish before the Host's transport shutdown can finish.
7
+ async function runOwnedNativeHelper(execute, signal) {
8
+ const controller = new AbortController();
9
+ const lifetime = register({ controller });
10
+ const onAbort = () => controller.abort(signal.reason);
11
+ signal?.addEventListener('abort', onAbort, { once: true });
12
+ if (signal?.aborted) onAbort();
13
+ try {
14
+ return await execute(controller.signal);
15
+ } finally {
16
+ signal?.removeEventListener('abort', onAbort);
17
+ lifetime.complete();
18
+ }
19
+ }
20
+
21
+ module.exports = { runOwnedNativeHelper };
@@ -0,0 +1,33 @@
1
+ 'use strict';
2
+
3
+ const path = require('node:path');
4
+
5
+ // Query only the selected browser's current-user default registration value.
6
+ // An unreadable/missing value is unknown discovery evidence, never healthy.
7
+ function queryWindowsNativeHostRegistration(target, options = {}) {
8
+ const env = options.env || process.env;
9
+ const run = options.spawnSync || require('node:child_process').spawnSync;
10
+ let result;
11
+ try { result = run(env.CODEX_OVERLEAF_REG_EXE || 'reg.exe', [
12
+ ...parseStringArrayEnv(env.CODEX_OVERLEAF_REG_EXE_ARGS_JSON), 'query', target.registryKey, '/ve'
13
+ ], {
14
+ env, encoding: 'utf8', windowsHide: true, timeout: 3000, maxBuffer: 64 * 1024
15
+ }); } catch (_) { return { verified: false, status: 'unavailable' }; }
16
+ if (result.error || result.status !== 0) return { verified: false, status: 'unavailable' };
17
+ const values = [...String(result.stdout || '').matchAll(/^\s*.+?\s+REG_SZ\s+(.+?)\s*$/gm)].map(match => match[1]);
18
+ if (values.length !== 1 || !path.win32.isAbsolute(values[0])) return { verified: false, status: 'unavailable' };
19
+ const matches = path.win32.normalize(values[0]).toLowerCase() === path.win32.normalize(target.manifestPath).toLowerCase();
20
+ return { verified: matches, status: matches ? 'verified' : 'mismatch', registeredManifestPath: values[0] };
21
+ }
22
+
23
+ // Match the existing install/uninstall registry command prefix contract.
24
+ function parseStringArrayEnv(value) {
25
+ if (!value) return [];
26
+ const parsed = JSON.parse(value);
27
+ if (!Array.isArray(parsed) || !parsed.every(item => typeof item === 'string')) {
28
+ throw new Error('CODEX_OVERLEAF_REG_EXE_ARGS_JSON must be a JSON array of strings');
29
+ }
30
+ return parsed;
31
+ }
32
+
33
+ module.exports = { queryWindowsNativeHostRegistration };
@@ -0,0 +1,71 @@
1
+ 'use strict';
2
+
3
+ const processStopBarrier = require('./processStopBarrier');
4
+ const { logDebug } = require('./debugLog');
5
+ const active = new Set();
6
+ let closingError;
7
+
8
+ // Shutdown ownership is independent of RPC ids: absent/reused ids must not
9
+ // hide a live run from transport shutdown or its durable quarantine.
10
+ function register({ controller, projectKey, rootDir }) {
11
+ let resolveDone;
12
+ const entry = { controller, projectKey, rootDir, processes: [], done: new Promise(resolve => { resolveDone = resolve; }) };
13
+ active.add(entry);
14
+ if (closingError) controller.abort(closingError);
15
+ return {
16
+ trackProcess(identity) {
17
+ if (!Number.isInteger(identity?.processId) || identity.processId <= 0) return;
18
+ entry.processes.push(identity);
19
+ if (closingError) quarantine(entry, identity);
20
+ },
21
+ complete() {
22
+ active.delete(entry);
23
+ try {
24
+ if (entry.processes.some(identity => identity.quarantined)) processStopBarrier.isBlocked(projectKey, rootDir);
25
+ } catch (error) { logDebug('native_transport.quarantine_cleanup_failed', { projectKey, message: error.message }); }
26
+ finally { resolveDone(); }
27
+ }
28
+ };
29
+ }
30
+
31
+ function abortAllActiveOperations(reason = 'Native messaging transport disconnected.') {
32
+ closingError = new Error(String(reason || 'Native messaging transport disconnected.'));
33
+ closingError.code = 'native_transport_closed';
34
+ const entries = Array.from(active);
35
+ for (const entry of entries) {
36
+ if (!entry.controller.signal.aborted) entry.controller.abort(closingError);
37
+ }
38
+ return Promise.all(entries.map(entry => entry.done));
39
+ }
40
+
41
+ function quarantineActiveOperations() {
42
+ const failures = [];
43
+ for (const entry of active) {
44
+ // A preparing run has no process to quarantine. Its aborted signal is
45
+ // checked again after provider preparation and immediately before spawn.
46
+ for (const identity of entry.processes) {
47
+ const failure = quarantine(entry, identity);
48
+ if (failure) failures.push(failure);
49
+ }
50
+ }
51
+ return failures;
52
+ }
53
+
54
+ function quarantine(entry, identity) {
55
+ if (identity.quarantined) return null;
56
+ const error = Object.assign(new Error('Native transport closed before run settlement.'), identity, {
57
+ code: 'codex_process_stop_unconfirmed'
58
+ });
59
+ try {
60
+ processStopBarrier.record(entry.projectKey, entry.rootDir, error);
61
+ if (error.stopBarrierPersistenceError) throw new Error(error.stopBarrierPersistenceError);
62
+ identity.quarantined = true;
63
+ return null;
64
+ } catch (failure) {
65
+ const detail = { projectKey: entry.projectKey, message: failure.message };
66
+ logDebug('native_transport.quarantine_failed', detail);
67
+ return detail;
68
+ }
69
+ }
70
+
71
+ module.exports = { register, abortAllActiveOperations, quarantineActiveOperations };