codex-overleaf-link 2.3.5 → 2.4.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.
Files changed (57) hide show
  1. package/README.md +63 -31
  2. package/README.zh-CN.md +543 -0
  3. package/extension/bootstrap/manifest.template.json +1 -1
  4. package/extension/src/background.js +7 -0
  5. package/extension/src/backgroundUpdateCoordinator.js +88 -4
  6. package/extension/src/content/activeTurnControl.js +1 -0
  7. package/extension/src/content/assetTransferBroker.js +2 -0
  8. package/extension/src/content/contentRuntime.js +77 -57
  9. package/extension/src/content/contextTray.js +4 -0
  10. package/extension/src/content/diagnosticsController.js +17 -3
  11. package/extension/src/content/diagnosticsPanel.js +129 -5
  12. package/extension/src/content/generated/content.bundle.js +1422 -348
  13. package/extension/src/content/generated/content.bundle.meta.json +7 -3
  14. package/extension/src/content/legacyGlobalRegistry.js +3 -0
  15. package/extension/src/content/modelPicker.js +21 -9
  16. package/extension/src/content/modelPickerSupport.js +38 -1
  17. package/extension/src/content/nativeCompatibilityController.js +4 -1
  18. package/extension/src/content/otWarmMirror.js +126 -92
  19. package/extension/src/content/otWarmMirrorController.js +122 -1
  20. package/extension/src/content/pageBridgeClient.js +2 -0
  21. package/extension/src/content/panelMaintenance.js +26 -15
  22. package/extension/src/content/providerSettingsCoordinator.js +1 -0
  23. package/extension/src/content/runFailureNotice.js +104 -0
  24. package/extension/src/content/runResultActions.js +16 -1
  25. package/extension/src/content/runScrollLayout.js +124 -0
  26. package/extension/src/content/runTimelineView.js +37 -11
  27. package/extension/src/content/sessionForkController.js +20 -11
  28. package/extension/src/content/settingsPanel.js +28 -11
  29. package/extension/src/content/trackedChangeCaptureController.js +153 -0
  30. package/extension/src/content/updateNotice.js +24 -4
  31. package/extension/src/content/writebackOrchestrator.js +31 -0
  32. package/extension/src/page/binaryAssetUploader.js +101 -29
  33. package/extension/src/page/overleafRealtimeObserver.js +120 -25
  34. package/extension/src/page/textFileCreator.js +493 -0
  35. package/extension/src/page/trackedChangeCapture.js +342 -0
  36. package/extension/src/page/trackedChangesLifecycle.js +145 -149
  37. package/extension/src/page/treeOperations.js +30 -2
  38. package/extension/src/page/writebackRouter.js +94 -48
  39. package/extension/src/pageBridge.js +62 -25
  40. package/extension/src/shared/compatibility.js +1 -1
  41. package/extension/src/shared/i18n.js +77 -37
  42. package/extension/src/shared/managedUpdateProjection.js +1 -1
  43. package/extension/src/shared/otText.js +0 -1
  44. package/extension/src/shared/pageRpcContract.js +2 -1
  45. package/extension/src/shared/sessionState.js +23 -23
  46. package/extension/src/shared/sessionTitle.js +120 -0
  47. package/extension/src/shared/storageDb.js +10 -12
  48. package/extension/src/shared/storageRunActions.js +29 -1
  49. package/extension/src/shared/updateRuntimeIdentity.js +32 -0
  50. package/extension/src/shared/writebackSettlement.js +19 -5
  51. package/extension/styles/panel.css +128 -24
  52. package/native-host/src/mirrorWorkspace.js +20 -1
  53. package/native-host/src/releaseProxy.js +177 -0
  54. package/native-host/src/releaseTransport.js +154 -0
  55. package/native-host/src/taskRunnerRuntime.js +25 -0
  56. package/native-host/src/updateManager.js +40 -39
  57. package/package.json +5 -1
@@ -1029,11 +1029,21 @@
1029
1029
  }
1030
1030
 
1031
1031
  #codex-overleaf-panel .codex-diagnostics-menu {
1032
- position: absolute;
1032
+ position: fixed;
1033
1033
  z-index: 2147483647;
1034
- top: 30px;
1035
- right: 0;
1036
- width: 260px;
1034
+ top: 44px;
1035
+ right: 12px;
1036
+ bottom: auto;
1037
+ left: auto;
1038
+ margin: 0;
1039
+ box-sizing: border-box;
1040
+ width: min(300px, calc(100vw - 24px));
1041
+ max-width: calc(100vw - 24px);
1042
+ max-height: calc(100vh - 24px);
1043
+ overflow: auto;
1044
+ overscroll-behavior: contain;
1045
+ overflow-wrap: anywhere;
1046
+ color: var(--tl-fg-2);
1037
1047
  border: 1px solid var(--tl-border-strong);
1038
1048
  border-radius: 6px;
1039
1049
  background: var(--tl-surface-2);
@@ -1084,15 +1094,30 @@
1084
1094
  z-index: 2147483647;
1085
1095
  top: 44px;
