codex-overleaf-link 1.1.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 (52) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +457 -0
  3. package/bin/codex-overleaf-link.mjs +223 -0
  4. package/extension/src/shared/agentTranscript.js +1175 -0
  5. package/extension/src/shared/auditRecords.js +568 -0
  6. package/extension/src/shared/compatibility.js +372 -0
  7. package/extension/src/shared/compileAdapter.js +176 -0
  8. package/extension/src/shared/governanceRules.js +252 -0
  9. package/extension/src/shared/i18n.js +565 -0
  10. package/extension/src/shared/models.js +106 -0
  11. package/extension/src/shared/otText.js +505 -0
  12. package/extension/src/shared/projectFiles.js +180 -0
  13. package/extension/src/shared/reviewing.js +99 -0
  14. package/extension/src/shared/sensitiveScan.js +116 -0
  15. package/extension/src/shared/sessionState.js +1084 -0
  16. package/extension/src/shared/staleGuard.js +150 -0
  17. package/extension/src/shared/storageDb.js +986 -0
  18. package/extension/src/shared/storageKeys.js +29 -0
  19. package/extension/src/shared/storageMigration.js +168 -0
  20. package/extension/src/shared/summary.js +248 -0
  21. package/extension/src/shared/undoOperations.js +369 -0
  22. package/native-host/src/codexArgs.js +43 -0
  23. package/native-host/src/codexHome.js +538 -0
  24. package/native-host/src/codexModels.js +247 -0
  25. package/native-host/src/codexPrompt.js +192 -0
  26. package/native-host/src/codexPromptAssembly.js +411 -0
  27. package/native-host/src/codexSessionRunner.js +1247 -0
  28. package/native-host/src/commandApproval.js +914 -0
  29. package/native-host/src/debugLog.js +78 -0
  30. package/native-host/src/diffEngine.js +247 -0
  31. package/native-host/src/index.js +132 -0
  32. package/native-host/src/launcher.js +81 -0
  33. package/native-host/src/localSkills.js +476 -0
  34. package/native-host/src/manifest.js +226 -0
  35. package/native-host/src/mirrorSensitiveScan.js +119 -0
  36. package/native-host/src/mirrorWorkspace.js +1019 -0
  37. package/native-host/src/nativeDoctor.js +826 -0
  38. package/native-host/src/nativeEnvironment.js +315 -0
  39. package/native-host/src/nativeHostPlatform.js +112 -0
  40. package/native-host/src/nativeMessaging.js +60 -0
  41. package/native-host/src/nativeQuotas.js +294 -0
  42. package/native-host/src/nativeResponseBudget.js +194 -0
  43. package/native-host/src/runtimeInstaller.js +357 -0
  44. package/native-host/src/taskRunner.js +3 -0
  45. package/native-host/src/taskRunnerRuntime.js +1083 -0
  46. package/native-host/src/textPatch.js +287 -0
  47. package/package.json +40 -0
  48. package/scripts/codex-json-agent.mjs +269 -0
  49. package/scripts/install-native-host.mjs +255 -0
  50. package/scripts/npm-package-files-v1.1.1.txt +52 -0
  51. package/scripts/uninstall-native-host.mjs +298 -0
  52. package/scripts/verify-npm-package.mjs +296 -0
