neoagent 3.0.1-beta.9 → 3.1.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 (97) hide show
  1. package/.env.example +2 -10
  2. package/README.md +12 -3
  3. package/docs/automation.md +37 -0
  4. package/docs/benchmarking.md +102 -0
  5. package/docs/billing.md +34 -8
  6. package/docs/configuration.md +11 -2
  7. package/docs/getting-started.md +10 -8
  8. package/docs/operations.md +1 -1
  9. package/flutter_app/lib/main.dart +3 -0
  10. package/flutter_app/lib/main_account_settings.dart +138 -0
  11. package/flutter_app/lib/main_app_shell.dart +38 -13
  12. package/flutter_app/lib/main_billing.dart +1465 -0
  13. package/flutter_app/lib/main_chat.dart +1612 -214
  14. package/flutter_app/lib/main_controller.dart +398 -26
  15. package/flutter_app/lib/main_devices.dart +293 -207
  16. package/flutter_app/lib/main_models.dart +142 -0
  17. package/flutter_app/lib/main_navigation.dart +19 -1
  18. package/flutter_app/lib/main_operations.dart +288 -9
  19. package/flutter_app/lib/main_settings.dart +510 -300
  20. package/flutter_app/lib/main_shared.dart +2 -0
  21. package/flutter_app/lib/main_timeline.dart +1378 -0
  22. package/flutter_app/lib/src/backend_client.dart +149 -19
  23. package/flutter_app/lib/src/desktop_companion_actions.dart +132 -21
  24. package/flutter_app/lib/src/desktop_companion_io.dart +65 -1
  25. package/flutter_app/lib/src/desktop_companion_stub.dart +12 -0
  26. package/flutter_app/lib/src/desktop_ocr_bridge.dart +2 -0
  27. package/flutter_app/lib/src/desktop_ocr_bridge_io.dart +125 -0
  28. package/flutter_app/lib/src/desktop_ocr_bridge_stub.dart +30 -0
  29. package/flutter_app/lib/src/desktop_passive_history.dart +332 -0
  30. package/flutter_app/lib/src/recording_bridge_io.dart +80 -72
  31. package/flutter_app/lib/src/recording_bridge_web.dart +127 -114
  32. package/flutter_app/lib/src/recording_chunk_queue.dart +149 -0
  33. package/flutter_app/lib/src/recording_chunk_queue_io.dart +182 -0
  34. package/flutter_app/lib/src/recording_payloads.dart +9 -0
  35. package/flutter_app/macos/Runner/AppDelegate.swift +25 -0
  36. package/flutter_app/windows/runner/flutter_window.cpp +75 -0
  37. package/landing/index.html +3 -3
  38. package/lib/manager.js +184 -66
  39. package/package.json +4 -1
  40. package/server/admin/access.js +12 -7
  41. package/server/admin/admin.js +436 -10
  42. package/server/admin/billing.js +158 -35
  43. package/server/admin/index.html +72 -2
  44. package/server/admin/users.js +15 -15
  45. package/server/db/database.js +125 -20
  46. package/server/http/routes.js +1 -0
  47. package/server/http/static.js +4 -2
  48. package/server/public/.last_build_id +1 -1
  49. package/server/public/assets/fonts/MaterialIcons-Regular.otf +0 -0
  50. package/server/public/canvaskit/wimp.js.symbols +8475 -8467
  51. package/server/public/canvaskit/wimp.wasm +0 -0
  52. package/server/public/flutter_bootstrap.js +2 -2
  53. package/server/public/main.dart.js +91077 -87037
  54. package/server/routes/account.js +53 -0
  55. package/server/routes/admin.js +345 -64
  56. package/server/routes/agents.js +203 -21
  57. package/server/routes/billing.js +5 -0
  58. package/server/routes/browser.js +8 -1
  59. package/server/routes/recordings.js +96 -6
  60. package/server/routes/screenHistory.js +140 -2
  61. package/server/routes/timeline.js +43 -0
  62. package/server/services/account/erasure.js +263 -0
  63. package/server/services/ai/hooks.js +4 -1
  64. package/server/services/ai/loop/agent_engine_core.js +8 -1
  65. package/server/services/ai/loop/blank_recovery.js +36 -0
  66. package/server/services/ai/loop/conversation_loop.js +166 -33
  67. package/server/services/ai/messagingFallback.js +22 -0
  68. package/server/services/ai/rate_limits.js +28 -5
  69. package/server/services/ai/systemPrompt.js +6 -5
  70. package/server/services/ai/taskAnalysis.js +2 -0
  71. package/server/services/ai/toolEvidence.js +15 -0
  72. package/server/services/ai/toolResult.js +40 -0
  73. package/server/services/ai/tools.js +163 -4
  74. package/server/services/android/controller.js +6 -2
  75. package/server/services/billing/plans.js +2 -1
  76. package/server/services/browser/anti_detection.js +192 -0
  77. package/server/services/browser/controller.js +180 -54
  78. package/server/services/desktop/auth.js +3 -0
  79. package/server/services/desktop/registry.js +50 -2
  80. package/server/services/integrations/google/calendar.js +22 -14
  81. package/server/services/manager.js +12 -42
  82. package/server/services/memory/ingestion_chunking.js +268 -0
  83. package/server/services/messaging/telnyx.js +9 -8
  84. package/server/services/recordings/manager.js +60 -27
  85. package/server/services/runtime/docker-vm-manager.js +157 -266
  86. package/server/services/runtime/guest_bootstrap.js +17 -5
  87. package/server/services/runtime/guest_image.js +188 -0
  88. package/server/services/runtime/manager.js +0 -1
  89. package/server/services/runtime/validation.js +3 -8
  90. package/server/services/social_video/service.js +60 -10
  91. package/server/services/tasks/runtime.js +234 -9
  92. package/server/services/tasks/task_repository.js +13 -0
  93. package/server/services/timeline/service.js +558 -0
  94. package/server/services/wearable/gateway.js +1 -1
  95. package/server/services/websocket.js +21 -3
  96. package/server/services/desktop/screenRecorder.js +0 -292
  97. package/server/services/desktop/screen_recorder_support.js +0 -46