1086
1096
  right: 12px;
1087
- width: min(316px, calc(var(--codex-overleaf-panel-width) - 24px));
1088
- max-height: min(520px, calc(100vh - 64px));
1097
+ bottom: auto;
1098
+ left: auto;
1099
+ margin: 0;
1100
+ padding: 0;
1101
+ box-sizing: border-box;
1102
+ width: min(560px, calc(100vw - 24px));
1103
+ max-width: calc(100vw - 24px);
1104
+ max-height: min(640px, calc(100vh - 24px));
1089
1105
  overflow: auto;
1106
+ overscroll-behavior: contain;
1107
+ overflow-wrap: anywhere;
1108
+ color: var(--tl-fg-2);
1090
1109
  border: 1px solid var(--tl-border-strong);
1091
1110
  border-radius: 8px;
1092
1111
  background: var(--tl-surface-1);
1093
1112
  box-shadow: 0 14px 34px rgba(0, 0, 0, 0.48);
1094
1113
  }
1095
1114
 
1115
+ #codex-overleaf-panel .codex-diagnostics-menu::backdrop,
1116
+ #codex-overleaf-panel .codex-diagnostics-result::backdrop {
1117
+ background: transparent;
1118
+ pointer-events: none;
1119
+ }
1120
+
1096
1121
  #codex-overleaf-panel .codex-diagnostics-result[hidden] {
1097
1122
  display: none;
1098
1123
  }
@@ -1581,7 +1606,7 @@
1581
1606
 
1582
1607
  #codex-overleaf-panel .codex-set-row-help {
1583
1608
  margin: 0;
1584
- color: var(--tl-fg-4);
1609
+ color: var(--tl-fg-3);
1585
1610
  font-size: 11px;
1586
1611
  line-height: 1.45;
1587
1612
  }
@@ -1821,9 +1846,12 @@
1821
1846
  #codex-overleaf-panel .codex-plugin-confirm::backdrop { background: transparent; }
1822
1847
 
1823
1848
  #codex-overleaf-panel .codex-plugin-confirm-card {
1849
+ display: flex;
1850
+ flex-direction: column;
1851
+ box-sizing: border-box;
1824
1852
  width: 100%;
1825
1853
  max-height: min(520px, calc(100vh - 28px));
1826
- overflow: hidden;
1854
+ overflow: auto;
1827
1855
  border: 1px solid var(--tl-border-strong);
1828
1856
  border-radius: 8px;
1829
1857
  background: var(--tl-surface-1);
@@ -1832,6 +1860,7 @@
1832
1860
 
1833
1861
  #codex-overleaf-panel .codex-plugin-confirm-head {
1834
1862
  display: flex;
1863
+ flex: 0 0 auto;
1835
1864
  align-items: center;
1836
1865
  gap: 10px;
1837
1866
  padding: 14px 14px 12px;
@@ -1860,6 +1889,8 @@
1860
1889
  }
1861
1890
 
1862
1891
  #codex-overleaf-panel .codex-plugin-confirm-body {
1892
+ flex: 0 1 auto;
1893
+ min-height: 0;
1863
1894
  max-height: 300px;
1864
1895
  overflow: auto;
1865
1896
  padding: 12px 14px;
@@ -1872,6 +1903,9 @@
1872
1903
 
1873
1904
  #codex-overleaf-panel .codex-plugin-confirm-actions {
1874
1905
  display: flex;
1906
+ flex: 0 0 auto;
1907
+ flex-wrap: wrap;
1908
+ align-items: stretch;
1875
1909
  justify-content: flex-end;
1876
1910
  gap: 8px;
1877
1911
  padding: 12px 14px 14px;
@@ -1879,12 +1913,23 @@
1879
1913
  }
1880
1914
 
1881
1915
  #codex-overleaf-panel .codex-plugin-confirm-actions button {
1882
- min-width: 72px;
1883
- height: 30px;
1916
+ display: flex;
1917
+ flex: 1 1 auto;
1918
+ align-items: center;
1919
+ justify-content: center;
1920
+ box-sizing: border-box;
1921
+ min-width: min(72px, 100%);
1922
+ max-width: 100%;
1923
+ min-height: 34px;
1924
+ height: auto;
1884
1925
  border: 1px solid var(--tl-border-strong);
1885
1926
  border-radius: 6px;
1886
- padding: 0 12px;
1927
+ padding: 7px 12px;
1887
1928
  font-weight: 650;
1929
+ line-height: 1.35;
1930
+ text-align: center;
1931
+ white-space: normal;
1932
+ overflow-wrap: anywhere;
1888
1933
  }
1889
1934
 
1890
1935
  #codex-overleaf-panel .codex-plugin-confirm-cancel {
@@ -2170,7 +2215,7 @@
2170
2215
  padding: 4px 10px;
2171
2216
  font-size: 11px;
2172
2217
  cursor: pointer;
2173
- box-shadow: 0 2px 8px rgba(0, 0, 0, 0.35);
2218
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12);
2174
2219
  animation: tl-fade-in 120ms ease-out;
2175
2220
  }
2176
2221
 
