neoagent 2.4.0 → 2.4.1-beta.5

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 (30) hide show
  1. package/LICENSE +619 -21
  2. package/README.md +1 -1
  3. package/extensions/chrome-browser/icons/icon128.png +0 -0
  4. package/extensions/chrome-browser/icons/icon16.png +0 -0
  5. package/extensions/chrome-browser/icons/icon48.png +0 -0
  6. package/extensions/chrome-browser/icons/logo.svg +12 -0
  7. package/extensions/chrome-browser/manifest.json +11 -1
  8. package/extensions/chrome-browser/popup.css +5 -0
  9. package/extensions/chrome-browser/popup.html +2 -4
  10. package/extensions/chrome-browser/popup.js +1 -5
  11. package/flutter_app/lib/main_controller.dart +9 -0
  12. package/flutter_app/lib/main_devices.dart +70 -1
  13. package/flutter_app/lib/main_settings.dart +172 -31
  14. package/flutter_app/lib/src/backend_client.dart +12 -0
  15. package/flutter_app/lib/src/desktop_companion_actions.dart +72 -0
  16. package/flutter_app/lib/src/desktop_companion_io.dart +9 -4
  17. package/flutter_app/macos/Runner/AppDelegate.swift +12 -1
  18. package/package.json +2 -2
  19. package/server/guest_agent.js +12 -1
  20. package/server/http/routes.js +190 -0
  21. package/server/public/.last_build_id +1 -1
  22. package/server/public/assets/fonts/MaterialIcons-Regular.otf +0 -0
  23. package/server/public/flutter_bootstrap.js +1 -1
  24. package/server/public/main.dart.js +43860 -43589
  25. package/server/services/ai/tools.js +39 -28
  26. package/server/services/desktop/protocol.js +1 -0
  27. package/server/services/desktop/provider.js +11 -0
  28. package/server/services/desktop/registry.js +51 -10
  29. package/server/services/runtime/docker-vm-manager.js +26 -3
  30. package/server/services/runtime/manager.js +25 -2
@@ -0,0 +1,12 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
2
+ <defs>
3
+ <linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
4
+ <stop offset="0%" stop-color="#8f6d3e"/>
5
+ <stop offset="100%" stop-color="#2f7d6e"/>
6
+ </linearGradient>
7
+ </defs>
8
+ <rect x="5.76" y="5.76" width="20.48" height="20.48" rx="6.96" fill="url(#bg)" stroke="#ffffff" stroke-opacity="0.16" stroke-width="1"/>
9
+ <polygon points="16,9.76 9.35,13.12 16,16.48 22.65,13.12" fill="white"/>
10
+ <polyline points="9.35,16.48 16,19.68 22.65,16.48" fill="none" stroke="white" stroke-width="1.44" stroke-linecap="round" stroke-linejoin="round"/>
11
+ <polyline points="9.35,19.68 16,22.72 22.65,19.68" fill="none" stroke="white" stroke-width="1.44" stroke-linecap="round" stroke-linejoin="round"/>
12
+ </svg>
@@ -6,9 +6,19 @@
6
6
  "minimum_chrome_version": "118",
7
7
  "permissions": ["debugger", "storage", "tabs"],
8
8
  "host_permissions": ["http://*/*", "https://*/*"],
9
+ "icons": {
10
+ "16": "icons/icon16.png",
11
+ "48": "icons/icon48.png",
12
+ "128": "icons/icon128.png"
13
+ },
9
14
  "action": {
10
15
  "default_title": "NeoAgent Browser",
11
- "default_popup": "popup.html"
16
+ "default_popup": "popup.html",
17
+ "default_icon": {
18
+ "16": "icons/icon16.png",
19
+ "48": "icons/icon48.png",
20
+ "128": "icons/icon128.png"
21
+ }
12
22
  },