@@ -0,0 +1,188 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const crypto = require('crypto');
6
+ const { spawnSync } = require('child_process');
7
+ const { DATA_DIR } = require('../../../runtime/paths');
8
+ const { stageGuestPayload, normalizeRuntimeProfile } = require('./guest_bootstrap');
9
+
10
+ // Where build contexts are staged. One directory per profile.
11
+ const BUILD_ROOT = path.join(DATA_DIR, 'runtime-vms', 'guest-image');
12
+ // Slim official Node base — Playwright browsers + OS deps are added at build time
13
+ // via `playwright install --with-deps`, so the image is self-contained and the
14
+ // browser version always matches the pinned `playwright-chromium` dependency.
15
+ const BASE_IMAGE = String(process.env.NEOAGENT_GUEST_BASE_IMAGE || 'node:20-bookworm-slim').trim();
16
+ const IMAGE_REPO = 'neoagent-guest-agent';
17
+ const BUILD_TIMEOUT_MS = Number(process.env.NEOAGENT_GUEST_IMAGE_BUILD_TIMEOUT_MS || 20 * 60 * 1000);
18
+
19
+ // The build context is the staged guest payload (package.json + runtime/ + server/).
20
+ // Dependencies and browsers are installed once, at image build time — never per
21
+ // container start. Copy package.json first so the dependency layer stays cached
22
+ // across source-only changes.
23
+ function dockerfileFor(profile) {
24
+ if (normalizeRuntimeProfile(profile) === 'android') {
25
+ return [
26
+ `FROM ${BASE_IMAGE}`,
27
+ 'ENV NODE_ENV=production',
28
+ 'ENV NEOAGENT_GUEST_PROFILE=android',
29
+ 'WORKDIR /opt/neoagent',
30
+ 'COPY package.json ./',
31
+ 'RUN npm install --omit=dev --no-audit --no-fund && npm cache clean --force',
32
+ 'COPY runtime ./runtime',
33
+ 'COPY server ./server',
34
+ // World-writable runtime dir so the agent works whether the container runs
35
+ // as root (macOS) or as the host uid:gid (Linux, for shared file ownership).
36
+ 'RUN mkdir -p /opt/neoagent/.runtime && chmod -R 0777 /opt/neoagent/.runtime',
37
+ 'CMD ["node", "server/guest_agent.js"]',
38
+ '',
39
+ ].join('\n');
40
+ }
41
+ return [
42
+ `FROM ${BASE_IMAGE}`,
43
+ 'ENV NODE_ENV=production',
44
+ 'ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright',
45
+ 'ENV NEOAGENT_GUEST_PROFILE=browser_cli',
46
+ 'WORKDIR /opt/neoagent',
47
+ 'COPY package.json ./',
48
+ // Install deps without running package postinstall scripts, then fetch the
49
+ // matching Chromium and its OS dependencies explicitly. This mirrors the
50
+ // proven QEMU guest bootstrap and keeps the browser revision deterministic.
51
+ 'RUN npm install --omit=dev --ignore-scripts --no-audit --no-fund \\',
52
+ ' && npx playwright install --with-deps chromium \\',
53
+ ' && npm cache clean --force',
54
+ 'COPY runtime ./runtime',
55
+ 'COPY server ./server',
56
+ // World-writable runtime dir + browser cache so the agent works whether the
57
+ // container runs as root (macOS) or as the host uid:gid (Linux, for shared
58
+ // file ownership). Chromium needs PLAYWRIGHT_BROWSERS_PATH readable by all.
59
+ 'RUN mkdir -p /opt/neoagent/.runtime && chmod -R 0777 /opt/neoagent/.runtime && chmod -R a+rX /ms-playwright',
60
+ // Chromium is baked in at build time, so the runtime-ready marker the browser
61
+ // controller polls for (BROWSER_READY_MARKER in server/services/browser/controller.js)
62
+ // is already satisfied. Create it here so ensureBrowser() launches immediately
63
+ // instead of waiting out its full 10-minute timeout for a marker that only the
64
+ // legacy QEMU per-boot bootstrap ever writes.
65
+ 'RUN mkdir -p /var/lib/neoagent && touch /var/lib/neoagent/browser-runtime-ready',
66
+ 'CMD ["node", "server/guest_agent.js"]',
67
+ '',
68
+ ].join('\n');
69
+ }
70
+
71
+ // Stable content hash over every staged file plus the Dockerfile, so the image
72
+ // tag changes whenever the guest source, its dependencies, or the base image
73
+ // change. A new tag forces a rebuild; an unchanged tag reuses the cached image.
74
+ function hashBuildContext(contextDir, dockerfile) {
75
+ const hash = crypto.createHash('sha256');
76
+ hash.update(dockerfile);
77
+ const walk = (dir) => {
78
+ for (const name of fs.readdirSync(dir).sort()) {
79
+ const full = path.join(dir, name);
80
+ const stat = fs.statSync(full);
81
+ if (stat.isDirectory()) {
82
+ walk(full);
83
+ } else {
84
+ hash.update(path.relative(contextDir, full));
85
+ hash.update(fs.readFileSync(full));
86
+ }
87
+ }
88
+ };
89
+ walk(contextDir);
90
+ return hash.digest('hex').slice(0, 12);
91
+ }
92
+
93
+ function docker(args, opts = {}) {
94
+ return spawnSync('docker', args, {
95
+ encoding: 'utf8',
96
+ stdio: ['ignore', 'pipe', 'pipe'],
97
+ timeout: opts.timeout || 30000,
98
+ ...opts,
99
+ });
100
+ }
101
+
102
+ function dockerAvailable() {
103
+ const result = docker(['info'], { timeout: 5000 });
104
+ return !result.error && result.status === 0;
105
+ }
106
+
107
+ // Builds and caches the per-profile guest-agent Docker image. The image bakes in
108
+ // the guest agent, its dependencies, and (for browser_cli) the Chromium browser,
109
+ // so containers start the agent directly with no runtime installation step.
110
+ class GuestImageBuilder {
111
+ #buildPromise = null;
112
+ #cachedTag = null;
113
+ #cachedBuiltAt = 0;
114
+
115
+ constructor(options = {}) {
116
+ this.profile = normalizeRuntimeProfile(options.runtimeProfile || 'browser_cli');
117
+ this.contextDir = path.join(BUILD_ROOT, this.profile);
118
+ }
119
+
120
+ // Stage the build context on disk and return its content-addressed image tag.
121
+ prepareContext() {
122
+ const dockerfile = dockerfileFor(this.profile);
123
+ stageGuestPayload(this.contextDir, this.profile);
124
+ fs.writeFileSync(path.join(this.contextDir, 'Dockerfile'), dockerfile);
125
+ fs.writeFileSync(path.join(this.contextDir, '.dockerignore'), 'node_modules\n');
126
+ const tag = `${IMAGE_REPO}:${this.profile}-${hashBuildContext(this.contextDir, dockerfile)}`;
127
+ this.#cachedTag = tag;
128
+ return tag;
129
+ }
130
+
131
+ imageExists(tag) {
132
+ const result = docker(['image', 'inspect', tag], { timeout: 10000 });
133
+ return !result.error && result.status === 0;
134
+ }
135
+
136
+ // Returns { dockerAvailable, imageBuilt, image } without triggering a build.
137
+ // Cheap enough to call from readiness polling (only stages the context once).
138
+ getState() {
139
+ if (!dockerAvailable()) {
140
+ return { dockerAvailable: false, imageBuilt: false, image: this.#cachedTag };
141
+ }
142
+ let tag = this.#cachedTag;
143
+ try {
144
+ if (!tag) tag = this.prepareContext();
145
+ } catch (err) {
146
+ console.warn(`[GuestImage:${this.profile}] Failed to stage build context: ${err.message}`);
147
+ return { dockerAvailable: true, imageBuilt: false, image: null };
148
+ }
149
+ return { dockerAvailable: true, imageBuilt: this.imageExists(tag), image: tag };
150
+ }
151
+
152
+ // Ensure the image exists, building it if necessary. Concurrent callers share
153
+ // the same in-flight build. Returns the resolved image tag.
154
+ async ensure() {
155
+ const tag = this.prepareContext();
156
+ if (this.imageExists(tag)) {
157
+ this.#cachedBuiltAt = Date.now();
158
+ return tag;
159
+ }
160
+ if (this.#buildPromise) return this.#buildPromise;
161
+ this.#buildPromise = this.#build(tag).finally(() => { this.#buildPromise = null; });
162
+ return this.#buildPromise;
163
+ }
164
+
165
+ async #build(tag) {
166
+ console.log(`[GuestImage:${this.profile}] Building guest image ${tag} (one-time; downloads browser + deps)…`);
167
+ const started = Date.now();
168
+ const result = docker(['build', '-t', tag, this.contextDir], {
169
+ timeout: BUILD_TIMEOUT_MS,
170
+ stdio: ['ignore', 'inherit', 'inherit'],
171
+ });
172
+ if (result.error) {
173
+ throw new Error(`Guest image build failed to start: ${result.error.message}`);
174
+ }
175
+ if (result.status !== 0) {
176
+ throw new Error(`Guest image build for ${tag} exited with status ${result.status}`);
177
+ }
178
+ this.#cachedBuiltAt = Date.now();
179
+ console.log(`[GuestImage:${this.profile}] Built ${tag} in ${Math.round((Date.now() - started) / 1000)}s`);
180
+ return tag;
181
+ }
182
+ }
183
+
184
+ module.exports = {
185
+ GuestImageBuilder,
186
+ dockerAvailable,
187
+ BASE_IMAGE,
188
+ };
@@ -19,7 +19,6 @@ class RuntimeManager {
19
19
 
20
20
  const browserVmManager = options.browserVmManager || new DockerVMManager({
21
21
  runtimeProfile: 'browser_cli',
22
- image: 'mcr.microsoft.com/playwright:v1.44.0-focal',
23
22
  memoryMb: DEFAULT_VM_MEMORY_MB,
24
23
  cpus: DEFAULT_VM_CPUS,
25
24
  });
@@ -11,14 +11,9 @@ function getRuntimeValidation(runtimeManager) {
11
11
 
12
12
  if (policy.profile === 'prod' || nodeEnvIsProd) {
13
13
  if (!browserVmReadiness) {
14
- issues.push('prod profile requires a working local VM runtime for browser/CLI.');
15
- } else if (!browserVmReadiness.ready) {
16
- if (!browserVmReadiness.qemuAvailable) {
17
- issues.push(`prod profile requires QEMU (${browserVmReadiness.qemuBinary}) to be installed for browser/CLI.`);
18
- }
19
- if (!browserVmReadiness.baseImageExists && !browserVmReadiness.downloadConfigured) {
20
- issues.push('prod profile requires a VM base image or a downloadable base image URL for browser/CLI.');
21
- }
14
+ issues.push('prod profile requires a working container runtime for browser/CLI.');
15
+ } else if (!browserVmReadiness.dockerAvailable) {
16
+ issues.push('prod profile requires Docker to be installed and running for the browser/CLI runtime.');
22
17
  }
23
18
  }
24
19
 
@@ -22,6 +22,41 @@ fs.mkdirSync(SOCIAL_VIDEO_TMP_DIR, { recursive: true });
22
22
 
23
23
  const HEALTH_CACHE_TTL_MS = 5 * 60 * 1000;
24
24
 
25
+ // A realistic desktop browser UA. Social platforms frequently return 403/bot
26
+ // challenge pages to the default Node/yt-dlp user agents, so we present a
27
+ // browser-like identity for every outbound request.
28
+ const BROWSER_USER_AGENT = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) '
29
+ + 'AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36';
30
+
31
+ // Network hardening flags applied to every yt-dlp invocation. Transient
32
+ // failures (rate limits, dropped fragments, slow extractors) are the most
33
+ // common cause of unreliable extraction, so we retry aggressively and cap
34
+ // socket waits instead of failing on the first hiccup.
35
+ const YT_DLP_NETWORK_FLAGS = [
36
+ '--retries', '5',
37
+ '--fragment-retries', '5',
38
+ '--extractor-retries', '3',
39
+ '--socket-timeout', '30',
40
+ '--user-agent', BROWSER_USER_AGENT,
41
+ ];
42
+
43
+ // Platforms that gate most video content behind authentication or aggressive
44
+ // bot detection. For these we always try to attach the user's browser cookies.
45
+ const COOKIE_ASSISTED_PLATFORMS = new Set(['instagram', 'youtube', 'tiktok', 'x']);
46
+
47
+ async function fetchWithBrowserHeaders(url, options = {}) {
48
+ return fetch(url, {
49
+ redirect: 'follow',
50
+ ...options,
51
+ headers: {
52
+ 'user-agent': BROWSER_USER_AGENT,
53
+ 'accept-language': 'en-US,en;q=0.9',
54
+ accept: '*/*',
55
+ ...(options.headers || {}),
56
+ },
57
+ });
58
+ }
59
+
25
60
  function shellEscape(value) {
26
61
  const text = String(value ?? '');
27
62
  if (!text.length) return process.platform === 'win32' ? '""' : "''";
@@ -178,12 +213,21 @@ function classifyExtractionError(error) {
178
213
  if (/unsupported social video url|unsupported url/.test(normalized)) {
179
214
  return { code: 'unsupported_url', message };
180
215
  }
181
- if (/private|login required|sign in to confirm/.test(normalized)) {
216
+ if (/private|login required|sign in to confirm|requested content is not available|account is private/.test(normalized)) {
182
217
  return { code: 'private_or_auth_required', message };
183
218
  }
184
- if (/403|forbidden|blocked/.test(normalized)) {
219
+ if (/\b429\b|rate.?limit|too many requests/.test(normalized)) {
220
+ return { code: 'rate_limited', message };
221
+ }
222
+ if (/\b403\b|forbidden|blocked|not a bot/.test(normalized)) {
185
223
  return { code: 'blocked_or_unavailable', message };
186
224
  }
225
+ if (/timed out|timeout|etimedout|socket hang up|network is unreachable/.test(normalized)) {
226
+ return { code: 'network_error', message };
227
+ }
228
+ if (/this video is unavailable|video unavailable|removed|404|not found/.test(normalized)) {
229
+ return { code: 'content_unavailable', message };
230
+ }
187
231
  return { code: 'social_video_extract_failed', message };
188
232
  }
189
233
 
@@ -358,6 +402,10 @@ class SocialVideoService {
358
402
  }
359
403
  }
360
404
 
405
+ #networkFlags() {
406
+ return YT_DLP_NETWORK_FLAGS.map(shellEscape).join(' ');
407
+ }
408
+
361
409
  async #runCommand(command, options = {}) {
362
410
  const result = await this.cliExecutor.execute(command, {
363
411
  cwd: options.cwd || process.cwd(),
@@ -423,7 +471,7 @@ class SocialVideoService {
423
471
  return browserMetadata;
424
472
  }
425
473
 
426
- const response = await fetch(normalizedUrl, { redirect: 'follow' });
474
+ const response = await fetchWithBrowserHeaders(normalizedUrl);
427
475
  const html = await response.text();
428
476
  const metadata = extractPublicMetadataFromHtml(html, response.url || normalizedUrl);
429
477
  return {
@@ -473,7 +521,7 @@ class SocialVideoService {
473
521
  const infoTemplate = path.join(jobDir, 'media.%(ext)s');
474
522
  const infoPath = path.join(jobDir, 'media.info.json');
475
523
  const cookieArg = cookieFilePath ? ` --cookies ${shellEscape(cookieFilePath)}` : '';
476
- const command = `${shellEscape(this.ytDlpBin)} --quiet --no-warnings --no-playlist --skip-download --write-info-json --no-clean-infojson${cookieArg} -o ${shellEscape(infoTemplate)} -- ${shellEscape(normalizedUrl)}`;
524
+ const command = `${shellEscape(this.ytDlpBin)} --quiet --no-warnings --no-playlist ${this.#networkFlags()} --skip-download --write-info-json --no-clean-infojson${cookieArg} -o ${shellEscape(infoTemplate)} -- ${shellEscape(normalizedUrl)}`;
477
525
  await this.#runCommand(command, { cwd: jobDir, timeout: 4 * 60 * 1000 });
478
526
  if (!fileExists(infoPath)) {
479
527
  throw new Error('yt-dlp did not produce an info JSON artifact.');
@@ -514,7 +562,7 @@ class SocialVideoService {
514
562
  }
515
563
 
516
564
  async #readTranscriptFromCaption(captionTrack) {
517
- const response = await fetch(captionTrack.url, { redirect: 'follow' });
565
+ const response = await fetchWithBrowserHeaders(captionTrack.url);
518
566
  if (!response.ok) {
519
567
  throw new Error(`Caption request failed (${response.status}).`);
520
568
  }
@@ -525,7 +573,7 @@ class SocialVideoService {
525
573
  async #transcribeViaStt(context) {
526
574
  const template = path.join(context.jobDir, 'audio.%(ext)s');
527
575
  const cookieArg = context.cookieFilePath ? ` --cookies ${shellEscape(context.cookieFilePath)}` : '';
528
- const command = `${shellEscape(this.ytDlpBin)} --quiet --no-warnings --no-playlist${cookieArg} -o ${shellEscape(template)} -f bestaudio -- ${shellEscape(context.sourceUrl)}`;
576
+ const command = `${shellEscape(this.ytDlpBin)} --quiet --no-warnings --no-playlist ${this.#networkFlags()}${cookieArg} -o ${shellEscape(template)} -f bestaudio/best -- ${shellEscape(context.sourceUrl)}`;
529
577
  await this.#runCommand(command, { cwd: context.jobDir, timeout: 10 * 60 * 1000 });
530
578
 
531
579
  const audioPath = firstFileMatching(context.jobDir, 'audio.');
@@ -561,7 +609,7 @@ class SocialVideoService {
561
609
  }
562
610
 
563
611
  async #resolveCookieFile(context) {
564
- if (context.platform !== 'instagram') {
612
+ if (!COOKIE_ASSISTED_PLATFORMS.has(context.platform)) {
565
613
  return null;
566
614
  }
567
615
  if (!this.runtimeManager || typeof this.runtimeManager.getBrowserProviderForUser !== 'function') {
@@ -581,7 +629,9 @@ class SocialVideoService {
581
629
  });
582
630
  const cookies = Array.isArray(payload?.cookies) ? payload.cookies : [];
583
631
  if (cookies.length === 0) {
584
- context.warnings.push('Browser cookie export returned no cookies for Instagram.');
632
+ // Not fatal: many videos are public. yt-dlp will simply attempt the
633
+ // extraction without an authenticated session.
634
+ context.warnings.push(`Browser cookie export returned no cookies for ${context.platform}.`);
585
635
  return null;
586
636
  }
587
637
 
@@ -610,7 +660,7 @@ class SocialVideoService {
610
660
  async #extractFrameFromVideo(context) {
611
661
  const template = path.join(context.jobDir, 'video.%(ext)s');
612
662
  const cookieArg = context.cookieFilePath ? ` --cookies ${shellEscape(context.cookieFilePath)}` : '';
613
- const downloadCommand = `${shellEscape(this.ytDlpBin)} --quiet --no-warnings --no-playlist${cookieArg} -o ${shellEscape(template)} -f "bv*[ext=mp4]+ba[ext=m4a]/b[ext=mp4]/best" --merge-output-format mp4 -- ${shellEscape(context.sourceUrl)}`;
663
+ const downloadCommand = `${shellEscape(this.ytDlpBin)} --quiet --no-warnings --no-playlist ${this.#networkFlags()}${cookieArg} -o ${shellEscape(template)} -f "bv*[ext=mp4]+ba[ext=m4a]/b[ext=mp4]/best" --merge-output-format mp4 -- ${shellEscape(context.sourceUrl)}`;
614
664
  await this.#runCommand(downloadCommand, { cwd: context.jobDir, timeout: 14 * 60 * 1000 });
615
665
 
616
666
  const videoPath = firstFileMatching(context.jobDir, 'video.');
@@ -630,7 +680,7 @@ class SocialVideoService {
630
680
  }
631
681
 
632
682
  async #downloadThumbnailArtifact(userId, thumbnailUrl) {
633
- const response = await fetch(thumbnailUrl, { redirect: 'follow' });
683
+ const response = await fetchWithBrowserHeaders(thumbnailUrl);
634
684
  if (!response.ok) {
635
685
  throw new Error(`Thumbnail request failed (${response.status}).`);
636
686
  }