@@ -2185,6 +2230,32 @@
2185
2230
  border-color: var(--tl-border-strong);
2186
2231
  }
2187
2232
 
2233
+ /* Let the browser anchor backscroll while the reader is detached. */
2234
+ #codex-overleaf-panel [data-log][data-log-auto-follow="false"],
2235
+ #codex-overleaf-panel [data-main][data-log-auto-follow="false"] {
2236
+ overflow-anchor: auto;
2237
+ }
2238
+
2239
+ #codex-overleaf-panel :is(.run-copy-action, .run-fork-action, .tl-jump-latest, .run-final-answer__recovery-action):focus-visible {
2240
+ outline: 2px solid var(--tl-accent);
2241
+ outline-offset: 2px;
2242
+ }
2243
+
2244
+ #codex-overleaf-panel :is(input, textarea)::placeholder {
2245
+ color: var(--tl-fg-3);
2246
+ opacity: 1;
2247
+ }
2248
+
2249
+ .codex-provider-dialog-root .codex-provider-field :is(input, textarea)::placeholder {
2250
+ color: var(--provider-muted);
2251
+ opacity: 1;
2252
+ }
2253
+
2254
+ .codex-provider-dialog-root :is(button, input, textarea, select):focus-visible {
2255
+ outline: 2px solid var(--provider-accent);
2256
+ outline-offset: 2px;
2257
+ }
2258
+
2188
2259
  /* Active-session bar: the title on the left, rename + delete on the right, so
2189
2260
  the session you are working in can be renamed/deleted in place. */
2190
2261
  #codex-overleaf-panel .codex-thread-title {
@@ -3084,13 +3155,13 @@
3084
3155
  }
3085
3156
 
3086
3157
  #codex-overleaf-panel .run-final-answer .run-system-label {
3087
- color: var(--tl-accent);
3158
+ color: var(--tl-fg-3);
3088
3159
  font-weight: 650;
3089
3160
  letter-spacing: 0.01em;
3090
3161
  }
3091
3162
 
3092
3163
  #codex-overleaf-panel .run-completion-report[data-status="failed"] .run-system-label[data-system-label="conclusion"] {
3093
- color: var(--tl-fail);
3164
+ color: var(--tl-fg-3);
3094
3165
  }
3095
3166
 
3096
3167
  #codex-overleaf-panel .run-final-answer .run-change-summary {
@@ -3179,9 +3250,8 @@
3179
3250
  }
3180
3251
 
3181
3252
  #codex-overleaf-panel .run-final-answer__meta {
3182
- margin: 10px 0 0;
3183
- padding: 8px 0 0;
3184
- border-top: 1px solid var(--tl-border);
3253
+ margin: 14px 0 0;
3254
+ padding: 0;
3185
3255
  display: grid;
3186
3256
  grid-template-columns: max-content 1fr;
3187
3257
  column-gap: 12px;
@@ -3205,18 +3275,15 @@
3205
3275
  }
3206
3276
 
3207
3277
  #codex-overleaf-panel .run-completion-report[data-status="failed"] {
3208
- color: var(--tl-fail);
3278
+ color: var(--tl-fg-2);
3209
3279
  }
3210
3280
 
3211
- /* .run-final-answer resets color to fg-1 for normal reports; a failed report's
3212
- conclusion must keep the alert color or failure reads like success. */
3281
+ /* Failure severity belongs to the status header and compact notice. */
3213
3282
  #codex-overleaf-panel .run-completion-report[data-status="failed"] .run-final-answer {
3214
- color: var(--tl-fail);
3283
+ color: var(--tl-fg-2);
3215
3284
  }
3216
3285
 
3217
- /* Failed-status reports keep their alert color for the conclusion, but the
3218
- meta block stays muted so it still reads as system info rather than amplifying
3219
- the alert. */
3286
+ /* Keep system metadata subordinate to the answer in both themes. */
3220
3287
  #codex-overleaf-panel .run-completion-report[data-status="failed"] .run-final-answer__meta,
3221
3288
  #codex-overleaf-panel .run-completion-report[data-status="failed"] .run-final-answer__meta-value {
3222
3289
  color: var(--tl-fg-3);
@@ -3252,6 +3319,43 @@
3252
3319
  opacity: 0.6;
3253
3320
  }
3254
3321
 