13
23
  "background": {
14
24
  "service_worker": "background.mjs",
@@ -46,6 +46,11 @@ header {
46
46
  gap: 6px;
47
47
  }
48
48
 
49
+ .logo {
50
+ width: 40px;
51
+ height: 40px;
52
+ }
53
+
49
54
  h1,
50
55
  h2,
51
56
  p {
@@ -8,6 +8,7 @@
8
8
  <body>
9
9
  <main>
10
10
  <header>
11
+ <img src="icons/logo.svg" alt="NeoAgent" class="logo">
11
12
  <p class="eyebrow">NeoAgent Browser</p>
12
13
  <h1>Connect this Chrome</h1>
13
14
  <p class="intro">Pair once, then let NeoAgent control this browser when you ask it to.</p>
@@ -31,8 +32,6 @@
31
32
  </div>
32
33
  </section>
33
34
 
34
- <button id="openApp" type="button" class="link-button">Open NeoAgent web page</button>
35
-
36
35
  <details id="settings" class="settings">
37
36
  <summary>Settings &amp; updates</summary>
38
37
  <label>
@@ -47,8 +46,7 @@
47
46
  <p class="hint">The server URL is usually filled in by the ZIP downloaded from NeoAgent.</p>
48
47
  </details>
49
48
 
50
- <p class="hint">Pairing opens NeoAgent in a tab. This extension never stores your NeoAgent password.</p>
51
- <p id="message" class="message"></p>
49
+ <p id="message" class="message"></p>
52
50
  </main>
53
51
  <script src="popup.js" type="module"></script>
54
52
  </body>
@@ -9,7 +9,6 @@ const flowTitleEl = document.querySelector('#flowTitle');
9
9
  const flowDescriptionEl = document.querySelector('#flowDescription');
10
10
  const primaryActionEl = document.querySelector('#primaryAction');
11
11
  const secondaryActionEl = document.querySelector('#secondaryAction');
12
- const openAppEl = document.querySelector('#openApp');
13
12
  const disconnectEl = document.querySelector('#disconnect');
14
13
  const checkUpdateEl = document.querySelector('#checkUpdate');
15
14
  const downloadEl = document.querySelector('#download');
@@ -59,7 +58,7 @@ function setBusy(isBusy, label = 'Working...') {
59
58
  }
60
59
  const busy = pendingActions > 0;
61
60
 
62
- [primaryActionEl, secondaryActionEl, openAppEl, disconnectEl, checkUpdateEl, downloadEl].forEach((button) => {
61
+ [primaryActionEl, secondaryActionEl, disconnectEl, checkUpdateEl, downloadEl].forEach((button) => {
63
62
  if (!button || button.hidden) return;
64
63
  if (busy) {
65
64
  if (!Object.prototype.hasOwnProperty.call(button.dataset, 'wasDisabled')) {
@@ -99,8 +98,6 @@ function updateFlow() {
99
98
  const hasToken = Boolean(currentState.token || currentState.tokenId);
100
99
  const approvalUrl = currentState.approvalUrl || '';
101
100
 
102
- openAppEl.disabled = !hasServerUrl;
103
-
104
101
  if (!hasServerUrl) {
105
102
  stepLabelEl.textContent = 'Step 1 of 3';
106
103
  flowTitleEl.textContent = 'Add your NeoAgent server';
@@ -243,7 +240,6 @@ serverUrlEl.addEventListener('input', updateFlow);
243
240
 
244
241
  bindAsyncClick(primaryActionEl, () => runAction(primaryActionEl.dataset.action));
245
242
  bindAsyncClick(secondaryActionEl, () => runAction(secondaryActionEl.dataset.action));
246
- bindAsyncClick(openAppEl, () => runAction('openApp'));
247
243
  bindAsyncClick(disconnectEl, async () => {
248
244
  await send('disconnect');
249
245
  await refresh();
@@ -1040,6 +1040,15 @@ class NeoAgentController extends ChangeNotifier {
1040
1040
  }
1041
1041
  }
1042
1042
 
1043
+ Future<Map<String, dynamic>> testCliRuntime() =>
1044
+ _backendClient.testCli(backendUrl);
1045
+
1046
+ Future<Map<String, dynamic>> testBrowserExtension() =>
1047
+ _backendClient.testExtension(backendUrl);
1048
+
1049
+ Future<Map<String, dynamic>> testDesktopCompanion() =>
1050
+ _backendClient.testDesktop(backendUrl);
1051
+
1043
1052
  Future<void> openAppUpdate() async {
1044
1053
  final release = availableAppUpdate;
1045
1054
  if (release == null || isOpeningAppUpdate) {
@@ -103,8 +103,13 @@ class _DevicesPanelState extends State<DevicesPanel> {
103
103
  _onlineDesktopDevices.length > 1 &&
104
104
  (widget.controller.selectedDesktopDeviceId ?? '').isEmpty;
105
105
 
106
+ bool get _extensionPreferredButOffline =>
107
+ widget.controller.browserBackend == 'extension' &&
108
+ !widget.controller.browserExtensionConnected;
109
+
106
110
  String? get _activeScreenshotPath {
107
111
  if (_isBrowser) {
112
+ if (_extensionPreferredButOffline) return null;
108
113
  return widget.controller.browserScreenshotPath;
109
114
  }
110
115
  if (_isDesktop) {
@@ -121,6 +126,7 @@ class _DevicesPanelState extends State<DevicesPanel> {
121
126
  Future<void> _ensurePreview() async {
122
127
  final controller = widget.controller;
123
128
  if (_isBrowser) {
129
+ if (_extensionPreferredButOffline) return;
124
130
  if (controller.browserRuntime['launched'] != true) {
125
131
  return;
126
132
  }
@@ -161,6 +167,7 @@ class _DevicesPanelState extends State<DevicesPanel> {
161
167
  return;
162
168
  }
163
169
  if (_isBrowser) {
170
+ if (_extensionPreferredButOffline) return;
164
171
  await widget.controller.refreshBrowserFrameRuntime();
165
172
  return;
166
173
  }
@@ -451,6 +458,14 @@ class _DevicesPanelState extends State<DevicesPanel> {
451
458
  browserExtensionActive: usingExtension,
452
459
  browserFallbackLabel: browserFallbackLabel,
453
460
  ),
461
+ if (_isBrowser && prefersExtension) ...<Widget>[
462
+ const SizedBox(height: 14),
463
+ _ExtensionStatusBar(
464
+ connected: extensionConnected,
465
+ onDownload: controller.downloadBrowserExtension,
466
+ onRefresh: controller.refreshBrowserExtensionStatus,
467
+ ),
468
+ ],
454
469
  if (_isDesktop) ...<Widget>[
455
470
  const SizedBox(height: 14),
456
471
  DropdownButtonFormField<String>(
@@ -558,7 +573,9 @@ class _DevicesPanelState extends State<DevicesPanel> {
558
573
  busy: _isCurrentSurfaceBusy,
559
574
  wakingUp: !_isBrowser && !_isDesktop && _androidStarting,
560
575
  enabled: _isBrowser || _isDesktop || _androidOnline,
561
- connectRequired: _desktopRequiresSelection,
576
+ connectRequired: _isBrowser
577
+ ? _extensionPreferredButOffline
578
+ : _desktopRequiresSelection,
562
579
  onTapPoint: _handleTap,
563
580
  onSwipe: _handleSwipe,
564
581
  onWakeRequested: _openPrimary,
@@ -1701,6 +1718,58 @@ class _RuntimePreview extends StatelessWidget {
1701
1718
  }
1702
1719
  }
1703
1720
 
1721
+ class _ExtensionStatusBar extends StatelessWidget {
1722
+ const _ExtensionStatusBar({
1723
+ required this.connected,
1724
+ required this.onDownload,
1725
+ required this.onRefresh,
1726
+ });
1727
+
1728
+ final bool connected;
1729
+ final Future<void> Function() onDownload;
1730
+ final Future<void> Function() onRefresh;
1731
+
1732
+ @override
1733
+ Widget build(BuildContext context) {
1734
+ return Container(
1735
+ padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
1736
+ decoration: BoxDecoration(
1737
+ color: _bgSecondary,
1738
+ borderRadius: BorderRadius.circular(16),
1739
+ border: Border.all(color: _borderLight),
1740
+ ),
1741
+ child: Row(
1742
+ children: <Widget>[
1743
+ _DotStatus(
1744
+ label: connected ? 'Extension connected' : 'Extension not connected',
1745
+ color: connected ? _success : _warning,
1746
+ ),
1747
+ const Spacer(),
1748
+ OutlinedButton.icon(
1749
+ onPressed: onDownload,
1750
+ icon: const Icon(Icons.download_outlined, size: 18),
1751
+ label: const Text('Download'),
1752
+ style: OutlinedButton.styleFrom(
1753
+ visualDensity: VisualDensity.compact,
1754
+ padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
1755
+ ),
1756
+ ),
1757
+ const SizedBox(width: 8),
1758
+ OutlinedButton.icon(
1759
+ onPressed: onRefresh,
1760
+ icon: const Icon(Icons.sync, size: 18),
1761
+ label: const Text('Refresh'),
1762
+ style: OutlinedButton.styleFrom(
1763
+ visualDensity: VisualDensity.compact,
1764
+ padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
1765
+ ),
1766
+ ),
1767
+ ],
1768
+ ),
1769
+ );
1770
+ }
1771
+ }
1772
+
1704
1773
  class _ResultBlock extends StatelessWidget {
1705
1774
  const _ResultBlock({required this.label, required this.value});
1706
1775
 
@@ -187,6 +187,14 @@ class _SettingsPanelState extends State<SettingsPanel> {
187
187
  <String, TextEditingController>{};
188
188
  final Set<String> _expandedProviderIds = <String>{};
189
189
 
190
+ // Inline runtime test state — ephemeral, not stored in controller.
191
+ bool _cliTestRunning = false;
192
+ Map<String, dynamic>? _cliTestResult;
193
+ bool _extensionTestRunning = false;
194
+ Map<String, dynamic>? _extensionTestResult;
195
+ bool _desktopTestRunning = false;
196
+ Map<String, dynamic>? _desktopTestResult;
197
+
190
198
  @override
191
199
  void initState() {
192
200
  super.initState();
@@ -636,31 +644,46 @@ class _SettingsPanelState extends State<SettingsPanel> {
636
644
  },
637
645
  ),
638
646
  const SizedBox(height: 10),
639
- Text(
640
- _browserBackend == 'extension'
641
- ? (controller.browserExtensionConnected
642
- ? 'Chrome extension connected.'
643
- : 'Chrome extension selected. Download it here, load it unpacked in Chrome on the remote machine, then pair after login.')
644
- : 'Cloud browser runtime is active.',
645
- style: TextStyle(color: _textSecondary, height: 1.4),
646
- ),
647
- const SizedBox(height: 10),
648
- Wrap(
649
- spacing: 10,
650
- runSpacing: 10,
651
- children: <Widget>[
652
- OutlinedButton.icon(
653
- onPressed: controller.downloadBrowserExtension,
654
- icon: Icon(Icons.download_outlined),
655
- label: Text('Download extension'),
656
- ),
657
- OutlinedButton.icon(
658
- onPressed: controller.refreshBrowserExtensionStatus,
659
- icon: Icon(Icons.sync),
660
- label: Text('Refresh status'),
661
- ),
662
- ],
663
- ),
647
+ if (_browserBackend == 'extension') ...<Widget>[
648
+ _buildInlineTestRow(
649
+ label: 'Chrome extension',
650
+ running: _extensionTestRunning,
651
+ result: _extensionTestResult,
652
+ note: controller.browserExtensionConnected
653
+ ? 'Connected tap Test to verify the live link.'
654
+ : 'Not connected — download the extension, load it in Chrome, then pair after login.',
655
+ onTest: () async {
656
+ setState(() { _extensionTestRunning = true; _extensionTestResult = null; });
657
+ try {
658
+ final r = await controller.testBrowserExtension();
659
+ setState(() => _extensionTestResult = r);
660
+ } catch (e) {
661
+ setState(() => _extensionTestResult = <String, dynamic>{'passed': false, 'detail': e.toString()});
662
+ } finally {
663
+ setState(() => _extensionTestRunning = false);
664
+ }
665
+ },
666
+ ),
667
+ const SizedBox(height: 10),
668
+ Wrap(
669
+ spacing: 10,
670
+ runSpacing: 10,
671
+ children: <Widget>[
672
+ OutlinedButton.icon(
673
+ onPressed: controller.downloadBrowserExtension,
674
+ icon: Icon(Icons.download_outlined),
675
+ label: Text('Download extension'),
676
+ ),
677
+ OutlinedButton.icon(
678
+ onPressed: controller.refreshBrowserExtensionStatus,
679
+ icon: Icon(Icons.sync),
680
+ label: Text('Refresh'),
681
+ ),
682
+ ],
683
+ ),
684
+ ] else ...<Widget>[
685
+ Text('Cloud browser runtime is active.', style: TextStyle(color: _textSecondary, height: 1.4)),
686
+ ],
664
687
  const Divider(height: 32),
665
688
  Text(
666
689
  'CLI Runtime',
@@ -692,13 +715,26 @@ class _SettingsPanelState extends State<SettingsPanel> {
692
715
  },
693
716
  ),
694
717
  const SizedBox(height: 10),
695
- Text(
696
- _cliBackend == 'desktop'
718
+ _buildInlineTestRow(
719
+ label: 'CLI',
720
+ running: _cliTestRunning,
721
+ result: _cliTestResult,
722
+ note: _cliBackend == 'desktop'
697
723
  ? (controller.desktopCompanionConnected
698
- ? 'Desktop app connected.'
699
- : 'Desktop app selected. Make sure the desktop companion is running and connected on your machine.')
700
- : 'Cloud CLI runtime is active.',
701
- style: TextStyle(color: _textSecondary, height: 1.4),
724
+ ? 'Desktop app connected — commands currently route through the cloud VM (desktop routing coming soon).'
725
+ : 'Desktop app selected but not connected. Commands fall back to cloud VM until the companion is online.')
726
+ : 'Cloud VM commands run in an isolated container.',
727
+ onTest: () async {
728
+ setState(() { _cliTestRunning = true; _cliTestResult = null; });
729
+ try {
730
+ final r = await controller.testCliRuntime();
731
+ setState(() => _cliTestResult = r);
732
+ } catch (e) {
733
+ setState(() => _cliTestResult = <String, dynamic>{'passed': false, 'detail': e.toString()});
734
+ } finally {
735
+ setState(() => _cliTestRunning = false);
736
+ }
737
+ },
702
738
  ),
703
739
  const Divider(height: 32),
704
740
  Text(
@@ -1344,6 +1380,47 @@ class _SettingsPanelState extends State<SettingsPanel> {
1344
1380
  const SizedBox(height: 12),
1345
1381
  _InlineError(message: message),
1346
1382
  ],
1383
+ const SizedBox(height: 12),
1384
+ _buildInlineTestRow(
1385
+ label: 'Desktop companion',
1386
+ running: _desktopTestRunning,
1387
+ result: _desktopTestResult != null
1388
+ ? <String, dynamic>{
1389
+ 'passed': _desktopTestResult!['passed'] == true,
1390
+ 'detail': _desktopTestResult!['detail']?.toString() ?? '',
1391
+ }
1392
+ : null,
1393
+ note: controller.desktopCompanionConnected
1394
+ ? 'Connected — tap Test to fetch live device status from the server.'
1395
+ : 'Not connected. Make sure the desktop app is running on the target machine.',
1396
+ onTest: () async {
1397
+ setState(() { _desktopTestRunning = true; _desktopTestResult = null; });
1398
+ try {
1399
+ final r = await controller.testDesktopCompanion();
1400
+ final active = r['activeDevice'];
1401
+ final multi = r['multipleOnline'] == true;
1402
+ String detail = r['detail']?.toString() ?? '';
1403
+ if (r['passed'] == true && active != null) {
1404
+ final label = active['label']?.toString() ?? 'Device';
1405
+ final plat = active['platform']?.toString() ?? '';
1406
+ final sc = active['permissions']?['screenCapture'] == true;
1407
+ final ic = active['permissions']?['inputControl'] == true;
1408
+ detail = '$label${plat.isNotEmpty ? " ($plat)" : ""}'
1409
+ ' — screen: ${sc ? "✓" : "✗"}, input: ${ic ? "✓" : "✗"}';
1410
+ } else if (multi) {
1411
+ detail = '${r['onlineCount']} devices online — select one in Desktop › Devices';
1412
+ }
1413
+ setState(() => _desktopTestResult = <String, dynamic>{
1414
+ ...r,
1415
+ 'detail': detail,
1416
+ });
1417
+ } catch (e) {
1418
+ setState(() => _desktopTestResult = <String, dynamic>{'passed': false, 'detail': e.toString()});
1419
+ } finally {
1420
+ setState(() => _desktopTestRunning = false);
1421
+ }
1422
+ },
1423
+ ),
1347
1424
  const SizedBox(height: 14),
1348
1425
  Builder(
1349
1426
  builder: (context) {
@@ -1999,6 +2076,70 @@ class _SettingsPanelState extends State<SettingsPanel> {
1999
2076
  )
2000
2077
  .toList();
2001
2078
  }
2079
+
2080
+ // Shared helper: small "Test" button + inline result row.
2081
+ Widget _buildInlineTestRow({
2082
+ required String label,
2083
+ required bool running,
2084
+ required Map<String, dynamic>? result,
2085
+ required VoidCallback onTest,
2086
+ String? note,
2087
+ }) {
2088
+ final passed = result?['passed'] == true;
2089
+ final detail = result?['detail']?.toString() ?? '';
2090
+ return Row(
2091
+ crossAxisAlignment: CrossAxisAlignment.start,
2092
+ children: <Widget>[
2093
+ Expanded(
2094
+ child: Column(
2095
+ crossAxisAlignment: CrossAxisAlignment.start,
2096
+ children: <Widget>[
2097
+ if (result != null)
2098
+ Row(
2099
+ children: <Widget>[
2100
+ Icon(
2101
+ passed ? Icons.check_circle_rounded : Icons.cancel_rounded,
2102
+ size: 15,
2103
+ color: passed ? const Color(0xFF22C55E) : const Color(0xFFEF4444),
2104
+ ),
2105
+ const SizedBox(width: 6),
2106
+ Expanded(
2107
+ child: Text(
2108
+ passed ? (detail.isNotEmpty ? detail : '$label: OK') : detail,
2109
+ style: TextStyle(
2110
+ fontSize: 13,
2111
+ color: passed ? null : const Color(0xFFEF4444),
2112
+ ),
2113
+ ),
2114
+ ),
2115
+ ],
2116
+ )
2117
+ else if (note != null)
2118
+ Text(note, style: TextStyle(fontSize: 13, color: _textSecondary, height: 1.4)),
2119
+ ],
2120
+ ),
2121
+ ),
2122
+ const SizedBox(width: 12),
2123
+ SizedBox(
2124
+ width: 80,
2125
+ child: OutlinedButton(
2126
+ onPressed: running ? null : onTest,
2127
+ style: OutlinedButton.styleFrom(
2128
+ padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
2129
+ textStyle: const TextStyle(fontSize: 12),
2130
+ ),
2131
+ child: running
2132
+ ? const SizedBox(
2133
+ width: 13,
2134
+ height: 13,
2135
+ child: CircularProgressIndicator(strokeWidth: 2),
2136
+ )
2137
+ : const Text('Test'),
2138
+ ),
2139
+ ),
2140
+ ],
2141
+ );
2142
+ }
2002
2143
  }
2003
2144
 
2004
2145
  class _AiProviderCard extends StatelessWidget {
@@ -476,6 +476,18 @@ class BackendClient {
476
476
  return getMap(baseUrl, '/api/runtime/config', allowUnauthorized: true);
477
477
  }
478
478
 
479
+ Future<Map<String, dynamic>> testCli(String baseUrl) async {
480
+ return getMap(baseUrl, '/api/system/test/cli');
481
+ }
482
+
483
+ Future<Map<String, dynamic>> testExtension(String baseUrl) async {
484
+ return getMap(baseUrl, '/api/system/test/extension');
485
+ }
486
+
487
+ Future<Map<String, dynamic>> testDesktop(String baseUrl) async {
488
+ return getMap(baseUrl, '/api/system/test/desktop');
489
+ }
490
+
479
491
  Future<Map<String, dynamic>> fetchBrowserStatus(String baseUrl) async {
480
492
  return getMap(baseUrl, '/api/browser/status');
481
493
  }
@@ -1,3 +1,4 @@
1
+ import 'dart:async';
1
2
  import 'dart:convert';
2
3
  import 'dart:io';
3
4
 
@@ -396,6 +397,77 @@ class DesktopCompanionActions {
396
397
  };
397
398
  }
398
399
 
400
+ Future<Map<String, Object?>> executeShellCommand({
401
+ required String command,
402
+ String? cwd,
403
+ int? timeoutMs,
404
+ String? stdinInput,
405
+ }) async {
406
+ final shell = Platform.isWindows ? 'cmd.exe' : (Platform.environment['SHELL'] ?? '/bin/sh');
407
+ final args = Platform.isWindows ? <String>['/c', command] : <String>['-lc', command];
408
+ final workingDir = cwd?.trim().isNotEmpty == true ? cwd : Platform.environment['HOME'];
409
+ final startedAt = DateTime.now();
410
+
411
+ final process = await Process.start(
412
+ shell,
413
+ args,
414
+ workingDirectory: workingDir,
415
+ runInShell: false,
416
+ );
417
+
418
+ if (stdinInput != null && stdinInput.isNotEmpty) {
419
+ process.stdin.write(stdinInput);
420
+ await process.stdin.close();
421
+ } else {
422
+ unawaited(process.stdin.close());
423
+ }
424
+
425
+ const maxChars = 50000;
426
+ final stdoutBuf = StringBuffer();
427
+ final stderrBuf = StringBuffer();
428
+
429
+ final stdoutSub = process.stdout.transform(utf8.decoder).listen((data) {
430
+ stdoutBuf.write(data);
431
+ });
432
+ final stderrSub = process.stderr.transform(utf8.decoder).listen((data) {
433
+ stderrBuf.write(data);
434
+ });
435
+
436
+ final effectiveTimeout = Duration(
437
+ milliseconds: (timeoutMs != null && timeoutMs > 0) ? timeoutMs : 15 * 60 * 1000,
438
+ );
439
+
440
+ bool timedOut = false;
441
+ int? exitCode;
442
+ try {
443
+ exitCode = await process.exitCode.timeout(effectiveTimeout);
444
+ } on TimeoutException {
445
+ timedOut = true;
446
+ process.kill(ProcessSignal.sigterm);
447
+ exitCode = null;
448
+ }
449
+
450
+ await stdoutSub.cancel();
451
+ await stderrSub.cancel();
452
+
453
+ String _trim(StringBuffer buf) {
454
+ final s = buf.toString().trim();
455
+ return s.length > maxChars ? '${s.substring(0, maxChars)}\n...[truncated, ${s.length} total chars]' : s;
456
+ }
457
+
458
+ return <String, Object?>{
459
+ 'exitCode': exitCode,
460
+ 'stdout': _trim(stdoutBuf),
461
+ 'stderr': _trim(stderrBuf),
462
+ 'timedOut': timedOut,
463
+ 'killed': timedOut,
464
+ 'durationMs': DateTime.now().difference(startedAt).inMilliseconds,
465
+ 'command': command,
466
+ 'cwd': workingDir,
467
+ 'backend': 'desktop-companion',
468
+ };
469
+ }
470
+
399
471
  Future<Map<String, Object?>> _capabilities({
400
472
  Map<String, Object?>? platformStatus,
401
473
  }) async {
@@ -52,7 +52,8 @@ class DesktopCompanionManager extends ChangeNotifier {
52
52
 
53
53
  Future<void> bootstrap(SharedPreferences prefs) async {
54
54
  _enabled = prefs.getBool(desktopCompanionEnabledPrefsKey) ?? false;
55
- _paused = prefs.getBool(desktopCompanionPausedPrefsKey) ?? false;
55
+ // Always start unpaused — paused state must not carry over across restarts.
56
+ _paused = false;
56
57
  _label =
57
58
  prefs.getString(desktopCompanionLabelPrefsKey)?.trim() ??
58
59
  _defaultLabel();
@@ -116,7 +117,6 @@ class DesktopCompanionManager extends ChangeNotifier {
116
117
 
117
118
  Future<void> setPaused(bool value, SharedPreferences prefs) async {
118
119
  _paused = value;
119
- await prefs.setBool(desktopCompanionPausedPrefsKey, value);
120
120
  notifyListeners();
121
121
  if (_connected) {
122
122
  await _sendEvent('statusChanged', <String, Object?>{'paused': value});
@@ -387,10 +387,15 @@ class DesktopCompanionManager extends ChangeNotifier {
387
387
  case 'pauseControl':
388
388
  final paused = payload['paused'] != false;
389
389
  _paused = paused;
390
- final prefs = await SharedPreferences.getInstance();
391
- await prefs.setBool(desktopCompanionPausedPrefsKey, paused);
392
390
  notifyListeners();
393
391
  return <String, Object?>{'success': true, 'paused': _paused};
392
+ case 'executeCommand':
393
+ return _actions.executeShellCommand(
394
+ command: payload['command']?.toString() ?? '',
395
+ cwd: payload['cwd']?.toString(),
396
+ timeoutMs: (payload['timeout'] as num?)?.toInt(),
397
+ stdinInput: payload['stdin_input']?.toString(),
398
+ );
394
399
  case 'ping':
395
400
  return <String, Object?>{'pong': true};
396
401
  default:
@@ -316,7 +316,18 @@ final class DesktopCompanionNativePlugin: NSObject {
316
316
  }
317
317
 
318
318
  private func isAccessibilityTrusted() -> Bool {
319
- AXIsProcessTrusted()
319
+ if AXIsProcessTrusted() { return true }
320
+ // AXIsProcessTrusted() may cache false on macOS 14+ after a System Settings grant.
321
+ // Probe with a live AX read: .apiDisabled is the error returned when the process
322
+ // lacks accessibility permission (AXError has no .notTrusted case in the macOS SDK).
323
+ let sysElement = AXUIElementCreateSystemWide()
324
+ var value: CFTypeRef?
325
+ let status = AXUIElementCopyAttributeValue(
326
+ sysElement,
327
+ kAXFocusedApplicationAttribute as CFString,
328
+ &value
329
+ )
330
+ return status != .apiDisabled
320
331
  }
321
332
 
322
333
  private func resolveDisplayId(_ raw: String?) -> CGDirectDisplayID {
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "neoagent",
3
- "version": "2.4.0",
3
+ "version": "2.4.1-beta.5",
4
4
  "description": "Proactive personal AI agent with no limits",
5
- "license": "MIT",
5
+ "license": "AGPL-3.0-only",
6
6
  "main": "server/index.js",
7
7
  "engines": {
8
8
  "node": ">=20"
@@ -62,7 +62,18 @@ function isInsideAllowedRoots(targetPath) {
62
62
  }
63
63
 
64
64
  function requireToken(req, res, next) {
65
- next();
65
+ if (!AUTH_TOKEN) {
66
+ // Token not configured in this environment — allow but unauthenticated.
67
+ // Pass NEOAGENT_VM_GUEST_TOKEN to the container to enforce auth.
68
+ return next();
69
+ }
70
+ const header = String(req.headers?.authorization || '').trim();
71
+ const prefix = 'Bearer ';
72
+ const provided = header.startsWith(prefix) ? header.slice(prefix.length).trim() : '';
73
+ if (!provided || provided !== AUTH_TOKEN) {
74
+ return res.status(401).json({ error: 'Unauthorized.' });
75
+ }
76
+ return next();
66
77
  }
67
78
 
68
79
  function sanitizeError(err) {