@@ -0,0 +1,1019 @@
1
+ 'use strict';
2
+
3
+ const crypto = require('node:crypto');
4
+ const fs = require('node:fs');
5
+ const path = require('node:path');
6
+ const { getHomeDir } = require('./nativeHostPlatform');
7
+
8
+ const BASELINE_FILE = 'baseline.json';
9
+ const MAX_BINARY_FILE_BYTES = 10 * 1024 * 1024;
10
+ const NATIVE_OUTPUT_LIMIT_BYTES = 1024 * 1024;
11
+ const SAFE_INLINE_BINARY_CHANGE_BYTES = 512 * 1024;
12
+ const SAFE_NATIVE_RESPONSE_PAYLOAD_BYTES = NATIVE_OUTPUT_LIMIT_BYTES - (64 * 1024);
13
+ const TURN_ATTACHMENTS_DIR = '.codex-overleaf-attachments';
14
+
15
+ function getProjectMirror(projectId, options = {}) {
16
+ const rootDir = path.resolve(options.rootDir || getDefaultMirrorRoot(options));
17
+ const projectKey = normalizeProjectKey(projectId);
18
+ const projectRoot = path.join(rootDir, projectKey);
19
+ return {
20
+ projectKey,
21
+ projectRoot,
22
+ workspacePath: path.join(projectRoot, 'workspace'),
23
+ metadataPath: path.join(projectRoot, 'metadata'),
24
+ baselinePath: path.join(projectRoot, 'metadata', BASELINE_FILE)
25
+ };
26
+ }
27
+
28
+ function getDefaultMirrorRoot(options = {}) {
29
+ return path.join(getHomeDir(options), '.codex-overleaf', 'projects');
30
+ }
31
+
32
+ async function syncOverleafToMirror({ projectId, project, rootDir }) {
33
+ const mirror = getProjectMirror(projectId, { rootDir });
34
+ const normalized = normalizeProjectFilesDetailed(project?.files || []);
35
+ const files = normalized.files;
36
+ const skippedFiles = normalized.skippedFiles;
37
+ const nextPaths = new Set(files.map(file => file.path));
38
+ const previous = readBaseline(mirror.baselinePath);
39
+ const fullProjectSnapshot = project?.capabilities?.fullProjectSnapshot !== false;
40
+
41
+ fs.mkdirSync(mirror.workspacePath, { recursive: true });
42
+ fs.mkdirSync(mirror.metadataPath, { recursive: true });
43
+
44
+ if (fullProjectSnapshot) {
45
+ for (const filePath of listWorkspaceFiles(mirror.workspacePath)) {
46
+ if (nextPaths.has(filePath)) {
47
+ continue;
48
+ }
49
+ const target = resolveWorkspacePath(mirror.workspacePath, filePath);
50
+ if (fs.existsSync(target)) {
51
+ fs.rmSync(target, { force: true });
52
+ removeEmptyParents(path.dirname(target), mirror.workspacePath);
53
+ }
54
+ }
55
+ }
56
+
57
+ let writtenCount = 0;
58
+ const previousByPath = new Map((previous.files || []).map(f => [f.path, f]));
59
+
60
+ for (const file of files) {
61
+ const target = resolveWorkspacePath(mirror.workspacePath, file.path);
62
+ const prev = previousByPath.get(file.path);
63
+ const nextHash = hashProjectFile(file);
64
+ if (prev && prev.hash === nextHash && workspaceFileMatchesBaseline(target, prev)) {
65
+ continue;
66
+ }
67
+ fs.mkdirSync(path.dirname(target), { recursive: true });
68
+ writeProjectFile(target, file);
69
+ writtenCount++;
70
+ }
71
+
72
+ const now = new Date().toISOString();
73
+ const nextBaselineFiles = fullProjectSnapshot
74
+ ? files.map(file => buildBaselineFile(file))
75
+ : mergePartialBaselineFiles(previous.files || [], files);
76
+
77
+ writeBaseline(mirror.baselinePath, {
78
+ ...previous,
79
+ projectKey: mirror.projectKey,
80
+ capturedAt: fullProjectSnapshot ? now : (previous.capturedAt || ''),
81
+ lastFullSyncAt: fullProjectSnapshot ? now : previous.lastFullSyncAt,
82
+ lastPartialSyncAt: fullProjectSnapshot ? previous.lastPartialSyncAt : now,
83
+ lastSyncSource: fullProjectSnapshot ? (project?.capabilities?.method || 'snapshot') : previous.lastSyncSource,
84
+ lastFileCount: files.length,
85
+ dirty: fullProjectSnapshot ? false : previous.dirty === true,
86
+ dirtyReason: fullProjectSnapshot ? '' : previous.dirtyReason || '',
87
+ dirtyAt: fullProjectSnapshot ? '' : previous.dirtyAt || '',
88
+ files: nextBaselineFiles
89
+ });
90
+
91
+ return {
92
+ ...mirror,
93
+ fileCount: files.length,
94
+ writtenCount,
95
+ skippedFiles,
96
+ partialSnapshot: !fullProjectSnapshot
97
+ };
98
+ }
99
+
100
+ async function collectMirrorChanges({ projectId, rootDir }) {
101
+ return (await collectMirrorChangesDetailed({ projectId, rootDir })).changes;
102
+ }
103
+
104
+ async function collectMirrorChangesDetailed({ projectId, rootDir }) {
105
+ const mirror = getProjectMirror(projectId, { rootDir });
106
+ const baseline = readBaseline(mirror.baselinePath);
107
+ const baselineByPath = new Map((baseline.files || []).map(file => [file.path, file]));
108
+ const currentPaths = listWorkspaceFiles(mirror.workspacePath);
109
+ const currentByPath = new Map();
110
+ const unsupportedChanges = [];
111
+
112
+ for (const filePath of currentPaths) {
113
+ const previous = baselineByPath.get(filePath);
114
+ const target = resolveWorkspacePath(mirror.workspacePath, filePath);
115
+ const stat = fs.statSync(target);
116
+ if (!previous && isGeneratedArtifactPath(filePath, baselineByPath)) {
117
+ unsupportedChanges.push({
118
+ type: 'unsupported-local-file',
119
+ path: filePath,
120
+ reason: 'generated_artifact',
121
+ size: stat.size
122
+ });
123
+ continue;
124
+ }
125
+ if (isSupportedBinaryAssetPath(filePath)) {
126
+ if (stat.size > MAX_BINARY_FILE_BYTES) {
127
+ unsupportedChanges.push({
128
+ type: 'unsupported-local-file',
129
+ path: filePath,
130
+ reason: 'binary_file_too_large',
131
+ size: stat.size,
132
+ previousExists: Boolean(previous),
133
+ previousKind: previous?.kind || ''
134
+ });
135
+ continue;
136
+ }
137
+ if (previous?.kind === 'binary') {
138
+ const bytes = fs.readFileSync(target);
139
+ if (hashBytes(bytes) === previous.hash) {
140
+ continue;
141
+ }
142
+ if (stat.size > SAFE_INLINE_BINARY_CHANGE_BYTES) {
143
+ unsupportedChanges.push(buildOversizedBinaryPayloadChange({
144
+ filePath,
145
+ size: stat.size,
146
+ previous
147
+ }));
148
+ continue;
149
+ }
150
+ currentByPath.set(filePath, {
151
+ binary: true,
152
+ bytes,
153
+ size: stat.size,
154
+ previous
155
+ });
156
+ continue;
157
+ }
158
+ if (!previous) {
159
+ if (stat.size > SAFE_INLINE_BINARY_CHANGE_BYTES) {
160
+ unsupportedChanges.push(buildOversizedBinaryPayloadChange({
161
+ filePath,
162
+ size: stat.size,
163
+ previous
164
+ }));
165
+ continue;
166
+ }
167
+ currentByPath.set(filePath, {
168
+ binary: true,
169
+ bytes: fs.readFileSync(target),
170
+ size: stat.size,
171
+ previous
172
+ });
173
+ continue;
174
+ }
175
+ }
176
+ if (previous?.kind === 'binary') {
177
+ unsupportedChanges.push({
178
+ type: 'unsupported-local-file',
179
+ path: filePath,
180
+ reason: 'unsupported_non_text_file',
181
+ size: stat.size,
182
+ previousExists: true,
183
+ previousKind: 'binary',
184
+ previousSize: previous.size
185
+ });
186
+ continue;
187
+ }
188
+ if (!previous && !isTextMirrorPath(filePath)) {
189
+ unsupportedChanges.push({
190
+ type: 'unsupported-local-file',
191
+ path: filePath,
192
+ reason: 'unsupported_non_text_file',
193
+ size: stat.size
194
+ });
195
+ continue;
196
+ }
197
+ const content = fs.readFileSync(target, 'utf8');
198
+ currentByPath.set(filePath, content);
199
+ }
200
+
201
+ const changes = [];
202
+ for (const [filePath, contentOrBinary] of currentByPath) {
203
+ const previous = baselineByPath.get(filePath);
204
+ if (contentOrBinary && typeof contentOrBinary === 'object' && contentOrBinary.binary === true) {
205
+ changes.push({
206
+ type: previous ? 'overwrite-binary' : 'binary-create',
207
+ path: filePath,
208
+ contentBase64: contentOrBinary.bytes.toString('base64'),
209
+ previousExists: Boolean(previous),
210
+ previousKind: previous?.kind || '',
211
+ previousSize: previous?.size,
212
+ size: contentOrBinary.size
213
+ });
214
+ continue;
215
+ }
216
+ const content = contentOrBinary;
217
+ if (!previous || previous.content !== content) {
218
+ changes.push({
219
+ type: 'write',
220
+ path: filePath,
221
+ content,
222
+ previousContent: previous?.content || '',
223
+ previousExists: Boolean(previous)
224
+ });
225
+ }
226
+ }
227
+
228
+ for (const [filePath, previous] of baselineByPath) {
229
+ if (previous.kind === 'binary') {
230
+ if (!currentByPath.has(filePath) && !currentPaths.includes(filePath)) {
231
+ unsupportedChanges.push({
232
+ type: 'unsupported-local-file',
233
+ path: filePath,
234
+ reason: 'binary_delete_unsupported',
235
+ previousExists: true,
236
+ previousKind: 'binary',
237
+ previousSize: previous.size
238
+ });
239
+ }
240
+ continue;
241
+ }
242
+ if (!currentByPath.has(filePath)) {
243
+ changes.push({
244
+ type: 'delete',
245
+ path: filePath,
246
+ previousContent: previous.content || '',
247
+ previousExists: true
248
+ });
249
+ }
250
+ }
251
+
252
+ const budgeted = enforceNativeResponsePayloadBudget({
253
+ changes,
254
+ unsupportedChanges,
255
+ mirror
256
+ });
257
+
258
+ return {
259
+ changes: budgeted.changes.sort(compareSyncChanges),
260
+ unsupportedChanges: budgeted.unsupportedChanges.sort((left, right) => left.path.localeCompare(right.path))
261
+ };
262
+ }
263
+
264
+ function enforceNativeResponsePayloadBudget({ changes, unsupportedChanges, mirror }) {
265
+ const nextChanges = [...changes];
266
+ const nextUnsupportedChanges = [...unsupportedChanges];
267
+ while (
268
+ estimateNativeResponsePayloadBytes(nextChanges, nextUnsupportedChanges, mirror) > SAFE_NATIVE_RESPONSE_PAYLOAD_BYTES
269
+ ) {
270
+ const index = findLargestInlineBinaryChangeIndex(nextChanges);
271
+ if (index < 0) {
272
+ break;
273
+ }
274
+ const [change] = nextChanges.splice(index, 1);
275
+ nextUnsupportedChanges.push(buildOversizedBinaryPayloadChange({
276
+ filePath: change.path,
277
+ size: change.size,
278
+ previousExists: change.previousExists === true,
279
+ previousKind: change.previousKind || '',
280
+ previousSize: change.previousSize,
281
+ attemptedChangeType: change.type,
282
+ aggregateBudgetExceeded: true
283
+ }));
284
+ }
285
+ return {
286
+ changes: nextChanges,
287
+ unsupportedChanges: nextUnsupportedChanges
288
+ };
289
+ }
290
+
291
+ function estimateNativeResponsePayloadBytes(changes, unsupportedChanges, mirror = {}) {
292
+ return Buffer.byteLength(JSON.stringify({
293
+ status: 'completed',
294
+ projectId: mirror.projectKey || '',
295
+ workspacePath: mirror.workspacePath || '',
296
+ assistantMessage: '',
297
+ threadId: '',
298
+ syncChanges: changes,
299
+ unsupportedChanges
300
+ }), 'utf8');
301
+ }
302
+
303
+ function findLargestInlineBinaryChangeIndex(changes) {
304
+ let largestIndex = -1;
305
+ let largestSize = -1;
306
+ for (let index = 0; index < changes.length; index += 1) {
307
+ const change = changes[index];
308
+ if (
309
+ (change?.type !== 'binary-create' && change?.type !== 'overwrite-binary')
310
+ || typeof change.contentBase64 !== 'string'
311
+ ) {
312
+ continue;
313
+ }
314
+ const size = Number.isFinite(Number(change.size)) ? Number(change.size) : estimateBase64Size(change.contentBase64);
315
+ if (size > largestSize) {
316
+ largestSize = size;
317
+ largestIndex = index;
318
+ }
319
+ }
320
+ return largestIndex;
321
+ }
322
+
323
+ function buildOversizedBinaryPayloadChange({
324
+ filePath,
325
+ size,
326
+ previous,
327
+ previousExists,
328
+ previousKind,
329
+ previousSize,
330
+ attemptedChangeType,
331
+ aggregateBudgetExceeded = false
332
+ }) {
333
+ const resolvedPreviousExists = typeof previousExists === 'boolean' ? previousExists : Boolean(previous);
334
+ const resolvedAttemptedChangeType = attemptedChangeType || (resolvedPreviousExists ? 'overwrite-binary' : 'binary-create');
335
+ const guidancePrefix = aggregateBudgetExceeded
336
+ ? `The ${resolvedAttemptedChangeType} payload would exceed the native messaging response budget when combined with other changes.`
337
+ : `The ${resolvedAttemptedChangeType} payload is too large for native messaging.`;
338
+ return {
339
+ type: 'unsupported-local-file',
340
+ path: filePath,
341
+ reason: 'binary_payload_exceeds_native_message_limit',
342
+ size,
343
+ attemptedChangeType: resolvedAttemptedChangeType,
344
+ previousExists: resolvedPreviousExists,
345
+ previousKind: previousKind ?? previous?.kind ?? '',
346
+ previousSize: previousSize ?? previous?.size,
347
+ limit: SAFE_INLINE_BINARY_CHANGE_BYTES,
348
+ aggregateLimit: SAFE_NATIVE_RESPONSE_PAYLOAD_BYTES,
349
+ nativeOutputLimit: NATIVE_OUTPUT_LIMIT_BYTES,
350
+ guidance: `${guidancePrefix} Update ${filePath} in Overleaf directly or reduce it below ${SAFE_INLINE_BINARY_CHANGE_BYTES} bytes.`
351
+ };
352
+ }
353
+
354
+ function normalizeProjectFiles(files) {
355
+ return normalizeProjectFilesDetailed(files).files;
356
+ }
357
+
358
+ function normalizeProjectFilesDetailed(files) {
359
+ const normalized = [];
360
+ const skippedFiles = [];
361
+ for (const file of files) {
362
+ const result = normalizeProjectFile(file);
363
+ if (result?.file) {
364
+ normalized.push(result.file);
365
+ } else if (result?.skipped) {
366
+ skippedFiles.push(result.skipped);
367
+ }
368
+ }
369
+ return {
370
+ files: normalized,
371
+ skippedFiles
372
+ };
373
+ }
374
+
375
+ function normalizeProjectFile(file) {
376
+ if (!file || typeof file.path !== 'string') {
377
+ return null;
378
+ }
379
+ const normalizedPath = normalizeRelativePath(file.path);
380
+ if (typeof file.content === 'string') {
381
+ return {
382
+ file: {
383
+ path: normalizedPath,
384
+ kind: 'text',
385
+ content: file.content
386
+ }
387
+ };
388
+ }
389
+ if (typeof file.contentBase64 === 'string') {
390
+ const size = Number.isFinite(Number(file.size)) ? Number(file.size) : estimateBase64Size(file.contentBase64);
391
+ if (size > MAX_BINARY_FILE_BYTES) {
392
+ return {
393
+ skipped: {
394
+ path: normalizedPath,
395
+ kind: 'binary',
396
+ size,
397
+ reason: 'binary_file_too_large'
398
+ }
399
+ };
400
+ }
401
+ return {
402
+ file: {
403
+ path: normalizedPath,
404
+ kind: 'binary',
405
+ contentBase64: file.contentBase64,
406
+ size
407
+ }
408
+ };
409
+ }
410
+ return null;
411
+ }
412
+
413
+ function estimateBase64Size(value) {
414
+ const clean = String(value || '').replace(/\s+/g, '');
415
+ if (!clean) {
416
+ return 0;
417
+ }
418
+ const padding = clean.endsWith('==') ? 2 : clean.endsWith('=') ? 1 : 0;
419
+ return Math.max(0, Math.floor(clean.length * 3 / 4) - padding);
420
+ }
421
+
422
+ function writeProjectFile(target, file) {
423
+ if (file.kind === 'binary') {
424
+ fs.writeFileSync(target, decodeBase64File(file.contentBase64));
425
+ return;
426
+ }
427
+ fs.writeFileSync(target, file.content, 'utf8');
428
+ }
429
+
430
+ function buildBaselineFile(file) {
431
+ const baseline = {
432
+ path: file.path,
433
+ kind: file.kind,
434
+ hash: hashProjectFile(file)
435
+ };
436
+ if (file.kind === 'binary') {
437
+ baseline.size = file.size;
438
+ } else {
439
+ baseline.content = file.content;
440
+ }
441
+ return baseline;
442
+ }
443
+
444
+ function workspaceFileMatchesBaseline(target, baselineFile = {}) {
445
+ if (!fs.existsSync(target)) {
446
+ return false;
447
+ }
448
+ if (baselineFile.kind === 'binary') {
449
+ return hashBytes(fs.readFileSync(target)) === baselineFile.hash;
450
+ }
451
+ return hashText(fs.readFileSync(target, 'utf8')) === baselineFile.hash;
452
+ }
453
+
454
+ function mergePartialBaselineFiles(previousFiles, overlayFiles) {
455
+ const filesByPath = new Map((previousFiles || []).map(file => [file.path, file]));
456
+ for (const file of overlayFiles || []) {
457
+ filesByPath.set(file.path, buildBaselineFile(file));
458
+ }
459
+ return Array.from(filesByPath.values()).sort((left, right) => left.path.localeCompare(right.path));
460
+ }
461
+
462
+ function mergePatchedBaselineFiles(previousFiles, patchedFilesByPath) {
463
+ const filesByPath = new Map((previousFiles || []).map(file => [file.path, file]));
464
+ for (const [filePath, file] of patchedFilesByPath) {
465
+ filesByPath.set(filePath, file);
466
+ }
467
+ return Array.from(filesByPath.values()).sort((left, right) => left.path.localeCompare(right.path));
468
+ }
469
+
470
+ function hashProjectFile(file) {
471
+ return hashBytes(getProjectFileBytes(file));
472
+ }
473
+
474
+ function getProjectFileBytes(file) {
475
+ if (file.kind === 'binary') {
476
+ return decodeBase64File(file.contentBase64);
477
+ }
478
+ return Buffer.from(String(file.content || ''), 'utf8');
479
+ }
480
+
481
+ function decodeBase64File(contentBase64) {
482
+ return Buffer.from(String(contentBase64 || ''), 'base64');
483
+ }
484
+
485
+ function normalizeProjectKey(projectId) {
486
+ const raw = String(projectId || '').trim();
487
+ const fromProjectUrl = raw.match(/\/project\/([^/?#]+)/)?.[1];
488
+ const candidate = fromProjectUrl || raw.split(/[/?#]/).filter(Boolean).pop() || 'unknown-project';
489
+ const safe = candidate.replace(/[^a-zA-Z0-9._-]/g, '-').replace(/^-+|-+$/g, '');
490
+ if (safe) {
491
+ return safe.slice(0, 80);
492
+ }
493
+ return hashText(raw || 'unknown-project').slice(0, 16);
494
+ }
495
+
496
+ function normalizeRelativePath(filePath) {
497
+ const normalized = String(filePath || '').replace(/\\/g, '/').replace(/^\/+/, '');
498
+ if (!normalized || normalized.split('/').some(part => part === '..' || part === '.')) {
499
+ throw new Error(`Unsafe project path: ${filePath}`);
500
+ }
501
+ return normalized;
502
+ }
503
+
504
+ function resolveWorkspacePath(workspacePath, filePath) {
505
+ const relative = normalizeRelativePath(filePath);
506
+ const target = path.resolve(workspacePath, relative);
507
+ const root = path.resolve(workspacePath);
508
+ if (target !== root && !target.startsWith(root + path.sep)) {
509
+ throw new Error(`Unsafe project path: ${filePath}`);
510
+ }
511
+ return target;
512
+ }
513
+
514
+ function listWorkspaceFiles(workspacePath) {
515
+ if (!fs.existsSync(workspacePath)) {
516
+ return [];
517
+ }
518
+ const files = [];
519
+ walk(workspacePath, '');
520
+ return files.sort();
521
+
522
+ function walk(dir, prefix) {
523
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
524
+ if (entry.name === '.DS_Store' || entry.name === TURN_ATTACHMENTS_DIR) {
525
+ continue;
526
+ }
527
+ const relative = prefix ? `${prefix}/${entry.name}` : entry.name;
528
+ const absolute = path.join(dir, entry.name);
529
+ if (entry.isDirectory()) {
530
+ walk(absolute, relative);
531
+ } else if (entry.isFile()) {
532
+ files.push(normalizeRelativePath(relative));
533
+ }
534
+ }
535
+ }
536
+ }
537
+
538
+ function readBaseline(baselinePath) {
539
+ try {
540
+ return JSON.parse(fs.readFileSync(baselinePath, 'utf8'));
541
+ } catch {
542
+ return { files: [] };
543
+ }
544
+ }
545
+
546
+ function writeBaseline(baselinePath, baseline) {
547
+ fs.mkdirSync(path.dirname(baselinePath), { recursive: true });
548
+ fs.writeFileSync(baselinePath, JSON.stringify(baseline, null, 2), 'utf8');
549
+ }
550
+
551
+ function removeEmptyParents(startDir, stopDir) {
552
+ let current = startDir;
553
+ const stop = path.resolve(stopDir);
554
+ while (current.startsWith(stop) && current !== stop) {
555
+ try {
556
+ fs.rmdirSync(current);
557
+ } catch {
558
+ return;
559
+ }
560
+ current = path.dirname(current);
561
+ }
562
+ }
563
+
564
+ function compareSyncChanges(left, right) {
565
+ if (left.type !== right.type) {
566
+ return left.type === 'write' ? -1 : 1;
567
+ }
568
+ return left.path.localeCompare(right.path);
569
+ }
570
+
571
+ function isGeneratedArtifactPath(filePath, baselineByPath) {
572
+ const normalized = normalizeRelativePath(filePath).toLowerCase();
573
+ const basename = path.posix.basename(normalized);
574
+ if (basename === '.latexmkrc') {
575
+ return false;
576
+ }
577
+ if (/^(?:\.|__latexindent_temp)/.test(basename)) {
578
+ return true;
579
+ }
580
+ if (/\.pdf$/i.test(normalized)) {
581
+ return !normalized.includes('/') && hasMatchingRootSourceFile(normalized, baselineByPath);
582
+ }
583
+ return /\.(aux|bbl|bcf|blg|brf|fdb_latexmk|fls|lof|log|lot|out|run\.xml|synctex(?:\.gz)?|toc|xdv)$/i.test(normalized);
584
+ }
585
+
586
+ function hasMatchingRootSourceFile(normalizedPdfPath, baselineByPath) {
587
+ if (!baselineByPath || typeof baselineByPath.has !== 'function') {
588
+ return true;
589
+ }
590
+ const stem = normalizedPdfPath.replace(/\.pdf$/i, '');
591
+ return baselineByPath.has(`${stem}.tex`) || stem === 'main' || stem === 'output';
592
+ }
593
+
594
+ function isSupportedBinaryAssetPath(filePath) {
595
+ return /\.(?:pdf|png|jpe?g|svg)$/i.test(normalizeRelativePath(filePath));
596
+ }
597
+
598
+ function isTextMirrorPath(filePath) {
599
+ const normalized = normalizeRelativePath(filePath).toLowerCase();
600
+ const basename = path.posix.basename(normalized);
601
+ if (basename === '.latexmkrc') {
602
+ return true;
603
+ }
604
+ return /\.(tex|bib|bst|cls|sty|clo|cfg|def|bbx|cbx|lbx|ist|tikz|pgf|asy|txt|md|csv|tsv|dat|json|ya?ml|py|r|m|sh)$/i.test(normalized);
605
+ }
606
+
607
+ function hashText(text) {
608
+ return hashBytes(Buffer.from(String(text || ''), 'utf8'));
609
+ }
610
+
611
+ function hashBytes(bytes) {
612
+ return crypto.createHash('sha256').update(bytes).digest('hex');
613
+ }
614
+
615
+ function getMirrorStatus(projectId, options = {}) {
616
+ const mirror = getProjectMirror(projectId, options);
617
+ const baseline = readBaseline(mirror.baselinePath);
618
+ if (!baseline.lastFullSyncAt) {
619
+ return {
620
+ exists: false,
621
+ projectKey: mirror.projectKey,
622
+ fileCount: 0,
623
+ ageMs: Infinity,
624
+ baselineCapturedAt: baseline.capturedAt || '',
625
+ lastFullSyncAt: '',
626
+ lastPartialSyncAt: baseline.lastPartialSyncAt || '',
627
+ lastSyncSource: baseline.lastSyncSource || '',
628
+ lastFileCount: Number.isFinite(Number(baseline.lastFileCount)) ? Number(baseline.lastFileCount) : (baseline.files || []).length,
629
+ dirty: baseline.dirty === true,
630
+ dirtyReason: baseline.dirtyReason || '',
631
+ workspacePath: mirror.workspacePath,
632
+ ...buildOtStatusFields(baseline, false)
633
+ };
634
+ }
635
+ if (baseline.dirty === true) {
636
+ return {
637
+ exists: false,
638
+ projectKey: mirror.projectKey,
639
+ fileCount: 0,
640
+ ageMs: Infinity,
641
+ baselineCapturedAt: baseline.capturedAt || baseline.lastFullSyncAt || '',
642
+ lastFullSyncAt: '',
643
+ lastPartialSyncAt: baseline.lastPartialSyncAt || '',
644
+ lastSyncSource: baseline.lastSyncSource || '',
645
+ lastFileCount: Number.isFinite(Number(baseline.lastFileCount)) ? Number(baseline.lastFileCount) : (baseline.files || []).length,
646
+ dirty: true,
647
+ dirtyReason: baseline.dirtyReason || 'dirty_mirror',
648
+ workspacePath: mirror.workspacePath,
649
+ ...buildOtStatusFields(baseline, false)
650
+ };
651
+ }
652
+ const lastFullSyncAt = baseline.lastFullSyncAt;
653
+ const lastFullSyncTime = new Date(lastFullSyncAt).getTime();
654
+ if (!Number.isFinite(lastFullSyncTime)) {
655
+ return {
656
+ exists: false,
657
+ projectKey: mirror.projectKey,
658
+ fileCount: 0,
659
+ ageMs: Infinity,
660
+ baselineCapturedAt: lastFullSyncAt,
661
+ lastFullSyncAt: '',
662
+ lastPartialSyncAt: baseline.lastPartialSyncAt || '',
663
+ lastSyncSource: baseline.lastSyncSource || '',
664
+ lastFileCount: Number.isFinite(Number(baseline.lastFileCount)) ? Number(baseline.lastFileCount) : (baseline.files || []).length,
665
+ dirty: false,
666
+ dirtyReason: '',
667
+ workspacePath: mirror.workspacePath,
668
+ ...buildOtStatusFields(baseline, false)
669
+ };
670
+ }
671
+ const integrity = verifyWorkspaceMatchesBaseline(mirror.workspacePath, baseline.files || []);
672
+ if (!integrity.ok) {
673
+ return {
674
+ exists: false,
675
+ projectKey: mirror.projectKey,
676
+ fileCount: 0,
677
+ ageMs: Infinity,
678
+ baselineCapturedAt: lastFullSyncAt,
679
+ lastFullSyncAt: '',
680
+ lastPartialSyncAt: baseline.lastPartialSyncAt || '',
681
+ lastSyncSource: baseline.lastSyncSource || '',
682
+ lastFileCount: Number.isFinite(Number(baseline.lastFileCount)) ? Number(baseline.lastFileCount) : (baseline.files || []).length,
683
+ dirty: true,
684
+ dirtyReason: integrity.reason,
685
+ dirtyPath: integrity.path || '',
686
+ workspacePath: mirror.workspacePath,
687
+ ...buildOtStatusFields(baseline, false)
688
+ };
689
+ }
690
+ const ageMs = Date.now() - lastFullSyncTime;
691
+ return {
692
+ exists: true,
693
+ projectKey: mirror.projectKey,
694
+ fileCount: (baseline.files || []).length,
695
+ ageMs: Math.max(0, ageMs),
696
+ baselineCapturedAt: lastFullSyncAt,
697
+ lastFullSyncAt,
698
+ lastPartialSyncAt: baseline.lastPartialSyncAt || '',
699
+ lastSyncSource: baseline.lastSyncSource || '',
700
+ lastFileCount: Number.isFinite(Number(baseline.lastFileCount)) ? Number(baseline.lastFileCount) : (baseline.files || []).length,
701
+ dirty: false,
702
+ dirtyReason: '',
703
+ workspacePath: mirror.workspacePath,
704
+ ...buildOtStatusFields(baseline, true)
705
+ };
706
+ }
707
+
708
+ function buildOtStatusFields(baseline = {}, trusted) {
709
+ const metadata = {
710
+ lastOtPatchAt: baseline.lastOtPatchAt || '',
711
+ lastOtErrorCode: baseline.lastOtErrorCode || ''
712
+ };
713
+ if (!trusted) {
714
+ return {
715
+ ...metadata,
716
+ otFreshFileCount: 0,
717
+ otStaleFileCount: 0,
718
+ otFreshFiles: []
719
+ };
720
+ }
721
+ const textFiles = (baseline.files || []).filter(file => file?.kind === 'text');
722
+ const freshFiles = textFiles
723
+ .filter(file => file.freshness?.source === 'ot' && file.freshness?.state === 'fresh')
724
+ .map(file => ({
725
+ path: file.path,
726
+ source: file.freshness.source,
727
+ state: file.freshness.state,
728
+ lastFullSyncAt: file.freshness.lastFullSyncAt || '',
729
+ lastPatchAt: file.freshness.lastPatchAt || '',
730
+ observedVersion: file.freshness.observedVersion ?? null
731
+ }))
732
+ .sort((left, right) => left.path.localeCompare(right.path));
733
+ return {
734
+ ...metadata,
735
+ otFreshFileCount: freshFiles.length,
736
+ otStaleFileCount: textFiles.length - freshFiles.length,
737
+ otFreshFiles: freshFiles
738
+ };
739
+ }
740
+
741
+ async function applyFileOverlays({ projectId, overlays, rootDir }) {
742
+ const mirror = getProjectMirror(projectId, { rootDir });
743
+ const baseline = readBaseline(mirror.baselinePath);
744
+ const filesByPath = new Map((baseline.files || []).map(f => [f.path, f]));
745
+
746
+ for (const overlay of overlays || []) {
747
+ if (!overlay?.path || typeof overlay.content !== 'string') {
748
+ continue;
749
+ }
750
+ const normalizedPath = normalizeRelativePath(overlay.path);
751
+ const target = resolveWorkspacePath(mirror.workspacePath, normalizedPath);
752
+ fs.mkdirSync(path.dirname(target), { recursive: true });
753
+ fs.writeFileSync(target, overlay.content, 'utf8');
754
+
755
+ filesByPath.set(normalizedPath, {
756
+ path: normalizedPath,
757
+ kind: 'text',
758
+ hash: overlay.hash || hashText(overlay.content),
759
+ content: overlay.content
760
+ });
761
+ }
762
+
763
+ // Write baseline preserving lastFullSyncAt unchanged
764
+ writeBaseline(mirror.baselinePath, {
765
+ ...baseline,
766
+ files: Array.from(filesByPath.values())
767
+ });
768
+ }
769
+
770
+ async function patchMirrorFiles({ projectId, files, rootDir, source = 'ot' }) {
771
+ const mirror = getProjectMirror(projectId, { rootDir });
772
+ const baseline = readBaseline(mirror.baselinePath);
773
+ const baselineByPath = new Map((baseline.files || []).map(file => [file.path, file]));
774
+ const patchedFilesByPath = new Map();
775
+ const appliedFiles = [];
776
+ const skippedFiles = [];
777
+ const patchSource = typeof source === 'string' && source ? source : 'ot';
778
+ let lastOtPatchAt = baseline.lastOtPatchAt || '';
779
+ let lastOtErrorCode = '';
780
+ let patchBatchAt = '';
781
+
782
+ for (const patch of Array.isArray(files) ? files : []) {
783
+ const normalized = normalizePatchPath(patch, mirror.workspacePath);
784
+ if (!normalized.ok) {
785
+ skippedFiles.push({ path: normalized.path, reason: 'unsafe_path' });
786
+ lastOtErrorCode = 'unsafe_path';
787
+ continue;
788
+ }
789
+
790
+ const baselineFile = baselineByPath.get(normalized.path);
791
+ if (!baselineFile) {
792
+ skippedFiles.push({ path: normalized.path, reason: 'missing_baseline' });
793
+ lastOtErrorCode = 'missing_baseline';
794
+ continue;
795
+ }
796
+ if (baselineFile.kind !== 'text') {
797
+ skippedFiles.push({ path: normalized.path, reason: 'not_text' });
798
+ lastOtErrorCode = 'not_text';
799
+ continue;
800
+ }
801
+ if (typeof patch?.nextContent !== 'string') {
802
+ skippedFiles.push({ path: normalized.path, reason: 'missing_content' });
803
+ lastOtErrorCode = 'missing_content';
804
+ continue;
805
+ }
806
+ if (typeof patch.baseHash !== 'string' || !patch.baseHash) {
807
+ skippedFiles.push({ path: normalized.path, reason: 'missing_base_hash' });
808
+ lastOtErrorCode = 'missing_base_hash';
809
+ continue;
810
+ }
811
+ const nextHash = hashText(patch.nextContent);
812
+ if (patch.baseHash !== baselineFile.hash) {
813
+ if (nextHash === baselineFile.hash) {
814
+ if (baseline.dirty === true) {
815
+ skippedFiles.push({ path: normalized.path, reason: 'dirty_mirror' });
816
+ lastOtErrorCode = 'dirty_mirror';
817
+ continue;
818
+ }
819
+ if (!isSafeWorkspaceWriteTarget(mirror.workspacePath, normalized.target)) {
820
+ skippedFiles.push({ path: normalized.path, reason: 'unsafe_path' });
821
+ lastOtErrorCode = 'unsafe_path';
822
+ continue;
823
+ }
824
+ if (!workspaceFileMatchesBaseline(normalized.target, baselineFile)) {
825
+ skippedFiles.push({ path: normalized.path, reason: 'workspace_mismatch' });
826
+ lastOtErrorCode = 'workspace_mismatch';
827
+ continue;
828
+ }
829
+ appliedFiles.push({
830
+ path: normalized.path,
831
+ hash: baselineFile.hash,
832
+ observedVersion: patch.observedVersion ?? null,
833
+ idempotent: true
834
+ });
835
+ continue;
836
+ }
837
+ skippedFiles.push({ path: normalized.path, reason: 'base_hash_mismatch' });
838
+ lastOtErrorCode = 'base_hash_mismatch';
839
+ continue;
840
+ }
841
+ if (baseline.dirty === true) {
842
+ skippedFiles.push({ path: normalized.path, reason: 'dirty_mirror' });
843
+ lastOtErrorCode = 'dirty_mirror';
844
+ continue;
845
+ }
846
+ if (!isSafeWorkspaceWriteTarget(mirror.workspacePath, normalized.target)) {
847
+ skippedFiles.push({ path: normalized.path, reason: 'unsafe_path' });
848
+ lastOtErrorCode = 'unsafe_path';
849
+ continue;
850
+ }
851
+ if (!workspaceFileMatchesBaseline(normalized.target, baselineFile)) {
852
+ skippedFiles.push({ path: normalized.path, reason: 'workspace_mismatch' });
853
+ lastOtErrorCode = 'workspace_mismatch';
854
+ continue;
855
+ }
856
+
857
+ patchBatchAt ||= new Date().toISOString();
858
+ lastOtPatchAt = patchBatchAt;
859
+ fs.writeFileSync(normalized.target, patch.nextContent, 'utf8');
860
+
861
+ const nextBaselineFile = {
862
+ ...baselineFile,
863
+ path: normalized.path,
864
+ kind: 'text',
865
+ hash: nextHash,
866
+ content: patch.nextContent,
867
+ freshness: {
868
+ source: patchSource,
869
+ state: 'fresh',
870
+ lastFullSyncAt: baseline.lastFullSyncAt || '',
871
+ lastPatchAt: patchBatchAt,
872
+ observedVersion: patch.observedVersion ?? null
873
+ }
874
+ };
875
+ patchedFilesByPath.set(normalized.path, nextBaselineFile);
876
+ baselineByPath.set(normalized.path, nextBaselineFile);
877
+ appliedFiles.push({
878
+ path: normalized.path,
879
+ hash: nextHash,
880
+ observedVersion: patch.observedVersion ?? null
881
+ });
882
+ }
883
+
884
+ if (appliedFiles.length || skippedFiles.length) {
885
+ writeBaseline(mirror.baselinePath, {
886
+ ...baseline,
887
+ lastOtPatchAt,
888
+ lastOtErrorCode,
889
+ files: mergePatchedBaselineFiles(baseline.files || [], patchedFilesByPath)
890
+ });
891
+ }
892
+
893
+ return {
894
+ ...mirror,
895
+ appliedCount: appliedFiles.length,
896
+ skippedCount: skippedFiles.length,
897
+ appliedFiles,
898
+ skippedFiles
899
+ };
900
+ }
901
+
902
+ function normalizePatchPath(patch, workspacePath) {
903
+ const rawPath = typeof patch?.path === 'string' ? patch.path : '';
904
+ try {
905
+ const normalizedPath = normalizeRelativePath(rawPath);
906
+ return {
907
+ ok: true,
908
+ path: normalizedPath,
909
+ target: resolveWorkspacePath(workspacePath, normalizedPath)
910
+ };
911
+ } catch {
912
+ return { ok: false, path: rawPath };
913
+ }
914
+ }
915
+
916
+ function isSafeWorkspaceWriteTarget(workspacePath, target) {
917
+ const root = path.resolve(workspacePath);
918
+ const resolvedTarget = path.resolve(target);
919
+ if (resolvedTarget === root || !resolvedTarget.startsWith(root + path.sep)) {
920
+ return false;
921
+ }
922
+ try {
923
+ const rootStat = fs.lstatSync(root);
924
+ if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) {
925
+ return false;
926
+ }
927
+ const rootRealPath = fs.realpathSync(root);
928
+ let current = root;
929
+ const parts = path.relative(root, resolvedTarget).split(path.sep).filter(Boolean);
930
+ for (let index = 0; index < parts.length; index++) {
931
+ current = path.join(current, parts[index]);
932
+ let stat;
933
+ try {
934
+ stat = fs.lstatSync(current);
935
+ } catch (error) {
936
+ if (error?.code === 'ENOENT') {
937
+ break;
938
+ }
939
+ return false;
940
+ }
941
+ if (stat.isSymbolicLink()) {
942
+ return false;
943
+ }
944
+ if (index < parts.length - 1 && !stat.isDirectory()) {
945
+ return false;
946
+ }
947
+ if (index === parts.length - 1 && !stat.isFile()) {
948
+ return false;
949
+ }
950
+ const realPath = fs.realpathSync(current);
951
+ if (realPath !== rootRealPath && !realPath.startsWith(rootRealPath + path.sep)) {
952
+ return false;
953
+ }
954
+ }
955
+ return true;
956
+ } catch {
957
+ return false;
958
+ }
959
+ }
960
+
961
+ function markMirrorDirty({ projectId, rootDir, reason = 'dirty_mirror' }) {
962
+ const mirror = getProjectMirror(projectId, { rootDir });
963
+ const baseline = readBaseline(mirror.baselinePath);
964
+ writeBaseline(mirror.baselinePath, {
965
+ ...baseline,
966
+ projectKey: mirror.projectKey,
967
+ dirty: true,
968
+ dirtyReason: reason,
969
+ dirtyAt: new Date().toISOString()
970
+ });
971
+ }
972
+
973
+ function verifyWorkspaceMatchesBaseline(workspacePath, baselineFiles = []) {
974
+ const baselinePaths = new Set();
975
+ for (const file of baselineFiles || []) {
976
+ if (!file?.path) {
977
+ continue;
978
+ }
979
+ baselinePaths.add(file.path);
980
+ const target = resolveWorkspacePath(workspacePath, file.path);
981
+ if (!fs.existsSync(target)) {
982
+ return { ok: false, reason: 'workspace_mismatch', path: file.path };
983
+ }
984
+ if (file.kind === 'binary') {
985
+ if (hashBytes(fs.readFileSync(target)) !== file.hash) {
986
+ return { ok: false, reason: 'workspace_mismatch', path: file.path };
987
+ }
988
+ continue;
989
+ }
990
+ const content = fs.readFileSync(target, 'utf8');
991
+ if (hashText(content) !== file.hash) {
992
+ return { ok: false, reason: 'workspace_mismatch', path: file.path };
993
+ }
994
+ }
995
+ for (const filePath of listWorkspaceFiles(workspacePath)) {
996
+ if (baselinePaths.has(filePath)) {
997
+ continue;
998
+ }
999
+ if (isTextMirrorPath(filePath) && !isGeneratedArtifactPath(filePath)) {
1000
+ return { ok: false, reason: 'workspace_extra_file', path: filePath };
1001
+ }
1002
+ }
1003
+ return { ok: true };
1004
+ }
1005
+
1006
+ module.exports = {
1007
+ NATIVE_OUTPUT_LIMIT_BYTES,
1008
+ SAFE_INLINE_BINARY_CHANGE_BYTES,
1009
+ SAFE_NATIVE_RESPONSE_PAYLOAD_BYTES,
1010
+ applyFileOverlays,
1011
+ collectMirrorChangesDetailed,
1012
+ collectMirrorChanges,
1013
+ getDefaultMirrorRoot,
1014
+ getMirrorStatus,
1015
+ getProjectMirror,
1016
+ markMirrorDirty,
1017
+ patchMirrorFiles,
1018
+ syncOverleafToMirror
1019
+ };