3322
+ #codex-overleaf-panel .run-feedback {
3323
+ display: grid;
3324
+ gap: 5px;
3325
+ margin: 12px 0 0;
3326
+ padding: 10px 12px;
3327
+ border-radius: 8px;
3328
+ background: var(--tl-surface-2);
3329
+ color: var(--tl-fg-2);
3330
+ font-size: 12px;
3331
+ line-height: 1.5;
3332
+ overflow-wrap: anywhere;
3333
+ }
3334
+
3335
+ #codex-overleaf-panel .run-feedback p { margin: 0; }
3336
+ #codex-overleaf-panel .run-feedback__title { color: var(--tl-fail); font-weight: 650; }
3337
+ #codex-overleaf-panel .run-feedback[data-tone="warning"] .run-feedback__title { color: var(--tl-review); }
3338
+ #codex-overleaf-panel .run-feedback .run-final-answer__recovery-action {
3339
+ justify-self: start;
3340
+ margin: 3px 0 0;
3341
+ padding: 3px 0;
3342
+ border: 0;
3343
+ background: transparent;
3344
+ color: var(--tl-accent);
3345
+ font-weight: 600;
3346
+ }
3347
+
3348
+ #codex-overleaf-panel :is(.run-copy-action, .run-fork-action) {
3349
+ color: var(--tl-fg-3);
3350
+ }
3351
+
3352
+ @media (prefers-reduced-motion: reduce) {
3353
+ #codex-overleaf-panel :is(.tl-jump-latest, .run-copy-action, .run-fork-action, .run-final-answer__recovery-action) {
3354
+ animation: none;
3355
+ transition: none;
3356
+ }
3357
+ }
3358
+
3255
3359
  #codex-overleaf-panel .codex-probe-line {
3256
3360
  flex: 0 0 auto;
3257
3361
  min-height: 18px;
@@ -712,6 +712,22 @@ function getMirrorStatus(projectId, options = {}) {
712
712
  };
713
713
  }
714
714
 
