neoagent 3.2.1-beta.1 → 3.2.1-beta.3

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 (144) hide show
  1. package/extensions/chrome-browser/background.mjs +318 -88
  2. package/extensions/chrome-browser/http.mjs +136 -0
  3. package/extensions/chrome-browser/protocol.mjs +511 -89
  4. package/flutter_app/lib/main_chat.dart +118 -739
  5. package/flutter_app/lib/main_controller.dart +29 -20
  6. package/flutter_app/lib/main_models.dart +3 -0
  7. package/flutter_app/lib/main_operations.dart +334 -321
  8. package/flutter_app/lib/main_settings.dart +4 -3
  9. package/flutter_app/lib/main_shared.dart +14 -11
  10. package/flutter_app/lib/src/desktop_companion_actions.dart +185 -31
  11. package/flutter_app/lib/src/desktop_companion_io.dart +319 -86
  12. package/flutter_app/windows/runner/flutter_window.cpp +143 -32
  13. package/lib/manager.js +106 -89
  14. package/lib/schema_migrations.js +67 -13
  15. package/package.json +20 -13
  16. package/runtime/paths.js +49 -5
  17. package/server/guest_agent.js +52 -30
  18. package/server/http/middleware.js +24 -0
  19. package/server/http/routes.js +4 -6
  20. package/server/public/.last_build_id +1 -1
  21. package/server/public/assets/fonts/MaterialIcons-Regular.otf +0 -0
  22. package/server/public/flutter_bootstrap.js +1 -1
  23. package/server/public/main.dart.js +30803 -30805
  24. package/server/routes/admin.js +1 -1
  25. package/server/routes/android.js +30 -34
  26. package/server/routes/browser.js +23 -15
  27. package/server/routes/desktop.js +18 -1
  28. package/server/routes/integrations.js +5 -1
  29. package/server/routes/memory.js +1 -0
  30. package/server/routes/settings.js +16 -5
  31. package/server/routes/social_reach.js +12 -3
  32. package/server/routes/social_video.js +4 -0
  33. package/server/services/ai/compaction.js +7 -2
  34. package/server/services/ai/history.js +44 -5
  35. package/server/services/ai/integrated_tools/http_request.js +8 -0
  36. package/server/services/ai/loop/agent_engine_core.js +358 -162
  37. package/server/services/ai/loop/callbacks.js +1 -0
  38. package/server/services/ai/loop/completion_judge.js +12 -0
  39. package/server/services/ai/loop/conversation_loop.js +348 -242
  40. package/server/services/ai/loop/messaging_delivery.js +129 -57
  41. package/server/services/ai/loop/model_call_guard.js +91 -0
  42. package/server/services/ai/loop/model_io.js +20 -45
  43. package/server/services/ai/loop/tool_dispatch.js +19 -8
  44. package/server/services/ai/loopPolicy.js +24 -19
  45. package/server/services/ai/model_discovery.js +227 -0
  46. package/server/services/ai/model_identity.js +71 -0
  47. package/server/services/ai/models.js +67 -162
  48. package/server/services/ai/providerRetry.js +17 -59
  49. package/server/services/ai/provider_selector.js +111 -0
  50. package/server/services/ai/providers/anthropic.js +2 -2
  51. package/server/services/ai/providers/claudeCode.js +21 -33
  52. package/server/services/ai/providers/githubCopilot.js +41 -20
  53. package/server/services/ai/providers/google.js +23 -5
  54. package/server/services/ai/providers/grok.js +4 -3
  55. package/server/services/ai/providers/grokOauth.js +19 -27
  56. package/server/services/ai/providers/nvidia.js +10 -5
  57. package/server/services/ai/providers/ollama.js +111 -84
  58. package/server/services/ai/providers/ollama_stream.js +142 -0
  59. package/server/services/ai/providers/openai.js +39 -5
  60. package/server/services/ai/providers/openaiCodex.js +11 -4
  61. package/server/services/ai/providers/openrouter.js +29 -7
  62. package/server/services/ai/providers/provider_error.js +36 -0
  63. package/server/services/ai/settings.js +26 -2
  64. package/server/services/ai/taskAnalysis.js +5 -1
  65. package/server/services/ai/terminal_reply.js +45 -0
  66. package/server/services/ai/toolEvidence.js +58 -29
  67. package/server/services/ai/tools.js +124 -111
  68. package/server/services/android/controller.js +770 -237
  69. package/server/services/android/process.js +140 -0
  70. package/server/services/android/sdk_download.js +143 -0
  71. package/server/services/android/uia.js +6 -5
  72. package/server/services/artifacts/store.js +24 -0
  73. package/server/services/browser/controller.js +736 -385
  74. package/server/services/browser/extension/gateway.js +40 -16
  75. package/server/services/browser/extension/protocol.js +12 -1
  76. package/server/services/browser/extension/provider.js +59 -47
  77. package/server/services/browser/extension/registry.js +155 -34
  78. package/server/services/cli/executor.js +62 -9
  79. package/server/services/desktop/gateway.js +41 -4
  80. package/server/services/desktop/protocol.js +3 -0
  81. package/server/services/desktop/provider.js +39 -42
  82. package/server/services/desktop/registry.js +137 -52
  83. package/server/services/integrations/figma/provider.js +78 -12
  84. package/server/services/integrations/github/common.js +11 -6
  85. package/server/services/integrations/github/provider.js +52 -53
  86. package/server/services/integrations/google/provider.js +55 -19
  87. package/server/services/integrations/home_assistant/network.js +17 -20
  88. package/server/services/integrations/home_assistant/provider.js +7 -5
  89. package/server/services/integrations/home_assistant/tools.js +17 -5
  90. package/server/services/integrations/http.js +51 -0
  91. package/server/services/integrations/manager.js +158 -53
  92. package/server/services/integrations/microsoft/provider.js +80 -13
  93. package/server/services/integrations/neoarchive/provider.js +55 -29
  94. package/server/services/integrations/neorecall/client.js +17 -10
  95. package/server/services/integrations/neorecall/provider.js +20 -11
  96. package/server/services/integrations/notion/provider.js +16 -13
  97. package/server/services/integrations/oauth_provider.js +115 -51
  98. package/server/services/integrations/slack/provider.js +98 -9
  99. package/server/services/integrations/spotify/provider.js +67 -71
  100. package/server/services/integrations/trello/provider.js +21 -7
  101. package/server/services/integrations/weather/provider.js +18 -12
  102. package/server/services/integrations/whatsapp/provider.js +76 -16
  103. package/server/services/manager.js +87 -1
  104. package/server/services/memory/embedding_index.js +20 -8
  105. package/server/services/memory/embeddings.js +151 -90
  106. package/server/services/memory/ingestion.js +50 -9
  107. package/server/services/memory/ingestion_documents.js +13 -3
  108. package/server/services/memory/manager.js +52 -19
  109. package/server/services/messaging/automation.js +84 -9
  110. package/server/services/messaging/http_platforms.js +33 -13
  111. package/server/services/messaging/inbound_queue.js +78 -24
  112. package/server/services/messaging/inbound_store.js +224 -0
  113. package/server/services/messaging/manager.js +326 -51
  114. package/server/services/messaging/typing_keepalive.js +5 -2
  115. package/server/services/network/http.js +210 -0
  116. package/server/services/network/safe_request.js +307 -0
  117. package/server/services/runtime/backends/local-vm.js +214 -66
  118. package/server/services/runtime/manager.js +17 -12
  119. package/server/services/social_reach/channels/github.js +10 -4
  120. package/server/services/social_reach/channels/reddit.js +4 -4
  121. package/server/services/social_reach/channels/rss.js +2 -2
  122. package/server/services/social_reach/channels/social_video.js +12 -7
  123. package/server/services/social_reach/channels/v2ex.js +21 -8
  124. package/server/services/social_reach/channels/x.js +2 -2
  125. package/server/services/social_reach/channels/xueqiu.js +5 -5
  126. package/server/services/social_reach/service.js +9 -6
  127. package/server/services/social_reach/utils.js +65 -14
  128. package/server/services/social_video/service.js +160 -50
  129. package/server/services/tasks/integration_runtime.js +18 -8
  130. package/server/services/tasks/runtime.js +39 -4
  131. package/server/services/voice/agentBridge.js +17 -4
  132. package/server/services/voice/bufferedLiveRelayAdapter.js +5 -0
  133. package/server/services/voice/liveSession.js +31 -0
  134. package/server/services/voice/openaiSpeech.js +33 -8
  135. package/server/services/voice/providers.js +233 -151
  136. package/server/services/voice/runtimeManager.js +118 -20
  137. package/server/services/voice/turnRunner.js +6 -0
  138. package/server/services/wearable/firmware_manifest.js +51 -13
  139. package/server/services/wearable/service.js +1 -0
  140. package/server/utils/abort.js +96 -0
  141. package/server/utils/cloud-security.js +110 -3
  142. package/server/utils/files.js +31 -0
  143. package/server/utils/image_payload.js +95 -0
  144. package/server/utils/retry.js +107 -0