715
+ function normalizeObservedPatchTime(value, fallback) {
716
+ const candidate = value == null ? fallback : value;
717
+ const timestamp = typeof candidate === 'number'
718
+ ? candidate
719
+ : typeof candidate === 'string' ? Date.parse(candidate) : NaN;
720
+ return Number.isFinite(timestamp) && Math.abs(timestamp) <= 8640000000000000
721
+ ? new Date(timestamp).toISOString()
722
+ : '';
723
+ }
724
+
725
+ function isFreshOtObservation(freshness) {
726
+ const timestamp = Date.parse(freshness?.observedAt ?? freshness?.lastPatchAt ?? '');
727
+ const ageMs = Date.now() - timestamp;
728
+ return Number.isFinite(timestamp) && ageMs >= 0 && ageMs <= 30000;
729
+ }
730
+
715
731
  function buildOtStatusFields(baseline = {}, trusted) {
716
732
  const metadata = {
717
733
  lastOtPatchAt: baseline.lastOtPatchAt || '',
@@ -727,13 +743,15 @@ function buildOtStatusFields(baseline = {}, trusted) {
727
743
  }
728
744
  const textFiles = (baseline.files || []).filter(file => file?.kind === 'text');
729
745
  const freshFiles = textFiles
730
- .filter(file => file.freshness?.source === 'ot' && file.freshness?.state === 'fresh')
746
+ .filter(file => file.freshness?.source === 'ot' && file.freshness?.state === 'fresh'
747
+ && isFreshOtObservation(file.freshness))
731
748
  .map(file => ({
732
749
  path: file.path,
733
750
  source: file.freshness.source,
734
751
  state: file.freshness.state,
735
752
  lastFullSyncAt: file.freshness.lastFullSyncAt || '',
736
753
  lastPatchAt: file.freshness.lastPatchAt || '',
754
+ observedAt: file.freshness.observedAt ?? file.freshness.lastPatchAt ?? '',
737
755
  observedVersion: file.freshness.observedVersion ?? null
738
756
  }))
739
757
  .sort((left, right) => left.path.localeCompare(right.path));
@@ -935,6 +953,7 @@ async function patchMirrorFiles({ projectId, files, rootDir, source = 'ot' }) {
935
953
  state: 'fresh',
936
954
  lastFullSyncAt: baseline.lastFullSyncAt || '',
937
955
  lastPatchAt: patchBatchAt,
956
+ observedAt: normalizeObservedPatchTime(patch.observedAt, patchBatchAt),
938
957
  observedVersion: patch.observedVersion ?? null
939
958
  }
940
959
  };
@@ -0,0 +1,177 @@
1
+ 'use strict';
2
+
3
+ const http = require('node:http');
4
+ const https = require('node:https');
5
+ const tls = require('node:tls');
6
+ const net = require('node:net');
7
+ const { Readable } = require('node:stream');
8
+ const { execFileSync } = require('node:child_process');
9
+ const { updateError } = require('./updateTrust');
10
+ let systemCache = { platform: '', at: 0, text: '' };
11
+
12
+ function proxyError(code, message, retryable = false) {
13
+ return Object.assign(updateError(code, message), { retryable });
14
+ }
15
+
16
+ function bypassProxy(url, values = '') {
17
+ const host = url.hostname.toLowerCase();
18
+ const port = url.port || (url.protocol === 'https:' ? '443' : '80');
19
+ return String(values).split(/[,;\s]+/).some(raw => {
20
+ if (!raw) return false;
21
+ if (raw === '*') return true;
22
+ if (raw === '<local>') return !host.includes('.');
23
+ const match = raw.toLowerCase().match(/^(.*?)(?::(\d+))?$/);
24
+ if (match[2] && match[2] !== port) return false;
25
+ const domain = match[1].replace(/^\*?\./, '');
26
+ return host === domain || host.endsWith('.' + domain);
27
+ });
28
+ }
29
+
30
+ function readSystemProxy(platform) {
31
+ if (systemCache.platform === platform && Date.now() - systemCache.at < 30000) return systemCache.text;
32
+ let text = '';
33
+ try {
34
+ const command = platform === 'darwin' ? '/usr/sbin/scutil' : 'reg.exe';
35
+ const args = platform === 'darwin' ? ['--proxy']
36
+ : ['query', 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings'];
37
+ text = execFileSync(command, args, { encoding: 'utf8', timeout: 1000, maxBuffer: 32768,
38
+ windowsHide: true, stdio: ['ignore', 'pipe', 'ignore'] });
39
+ } catch (_error) { /* environment proxies and direct access remain available */ }
40
+ systemCache = { platform, at: Date.now(), text };
41
+ return text;
42
+ }
43
+
44
+ function resolveReleaseProxy(value, options = {}) {
45
+ const url = new URL(value);
46
+ const env = options.env || process.env;
47
+ if (bypassProxy(url, env.NO_PROXY || env.no_proxy)) return null;
48
+ const protocol = url.protocol === 'https:' ? 'HTTPS' : 'HTTP';
49
+ let configured = env[protocol + '_PROXY'] || env[protocol.toLowerCase() + '_proxy'] || env.ALL_PROXY || env.all_proxy;
50
+ let source = 'environment';
51
+ const platform = options.platform || process.platform;
52
+ if (!configured && ['darwin', 'win32'].includes(platform)) {
53
+ source = 'system';
54
+ const text = (options.systemProxy || readSystemProxy)(platform);
55
+ if (platform === 'darwin') {
56
+ const field = key => text.match(new RegExp('(?:^|\\n)\\s*' + key + '\\s*:\\s*([^\\n]+)'))?.[1]?.trim() || '';
57
+ if (field(protocol + 'Enable') === '1') {
58
+ const host = field(protocol + 'Proxy');
59
+ const port = Number(field(protocol + 'Port'));
60
+ if (host && Number.isInteger(port) && port > 0 && port <= 65535) {
61
+ configured = 'http://' + (host.includes(':') ? '[' + host + ']' : host) + ':' + port;
62
+ }
63
+ }
64
+ const exceptions = text.match(/ExceptionsList\s*:[\s\S]*?\n\s*}/)?.[0] || '';
65
+ const values = Array.from(exceptions.matchAll(/\d+\s*:\s*([^\n]+)/g), match => match[1].trim()).join(',');
66
+ if (bypassProxy(url, values)) return null;
67
+ } else if (/ProxyEnable\s+REG_DWORD\s+0x1\b/i.test(text)) {
68
+ const server = text.match(/ProxyServer\s+REG_SZ\s+([^\r\n]+)/i)?.[1]?.trim() || '';
69
+ const exceptions = text.match(/ProxyOverride\s+REG_SZ\s+([^\r\n]+)/i)?.[1] || '';
70
+ if (bypassProxy(url, exceptions)) return null;
71
+ configured = server.includes('=')
72
+ ? server.split(';').find(part => part.toLowerCase().startsWith(protocol.toLowerCase() + '='))?.split('=').slice(1).join('=')
73
+ : server;
74
+ if (configured && !configured.includes('://')) configured = 'http://' + configured;
75
+ }
76
+ }
77
+ if (!configured) return null;
78
+ let proxy;
79
+ try { proxy = new URL(configured); } catch (_error) {
80
+ throw proxyError('update_proxy_invalid', 'The configured update proxy address is invalid.');
81
+ }
82
+ if (!['http:', 'https:'].includes(proxy.protocol)) {
83
+ throw proxyError('update_proxy_unsupported', 'The update proxy must use HTTP or HTTPS. SOCKS and PAC require an HTTP proxy endpoint.');
84
+ }
85
+ return { url: proxy.href, source };
86
+ }
87
+
88
+ function hostname(url) { return url.hostname.replace(/^\[|\]$/g, ''); }
89
+ function proxyHeaders(proxy) {
90
+ if (!proxy.username && !proxy.password) return {};
91
+ let credentials;
92
+ try { credentials = decodeURIComponent(proxy.username) + ':' + decodeURIComponent(proxy.password); }
93
+ catch (_error) { throw proxyError('update_proxy_invalid', 'The configured proxy credentials are malformed.'); }
94
+ return { 'Proxy-Authorization': 'Basic ' + Buffer.from(credentials).toString('base64') };
95
+ }
96
+
97
+ function tunnel(target, proxy, signal) {
98
+ return new Promise((resolve, reject) => {
99
+ const authority = target.hostname + ':' + (target.port || '443');
100
+ const client = proxy.protocol === 'https:' ? https : http;
101
+ const request = client.request({ protocol: proxy.protocol, hostname: hostname(proxy), port: proxy.port,
102
+ method: 'CONNECT', path: authority, headers: { Host: authority, ...proxyHeaders(proxy) }, agent: false, signal });
103
+ request.once('error', reject);
104
+ request.once('connect', (response, socket, head) => {
105
+ if (response.statusCode !== 200) {
106
+ socket.destroy();
107
+ reject(proxyError(response.statusCode === 407 ? 'update_proxy_auth_required' : 'update_proxy_failed',
108
+ 'The proxy refused the secure update connection (HTTP ' + response.statusCode + ').', response.statusCode >= 500));
109
+ return;
110
+ }
111
+ if (signal.aborted) { socket.destroy(); reject(signal.reason); return; }
112
+ if (head.length) socket.unshift(head);
113
+ const secure = tls.connect({ socket, host: hostname(target),
114
+ servername: net.isIP(hostname(target)) ? undefined : hostname(target), ALPNProtocols: ['http/1.1'] });
115
+ const abort = () => secure.destroy(signal.reason);
116
+ signal.addEventListener('abort', abort, { once: true });
117
+ secure.once('close', () => signal.removeEventListener('abort', abort));
118
+ secure.once('error', reject);
119
+ secure.once('secureConnect', () => resolve(secure));
120
+ });
121
+ request.end();
122
+ });
123
+ }
124
+
125
+ async function proxyRequest(target, proxy, init) {
126
+ let agent;
127
+ if (target.protocol === 'https:') {
128
+ const socket = await tunnel(target, proxy, init.signal);
129
+ if (init.signal.aborted) { socket.destroy(); throw init.signal.reason; }
130
+ agent = new https.Agent({ keepAlive: false });
131
+ agent.createConnection = () => socket;
132
+ }
133
+ return new Promise((resolve, reject) => {
134
+ const headers = { ...Object.fromEntries(new Headers(init.headers || {})), Host: target.host };
135
+ const secure = target.protocol === 'https:';
136
+ const client = secure || proxy.protocol === 'https:' ? https : http;
137
+ const request = secure
138
+ ? client.request(target, { method: init.method, headers, agent, signal: init.signal })
139
+ : client.request({ protocol: proxy.protocol, hostname: hostname(proxy), port: proxy.port,
140
+ method: init.method, path: target.href, headers: { ...headers, ...proxyHeaders(proxy) }, agent: false, signal: init.signal });
141
+ request.once('error', error => { agent?.destroy(); reject(error); });
142
+ request.once('response', incoming => {
143
+ incoming.once('close', () => agent?.destroy());
144
+ const headers = new Headers();
145
+ for (let i = 0; i < incoming.rawHeaders.length; i += 2) headers.append(incoming.rawHeaders[i], incoming.rawHeaders[i + 1]);
146
+ const empty = init.method === 'HEAD' || [204, 205, 304].includes(incoming.statusCode);
147
+ if (empty) incoming.resume();
148
+ const response = new Response(empty ? null : Readable.toWeb(incoming),
149
+ { status: incoming.statusCode, statusText: incoming.statusMessage, headers });
150
+ Object.defineProperty(response, 'url', { value: target.href });
151
+ resolve(response);
152
+ });
153
+ request.end();
154
+ });
155
+ }
156
+
157
+ async function fetchRelease(url, init, options = {}) {
158
+ const proxy = resolveReleaseProxy(url, options);
159
+ if (!proxy) return globalThis.fetch(url, init);
160
+ let target = new URL(url);
161
+ for (let redirect = 0; redirect <= 5; redirect += 1) {
162
+ const response = await proxyRequest(target, new URL(proxy.url), init);
163
+ if (![301, 302, 303, 307, 308].includes(response.status)) return response;
164
+ const location = response.headers.get('location');
165
+ if (!location) return response;
166
+ if (response.body) await response.body.cancel();
167
+ const next = new URL(location, target);
168
+ if (!['http:', 'https:'].includes(next.protocol) || target.protocol === 'https:' && next.protocol !== 'https:' ||
169
+ options.allowedHosts && !options.allowedHosts.has(next.hostname)) {
170
+ throw proxyError('update_asset_redirect_forbidden', 'Release request redirected to an untrusted host.');
171
+ }
172
+ target = next;
173
+ }
174
+ throw proxyError('update_network_redirect_limit', 'The update request exceeded its redirect limit.');
175
+ }
176
+
177
+ module.exports = { fetchRelease, resolveReleaseProxy };
@@ -0,0 +1,154 @@
1
+ 'use strict';
2
+
3
+ const { updateError } = require('./updateTrust');
4
+ const { fetchRelease } = require('./releaseProxy');
5
+
6
+ const RETRYABLE_HTTP = new Set([408, 429, 500, 502, 503, 504]);
7
+ const CERTIFICATE_ERRORS = /CERT|TLS_CERT|SELF_SIGNED|UNABLE_TO_VERIFY|DEPTH_ZERO/;
8
+
9
+ function abortable(promise, signal) {
10
+ if (signal.aborted) {
11
+ void Promise.resolve(promise).catch(() => {});
12
+ return Promise.reject(signal.reason);
13
+ }
14
+ return new Promise((resolve, reject) => {
15
+ const abort = () => reject(signal.reason);
16
+ signal.addEventListener('abort', abort, { once: true });
17
+ Promise.resolve(promise).then(resolve, reject).finally(() => signal.removeEventListener('abort', abort));
18
+ });
19
+ }
20
+
21
+ function terminalError(code, message) {
22
+ return Object.assign(updateError(code, message), { retryable: false });
23
+ }
24
+
25
+ async function readBoundedBody(response, limit, signal) {
26
+ const declared = Number(response.headers?.get?.('content-length') || 0);
27
+ if (declared > limit) throw terminalError('update_download_limit', 'Update response exceeds its size limit.');
28
+ const reader = response.body?.getReader?.();
29
+ if (!reader) {
30
+ const bytes = Buffer.from(await abortable(response.arrayBuffer(), signal));
31
+ if (!bytes.length || bytes.length > limit) throw terminalError('update_download_limit', 'Update response exceeds its size limit.');
32
+ return bytes;
33
+ }
34
+ const chunks = [];
35
+ let length = 0;
36
+ try {
37
+ while (true) {
38
+ const chunk = await abortable(reader.read(), signal);
39
+ if (chunk.done) break;
40
+ const bytes = Buffer.from(chunk.value);
41
+ length += bytes.length;
42
+ if (length > limit) throw terminalError('update_download_limit', 'Update response exceeds its size limit.');
43
+ chunks.push(bytes);
44
+ }
45
+ if (!length) throw terminalError('update_download_limit', 'Update response is empty.');
46
+ return Buffer.concat(chunks, length);
47
+ } catch (error) {
48
+ void reader.cancel().catch(() => {});
49
+ throw error;
50
+ } finally {
51
+ reader.releaseLock();
52
+ }
53
+ }
54
+
55
+ function networkFailure(error, signal, callerSignal) {
56
+ let cause = error;
57
+ for (let depth = 0; depth < 8 && cause?.cause && cause.cause !== cause; depth += 1) cause = cause.cause;
58
+ const reason = String(typeof cause?.code === 'string' ? cause.code : cause?.name || error?.name || 'network_error');
59
+ if (callerSignal?.aborted) return { code: 'update_network_cancelled', reason: 'cancelled', retryable: false };
60
+ if (error?.retryable === false) return { code: error.code || 'update_network_failed', reason, retryable: false };
61
+ if (CERTIFICATE_ERRORS.test(reason)) return { code: 'update_network_certificate', reason, retryable: false };
62
+ if (signal.aborted || /TIMEOUT|Timeout|AbortError/.test(reason)) {
63
+ return { code: 'update_network_timeout', reason, retryable: true };
64
+ }
65
+ if (/ENOTFOUND|EAI_AGAIN/.test(reason)) return { code: 'update_network_dns', reason, retryable: true };
66
+ return { code: /^update_[a-z0-9_]+$/.test(error?.code || '') ? error.code : 'update_network_failed',
67
+ reason, retryable: error?.retryable !== false };
68
+ }
69
+
70
+ function retryDelay(response, fallback) {
71
+ const value = response?.headers?.get?.('retry-after');
72
+ if (!value) return fallback;
73
+ const seconds = Number(value);
74
+ const delay = Number.isFinite(seconds) ? seconds * 1000 : Date.parse(value) - Date.now();
75
+ return Number.isFinite(delay) ? Math.max(fallback, delay, 0) : fallback;
76
+ }
77
+
78
+ function delay(milliseconds, signal) {
79
+ if (!milliseconds) return Promise.resolve();
80
+ return new Promise((resolve, reject) => {
81
+ const abort = () => { clearTimeout(timer); reject(signal.reason); };
82
+ const timer = setTimeout(() => { signal?.removeEventListener('abort', abort); resolve(); }, milliseconds);
83
+ if (signal?.aborted) abort();
84
+ else signal?.addEventListener('abort', abort, { once: true });
85
+ });
86
+ }
87
+
88
+ async function requestRelease(url, options = {}) {
89
+ const started = Date.now();
90
+ const deadline = options.deadlineAt || started + (options.totalTimeoutMs ?? 45000);
91
+ const maxAttempts = Math.max(1, Math.min(5, options.maxAttempts ?? 3));
92
+ const host = new URL(url).hostname;
93
+ let failure;
94
+ for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
95
+ const remaining = deadline - Date.now();
96
+ if (remaining <= 0) break;
97
+ const controller = new AbortController();
98
+ const timer = setTimeout(() => controller.abort(new DOMException('Update request timed out.', 'TimeoutError')),
99
+ Math.min(remaining, options.timeoutMs ?? 12000));
100
+ const cancel = () => controller.abort(options.signal.reason);
101
+ if (options.signal?.aborted) cancel();
102
+ else options.signal?.addEventListener('abort', cancel, { once: true });
103
+ let response;
104
+ let retryAfter = 0;
105
+ try {
106
+ const fetchImpl = options.fetch || ((value, init) => fetchRelease(value, init, options));
107
+ response = await abortable(fetchImpl(url, {
108
+ method: options.method || 'GET', headers: options.headers,
109
+ redirect: 'follow', signal: controller.signal
110
+ }), controller.signal);
111
+ const finalUrl = new URL(response.url || url);
112
+ if (options.allowedHosts && (finalUrl.protocol !== 'https:' || !options.allowedHosts.has(finalUrl.hostname))) {
113
+ throw terminalError('update_asset_redirect_forbidden', 'Release request redirected to an untrusted host.');
114
+ }
115
+ if (!response.ok && response.status !== 304) {
116
+ throw Object.assign(updateError(options.httpErrorCode || 'update_asset_http_error',
117
+ 'Update request failed with HTTP ' + response.status + '.'), { retryable: RETRYABLE_HTTP.has(response.status) });
118
+ }
119
+ const bytes = options.method === 'HEAD' || response.status === 304
120
+ ? null : await readBoundedBody(response, options.limit ?? 32 * 1024 * 1024, controller.signal);
121
+ return { ok: response.ok, status: response.status, url: finalUrl.href, headers: response.headers,
122
+ bytes, attempts: attempt, elapsedMs: Date.now() - started };
123
+ } catch (error) {
124
+ const classified = networkFailure(error, controller.signal, options.signal);
125
+ const diagnostics = { stage: options.stage || 'download', host, attempt,
126
+ elapsedMs: Date.now() - started, reason: classified.reason, retryable: classified.retryable,
127
+ ...(response ? { httpStatus: response.status } : {}) };
128
+ const description = classified.code === 'update_network_timeout' ? 'Connection or download timed out'
129
+ : classified.code === 'update_network_dns' ? 'DNS lookup failed'
130
+ : classified.code === 'update_network_certificate' ? 'TLS certificate verification failed'
131
+ : classified.code === 'update_network_cancelled' ? 'Update request was cancelled'
132
+ : error?.retryable === false ? error.message : 'Network request failed';
133
+ const reason = response ? 'HTTP ' + response.status
134
+ : /^[A-Z][A-Z0-9_]{1,79}$/.test(classified.reason) ? classified.reason : '';
135
+ failure = updateError(classified.code,
136
+ description.replace(/\.$/, '') + ' (' + diagnostics.stage + ', ' + host + ', attempt ' + attempt +
137
+ (reason ? ', ' + reason : '') + ', ' + (diagnostics.elapsedMs / 1000).toFixed(1) + 's).',
138
+ { cause: error, network: diagnostics });
139
+ try { options.onDiagnostic?.(diagnostics); } catch (_error) { /* diagnostics are best effort */ }
140
+ if (!classified.retryable || attempt === maxAttempts) throw failure;
141
+ retryAfter = retryDelay(response, (options.delayMs ?? 500) * attempt);
142
+ } finally {
143
+ clearTimeout(timer);
144
+ options.signal?.removeEventListener('abort', cancel);
145
+ controller.abort();
146
+ if (response?.body && !response.body.locked) void response.body.cancel().catch(() => {});
147
+ }
148
+ if (Date.now() + retryAfter >= deadline) break;
149
+ await delay(retryAfter, options.signal);
150
+ }
151
+ throw failure || updateError('update_network_timeout', 'The update network time budget was exhausted.');
152
+ }
153
+
154
+ module.exports = { requestRelease };
@@ -82,6 +82,10 @@ async function handleRequest(request, env = process.env, emit = () => {}) {
82
82
  return handleMirrorStatus(request, env);
83
83
  }
84
84
 
85
+ if (request.method === 'mirror.invalidate') {
86
+ return handleMirrorInvalidate(request, env);
87
+ }
88
+
85
89
  if (request.method === 'mirror.scanSensitive') {
86
90
  return handleMirrorScanSensitive(request, env);
87
91
  }
@@ -709,6 +713,27 @@ async function handleMirrorConfirmWriteback(request, env) {
709
713
  }
710
714
  }
711
715
 
716
+ function handleMirrorInvalidate(request, env) {
717
+ const projectId = request.params?.projectId;
718
+ if (typeof projectId !== 'string' || !projectId.trim()) {
719
+ return errorResponse(request.id, 'invalid_project_id', 'Mirror invalidation requires an explicit project id.');
720
+ }
721
+ const projectKey = resolveProjectKey({ projectId });
722
+ const lockToken = acquireProjectLock(projectKey);
723
+ if (!lockToken) {
724
+ return errorResponse(request.id, 'project_locked', `Project ${projectKey} is currently in use by codex.run`);
725
+ }
726
+ try {
727
+ const { markMirrorDirty } = require('./mirrorWorkspace');
728
+ markMirrorDirty({ projectId, rootDir: env.CODEX_OVERLEAF_MIRROR_ROOT, reason: 'overleaf_undo' });
729
+ return okResponse(request.id, { invalidated: true, projectKey });
730
+ } catch (error) {
731
+ return errorResponse(request.id, 'mirror_invalidation_failed', error.message);
732
+ } finally {
733
+ releaseProjectLock(projectKey, lockToken);
734
+ }
735
+ }
736
+
712
737
  function handleMirrorStatus(request, env) {
713
738
  const { getMirrorStatus } = require('./mirrorWorkspace');
714
739
  const params = request.params || {};