@@ -6,6 +6,7 @@
6
6
  #include <shellapi.h>
7
7
  #include <windows.h>
8
8
 
9
+ #include <algorithm>
9
10
  #include <cctype>
10
11
  #include <cstring>
11
12
  #include <cwchar>
@@ -162,6 +163,24 @@ DisplayInfo ResolveDisplay(const std::string& requested_id) {
162
163
  : displays.front();
163
164
  }
164
165
 
166
+ POINT CapturedPixelToDesktopPoint(const DisplayInfo& display, int x, int y) {
167
+ const int width = std::max(
168
+ 1, static_cast<int>(display.rect.right - display.rect.left));
169
+ const int height = std::max(
170
+ 1, static_cast<int>(display.rect.bottom - display.rect.top));
171
+ return POINT{
172
+ display.rect.left + std::clamp(x, 0, width - 1),
173
+ display.rect.top + std::clamp(y, 0, height - 1),
174
+ };
175
+ }
176
+
177
+ POINT DisplayCenterPoint(const DisplayInfo& display) {
178
+ return POINT{
179
+ display.rect.left + ((display.rect.right - display.rect.left) / 2),
180
+ display.rect.top + ((display.rect.bottom - display.rect.top) / 2),
181
+ };
182
+ }
183
+
165
184
  CLSID PngEncoderClsid() {
166
185
  UINT count = 0;
167
186
  UINT size = 0;
@@ -219,6 +238,10 @@ EncodableList DisplaysToEncodable() {
219
238
  EncodableMap item;
220
239
  item[EncodableValue("id")] = EncodableValue(WideToUtf8(display.id));
221
240
  item[EncodableValue("label")] = EncodableValue(WideToUtf8(display.label));
241
+ item[EncodableValue("x")] =
242
+ EncodableValue(static_cast<int>(display.rect.left));
243
+ item[EncodableValue("y")] =
244
+ EncodableValue(static_cast<int>(display.rect.top));
222
245
  item[EncodableValue("width")] = EncodableValue(width);
223
246
  item[EncodableValue("height")] = EncodableValue(height);
224
247
  item[EncodableValue("scaleFactor")] = EncodableValue(1.0);
@@ -305,18 +328,19 @@ bool IsWorkstationLocked() {
305
328
  return _wcsicmp(name, L"Default") != 0;
306
329
  }
307
330
 
308
- void SendMouseButton(DWORD flag) {
331
+ bool SendMouseButton(DWORD flag) {
309
332
  INPUT input{};
310
333
  input.type = INPUT_MOUSE;
311
334
  input.mi.dwFlags = flag;
312
- SendInput(1, &input, sizeof(INPUT));
335
+ return SendInput(1, &input, sizeof(INPUT)) == 1;
313
336
  }
314
337
 
315
338
  WORD VirtualKeyForString(const std::string& key) {
316
339
  const std::string lowered = [&]() {
317
340
  std::string value = key;
318
341
  for (auto& ch : value) {
319
- ch = static_cast<char>(tolower(ch));
342
+ ch = static_cast<char>(
343
+ std::tolower(static_cast<unsigned char>(ch)));
320
344
  }
321
345
  return value;
322
346
  }();
@@ -332,17 +356,21 @@ WORD VirtualKeyForString(const std::string& key) {
332
356
  return 0;
333
357
  }
334
358
 
335
- void SendVirtualKey(WORD key_code) {
359
+ bool SendVirtualKey(WORD key_code) {
336
360
  INPUT inputs[2]{};
337
361
  inputs[0].type = INPUT_KEYBOARD;
338
362
  inputs[0].ki.wVk = key_code;
339
363
  inputs[1].type = INPUT_KEYBOARD;
340
364
  inputs[1].ki.wVk = key_code;
341
365
  inputs[1].ki.dwFlags = KEYEVENTF_KEYUP;
342
- SendInput(2, inputs, sizeof(INPUT));
366
+ const UINT sent = SendInput(2, inputs, sizeof(INPUT));
367
+ if (sent == 1) {
368
+ SendInput(1, &inputs[1], sizeof(INPUT));
369
+ }
370
+ return sent == 2;
343
371
  }
344
372
 
345
- void SendUnicodeText(const std::wstring& text) {
373
+ bool SendUnicodeText(const std::wstring& text) {
346
374
  for (const wchar_t ch : text) {
347
375
  INPUT inputs[2]{};
348
376
  inputs[0].type = INPUT_KEYBOARD;
@@ -351,8 +379,13 @@ void SendUnicodeText(const std::wstring& text) {
351
379
  inputs[1].type = INPUT_KEYBOARD;
352
380
  inputs[1].ki.dwFlags = KEYEVENTF_UNICODE | KEYEVENTF_KEYUP;
353
381
  inputs[1].ki.wScan = ch;
354
- SendInput(2, inputs, sizeof(INPUT));
382
+ const UINT sent = SendInput(2, inputs, sizeof(INPUT));
383
+ if (sent == 1) {
384
+ SendInput(1, &inputs[1], sizeof(INPUT));
385
+ }
386
+ if (sent != 2) return false;
355
387
  }
388
+ return true;
356
389
  }
357
390
 
358
391
  } // namespace
@@ -462,19 +495,59 @@ bool FlutterWindow::OnCreate() {
462
495
  }
463
496
  int x = 0;
464
497
  int y = 0;
465
- GetInt(*arguments, "x", &x);
466
- GetInt(*arguments, "y", &y);
498
+ if (!GetInt(*arguments, "x", &x) ||
499
+ !GetInt(*arguments, "y", &y)) {
500
+ result->Error("invalid_arguments", "Missing click coordinates.");
501
+ return;
502
+ }
467
503
  const std::string button = GetString(*arguments, "button", "left");
468
- SetCursorPos(x, y);
504
+ const DisplayInfo display =
505
+ ResolveDisplay(GetString(*arguments, "displayId"));
506
+ const POINT point = CapturedPixelToDesktopPoint(display, x, y);
507
+ if (!SetCursorPos(point.x, point.y)) {
508
+ result->Error("input_failed", "Unable to move the desktop cursor.");
509
+ return;
510
+ }
511
+ bool sent = false;
469
512
  if (button == "right") {
470
- SendMouseButton(MOUSEEVENTF_RIGHTDOWN);
471
- SendMouseButton(MOUSEEVENTF_RIGHTUP);
513
+ const bool down = SendMouseButton(MOUSEEVENTF_RIGHTDOWN);
514
+ const bool up = SendMouseButton(MOUSEEVENTF_RIGHTUP);
515
+ sent = down && up;
472
516
  } else if (button == "middle") {
473
- SendMouseButton(MOUSEEVENTF_MIDDLEDOWN);
474
- SendMouseButton(MOUSEEVENTF_MIDDLEUP);
517
+ const bool down = SendMouseButton(MOUSEEVENTF_MIDDLEDOWN);
518
+ const bool up = SendMouseButton(MOUSEEVENTF_MIDDLEUP);
519
+ sent = down && up;
475
520
  } else {
476
- SendMouseButton(MOUSEEVENTF_LEFTDOWN);
477
- SendMouseButton(MOUSEEVENTF_LEFTUP);
521
+ const bool down = SendMouseButton(MOUSEEVENTF_LEFTDOWN);
522
+ const bool up = SendMouseButton(MOUSEEVENTF_LEFTUP);
523
+ sent = down && up;
524
+ }
525
+ if (!sent) {
526
+ result->Error("input_failed", "Unable to send the desktop click.");
527
+ return;
528
+ }
529
+ result->Success(EncodableValue());
530
+ return;
531
+ }
532
+
533
+ if (call.method_name() == "mouseMove") {
534
+ if (arguments == nullptr) {
535
+ result->Error("invalid_arguments", "Missing mouseMove payload.");
536
+ return;
537
+ }
538
+ int x = 0;
539
+ int y = 0;
540
+ if (!GetInt(*arguments, "x", &x) ||
541
+ !GetInt(*arguments, "y", &y)) {
542
+ result->Error("invalid_arguments", "Missing mouseMove coordinates.");
543
+ return;
544
+ }
545
+ const DisplayInfo display =
546
+ ResolveDisplay(GetString(*arguments, "displayId"));
547
+ const POINT point = CapturedPixelToDesktopPoint(display, x, y);
548
+ if (!SetCursorPos(point.x, point.y)) {
549
+ result->Error("input_failed", "Unable to move the desktop cursor.");
550
+ return;
478
551
  }
479
552
  result->Success(EncodableValue());
480
553
  return;
@@ -486,22 +559,40 @@ bool FlutterWindow::OnCreate() {
486
559
  return;
487
560
  }
488
561
  int x1 = 0, y1 = 0, x2 = 0, y2 = 0, duration_ms = 280;
489
- GetInt(*arguments, "x1", &x1);
490
- GetInt(*arguments, "y1", &y1);
491
- GetInt(*arguments, "x2", &x2);
492
- GetInt(*arguments, "y2", &y2);
562
+ if (!GetInt(*arguments, "x1", &x1) ||
563
+ !GetInt(*arguments, "y1", &y1) ||
564
+ !GetInt(*arguments, "x2", &x2) ||
565
+ !GetInt(*arguments, "y2", &y2)) {
566
+ result->Error("invalid_arguments", "Missing drag coordinates.");
567
+ return;
568
+ }
493
569
  GetInt(*arguments, "durationMs", &duration_ms);
570
+ duration_ms = std::clamp(duration_ms, 40, 2000);
571
+ const DisplayInfo display =
572
+ ResolveDisplay(GetString(*arguments, "displayId"));
573
+ const POINT start = CapturedPixelToDesktopPoint(display, x1, y1);
574
+ const POINT end = CapturedPixelToDesktopPoint(display, x2, y2);
494
575
  const int steps = std::max(4, std::min(90, duration_ms / 16));
495
- SetCursorPos(x1, y1);
496
- SendMouseButton(MOUSEEVENTF_LEFTDOWN);
576
+ if (!SetCursorPos(start.x, start.y) ||
577
+ !SendMouseButton(MOUSEEVENTF_LEFTDOWN)) {
578
+ result->Error("input_failed", "Unable to begin the desktop drag.");
579
+ return;
580
+ }
581
+ bool moved = true;
497
582
  for (int step = 1; step <= steps; ++step) {
498
583
  const double t = static_cast<double>(step) / steps;
499
- const int nx = static_cast<int>(std::lround(x1 + ((x2 - x1) * t)));
500
- const int ny = static_cast<int>(std::lround(y1 + ((y2 - y1) * t)));
501
- SetCursorPos(nx, ny);
584
+ const int nx = static_cast<int>(
585
+ std::lround(start.x + ((end.x - start.x) * t)));
586
+ const int ny = static_cast<int>(
587
+ std::lround(start.y + ((end.y - start.y) * t)));
588
+ moved = SetCursorPos(nx, ny) && moved;
502
589
  Sleep(std::max(1, duration_ms / std::max(1, steps)));
503
590
  }
504
- SendMouseButton(MOUSEEVENTF_LEFTUP);
591
+ const bool released = SendMouseButton(MOUSEEVENTF_LEFTUP);
592
+ if (!moved || !released) {
593
+ result->Error("input_failed", "Unable to complete the desktop drag.");
594
+ return;
595
+ }
505
596
  result->Success(EncodableValue());
506
597
  return;
507
598
  }
@@ -515,19 +606,31 @@ bool FlutterWindow::OnCreate() {
515
606
  int delta_y = 0;
516
607
  GetInt(*arguments, "deltaX", &delta_x);
517
608
  GetInt(*arguments, "deltaY", &delta_y);
609
+ const DisplayInfo display =
610
+ ResolveDisplay(GetString(*arguments, "displayId"));
611
+ const POINT anchor = DisplayCenterPoint(display);
612
+ if (!SetCursorPos(anchor.x, anchor.y)) {
613
+ result->Error("input_failed", "Unable to position the desktop scroll.");
614
+ return;
615
+ }
616
+ bool sent = true;
518
617
  if (delta_y != 0) {
519
618
  INPUT input{};
520
619
  input.type = INPUT_MOUSE;
521
620
  input.mi.dwFlags = MOUSEEVENTF_WHEEL;
522
621
  input.mi.mouseData = static_cast<DWORD>(delta_y);
523
- SendInput(1, &input, sizeof(INPUT));
622
+ sent = SendInput(1, &input, sizeof(INPUT)) == 1 && sent;
524
623
  }
525
624
  if (delta_x != 0) {
526
625
  INPUT input{};
527
626
  input.type = INPUT_MOUSE;
528
627
  input.mi.dwFlags = MOUSEEVENTF_HWHEEL;
529
628
  input.mi.mouseData = static_cast<DWORD>(delta_x);
530
- SendInput(1, &input, sizeof(INPUT));
629
+ sent = SendInput(1, &input, sizeof(INPUT)) == 1 && sent;
630
+ }
631
+ if (!sent) {
632
+ result->Error("input_failed", "Unable to send the desktop scroll.");
633
+ return;
531
634
  }
532
635
  result->Success(EncodableValue());
533
636
  return;
@@ -540,11 +643,16 @@ bool FlutterWindow::OnCreate() {
540
643
  }
541
644
  const std::wstring text = Utf8ToWide(GetString(*arguments, "text", ""));
542
645
  const bool press_enter = GetBool(*arguments, "pressEnter", false);
646
+ bool sent = true;
543
647
  if (!text.empty()) {
544
- SendUnicodeText(text);
648
+ sent = SendUnicodeText(text);
649
+ }
650
+ if (press_enter && sent) {
651
+ sent = SendVirtualKey(VK_RETURN);
545
652
  }
546
- if (press_enter) {
547
- SendVirtualKey(VK_RETURN);
653
+ if (!sent) {
654
+ result->Error("input_failed", "Unable to send desktop text input.");
655
+ return;
548
656
  }
549
657
  result->Success(EncodableValue());
550
658
  return;
@@ -561,7 +669,10 @@ bool FlutterWindow::OnCreate() {
561
669
  result->Error("unsupported_key", "Key is not supported.");
562
670
  return;
563
671
  }
564
- SendVirtualKey(virtual_key);
672
+ if (!SendVirtualKey(virtual_key)) {
673
+ result->Error("input_failed", "Unable to send the desktop key.");
674
+ return;
675
+ }
565
676
  result->Success(EncodableValue());
566
677
  return;
567
678
  }
package/lib/manager.js CHANGED
@@ -1,3 +1,5 @@
1
+ 'use strict';
2
+
1
3
  const fs = require('fs');
2
4
  const os = require('os');
3
5
  const path = require('path');
@@ -22,7 +24,9 @@ const {
22
24
  PID_FILE,
23
25
  getDefaultVmBaseImageUrl,
24
26
  ensureRuntimeDirs,
25
- migrateLegacyRuntime
27
+ migrateLegacyRuntime,
28
+ removeEnvValue: removeRuntimeEnvValue,
29
+ upsertEnvValue: upsertRuntimeEnvValue,
26
30
  } = require('../runtime/paths');
27
31
  const {
28
32
  parseReleaseChannel,
@@ -44,6 +48,8 @@ const {
44
48
  cmdMigrateDryRun,
45
49
  cmdMigrateRun
46
50
  } = require('./migrations');
51
+ const { fetchResponseText } = require('../server/services/network/http');
52
+ const { abortableDelay } = require('../server/utils/retry');
47
53
 
48
54
  const APP_NAME = 'NeoAgent';
49
55
  const SERVICE_LABEL = 'com.neoagent';
@@ -71,6 +77,38 @@ const COLORS = process.stdout.isTTY
71
77
  const CLI_INTERACTIVE = process.stdout.isTTY;
72
78
  const installActionItems = [];
73
79
 
80
+ async function fetchBoundedJsonResponse(url, init = {}, label = 'Authentication request') {
81
+ const { response, text } = await fetchResponseText(url, {
82
+ ...init,
83
+ redirect: init.redirect || 'error',
84
+ timeoutMs: 20000,
85
+ maxResponseBytes: 1024 * 1024,
86
+ serviceName: label,
87
+ timeoutCode: 'AUTH_HTTP_TIMEOUT',
88
+ tooLargeCode: 'AUTH_RESPONSE_TOO_LARGE',
89
+ });
90
+ let data = null;
91
+ try {
92
+ data = JSON.parse(text || '{}');
93
+ } catch {
94
+ data = null;
95
+ }
96
+ return { response, text, data };
97
+ }
98
+
99
+ async function fetchAuthJson(url, init = {}, label = 'Authentication request') {
100
+ const result = await fetchBoundedJsonResponse(url, init, label);
101
+ if (!result.response.ok) {
102
+ throw new Error(
103
+ `${label} failed: HTTP ${result.response.status} — ${result.text.slice(0, 500) || 'empty response'}`,
104
+ );
105
+ }
106
+ if (!result.data || typeof result.data !== 'object') {
107
+ throw new Error(`${label} returned malformed JSON.`);
108
+ }
109
+ return result.data;
110
+ }
111
+
74
112
  function logInfo(msg) {
75
113
  const mark = CLI_INTERACTIVE ? `${COLORS.blue}◇${COLORS.reset}` : '->';
76
114
  console.log(` ${mark} ${msg}`);
@@ -182,10 +220,6 @@ function sanitizeEnvKey(key) {
182
220
  return String(key).replace(/[\r\n]/g, '');
183
221
  }
184
222
 
185
- function sanitizeEnvValue(value) {
186
- return String(value).replace(/[\r\n]/g, '');
187
- }
188
-
189
223
  function validateEnvKey(key) {
190
224
  if (!/^[A-Z][A-Z0-9_]*$/.test(key)) {
191
225
  throw new Error(`Invalid env key "${key}". Keys must be uppercase letters, digits, and underscores (e.g. PORT, ANTHROPIC_API_KEY).`);
@@ -193,33 +227,11 @@ function validateEnvKey(key) {
193
227
  }
194
228
 
195
229
  function upsertEnvValue(key, value) {
196
- const safeKey = sanitizeEnvKey(key);
197
- const safeValue = sanitizeEnvValue(value);
198
- const raw = readEnvFileRaw();
199
- const lines = raw ? raw.split('\n') : [];
200
- let replaced = false;
201
-
202
- for (let i = 0; i < lines.length; i++) {
203
- if (lines[i].startsWith(`${safeKey}=`)) {
204
- lines[i] = `${safeKey}=${safeValue}`;
205
- replaced = true;
206
- break;
207
- }
208
- }
209
-
210
- if (!replaced) lines.push(`${safeKey}=${safeValue}`);
211
- const output = lines.filter((_, idx, arr) => idx !== arr.length - 1 || arr[idx] !== '').join('\n') + '\n';
212
- fs.writeFileSync(ENV_FILE, output, { mode: 0o600 });
230
+ upsertRuntimeEnvValue(ENV_FILE, key, value);
213
231
  }
214
232
 
215
233
  function removeEnvValue(key) {
216
- const safeKey = sanitizeEnvKey(key);
217
- const raw = readEnvFileRaw();
218
- if (!raw) return false;
219
- const lines = raw.split('\n').filter((line) => !line.startsWith(`${safeKey}=`));
220
- const output = lines.filter((_, idx, arr) => idx !== arr.length - 1 || arr[idx] !== '').join('\n') + '\n';
221
- fs.writeFileSync(ENV_FILE, output, { mode: 0o600 });
222
- return true;
234
+ return removeRuntimeEnvValue(ENV_FILE, sanitizeEnvKey(key));
223
235
  }
224
236
 
225
237
  function readAdminCredentials() {
@@ -870,29 +882,52 @@ async function cmdMigrate(args = []) {
870
882
  });
871
883
  }
872
884
 
873
- async function pollDeviceCode({ pollUrl, pollBody, pollHeaders = {}, intervalMs, timeoutMs, onToken }) {
885
+ async function pollDeviceCode({
886
+ pollUrl,
887
+ pollBody,
888
+ pollHeaders = {},
889
+ intervalMs,
890
+ timeoutMs,
891
+ onToken,
892
+ signal = null,
893
+ }) {
874
894
  const start = Date.now();
875
- let currentInterval = intervalMs;
895
+ let currentInterval = Math.max(1000, Number(intervalMs) || 5000);
876
896
  while (Date.now() - start < timeoutMs) {
877
- await new Promise((r) => setTimeout(r, currentInterval));
878
- const res = await fetch(pollUrl, {
879
- method: 'POST',
880
- headers: { 'Accept': 'application/json', 'Content-Type': 'application/json', ...pollHeaders },
881
- body: JSON.stringify(pollBody()),
882
- });
883
- if (res.status === 403 || res.status === 404) continue;
884
- if (!res.ok) {
885
- const text = await res.text().catch(() => 'Unknown error');
886
- throw new Error(`Token poll failed: HTTP ${res.status} — ${text}`);
897
+ await abortableDelay(currentInterval, signal);
898
+ let result;
899
+ try {
900
+ result = await fetchBoundedJsonResponse(pollUrl, {
901
+ method: 'POST',
902
+ headers: { 'Accept': 'application/json', 'Content-Type': 'application/json', ...pollHeaders },
903
+ body: JSON.stringify(pollBody()),
904
+ signal,
905
+ }, 'Device authorization poll');
906
+ } catch (error) {
907
+ if (signal?.aborted) throw signal.reason || error;
908
+ if (/timed out|fetch failed|ECONNRESET|ECONNREFUSED|socket/i.test(String(error?.message || error))) {
909
+ continue;
910
+ }
911
+ throw error;
912
+ }
913
+ const { response, text, data } = result;
914
+ if (response.status === 403 || response.status === 404) continue;
915
+ if (!response.ok) {
916
+ throw new Error(`Token poll failed: HTTP ${response.status} — ${text.slice(0, 500)}`);
917
+ }
918
+ if (!data || typeof data !== 'object') {
919
+ throw new Error('Device authorization poll returned malformed JSON.');
887
920
  }
888
- const data = await res.json();
889
921
  const done = await onToken(data);
890
922
  if (done) return;
891
923
  if (data.error === 'authorization_pending') continue;
892
- if (data.error === 'slow_down') { currentInterval += 5000; continue; }
924
+ if (data.error === 'slow_down') {
925
+ currentInterval = Math.min(currentInterval + 5000, 30000);
926
+ continue;
927
+ }
893
928
  if (data.error) throw new Error(`Authentication failed: ${data.error_description || data.error}`);
894
929
  }
895
- throw new Error('Authentication timed out after 15 minutes.');
930
+ throw new Error(`Authentication timed out after ${Math.ceil(timeoutMs / 60000)} minutes.`);
896
931
  }
897
932
 
898
933
  async function cmdLoginClaudeCode() {
@@ -958,8 +993,6 @@ async function cmdLoginClaudeCode() {
958
993
  const openCmd = process.platform === 'darwin' ? 'open'
959
994
  : process.platform === 'win32' ? 'start'
960
995
  : 'xdg-open';
961
- spawnSync(openCmd, [authUrl.toString()], { stdio: 'ignore' });
962
-
963
996
  // Start local redirect server to capture authorization code
964
997
  const authCode = await new Promise((resolve, reject) => {
965
998
  const timeout = setTimeout(() => {
@@ -983,7 +1016,7 @@ async function cmdLoginClaudeCode() {
983
1016
  return;
984
1017
  }
985
1018
 
986
- if (returnedState && returnedState !== state) {
1019
+ if (returnedState !== state) {
987
1020
  res.writeHead(200, { 'Content-Type': 'text/html' });
988
1021
  res.end('<html><body><h2>Authorization failed.</h2><p>State mismatch. You can close this tab.</p></body></html>');
989
1022
  clearTimeout(timeout);
@@ -998,7 +1031,10 @@ async function cmdLoginClaudeCode() {
998
1031
  clearTimeout(timeout);
999
1032
  server.close();
1000
1033
  resolve(code);
1034
+ return;
1001
1035
  }
1036
+ res.writeHead(400, { 'Content-Type': 'text/html' });
1037
+ res.end('<html><body><h2>Authorization failed.</h2><p>Missing authorization code.</p></body></html>');
1002
1038
  } catch (err) {
1003
1039
  res.writeHead(500);
1004
1040
  res.end('Internal error');
@@ -1007,6 +1043,7 @@ async function cmdLoginClaudeCode() {
1007
1043
 
1008
1044
  server.listen(redirectPort, 'localhost', () => {
1009
1045
  logInfo(`Waiting for OAuth callback on ${redirectUri} ...`);
1046
+ spawnSync(openCmd, [authUrl.toString()], { stdio: 'ignore' });
1010
1047
  });
1011
1048
  server.on('error', (err) => {
1012
1049
  clearTimeout(timeout);
@@ -1015,7 +1052,7 @@ async function cmdLoginClaudeCode() {
1015
1052
  });
1016
1053
 
1017
1054
  logInfo('Exchanging authorization code for access token...');
1018
- const tokenRes = await fetch('https://platform.claude.com/v1/oauth/token', {
1055
+ const tokenData = await fetchAuthJson('https://platform.claude.com/v1/oauth/token', {
1019
1056
  method: 'POST',
1020
1057
  headers: {
1021
1058
  'Content-Type': 'application/json',
@@ -1031,14 +1068,7 @@ async function cmdLoginClaudeCode() {
1031
1068
  scope: SCOPES,
1032
1069
  state,
1033
1070
  }),
1034
- });
1035
-
1036
- if (!tokenRes.ok) {
1037
- const text = await tokenRes.text().catch(() => 'Unknown error');
1038
- throw new Error(`Token exchange failed: HTTP ${tokenRes.status} — ${text}`);
1039
- }
1040
-
1041
- const tokenData = await tokenRes.json();
1071
+ }, 'Claude Code token exchange');
1042
1072
  const accessToken = tokenData.access_token;
1043
1073
  if (!accessToken) {
1044
1074
  throw new Error('Token exchange succeeded but no access_token was returned.');
@@ -1087,8 +1117,6 @@ async function cmdLoginGrokOAuth() {
1087
1117
  const openCmd = process.platform === 'darwin' ? 'open'
1088
1118
  : process.platform === 'win32' ? 'start'
1089
1119
  : 'xdg-open';
1090
- spawnSync(openCmd, [authUrl.toString()], { stdio: 'ignore' });
1091
-
1092
1120
  const authCode = await new Promise((resolve, reject) => {
1093
1121
  const timeout = setTimeout(() => {
1094
1122
  server.close();
@@ -1111,7 +1139,7 @@ async function cmdLoginGrokOAuth() {
1111
1139
  return;
1112
1140
  }
1113
1141
 
1114
- if (returnedState && returnedState !== state) {
1142
+ if (returnedState !== state) {
1115
1143
  res.writeHead(200, { 'Content-Type': 'text/html' });
1116
1144
  res.end('<html><body><h2>Authorization failed.</h2><p>State mismatch. You can close this tab.</p></body></html>');
1117
1145
  clearTimeout(timeout);
@@ -1126,7 +1154,10 @@ async function cmdLoginGrokOAuth() {
1126
1154
  clearTimeout(timeout);
1127
1155
  server.close();
1128
1156
  resolve(code);
1157
+ return;
1129
1158
  }
1159
+ res.writeHead(400, { 'Content-Type': 'text/html' });
1160
+ res.end('<html><body><h2>Authorization failed.</h2><p>Missing authorization code.</p></body></html>');
1130
1161
  } catch (err) {
1131
1162
  res.writeHead(500);
1132
1163
  res.end('Internal error');
@@ -1135,6 +1166,7 @@ async function cmdLoginGrokOAuth() {
1135
1166
 
1136
1167
  server.listen(redirectPort, '127.0.0.1', () => {
1137
1168
  logInfo(`Waiting for OAuth callback on ${redirectUri} ...`);
1169
+ spawnSync(openCmd, [authUrl.toString()], { stdio: 'ignore' });
1138
1170
  });
1139
1171
  server.on('error', (err) => {
1140
1172
  clearTimeout(timeout);
@@ -1143,7 +1175,7 @@ async function cmdLoginGrokOAuth() {
1143
1175
  });
1144
1176
 
1145
1177
  logInfo('Exchanging authorization code for access token...');
1146
- const tokenRes = await fetch('https://auth.x.ai/oauth2/token', {
1178
+ const tokenData = await fetchAuthJson('https://auth.x.ai/oauth2/token', {
1147
1179
  method: 'POST',
1148
1180
  headers: {
1149
1181
  'Content-Type': 'application/x-www-form-urlencoded',
@@ -1156,14 +1188,7 @@ async function cmdLoginGrokOAuth() {
1156
1188
  client_id: clientId,
1157
1189
  code_verifier: codeVerifier,
1158
1190
  }),
1159
- });
1160
-
1161
- if (!tokenRes.ok) {
1162
- const text = await tokenRes.text().catch(() => 'Unknown error');
1163
- throw new Error(`Token exchange failed: HTTP ${tokenRes.status} — ${text}`);
1164
- }
1165
-
1166
- const tokenData = await tokenRes.json();
1191
+ }, 'Grok OAuth token exchange');
1167
1192
  const accessToken = tokenData.access_token;
1168
1193
  if (!accessToken) {
1169
1194
  throw new Error('Token exchange succeeded but no access_token was returned.');
@@ -1189,14 +1214,16 @@ async function cmdLogin(args = []) {
1189
1214
  const clientId = '01ab8ac9400c4e429b23';
1190
1215
  logInfo('Requesting device code from GitHub...');
1191
1216
 
1192
- const reqRes = await fetch('https://github.com/login/device/code', {
1217
+ const deviceData = await fetchAuthJson('https://github.com/login/device/code', {
1193
1218
  method: 'POST',
1194
1219
  headers: { 'Accept': 'application/json', 'Content-Type': 'application/json' },
1195
1220
  body: JSON.stringify({ client_id: clientId, scope: 'user:email' })
1196
- });
1197
- if (!reqRes.ok) throw new Error(`Failed to request device code: HTTP ${reqRes.status}`);
1221
+ }, 'GitHub device-code request');
1198
1222
 
1199
- const { device_code, user_code, verification_uri, interval } = await reqRes.json();
1223
+ const { device_code, user_code, verification_uri, interval } = deviceData;
1224
+ if (!device_code || !user_code || !verification_uri) {
1225
+ throw new Error('GitHub device-code response was missing required fields.');
1226
+ }
1200
1227
  console.log(`\n ${COLORS.cyan}Please visit:${COLORS.reset} ${verification_uri}`);
1201
1228
  console.log(` ${COLORS.cyan}Enter code:${COLORS.reset} ${COLORS.bold}${user_code}${COLORS.reset}\n`);
1202
1229
  logInfo('Waiting for authorization (timeout in 15m)...');
@@ -1221,20 +1248,17 @@ async function cmdLogin(args = []) {
1221
1248
  const clientId = 'app_EMoamEEZ73f0CkXaXp7hrann';
1222
1249
  logInfo('Requesting device code from OpenAI...');
1223
1250
 
1224
- const reqRes = await fetch('https://auth.openai.com/api/accounts/deviceauth/usercode', {
1251
+ const data = await fetchAuthJson('https://auth.openai.com/api/accounts/deviceauth/usercode', {
1225
1252
  method: 'POST',
1226
1253
  headers: { 'Accept': 'application/json', 'Content-Type': 'application/json' },
1227
1254
  body: JSON.stringify({ client_id: clientId, scope: 'openid profile email offline_access model.request model.read model.create' })
1228
- });
1229
-
1230
- if (!reqRes.ok) {
1231
- throw new Error(`Failed to request device code: HTTP ${reqRes.status}`);
1232
- }
1233
-
1234
- const data = await reqRes.json();
1255
+ }, 'OpenAI device-code request');
1235
1256
  const { device_auth_id, interval } = data;
1236
1257
  const user_code = data.user_code || data.usercode;
1237
1258
  const verification_uri = 'https://auth.openai.com/codex/device';
1259
+ if (!device_auth_id || !user_code) {
1260
+ throw new Error('OpenAI device-code response was missing required fields.');
1261
+ }
1238
1262
 
1239
1263
  console.log(`\n ${COLORS.cyan}Please visit:${COLORS.reset} ${verification_uri}`);
1240
1264
  console.log(` ${COLORS.cyan}Enter code:${COLORS.reset} ${COLORS.bold}${user_code}${COLORS.reset}\n`);
@@ -1257,7 +1281,7 @@ async function cmdLogin(args = []) {
1257
1281
  });
1258
1282
 
1259
1283
  logInfo('Exchanging authorization code for access token...');
1260
- const exchangeRes = await fetch('https://auth.openai.com/oauth/token', {
1284
+ const exchangeData = await fetchAuthJson('https://auth.openai.com/oauth/token', {
1261
1285
  method: 'POST',
1262
1286
  headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
1263
1287
  body: new URLSearchParams({
@@ -1267,14 +1291,7 @@ async function cmdLogin(args = []) {
1267
1291
  client_id: clientId,
1268
1292
  code_verifier: codeVerifier,
1269
1293
  }),
1270
- });
1271
-
1272
- if (!exchangeRes.ok) {
1273
- const errorText = await exchangeRes.text().catch(() => 'Unknown error');
1274
- throw new Error(`OpenAI token exchange failed: HTTP ${exchangeRes.status} — ${errorText}`);
1275
- }
1276
-
1277
- const exchangeData = await exchangeRes.json();
1294
+ }, 'OpenAI token exchange');
1278
1295
  if (!exchangeData.access_token) {
1279
1296
  throw new Error('OpenAI token exchange succeeded but did not return an access token.');
1280
1297
  }