neoagent 3.2.1-beta.1 → 3.2.1-beta.11

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 (198) 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 +654 -90
  4. package/flutter_app/lib/main_chat.dart +118 -739
  5. package/flutter_app/lib/main_controller.dart +157 -24
  6. package/flutter_app/lib/main_integrations.dart +607 -8
  7. package/flutter_app/lib/main_models.dart +7 -2
  8. package/flutter_app/lib/main_operations.dart +334 -370
  9. package/flutter_app/lib/main_security.dart +266 -112
  10. package/flutter_app/lib/main_settings.dart +342 -3
  11. package/flutter_app/lib/main_shared.dart +14 -11
  12. package/flutter_app/lib/src/backend_client.dart +97 -0
  13. package/flutter_app/lib/src/desktop_companion_actions.dart +185 -31
  14. package/flutter_app/lib/src/desktop_companion_io.dart +319 -86
  15. package/flutter_app/windows/runner/flutter_window.cpp +143 -32
  16. package/landing/index.html +3 -1
  17. package/lib/manager.js +106 -89
  18. package/lib/schema_migrations.js +115 -13
  19. package/package.json +30 -15
  20. package/runtime/paths.js +49 -5
  21. package/server/db/database.js +2 -2
  22. package/server/guest-agent.cli.package.json +13 -0
  23. package/server/guest_agent.js +85 -40
  24. package/server/http/middleware.js +24 -0
  25. package/server/http/routes.js +12 -6
  26. package/server/public/.last_build_id +1 -1
  27. package/server/public/assets/fonts/MaterialIcons-Regular.otf +0 -0
  28. package/server/public/flutter_bootstrap.js +2 -2
  29. package/server/public/main.dart.js +81930 -80509
  30. package/server/routes/admin.js +1 -1
  31. package/server/routes/android.js +30 -34
  32. package/server/routes/behavior.js +80 -0
  33. package/server/routes/browser.js +23 -15
  34. package/server/routes/desktop.js +18 -1
  35. package/server/routes/integrations.js +107 -1
  36. package/server/routes/memory.js +1 -0
  37. package/server/routes/settings.js +20 -5
  38. package/server/routes/social_reach.js +12 -3
  39. package/server/routes/social_video.js +4 -0
  40. package/server/services/agents/manager.js +1 -1
  41. package/server/services/ai/capabilityHealth.js +62 -96
  42. package/server/services/ai/compaction.js +7 -2
  43. package/server/services/ai/history.js +45 -6
  44. package/server/services/ai/integrated_tools/http_request.js +8 -0
  45. package/server/services/ai/loop/agent_engine_core.js +452 -176
  46. package/server/services/ai/loop/blank_recovery.js +5 -4
  47. package/server/services/ai/loop/callbacks.js +1 -0
  48. package/server/services/ai/loop/completion_judge.js +291 -8
  49. package/server/services/ai/loop/conversation_loop.js +515 -342
  50. package/server/services/ai/loop/messaging_delivery.js +176 -57
  51. package/server/services/ai/loop/model_call_guard.js +91 -0
  52. package/server/services/ai/loop/model_io.js +20 -45
  53. package/server/services/ai/loop/progress_classification.js +2 -0
  54. package/server/services/ai/loop/tool_dispatch.js +19 -8
  55. package/server/services/ai/loopPolicy.js +48 -21
  56. package/server/services/ai/messagingFallback.js +17 -17
  57. package/server/services/ai/model_discovery.js +227 -0
  58. package/server/services/ai/model_failure_cache.js +108 -0
  59. package/server/services/ai/model_identity.js +71 -0
  60. package/server/services/ai/models.js +68 -163
  61. package/server/services/ai/providerRetry.js +17 -59
  62. package/server/services/ai/provider_selector.js +166 -0
  63. package/server/services/ai/providers/anthropic.js +2 -2
  64. package/server/services/ai/providers/claudeCode.js +21 -33
  65. package/server/services/ai/providers/githubCopilot.js +41 -20
  66. package/server/services/ai/providers/google.js +135 -97
  67. package/server/services/ai/providers/grok.js +4 -3
  68. package/server/services/ai/providers/grokOauth.js +19 -27
  69. package/server/services/ai/providers/nvidia.js +10 -5
  70. package/server/services/ai/providers/ollama.js +111 -84
  71. package/server/services/ai/providers/ollama_stream.js +142 -0
  72. package/server/services/ai/providers/openai.js +39 -5
  73. package/server/services/ai/providers/openaiCodex.js +11 -4
  74. package/server/services/ai/providers/openrouter.js +29 -7
  75. package/server/services/ai/providers/provider_error.js +36 -0
  76. package/server/services/ai/settings.js +26 -2
  77. package/server/services/ai/systemPrompt.js +32 -123
  78. package/server/services/ai/taskAnalysis.js +104 -11
  79. package/server/services/ai/toolEvidence.js +256 -29
  80. package/server/services/ai/tools.js +248 -117
  81. package/server/services/android/controller.js +770 -237
  82. package/server/services/android/process.js +140 -0
  83. package/server/services/android/sdk_download.js +143 -0
  84. package/server/services/android/uia.js +6 -5
  85. package/server/services/artifacts/store.js +24 -0
  86. package/server/services/behavior/config.js +251 -0
  87. package/server/services/behavior/defaults.js +68 -0
  88. package/server/services/behavior/delivery.js +176 -0
  89. package/server/services/behavior/index.js +43 -0
  90. package/server/services/behavior/model_client.js +35 -0
  91. package/server/services/behavior/modules/agent_identity.js +37 -0
  92. package/server/services/behavior/modules/channel_style.js +28 -0
  93. package/server/services/behavior/modules/index.js +29 -0
  94. package/server/services/behavior/modules/norms.js +101 -0
  95. package/server/services/behavior/modules/persona.js +48 -0
  96. package/server/services/behavior/modules/persona_prompt.js +33 -0
  97. package/server/services/behavior/modules/social_memory.js +86 -0
  98. package/server/services/behavior/modules/social_observability.js +94 -0
  99. package/server/services/behavior/modules/social_signals.js +41 -0
  100. package/server/services/behavior/modules/theory_of_mind.js +110 -0
  101. package/server/services/behavior/modules/turn_taking.js +237 -0
  102. package/server/services/behavior/pipeline.js +285 -0
  103. package/server/services/behavior/registry.js +78 -0
  104. package/server/services/behavior/signals.js +107 -0
  105. package/server/services/behavior/state.js +99 -0
  106. package/server/services/behavior/system_prompt.js +75 -0
  107. package/server/services/browser/controller.js +843 -385
  108. package/server/services/browser/extension/gateway.js +40 -16
  109. package/server/services/browser/extension/protocol.js +15 -1
  110. package/server/services/browser/extension/provider.js +71 -47
  111. package/server/services/browser/extension/registry.js +155 -34
  112. package/server/services/cli/executor.js +62 -9
  113. package/server/services/credentials/bitwarden_cli.js +322 -0
  114. package/server/services/credentials/broker.js +594 -0
  115. package/server/services/desktop/gateway.js +41 -4
  116. package/server/services/desktop/protocol.js +3 -0
  117. package/server/services/desktop/provider.js +39 -42
  118. package/server/services/desktop/registry.js +137 -52
  119. package/server/services/integrations/bitwarden/constants.js +14 -0
  120. package/server/services/integrations/bitwarden/provider.js +197 -0
  121. package/server/services/integrations/bitwarden/snapshot.js +65 -0
  122. package/server/services/integrations/figma/provider.js +78 -12
  123. package/server/services/integrations/github/common.js +11 -6
  124. package/server/services/integrations/github/provider.js +52 -53
  125. package/server/services/integrations/google/provider.js +55 -19
  126. package/server/services/integrations/home_assistant/network.js +17 -20
  127. package/server/services/integrations/home_assistant/provider.js +7 -5
  128. package/server/services/integrations/home_assistant/tools.js +17 -5
  129. package/server/services/integrations/http.js +51 -0
  130. package/server/services/integrations/manager.js +159 -53
  131. package/server/services/integrations/microsoft/provider.js +80 -13
  132. package/server/services/integrations/neoarchive/provider.js +55 -29
  133. package/server/services/integrations/neorecall/client.js +17 -10
  134. package/server/services/integrations/neorecall/provider.js +20 -11
  135. package/server/services/integrations/notion/provider.js +16 -13
  136. package/server/services/integrations/oauth_provider.js +115 -51
  137. package/server/services/integrations/registry.js +2 -0
  138. package/server/services/integrations/slack/provider.js +98 -9
  139. package/server/services/integrations/spotify/provider.js +67 -71
  140. package/server/services/integrations/trello/provider.js +21 -7
  141. package/server/services/integrations/weather/provider.js +18 -12
  142. package/server/services/integrations/whatsapp/provider.js +76 -16
  143. package/server/services/manager.js +110 -1
  144. package/server/services/memory/embedding_index.js +20 -8
  145. package/server/services/memory/embeddings.js +151 -90
  146. package/server/services/memory/ingestion.js +50 -9
  147. package/server/services/memory/ingestion_documents.js +13 -3
  148. package/server/services/memory/manager.js +66 -52
  149. package/server/services/messaging/access_policy.js +10 -6
  150. package/server/services/messaging/automation.js +240 -34
  151. package/server/services/messaging/discord.js +11 -1
  152. package/server/services/messaging/formatting_guides.js +5 -4
  153. package/server/services/messaging/http_platforms.js +37 -16
  154. package/server/services/messaging/inbound_queue.js +143 -28
  155. package/server/services/messaging/inbound_store.js +257 -0
  156. package/server/services/messaging/manager.js +344 -51
  157. package/server/services/messaging/telegram.js +10 -1
  158. package/server/services/messaging/typing_keepalive.js +5 -2
  159. package/server/services/messaging/whatsapp.js +33 -14
  160. package/server/services/network/http.js +210 -0
  161. package/server/services/network/safe_request.js +307 -0
  162. package/server/services/runtime/backends/local-vm.js +227 -67
  163. package/server/services/runtime/docker-vm-manager.js +9 -0
  164. package/server/services/runtime/guest_bootstrap.js +30 -4
  165. package/server/services/runtime/guest_image.js +43 -12
  166. package/server/services/runtime/manager.js +77 -23
  167. package/server/services/runtime/validation.js +7 -6
  168. package/server/services/security/tool_categories.js +6 -0
  169. package/server/services/social_reach/channels/github.js +10 -4
  170. package/server/services/social_reach/channels/reddit.js +4 -4
  171. package/server/services/social_reach/channels/rss.js +2 -2
  172. package/server/services/social_reach/channels/social_video.js +13 -8
  173. package/server/services/social_reach/channels/v2ex.js +21 -8
  174. package/server/services/social_reach/channels/x.js +2 -2
  175. package/server/services/social_reach/channels/xueqiu.js +5 -5
  176. package/server/services/social_reach/service.js +9 -6
  177. package/server/services/social_reach/utils.js +65 -14
  178. package/server/services/social_video/captions.js +2 -2
  179. package/server/services/social_video/service.js +343 -68
  180. package/server/services/tasks/integration_runtime.js +18 -8
  181. package/server/services/tasks/runtime.js +39 -4
  182. package/server/services/voice/agentBridge.js +17 -4
  183. package/server/services/voice/bufferedLiveRelayAdapter.js +5 -0
  184. package/server/services/voice/liveSession.js +31 -0
  185. package/server/services/voice/message.js +1 -1
  186. package/server/services/voice/openaiSpeech.js +33 -8
  187. package/server/services/voice/providers.js +233 -151
  188. package/server/services/voice/runtime.js +2 -2
  189. package/server/services/voice/runtimeManager.js +118 -20
  190. package/server/services/voice/turnRunner.js +6 -0
  191. package/server/services/wearable/firmware_manifest.js +51 -13
  192. package/server/services/wearable/service.js +1 -0
  193. package/server/utils/abort.js +96 -0
  194. package/server/utils/cloud-security.js +110 -3
  195. package/server/utils/files.js +31 -0
  196. package/server/utils/image_payload.js +95 -0
  197. package/server/utils/logger.js +19 -0
  198. package/server/utils/retry.js +107 -0
@@ -1,29 +1,34 @@
1
1
  'use strict';
2
2
 
3
- const { spawn, spawnSync } = require('child_process');
3
+ const { spawn } = require('child_process');
4
4
  const fs = require('fs');
5
- const https = require('https');
6
5
  const net = require('net');
7
6
  const os = require('os');
8
7
  const path = require('path');
9
- const { DATA_DIR } = require('../../../runtime/paths');
8
+ const { DATA_DIR, RUNTIME_HOME } = require('../../../runtime/paths');
9
+ const { validateAndroidIntentUrl } = require('../../utils/cloud-security');
10
+ const { validateImageBuffer } = require('../../utils/image_payload');
11
+ const { downloadFile, resolveCommandLineToolsRelease } = require('./sdk_download');
12
+ const { findBestNode, parseUiDump, summarizeNode } = require('./uia');
13
+ const { clampNumber, runProcess } = require('./process');
10
14
 
11
15
  // ─── Constants ───────────────────────────────────────────────────────────────
12
16
 
13
- const DEFAULT_SDK_DIR = path.join(os.homedir(), '.neoagent', 'android-sdk');
17
+ const DEFAULT_SDK_DIR = path.join(RUNTIME_HOME, 'android-sdk');
18
+ const DEFAULT_AVD_DIR = path.join(RUNTIME_HOME, 'android', 'avd');
14
19
  const STATE_DIR = path.join(DATA_DIR, 'android', 'state');
15
20
  const LOGO_PATH = path.join(__dirname, '..', '..', '..', 'flutter_app', 'assets', 'branding', 'app_icon_512.png');
16
21
 
17
- // Even ports in 5554–5682 (documented ADB range). 65 slots for 65 concurrent users.
22
+ // Even console ports in the documented emulator range. Each emulator also owns
23
+ // the adjacent ADB port, so 64 pairs fit without crossing the supported range.
18
24
  const ADB_PORT_BASE = 5554;
19
- const ADB_PORT_SLOTS = 65;
25
+ const ADB_PORT_SLOTS = 64;
26
+ const RESERVED_ADB_PORTS = new Set();
27
+ const SDK_PROVISIONING = new Map();
20
28
 
21
- const CMDLINE_TOOLS_VERSION = '14742923';
22
- const CMDLINE_TOOLS_URLS = {
23
- darwin: `https://dl.google.com/android/repository/commandlinetools-mac-${CMDLINE_TOOLS_VERSION}_latest.zip`,
24
- linux: `https://dl.google.com/android/repository/commandlinetools-linux-${CMDLINE_TOOLS_VERSION}_latest.zip`,
25
- win32: `https://dl.google.com/android/repository/commandlinetools-win-${CMDLINE_TOOLS_VERSION}_latest.zip`,
26
- };
29
+ const MAX_ANDROID_TEXT_CHARS = 8000;
30
+ const MAX_ANDROID_INTENT_EXTRAS = 100;
31
+ const MAX_ANDROID_PACKAGE_BYTES = 1024 * 1024 * 1024;
27
32
 
28
33
  fs.mkdirSync(STATE_DIR, { recursive: true });
29
34
 
@@ -38,7 +43,14 @@ function readState(userId) {
38
43
 
39
44
  function writeState(userId, patch) {
40
45
  const current = readState(userId);
41
- fs.writeFileSync(stateFile(userId), JSON.stringify({ ...current, ...patch }, null, 2));
46
+ const destination = stateFile(userId);
47
+ const temporary = `${destination}.${process.pid}.${Math.random().toString(16).slice(2)}.tmp`;
48
+ try {
49
+ fs.writeFileSync(temporary, JSON.stringify({ ...current, ...patch }, null, 2), { mode: 0o600 });
50
+ fs.renameSync(temporary, destination);
51
+ } finally {
52
+ try { fs.unlinkSync(temporary); } catch {}
53
+ }
42
54
  }
43
55
 
44
56
  // ─── SDK resolution ──────────────────────────────────────────────────────────
@@ -111,86 +123,197 @@ function pickSystemImage(sdkDir) {
111
123
 
112
124
  function defaultSystemImage() {
113
125
  const abi = (os.arch() === 'arm64' || os.arch() === 'arm') ? 'arm64-v8a' : 'x86_64';
114
- return `system-images;android-33;google_apis;${abi}`;
126
+ return `system-images;android-36;google_apis;${abi}`;
115
127
  }
116
128
 
117
129
  // ─── SDK setup ───────────────────────────────────────────────────────────────
118
130
 
119
- function downloadFile(url, dest) {
131
+ function abortError(signal, message = 'Android operation was aborted.') {
132
+ if (signal?.reason instanceof Error) return signal.reason;
133
+ const error = new Error(String(signal?.reason || message));
134
+ error.name = 'AbortError';
135
+ error.code = 'ABORT_ERR';
136
+ return error;
137
+ }
138
+
139
+ function delay(ms, signal = null) {
140
+ if (signal?.aborted) return Promise.reject(abortError(signal));
120
141
  return new Promise((resolve, reject) => {
121
- const file = fs.createWriteStream(dest);
122
- const follow = u => https.get(u, res => {
123
- if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
124
- file.close(); return follow(res.headers.location);
125
- }
126
- if (res.statusCode !== 200) { file.close(); return reject(new Error(`HTTP ${res.statusCode}`)); }
127
- res.pipe(file);
128
- file.on('finish', () => file.close(resolve));
129
- }).on('error', err => { file.close(); fs.unlink(dest, () => {}); reject(err); });
130
- follow(url);
142
+ const timer = setTimeout(() => {
143
+ signal?.removeEventListener('abort', onAbort);
144
+ resolve();
145
+ }, ms);
146
+ const onAbort = () => {
147
+ clearTimeout(timer);
148
+ reject(abortError(signal));
149
+ };
150
+ signal?.addEventListener('abort', onAbort, { once: true });
151
+ if (signal?.aborted) onAbort();
152
+ });
153
+ }
154
+
155
+ function waitForAbortable(promise, signal = null) {
156
+ if (signal?.aborted) return Promise.reject(abortError(signal));
157
+ if (!signal) return promise;
158
+ return new Promise((resolve, reject) => {
159
+ let settled = false;
160
+ const finish = (callback, value) => {
161
+ if (settled) return;
162
+ settled = true;
163
+ signal.removeEventListener('abort', onAbort);
164
+ callback(value);
165
+ };
166
+ const onAbort = () => finish(reject, abortError(signal));
167
+ signal.addEventListener('abort', onAbort, { once: true });
168
+ if (signal.aborted) onAbort();
169
+ Promise.resolve(promise).then(
170
+ (value) => finish(resolve, value),
171
+ (error) => finish(reject, error),
172
+ );
131
173
  });
132
174
  }
133
175
 
134
- async function ensureSdk(sdkDir, onProgress) {
176
+ async function ensureSdk(sdkDir, onProgress, options = {}) {
135
177
  if (fs.existsSync(sdkManagerBin(sdkDir))) return;
136
- const url = CMDLINE_TOOLS_URLS[process.platform];
137
- if (!url) throw new Error(`No cmdline-tools download for platform: ${process.platform}`);
178
+ const release = resolveCommandLineToolsRelease();
138
179
 
139
180
  fs.mkdirSync(sdkDir, { recursive: true });
140
- onProgress('Downloading Android SDK command-line tools (~150 MB)…');
141
- const zip = path.join(os.tmpdir(), 'cmdline-tools.zip');
142
- await downloadFile(url, zip);
181
+ onProgress('Downloading Android SDK command-line tools (~182 MB)…');
182
+ const downloadDir = fs.mkdtempSync(path.join(os.tmpdir(), 'neoagent-android-sdk-'));
183
+ const zip = path.join(downloadDir, 'cmdline-tools.zip');
184
+ try {
185
+ await downloadFile(release.url, zip, {
186
+ expectedSha256: release.sha256,
187
+ signal: options.signal,
188
+ });
189
+ await runProcess('unzip', ['-tq', zip], {
190
+ timeoutMs: 120_000,
191
+ maxOutputBytes: 1024 * 1024,
192
+ signal: options.signal,
193
+ });
143
194
 
144
- onProgress('Extracting…');
145
- const toolsDir = path.join(sdkDir, 'cmdline-tools');
146
- fs.mkdirSync(toolsDir, { recursive: true });
147
- const unzip = spawnSync('unzip', ['-qo', zip, '-d', toolsDir]);
148
- fs.unlinkSync(zip);
149
- if (unzip.status !== 0) throw new Error('unzip failed');
195
+ onProgress('Extracting…');
196
+ const toolsDir = path.join(sdkDir, 'cmdline-tools');
197
+ fs.mkdirSync(toolsDir, { recursive: true });
198
+ await runProcess('unzip', ['-qo', zip, '-d', toolsDir], {
199
+ timeoutMs: 120_000,
200
+ maxOutputBytes: 1024 * 1024,
201
+ signal: options.signal,
202
+ });
203
+ } finally {
204
+ fs.rmSync(downloadDir, { recursive: true, force: true });
205
+ }
150
206
 
207
+ const toolsDir = path.join(sdkDir, 'cmdline-tools');
151
208
  const extracted = path.join(toolsDir, 'cmdline-tools');
152
209
  const latest = path.join(toolsDir, 'latest');
153
210
  if (fs.existsSync(extracted) && !fs.existsSync(latest)) fs.renameSync(extracted, latest);
154
211
  if (!fs.existsSync(sdkManagerBin(sdkDir))) throw new Error('sdkmanager not found after extraction');
155
212
  }
156
213
 
157
- async function ensurePackages(sdkDir, onProgress) {
214
+ async function ensurePackages(sdkDir, onProgress, options = {}) {
158
215
  const env = { ...process.env, ANDROID_SDK_ROOT: sdkDir, ANDROID_HOME: sdkDir };
159
216
  const sdkman = sdkManagerBin(sdkDir);
160
217
 
161
218
  onProgress('Accepting Android SDK licenses…');
162
- spawnSync(sdkman, ['--licenses', `--sdk_root=${sdkDir}`], { input: 'y\n'.repeat(20), encoding: 'utf8', env, stdio: ['pipe', 'pipe', 'pipe'] });
219
+ await runProcess(sdkman, ['--licenses', `--sdk_root=${sdkDir}`], {
220
+ input: 'y\n'.repeat(20),
221
+ env,
222
+ timeoutMs: 5 * 60 * 1000,
223
+ maxOutputBytes: 8 * 1024 * 1024,
224
+ signal: options.signal,
225
+ });
163
226
 
164
- const img = defaultSystemImage();
165
- onProgress(`Installing platform-tools, emulator, ${img} (~1–2 GB, first run only)…`);
166
- const r = spawnSync(sdkman, ['platform-tools', 'emulator', img, `--sdk_root=${sdkDir}`], {
167
- encoding: 'utf8', env, stdio: ['pipe', 'pipe', 'pipe'], timeout: 20 * 60 * 1000,
227
+ const existingImage = pickSystemImage(sdkDir);
228
+ const packages = ['platform-tools', 'emulator'];
229
+ if (!existingImage) packages.push(defaultSystemImage());
230
+ onProgress(`Installing ${packages.join(', ')} (first run only)…`);
231
+ await runProcess(sdkman, [...packages, `--sdk_root=${sdkDir}`], {
232
+ env,
233
+ timeoutMs: 20 * 60 * 1000,
234
+ maxOutputBytes: 32 * 1024 * 1024,
235
+ signal: options.signal,
168
236
  });
169
- if (r.status !== 0) throw new Error(`sdkmanager failed: ${(r.stderr || r.stdout || '').slice(0, 500)}`);
170
237
  }
171
238
 
172
- function ensureEmulatorRegistered(sdkDir) {
239
+ async function ensureSdkProvisioned(sdkDir, onProgress, options = {}) {
240
+ const key = path.resolve(sdkDir);
241
+ let entry = SDK_PROVISIONING.get(key);
242
+ if (entry?.controller.signal.aborted && !entry.settled) {
243
+ await entry.promise.catch(() => {});
244
+ entry = null;
245
+ }
246
+ if (!entry) {
247
+ const controller = new AbortController();
248
+ entry = {
249
+ controller,
250
+ promise: null,
251
+ settled: false,
252
+ waiters: 0,
253
+ };
254
+ entry.promise = (async () => {
255
+ await ensureSdk(sdkDir, onProgress, { signal: controller.signal });
256
+ const hasAdb = fs.existsSync(adbBin(sdkDir));
257
+ const hasEmulator = fs.existsSync(emulatorBin(sdkDir));
258
+ const hasImage = Boolean(pickSystemImage(sdkDir));
259
+ if (!hasAdb || !hasEmulator || !hasImage) {
260
+ await ensurePackages(sdkDir, onProgress, { signal: controller.signal });
261
+ }
262
+ })();
263
+ SDK_PROVISIONING.set(key, entry);
264
+ const settle = () => {
265
+ entry.settled = true;
266
+ if (SDK_PROVISIONING.get(key) === entry) SDK_PROVISIONING.delete(key);
267
+ };
268
+ entry.promise.then(settle, settle);
269
+ }
270
+
271
+ entry.waiters += 1;
272
+ try {
273
+ await waitForAbortable(entry.promise, options.signal);
274
+ } finally {
275
+ entry.waiters -= 1;
276
+ if (entry.waiters === 0 && !entry.settled && !entry.controller.signal.aborted) {
277
+ entry.controller.abort(new Error('Android SDK provisioning no longer has an active startup request.'));
278
+ }
279
+ }
280
+ }
281
+
282
+ async function ensureEmulatorRegistered(sdkDir, options = {}) {
173
283
  const packageXml = path.join(sdkDir, 'emulator', 'package.xml');
174
284
  if (fs.existsSync(packageXml) || !fs.existsSync(emulatorBin(sdkDir))) return;
175
285
  const env = { ...process.env, ANDROID_SDK_ROOT: sdkDir, ANDROID_HOME: sdkDir };
176
- spawnSync(sdkManagerBin(sdkDir), ['emulator', `--sdk_root=${sdkDir}`], {
177
- encoding: 'utf8', env, input: 'y\n'.repeat(5), stdio: ['pipe', 'pipe', 'pipe'], timeout: 5 * 60 * 1000,
286
+ await runProcess(sdkManagerBin(sdkDir), ['emulator', `--sdk_root=${sdkDir}`], {
287
+ env,
288
+ input: 'y\n'.repeat(5),
289
+ timeoutMs: 5 * 60 * 1000,
290
+ maxOutputBytes: 16 * 1024 * 1024,
291
+ signal: options.signal,
178
292
  });
179
293
  }
180
294
 
181
- function ensureAvd(sdkDir, avdName, onProgress) {
182
- const avdDir = path.join(os.homedir(), '.android', 'avd', `${avdName}.avd`);
295
+ async function ensureAvd(sdkDir, avdName, avdHome, onProgress, options = {}) {
296
+ const avdDir = path.join(avdHome, `${avdName}.avd`);
183
297
  if (fs.existsSync(avdDir)) return;
184
298
 
185
- ensureEmulatorRegistered(sdkDir);
299
+ await ensureEmulatorRegistered(sdkDir, options);
186
300
  const img = pickSystemImage(sdkDir) || defaultSystemImage();
187
301
  onProgress(`Creating AVD "${avdName}" using ${img}…`);
188
302
 
189
- const env = { ...process.env, ANDROID_SDK_ROOT: sdkDir, ANDROID_HOME: sdkDir };
190
- const r = spawnSync(avdManagerBin(sdkDir), ['create', 'avd', '-n', avdName, '-k', img, '--device', 'pixel', '--force'], {
191
- encoding: 'utf8', env, stdio: ['pipe', 'pipe', 'pipe'], input: '\n',
303
+ fs.mkdirSync(avdHome, { recursive: true });
304
+ const env = {
305
+ ...process.env,
306
+ ANDROID_AVD_HOME: avdHome,
307
+ ANDROID_SDK_ROOT: sdkDir,
308
+ ANDROID_HOME: sdkDir,
309
+ };
310
+ await runProcess(avdManagerBin(sdkDir), ['create', 'avd', '-n', avdName, '-k', img, '--device', 'pixel', '--force'], {
311
+ env,
312
+ input: '\n',
313
+ timeoutMs: 120_000,
314
+ maxOutputBytes: 4 * 1024 * 1024,
315
+ signal: options.signal,
192
316
  });
193
- if (r.status !== 0) throw new Error(`avdmanager failed: ${(r.stderr || r.stdout || '').slice(0, 500)}`);
194
317
 
195
318
  // Patch config: sparse QCOW2 (no pre-allocation), smaller cache partition.
196
319
  const cfgPath = path.join(avdDir, 'config.ini');
@@ -222,25 +345,62 @@ function isSafeIdentifier(str) {
222
345
  return /^[\w.]+$/.test(String(str || ''));
223
346
  }
224
347
 
348
+ function isSafeActivity(str) {
349
+ return /^[\w.$]+$/.test(String(str || ''));
350
+ }
351
+
352
+ function isSafeComponent(str) {
353
+ return /^[\w.$]+\/[\w.$]+$/.test(String(str || ''));
354
+ }
355
+
356
+ function isSafeMimeType(str) {
357
+ return /^[\w.+-]+\/[\w.+*-]+$/.test(String(str || ''));
358
+ }
359
+
360
+ function normalizeUserId(value) {
361
+ const userId = String(value || 'default').trim();
362
+ if (!/^[a-zA-Z0-9_-]{1,64}$/.test(userId)) {
363
+ throw new Error('Android user ID contains unsupported characters.');
364
+ }
365
+ return userId;
366
+ }
367
+
368
+ function hasUiSelector(value = {}) {
369
+ return ['text', 'resourceId', 'description', 'className', 'packageName']
370
+ .some((key) => String(value[key] || '').trim())
371
+ || value.clickable === true;
372
+ }
373
+
374
+ function requireCoordinate(value, label) {
375
+ const number = Number(value);
376
+ if (!Number.isFinite(number) || number < 0) {
377
+ throw new Error(`${label} must be a non-negative number.`);
378
+ }
379
+ return Math.round(number);
380
+ }
381
+
225
382
  // ─── AndroidController ───────────────────────────────────────────────────────
226
383
 
227
384
  class AndroidController {
228
385
  constructor(options = {}) {
229
- this.userId = String(options.userId || 'default').trim();
386
+ this.userId = normalizeUserId(options.userId);
230
387
  this.avdName = `neoagent_${this.userId}`;
231
388
  // Deterministic ADB console port per user, within documented range 5554–5682 (even only).
232
389
  this.adbPort = ADB_PORT_BASE + ((hashCode(this.userId) >>> 0) % ADB_PORT_SLOTS) * 2;
233
390
  this.adbSerial = `emulator-${this.adbPort}`;
234
391
  this.sdkDir = options.sdkDir || findExistingSdk() || DEFAULT_SDK_DIR;
392
+ this.avdHome = options.avdHome || DEFAULT_AVD_DIR;
235
393
  this.artifactStore = options.artifactStore || null;
236
394
  this.startPromise = null;
395
+ this.startAbortController = null;
396
+ this.emulatorProcess = null;
237
397
  }
238
398
 
239
399
  // ── Status ────────────────────────────────────────────────────────────────
240
400
 
241
401
  getStatusSync() { return readState(this.userId); }
242
402
 
243
- async getStatus() {
403
+ async getStatus(options = {}) {
244
404
  const state = readState(this.userId);
245
405
  const base = {
246
406
  bootstrapped: state.bootstrapped || false,
@@ -255,204 +415,476 @@ class AndroidController {
255
415
  if (!this.#isPidAlive(state.pid)) return { ...base, bootstrapped: false };
256
416
 
257
417
  try {
258
- const r = spawnSync(adbBin(this.sdkDir), ['-s', state.adbSerial, 'shell', 'getprop', 'sys.boot_completed'],
259
- { encoding: 'utf8', timeout: 5000 });
260
- const booted = r.stdout?.trim() === '1';
418
+ const result = await runProcess(
419
+ adbBin(this.sdkDir),
420
+ ['-s', state.adbSerial, 'shell', 'getprop', 'sys.boot_completed'],
421
+ { timeoutMs: 5000, maxOutputBytes: 64 * 1024, signal: options.signal },
422
+ );
423
+ const booted = result.stdout.trim() === '1';
261
424
  return {
262
425
  ...base,
263
426
  bootstrapped: booted,
264
427
  devices: booted ? [{ serial: state.adbSerial, status: 'device', emulator: true }] : [],
265
428
  };
266
- } catch {
429
+ } catch (error) {
430
+ if (options.signal?.aborted) throw error;
267
431
  return base;
268
432
  }
269
433
  }
270
434
 
271
435
  // ── Lifecycle ─────────────────────────────────────────────────────────────
272
436
 
273
- async requestStartEmulator() {
437
+ async requestStartEmulator(options = {}) {
274
438
  console.log(`[Android] requestStartEmulator for user ${this.userId}`);
275
439
  const state = readState(this.userId);
276
440
  if (state.adbSerial && this.#isPidAlive(state.pid)) {
277
441
  console.log(`[Android] Emulator already running (pid=${state.pid})`);
278
- return { success: true, pending: false, adbSerial: state.adbSerial };
442
+ const status = await this.getStatus(options);
443
+ return {
444
+ success: true,
445
+ pending: !status.bootstrapped,
446
+ bootstrapped: status.bootstrapped,
447
+ adbSerial: state.adbSerial,
448
+ };
279
449
  }
280
450
  if (!this.startPromise) {
281
451
  writeState(this.userId, { starting: true, startupPhase: 'Initializing', lastStartError: null });
282
- this.startPromise = this.#setup().finally(() => { this.startPromise = null; });
283
- this.startPromise.catch(() => {});
452
+ const startAbortController = new AbortController();
453
+ this.startAbortController = startAbortController;
454
+ const externalSignal = options.signal || null;
455
+ const forwardAbort = () => startAbortController.abort(externalSignal.reason);
456
+ if (externalSignal?.aborted) forwardAbort();
457
+ else externalSignal?.addEventListener('abort', forwardAbort, { once: true });
458
+ let trackedStart;
459
+ trackedStart = this.#setup({
460
+ ...options,
461
+ signal: startAbortController.signal,
462
+ }).finally(() => {
463
+ externalSignal?.removeEventListener('abort', forwardAbort);
464
+ if (this.startAbortController === startAbortController) {
465
+ this.startAbortController = null;
466
+ }
467
+ if (this.startPromise === trackedStart) this.startPromise = null;
468
+ });
469
+ this.startPromise = trackedStart;
470
+ trackedStart.catch(() => {});
284
471
  }
285
472
  const s = readState(this.userId);
286
473
  return { success: true, pending: true, bootstrapped: false, starting: true, startupPhase: s.startupPhase };
287
474
  }
288
475
 
289
- async stopEmulator() {
476
+ async startEmulator(options = {}) {
477
+ const start = await this.requestStartEmulator(options);
478
+ if (start.bootstrapped) return start;
479
+ const timeoutMs = clampNumber(options.timeoutMs, 240_000, 1000, 10 * 60 * 1000);
480
+ const adbSerial = await this.waitForDevice({ timeoutMs, signal: options.signal });
481
+ return { success: true, pending: false, bootstrapped: true, adbSerial };
482
+ }
483
+
484
+ async stopEmulator(options = {}) {
485
+ this.startAbortController?.abort(new Error('Android emulator startup was stopped.'));
486
+ const starting = this.startPromise;
487
+ if (starting) await starting.catch(() => {});
290
488
  const state = readState(this.userId);
291
- if (state.pid) { try { process.kill(Number(state.pid), 'SIGTERM'); } catch {} }
292
- writeState(this.userId, { bootstrapped: false, starting: false, pid: null, adbSerial: null, startupPhase: null });
489
+ let adbStopError = null;
490
+ if (state.adbSerial) {
491
+ try {
492
+ await runProcess(
493
+ adbBin(this.sdkDir),
494
+ ['-s', state.adbSerial, 'emu', 'kill'],
495
+ { timeoutMs: 5000, maxOutputBytes: 64 * 1024, signal: options.signal },
496
+ );
497
+ } catch (error) {
498
+ adbStopError = error;
499
+ }
500
+ }
501
+ await this.#terminateOwnedEmulatorProcess();
502
+ if (state.pid) await this.#waitForPidExit(state.pid, 5000);
503
+ if (state.pid && this.#isPidAlive(state.pid)) {
504
+ if (options.signal?.aborted) throw abortError(options.signal);
505
+ if (adbStopError) throw adbStopError;
506
+ throw new Error('Android emulator did not exit after the stop request.');
507
+ }
508
+ RESERVED_ADB_PORTS.delete(Number(String(state.adbSerial || '').replace(/^emulator-/, '')));
509
+ RESERVED_ADB_PORTS.delete(this.adbPort);
510
+ writeState(this.userId, {
511
+ bootstrapped: false,
512
+ starting: false,
513
+ pid: null,
514
+ adbSerial: null,
515
+ startupPhase: null,
516
+ lastStartError: null,
517
+ });
518
+ if (options.signal?.aborted) throw abortError(options.signal);
293
519
  console.log('[Android] Emulator stopped');
520
+ return { success: true };
294
521
  }
295
522
 
296
523
  async close() { await this.stopEmulator().catch(() => {}); }
297
524
 
298
525
  async waitForDevice(options = {}) {
299
- const deadline = Date.now() + (options.timeoutMs || 600000);
526
+ const timeoutMs = clampNumber(options.timeoutMs, 600_000, 1000, 10 * 60 * 1000);
527
+ const deadline = Date.now() + timeoutMs;
300
528
  while (Date.now() < deadline) {
301
- const s = await this.getStatus();
529
+ if (options.signal?.aborted) {
530
+ const error = new Error('Waiting for the Android emulator was aborted.');
531
+ error.name = 'AbortError';
532
+ error.code = 'ABORT_ERR';
533
+ throw error;
534
+ }
535
+ const s = await this.getStatus(options);
302
536
  if (s.bootstrapped) return this.adbSerial;
303
- await new Promise(r => setTimeout(r, 2000));
537
+ if (!s.starting && s.lastStartError) throw new Error(s.lastStartError);
538
+ await delay(2000, options.signal);
304
539
  }
305
540
  throw new Error('Android emulator did not become ready in time');
306
541
  }
307
542
 
308
- async listDevices() {
309
- const s = await this.getStatus();
543
+ async listDevices(options = {}) {
544
+ const s = await this.getStatus(options);
310
545
  return s.bootstrapped ? [{ serial: this.adbSerial, status: 'device', emulator: true }] : [];
311
546
  }
312
547
 
313
- async ensureBootstrapped() {
314
- const s = readState(this.userId);
315
- if (!s.bootstrapped) await this.requestStartEmulator();
548
+ async ensureBootstrapped(options = {}) {
549
+ const status = await this.getStatus(options);
550
+ if (!status.bootstrapped) await this.startEmulator(options);
551
+ return this.adbSerial;
316
552
  }
317
553
 
318
554
  // ── Shell / ADB ───────────────────────────────────────────────────────────
319
555
 
320
556
  async shell(commandOrObj) {
321
557
  const command = typeof commandOrObj === 'string' ? commandOrObj : String(commandOrObj?.command || '');
558
+ if (!command.trim()) throw new Error('Android shell command is required.');
322
559
  const serial = this.#requireSerial();
323
560
  const adb = adbBin(this.sdkDir);
324
- return new Promise((resolve, reject) => {
325
- const proc = spawn(adb, ['-s', serial, 'shell', command], { encoding: 'utf8' });
326
- let out = '', err = '';
327
- proc.stdout?.on('data', d => { out += d; });
328
- proc.stderr?.on('data', d => { err += d; });
329
- proc.on('close', code => code === 0 ? resolve(out) : reject(new Error(err || out || `exit ${code}`)));
330
- proc.on('error', reject);
561
+ const options = typeof commandOrObj === 'object' && commandOrObj ? commandOrObj : {};
562
+ const result = await runProcess(adb, ['-s', serial, 'shell', command], {
563
+ timeoutMs: options.timeoutMs,
564
+ maxOutputBytes: 4 * 1024 * 1024,
565
+ signal: options.signal,
331
566
  });
567
+ if (options.screenshot === true) {
568
+ return { output: result.stdout, ...await this.screenshot({ signal: options.signal }) };
569
+ }
570
+ return result.stdout;
332
571
  }
333
572
 
334
573
  async adb(...args) {
335
574
  const state = readState(this.userId);
336
575
  const adb = adbBin(this.sdkDir);
337
- return new Promise((resolve, reject) => {
338
- const proc = spawn(adb, ['-s', state.adbSerial || this.adbSerial, ...args], { encoding: 'utf8' });
339
- let out = '', err = '';
340
- proc.stdout?.on('data', d => { out += d; });
341
- proc.stderr?.on('data', d => { err += d; });
342
- proc.on('close', code => code === 0 ? resolve(out) : reject(new Error(err || `adb ${args[0]} exit ${code}`)));
343
- proc.on('error', reject);
344
- });
576
+ const result = await runProcess(
577
+ adb,
578
+ ['-s', state.adbSerial || this.adbSerial, ...args],
579
+ { timeoutMs: 60_000, maxOutputBytes: 16 * 1024 * 1024 },
580
+ );
581
+ return result.stdout;
345
582
  }
346
583
 
347
584
  // ── Actions ───────────────────────────────────────────────────────────────
348
585
 
349
- async screenshot(_opts = {}) {
350
- const r = await this.capturePng();
586
+ async screenshot(options = {}) {
587
+ const r = await this.capturePng(options);
351
588
  if (!r?.length) throw new Error('screencap returned no data');
352
- return { screenshotPath: this.#saveArtifact(r) };
589
+ return { screenshotPath: await this.#saveArtifact(r, options) };
353
590
  }
354
591
 
355
- async capturePng(_opts = {}) {
592
+ async capturePng(options = {}) {
356
593
  const serial = this.#requireSerial();
357
- return this.#adbCaptureAsync(serial, ['exec-out', 'screencap', '-p']);
594
+ return this.#adbCaptureAsync(serial, ['exec-out', 'screencap', '-p'], options);
358
595
  }
359
596
 
360
- async observe(_opts = {}) { return this.screenshot(); }
597
+ async observe({ includeNodes = true, signal } = {}) {
598
+ const screenshot = await this.screenshot({ signal });
599
+ const dump = await this.dumpUi({ includeNodes, signal });
600
+ return {
601
+ ...screenshot,
602
+ uiDumpPath: dump.devicePath,
603
+ nodeCount: dump.nodeCount,
604
+ ...(includeNodes === false ? {} : { nodes: dump.nodes }),
605
+ };
606
+ }
361
607
 
362
- async tap({ x, y } = {}) {
363
- await this.shell(`input tap ${Math.round(x)} ${Math.round(y)}`);
364
- return { success: true, screenshotPath: this.#saveArtifact(this.#adbCapture(this.#requireSerial(), ['exec-out', 'screencap', '-p'])) };
608
+ async findUiNode(selector = {}) {
609
+ if (!hasUiSelector(selector)) {
610
+ throw new Error('Provide x and y coordinates or a UI selector.');
611
+ }
612
+ const dump = await this.dumpUi({ includeNodes: true, signal: selector.signal });
613
+ const node = findBestNode(dump.nodes, selector);
614
+ if (!node) {
615
+ throw new Error(`No Android UI element matched ${JSON.stringify(selector)}.`);
616
+ }
617
+ return node;
365
618
  }
366
619
 
367
- async longPress({ x, y, durationMs = 1000 } = {}) {
368
- await this.shell(`input swipe ${Math.round(x)} ${Math.round(y)} ${Math.round(x)} ${Math.round(y)} ${durationMs}`);
369
- return { success: true };
620
+ async resolvePoint(options = {}) {
621
+ const hasX = Number.isFinite(Number(options.x));
622
+ const hasY = Number.isFinite(Number(options.y));
623
+ if (hasX || hasY) {
624
+ if (!hasX || !hasY) throw new Error('Both x and y coordinates are required.');
625
+ return {
626
+ x: requireCoordinate(options.x, 'x'),
627
+ y: requireCoordinate(options.y, 'y'),
628
+ node: null,
629
+ };
630
+ }
631
+ const node = await this.findUiNode(options);
632
+ if (node.bounds.width <= 0 || node.bounds.height <= 0) {
633
+ throw new Error('The matched Android UI element has no tappable bounds.');
634
+ }
635
+ return { x: node.bounds.centerX, y: node.bounds.centerY, node };
370
636
  }
371
637
 
372
- async swipe({ x1, y1, x2, y2, durationMs = 300 } = {}) {
373
- await this.shell(`input swipe ${Math.round(x1)} ${Math.round(y1)} ${Math.round(x2)} ${Math.round(y2)} ${durationMs}`);
374
- return { success: true, screenshotPath: this.#saveArtifact(this.#adbCapture(this.#requireSerial(), ['exec-out', 'screencap', '-p'])) };
638
+ async tap(options = {}) {
639
+ const target = await this.resolvePoint(options);
640
+ await this.shell({ command: `input tap ${target.x} ${target.y}`, signal: options.signal });
641
+ return {
642
+ success: true,
643
+ target: target.node ? summarizeNode(target.node) : { x: target.x, y: target.y },
644
+ ...await this.screenshot({ signal: options.signal }),
645
+ };
375
646
  }
376
647
 
377
- async type({ text, pressEnter } = {}) {
378
- if (!text) return { success: true };
648
+ async longPress(options = {}) {
649
+ const target = await this.resolvePoint(options);
650
+ const durationMs = clampNumber(options.durationMs, 650, 100, 30_000);
651
+ await this.shell({
652
+ command: `input swipe ${target.x} ${target.y} ${target.x} ${target.y} ${durationMs}`,
653
+ signal: options.signal,
654
+ });
655
+ return {
656
+ success: true,
657
+ target: target.node ? summarizeNode(target.node) : { x: target.x, y: target.y },
658
+ };
659
+ }
660
+
661
+ async swipe({ x1, y1, x2, y2, durationMs = 300, signal } = {}) {
662
+ const points = {
663
+ x1: requireCoordinate(x1, 'x1'),
664
+ y1: requireCoordinate(y1, 'y1'),
665
+ x2: requireCoordinate(x2, 'x2'),
666
+ y2: requireCoordinate(y2, 'y2'),
667
+ };
668
+ const duration = clampNumber(durationMs, 300, 50, 30_000);
669
+ await this.shell({
670
+ command: `input swipe ${points.x1} ${points.y1} ${points.x2} ${points.y2} ${duration}`,
671
+ signal,
672
+ });
673
+ return { success: true, ...await this.screenshot({ signal }) };
674
+ }
675
+
676
+ async type({ text, textSelector, resourceId, description, className, clear = false, pressEnter, signal } = {}) {
677
+ if (text == null) throw new Error('Text is required.');
678
+ if (String(text).length > MAX_ANDROID_TEXT_CHARS) {
679
+ throw new Error(`Text exceeds the ${MAX_ANDROID_TEXT_CHARS}-character limit.`);
680
+ }
681
+ const selector = {
682
+ text: textSelector,
683
+ resourceId,
684
+ description,
685
+ className,
686
+ signal,
687
+ };
688
+ let target = null;
689
+ if (hasUiSelector(selector)) {
690
+ target = await this.resolvePoint(selector);
691
+ await this.shell({ command: `input tap ${target.x} ${target.y}`, signal });
692
+ }
693
+ if (clear === true) {
694
+ await this.shell({
695
+ command: 'input keyevent KEYCODE_MOVE_END; for i in $(seq 1 200); do input keyevent KEYCODE_DEL; done',
696
+ signal,
697
+ });
698
+ }
379
699
  // ADB input text encoding: %% = literal %, %s = space.
380
700
  const encoded = String(text).replace(/%/g, '%%').replace(/ /g, '%s');
381
- await this.shell(`input text '${shellEscape(encoded)}'`);
382
- if (pressEnter) await this.shell('input keyevent KEYCODE_ENTER');
383
- return { success: true };
701
+ if (encoded) await this.shell({ command: `input text '${shellEscape(encoded)}'`, signal });
702
+ if (pressEnter) await this.shell({ command: 'input keyevent KEYCODE_ENTER', signal });
703
+ return {
704
+ success: true,
705
+ ...(target?.node ? { target: summarizeNode(target.node) } : {}),
706
+ };
384
707
  }
385
708
 
386
709
  async pressKey(keyOrObj) {
387
710
  const raw = typeof keyOrObj === 'string' ? keyOrObj : (keyOrObj?.key || '');
711
+ const signal = typeof keyOrObj === 'object' ? keyOrObj?.signal : null;
388
712
  const KEY_MAP = {
389
713
  back: 'KEYCODE_BACK', home: 'KEYCODE_HOME', app_switch: 'KEYCODE_APP_SWITCH',
390
714
  enter: 'KEYCODE_ENTER', del: 'KEYCODE_DEL', escape: 'KEYCODE_ESCAPE',
391
715
  menu: 'KEYCODE_MENU', power: 'KEYCODE_POWER',
392
716
  volume_up: 'KEYCODE_VOLUME_UP', volume_down: 'KEYCODE_VOLUME_DOWN',
393
717
  };
394
- const keycode = KEY_MAP[raw.toLowerCase()] || raw.toUpperCase();
395
- await this.shell(`input keyevent ${keycode}`);
718
+ const normalized = String(raw || '').trim();
719
+ const keycode = KEY_MAP[normalized.toLowerCase()]
720
+ || (/^\d+$/.test(normalized) ? normalized : normalized.toUpperCase());
721
+ if (!/^\d+$/.test(keycode) && !/^KEYCODE_[A-Z0-9_]+$/.test(keycode)) {
722
+ throw new Error(`Unsupported Android key: ${normalized || '(empty)'}`);
723
+ }
724
+ await this.shell({ command: `input keyevent ${keycode}`, signal });
396
725
  return { success: true };
397
726
  }
398
727
 
399
- async dumpUi(_opts = {}) {
400
- const serial = this.#requireSerial();
401
- await this.shell('uiautomator dump /sdcard/window_dump.xml');
402
- const r = spawnSync(adbBin(this.sdkDir), ['-s', serial, 'shell', 'cat', '/sdcard/window_dump.xml'], { encoding: 'utf8', timeout: 10000 });
403
- return { xml: r.stdout || '' };
728
+ async dumpUi({ includeNodes = true, signal } = {}) {
729
+ this.#requireSerial();
730
+ const devicePath = '/sdcard/window_dump.xml';
731
+ await this.shell({ command: 'uiautomator dump /sdcard/window_dump.xml', signal });
732
+ const xml = await this.shell({ command: `cat '${devicePath}'`, timeoutMs: 10_000, signal });
733
+ const nodes = parseUiDump(xml);
734
+ return {
735
+ xml,
736
+ devicePath,
737
+ nodeCount: nodes.length,
738
+ ...(includeNodes === false ? {} : { nodes: nodes.slice(0, 200).map(summarizeNode) }),
739
+ };
404
740
  }
405
741
 
406
- async listApps({ includeSystem = false } = {}) {
407
- const out = await this.shell(includeSystem ? 'pm list packages' : 'pm list packages -3');
742
+ async listApps({ includeSystem = false, signal } = {}) {
743
+ const out = await this.shell({
744
+ command: includeSystem ? 'pm list packages' : 'pm list packages -3',
745
+ signal,
746
+ });
408
747
  const packages = out.trim().split('\n').filter(Boolean).map(l => l.replace('package:', '').trim());
409
748
  return { packages };
410
749
  }
411
750
 
412
- async openApp({ packageName } = {}) {
751
+ async openApp({ packageName, activity, signal } = {}) {
413
752
  if (!isSafeIdentifier(packageName)) throw new Error('Invalid package name');
414
- await this.shell(`monkey -p '${shellEscape(packageName)}' -c android.intent.category.LAUNCHER 1`);
415
- await new Promise(r => setTimeout(r, 1500));
416
- return this.screenshot();
753
+ if (activity) {
754
+ if (!isSafeActivity(activity)) throw new Error('Invalid Android activity name');
755
+ await this.shell({ command: `am start -n '${shellEscape(`${packageName}/${activity}`)}'`, signal });
756
+ } else {
757
+ await this.shell({
758
+ command: `monkey -p '${shellEscape(packageName)}' -c android.intent.category.LAUNCHER 1`,
759
+ signal,
760
+ });
761
+ }
762
+ await delay(1500, signal);
763
+ return this.screenshot({ signal });
417
764
  }
418
765
 
419
- async openIntent({ action, dataUri, extras = {} } = {}) {
766
+ async openIntent({ action, dataUri, data, url, uri, packageName, component, mimeType, extras = {}, signal } = {}) {
420
767
  const safeAction = isSafeIdentifier(action) ? action : 'android.intent.action.VIEW';
421
768
  let cmd = `am start -a '${shellEscape(safeAction)}'`;
422
- if (dataUri) cmd += ` -d '${shellEscape(dataUri)}'`;
423
- for (const [k, v] of Object.entries(extras || {})) {
424
- if (isSafeIdentifier(k)) cmd += ` --es '${shellEscape(k)}' '${shellEscape(v)}'`;
769
+ const resolvedDataUri = dataUri || data || url || uri;
770
+ if (resolvedDataUri) {
771
+ if (String(resolvedDataUri).length > MAX_ANDROID_TEXT_CHARS) {
772
+ throw new Error('Android intent URI is too long.');
773
+ }
774
+ const validation = await validateAndroidIntentUrl(String(resolvedDataUri), { signal });
775
+ if (!validation.allowed) throw new Error('This Android intent URI is not permitted.');
776
+ cmd += ` -d '${shellEscape(resolvedDataUri)}'`;
777
+ }
778
+ if (packageName) {
779
+ if (!isSafeIdentifier(packageName)) throw new Error('Invalid Android package name');
780
+ cmd += ` -p '${shellEscape(packageName)}'`;
781
+ }
782
+ if (component) {
783
+ if (!isSafeComponent(component)) throw new Error('Invalid Android component name');
784
+ cmd += ` -n '${shellEscape(component)}'`;
785
+ }
786
+ if (mimeType) {
787
+ if (!isSafeMimeType(mimeType)) throw new Error('Invalid Android MIME type');
788
+ cmd += ` -t '${shellEscape(mimeType)}'`;
789
+ }
790
+ const extraEntries = Object.entries(extras || {});
791
+ if (extraEntries.length > MAX_ANDROID_INTENT_EXTRAS) {
792
+ throw new Error(`Android intent extras exceed the ${MAX_ANDROID_INTENT_EXTRAS}-item limit.`);
425
793
  }
426
- await this.shell(cmd);
427
- await new Promise(r => setTimeout(r, 2000));
428
- return this.screenshot();
794
+ for (const [k, v] of extraEntries) {
795
+ if (!isSafeIdentifier(k)) throw new Error(`Invalid Android intent extra name: ${k}`);
796
+ if (String(v).length > MAX_ANDROID_TEXT_CHARS) {
797
+ throw new Error(`Android intent extra is too long: ${k}`);
798
+ }
799
+ cmd += ` --es '${shellEscape(k)}' '${shellEscape(v)}'`;
800
+ }
801
+ await this.shell({ command: cmd, signal });
802
+ await delay(2000, signal);
803
+ return this.screenshot({ signal });
429
804
  }
430
805
 
431
- async waitFor({ timeout = 10000 } = {}) {
432
- const deadline = Date.now() + timeout;
806
+ async waitFor(options = {}) {
807
+ if (!hasUiSelector(options)) {
808
+ throw new Error('android_wait_for requires at least one UI selector.');
809
+ }
810
+ const timeoutMs = clampNumber(options.timeoutMs ?? options.timeout, 20_000, 100, 120_000);
811
+ const intervalMs = clampNumber(options.intervalMs, 1500, 100, 5000);
812
+ const deadline = Date.now() + timeoutMs;
433
813
  while (Date.now() < deadline) {
434
- const s = await this.getStatus();
435
- if (s.bootstrapped) return { ready: true };
436
- await new Promise(r => setTimeout(r, 1000));
814
+ if (options.signal?.aborted) {
815
+ throw abortError(options.signal, 'Waiting for the Android UI was aborted.');
816
+ }
817
+ const dump = await this.dumpUi({ includeNodes: true, signal: options.signal });
818
+ const node = findBestNode(dump.nodes, options);
819
+ if (node) {
820
+ return {
821
+ found: true,
822
+ ready: true,
823
+ node: summarizeNode(node),
824
+ ...(options.screenshot === false ? {} : await this.screenshot({ signal: options.signal })),
825
+ };
826
+ }
827
+ await delay(intervalMs, options.signal);
437
828
  }
438
- return { ready: false };
829
+ return { found: false, ready: false, timeoutMs };
439
830
  }
440
831
 
441
- async installApk({ apkPath } = {}) {
832
+ async installApk({ apkPath, signal } = {}) {
442
833
  if (!apkPath) throw new Error('apkPath required');
834
+ const resolvedPath = fs.realpathSync(apkPath);
835
+ const packageStat = fs.statSync(resolvedPath);
836
+ if (!packageStat.isFile()) throw new Error('Android package path must be a file.');
837
+ if (packageStat.size > MAX_ANDROID_PACKAGE_BYTES) {
838
+ throw new Error('Android package exceeds the 1GB installation limit.');
839
+ }
840
+ const extension = path.extname(resolvedPath).toLowerCase();
841
+ if (extension !== '.apk' && extension !== '.apks') {
842
+ throw new Error('Android package must be an .apk or universal .apks bundle.');
843
+ }
443
844
  const serial = this.#requireSerial();
444
845
  const adb = adbBin(this.sdkDir);
445
- return new Promise((resolve, reject) => {
446
- const proc = spawn(adb, ['-s', serial, 'install', '-r', apkPath]);
447
- let out = '', err = '';
448
- proc.stdout?.on('data', d => { out += d; });
449
- proc.stderr?.on('data', d => { err += d; });
450
- proc.on('close', code => {
451
- if (code === 0 && out.includes('Success')) resolve({ success: true, output: out });
452
- else reject(new Error(err || out || `adb install exit ${code}`));
846
+ let installPath = resolvedPath;
847
+ let extractionDir = null;
848
+ try {
849
+ if (extension === '.apks') {
850
+ extractionDir = fs.mkdtempSync(path.join(os.tmpdir(), 'neoagent-apks-'));
851
+ const listing = await runProcess('unzip', ['-l', resolvedPath, 'universal.apk'], {
852
+ timeoutMs: 30_000,
853
+ maxOutputBytes: 1024 * 1024,
854
+ signal,
855
+ });
856
+ const universalSize = String(listing.stdout)
857
+ .split(/\r?\n/)
858
+ .map((line) => line.trim().match(/^(\d+)\s+.*\suniversal\.apk$/i))
859
+ .find(Boolean);
860
+ if (!universalSize) {
861
+ throw new Error('The .apks bundle does not contain universal.apk. Export it with bundletool --mode=universal.');
862
+ }
863
+ if (Number(universalSize[1]) > MAX_ANDROID_PACKAGE_BYTES) {
864
+ throw new Error('The universal APK exceeds the 1GB installation limit.');
865
+ }
866
+ await runProcess('unzip', ['-jo', resolvedPath, 'universal.apk', '-d', extractionDir], {
867
+ timeoutMs: 120_000,
868
+ maxOutputBytes: 1024 * 1024,
869
+ signal,
870
+ });
871
+ installPath = path.join(extractionDir, 'universal.apk');
872
+ if (!fs.existsSync(installPath)) {
873
+ throw new Error('The .apks bundle does not contain universal.apk. Export it with bundletool --mode=universal.');
874
+ }
875
+ }
876
+ const result = await runProcess(adb, ['-s', serial, 'install', '-r', installPath], {
877
+ timeoutMs: 5 * 60 * 1000,
878
+ maxOutputBytes: 4 * 1024 * 1024,
879
+ signal,
453
880
  });
454
- proc.on('error', reject);
455
- });
881
+ if (!String(result.stdout).includes('Success')) {
882
+ throw new Error(String(result.stderr || result.stdout || 'adb install did not report success'));
883
+ }
884
+ return { success: true, output: result.stdout };
885
+ } finally {
886
+ if (extractionDir) fs.rmSync(extractionDir, { recursive: true, force: true });
887
+ }
456
888
  }
457
889
 
458
890
  // ── Private helpers ───────────────────────────────────────────────────────
@@ -468,52 +900,30 @@ class AndroidController {
468
900
  try { process.kill(Number(pid), 0); return true; } catch { return false; }
469
901
  }
470
902
 
471
- #adbCapture(serial, args) {
472
- const r = spawnSync(adbBin(this.sdkDir), ['-s', serial, ...args], { maxBuffer: 20 * 1024 * 1024, timeout: 15000 });
473
- return (r.status === 0 && r.stdout?.length) ? r.stdout : null;
474
- }
475
-
476
- #adbCaptureAsync(serial, args) {
477
- return new Promise((resolve, reject) => {
478
- const proc = spawn(adbBin(this.sdkDir), ['-s', serial, ...args]);
479
- const chunks = [];
480
- const errors = [];
481
- let settled = false;
482
- const timer = setTimeout(() => {
483
- if (settled) return;
484
- settled = true;
485
- try { proc.kill('SIGKILL'); } catch {}
486
- reject(new Error('adb capture timed out'));
487
- }, 15000);
488
- timer.unref?.();
489
- proc.stdout?.on('data', (chunk) => chunks.push(chunk));
490
- proc.stderr?.on('data', (chunk) => errors.push(chunk));
491
- proc.on('error', (error) => {
492
- if (settled) return;
493
- settled = true;
494
- clearTimeout(timer);
495
- reject(error);
496
- });
497
- proc.on('close', (code) => {
498
- if (settled) return;
499
- settled = true;
500
- clearTimeout(timer);
501
- if (code === 0 && chunks.length) {
502
- resolve(Buffer.concat(chunks));
503
- return;
504
- }
505
- const message = Buffer.concat(errors).toString('utf8').trim();
506
- reject(new Error(message || `adb capture exit ${code}`));
507
- });
903
+ async #adbCaptureAsync(serial, args, options = {}) {
904
+ const result = await runProcess(adbBin(this.sdkDir), ['-s', serial, ...args], {
905
+ timeoutMs: 15_000,
906
+ maxOutputBytes: 20 * 1024 * 1024,
907
+ encoding: null,
908
+ signal: options.signal,
508
909
  });
910
+ if (!result.stdout.length) throw new Error('ADB capture returned no data.');
911
+ return result.stdout;
509
912
  }
510
913
 
511
- #saveArtifact(data) {
914
+ async #saveArtifact(data, options = {}) {
512
915
  if (!data || !this.artifactStore) return null;
513
- const alloc = this.artifactStore.allocateFile(this.userId, { kind: 'screenshot', extension: 'png', contentType: 'image/png' });
514
- fs.writeFileSync(alloc.storagePath, data);
515
- const fin = this.artifactStore.finalizeFile(alloc.artifactId, alloc.storagePath);
516
- return fin.url;
916
+ const image = validateImageBuffer(data, { allowedTypes: ['image/png'] });
917
+ const artifact = await this.artifactStore.createBufferArtifact(this.userId, {
918
+ kind: 'android-screenshot',
919
+ backend: 'android-emulator',
920
+ extension: image.extension,
921
+ contentType: image.contentType,
922
+ filenameBase: 'android-emulator-screenshot',
923
+ content: image.buffer,
924
+ signal: options.signal,
925
+ });
926
+ return artifact.url;
517
927
  }
518
928
 
519
929
  // ── Setup pipeline ────────────────────────────────────────────────────────
@@ -523,21 +933,59 @@ class AndroidController {
523
933
  for (let i = 0; i < ADB_PORT_SLOTS; i++) {
524
934
  const slot = (base + i) % ADB_PORT_SLOTS;
525
935
  const port = ADB_PORT_BASE + slot * 2;
526
- const free = await new Promise(resolve => {
527
- const srv = net.createServer();
528
- srv.listen(port, '127.0.0.1', () => srv.close(() => resolve(true)));
529
- srv.on('error', () => resolve(false));
530
- });
936
+ if (RESERVED_ADB_PORTS.has(port)) continue;
937
+ const free = await this.#isPortPairFree(port);
531
938
  if (free) {
939
+ RESERVED_ADB_PORTS.add(port);
532
940
  this.adbPort = port;
533
941
  this.adbSerial = `emulator-${port}`;
534
942
  return;
535
943
  }
536
944
  }
537
- throw new Error(`No free ADB port in range ${ADB_PORT_BASE}–${ADB_PORT_BASE + ADB_PORT_SLOTS * 2}`);
945
+ throw new Error(`No free ADB port pair in range ${ADB_PORT_BASE}–${ADB_PORT_BASE + (ADB_PORT_SLOTS * 2) - 1}`);
946
+ }
947
+
948
+ async #isPortPairFree(consolePort) {
949
+ const servers = [];
950
+ const listen = (port) => new Promise((resolve) => {
951
+ const server = net.createServer();
952
+ server.unref();
953
+ server.once('error', () => resolve(false));
954
+ server.listen(port, '127.0.0.1', () => {
955
+ servers.push(server);
956
+ resolve(true);
957
+ });
958
+ });
959
+ try {
960
+ return await listen(consolePort) && await listen(consolePort + 1);
961
+ } finally {
962
+ await Promise.allSettled(servers.map((server) => new Promise((resolve) => server.close(resolve))));
963
+ }
538
964
  }
539
965
 
540
- async #setup() {
966
+ async #waitForPidExit(pid, timeoutMs) {
967
+ const deadline = Date.now() + Math.max(0, Number(timeoutMs) || 0);
968
+ while (this.#isPidAlive(pid) && Date.now() < deadline) {
969
+ await delay(100);
970
+ }
971
+ return !this.#isPidAlive(pid);
972
+ }
973
+
974
+ async #terminateOwnedEmulatorProcess() {
975
+ const proc = this.emulatorProcess;
976
+ if (!proc) return;
977
+ if (proc.exitCode == null && proc.signalCode == null) {
978
+ try { proc.kill('SIGTERM'); } catch {}
979
+ await this.#waitForPidExit(proc.pid, 3000);
980
+ }
981
+ if (proc.pid && this.#isPidAlive(proc.pid)) {
982
+ try { proc.kill('SIGKILL'); } catch {}
983
+ await this.#waitForPidExit(proc.pid, 2000);
984
+ }
985
+ if (this.emulatorProcess === proc) this.emulatorProcess = null;
986
+ }
987
+
988
+ async #setup(options = {}) {
541
989
  const progress = msg => {
542
990
  console.log(`[Android] ${msg}`);
543
991
  writeState(this.userId, { startupPhase: msg });
@@ -549,52 +997,113 @@ class AndroidController {
549
997
  this.sdkDir = existing;
550
998
  progress(`Found existing Android SDK at ${existing}`);
551
999
  } else {
552
- progress('Downloading Android SDK…');
553
- await ensureSdk(this.sdkDir, progress);
554
- await ensurePackages(this.sdkDir, progress);
1000
+ progress('Preparing Android SDK…');
555
1001
  }
556
- ensureAvd(this.sdkDir, this.avdName, progress);
557
- await this.#startEmulatorProcess(progress);
1002
+ await ensureSdkProvisioned(this.sdkDir, progress, options);
1003
+ await ensureAvd(this.sdkDir, this.avdName, this.avdHome, progress, options);
1004
+ await this.#startEmulatorProcess(progress, options);
558
1005
  } catch (err) {
559
1006
  console.error(`[Android] Setup failed: ${err.message}`);
560
- writeState(this.userId, { starting: false, startupPhase: 'Failed', lastStartError: err.message });
1007
+ await this.#terminateOwnedEmulatorProcess();
1008
+ RESERVED_ADB_PORTS.delete(this.adbPort);
1009
+ writeState(this.userId, {
1010
+ bootstrapped: false,
1011
+ starting: false,
1012
+ startupPhase: 'Failed',
1013
+ lastStartError: err.message,
1014
+ pid: null,
1015
+ adbSerial: null,
1016
+ });
1017
+ throw err;
561
1018
  }
562
1019
  }
563
1020
 
564
- async #startEmulatorProcess(progress) {
1021
+ async #startEmulatorProcess(progress, options = {}) {
565
1022
  progress('Starting Android emulator…');
566
- const env = { ...process.env, ANDROID_SDK_ROOT: this.sdkDir, ANDROID_HOME: this.sdkDir };
567
- const proc = spawn(emulatorBin(this.sdkDir), [
1023
+ const env = {
1024
+ ...process.env,
1025
+ ANDROID_AVD_HOME: this.avdHome,
1026
+ ANDROID_SDK_ROOT: this.sdkDir,
1027
+ ANDROID_HOME: this.sdkDir,
1028
+ };
1029
+ const emulatorArgs = [
568
1030
  '-avd', this.avdName,
569
- '-no-window', '-no-audio', '-no-boot-anim',
1031
+ '-no-audio', '-no-boot-anim',
570
1032
  '-port', String(this.adbPort),
571
- '-gpu', 'swiftshader_indirect',
572
1033
  '-partition-size', '800',
573
- ], { env, detached: false, stdio: ['ignore', 'pipe', 'pipe'] });
574
-
575
- proc.stdout.on('data', d => console.log(`[Android/emu] ${d.toString().trimEnd()}`));
576
- proc.stderr.on('data', d => console.log(`[Android/emu] ${d.toString().trimEnd()}`));
577
- writeState(this.userId, { pid: proc.pid, adbSerial: this.adbSerial });
1034
+ ];
1035
+ if (options.headless !== false) emulatorArgs.splice(2, 0, '-no-window');
1036
+ const proc = spawn(emulatorBin(this.sdkDir), emulatorArgs, {
1037
+ env,
1038
+ detached: false,
1039
+ stdio: ['ignore', 'pipe', 'pipe'],
1040
+ });
1041
+ this.emulatorProcess = proc;
578
1042
 
579
- proc.on('exit', code => {
1043
+ let launchOutput = '';
1044
+ const collectLaunchOutput = (chunk) => {
1045
+ if (launchOutput.length >= 64 * 1024) return;
1046
+ launchOutput += chunk.toString().slice(0, (64 * 1024) - launchOutput.length);
1047
+ };
1048
+ proc.stdout.on('data', collectLaunchOutput);
1049
+ proc.stderr.on('data', collectLaunchOutput);
1050
+ writeState(this.userId, { pid: proc.pid || null, adbSerial: proc.pid ? this.adbSerial : null });
1051
+
1052
+ let bootReady = false;
1053
+ let rejectProcessFailure;
1054
+ const processFailure = new Promise((_, reject) => { rejectProcessFailure = reject; });
1055
+ proc.once('error', (error) => rejectProcessFailure(error));
1056
+ proc.on('exit', (code, exitSignal) => {
580
1057
  console.log(`[Android] Emulator exited with code ${code}`);
581
- writeState(this.userId, { bootstrapped: false, starting: false, pid: null });
1058
+ this.emulatorProcess = null;
1059
+ RESERVED_ADB_PORTS.delete(this.adbPort);
1060
+ const current = readState(this.userId);
1061
+ if (Number(current.pid) === Number(proc.pid)) {
1062
+ writeState(this.userId, { bootstrapped: false, starting: false, pid: null, adbSerial: null });
1063
+ }
1064
+ if (!bootReady) {
1065
+ const details = launchOutput.trim().slice(-4000);
1066
+ rejectProcessFailure(new Error(
1067
+ `Android emulator exited before boot completed (${exitSignal || (code ?? 'unknown')}).${details ? ` ${details}` : ''}`,
1068
+ ));
1069
+ }
582
1070
  });
583
1071
 
584
1072
  progress('Waiting for Android to boot (can take 2–5 min on first run)…');
585
1073
  // Abort early if the emulator process dies, instead of polling until timeout.
586
- await this.#waitForBoot({ isAlive: () => this.#isPidAlive(proc.pid) });
1074
+ const cancelEmulator = () => {
1075
+ try { proc.kill('SIGTERM'); } catch {}
1076
+ };
1077
+ options.signal?.addEventListener('abort', cancelEmulator, { once: true });
1078
+ try {
1079
+ await Promise.race([
1080
+ this.#waitForBoot({
1081
+ isAlive: () => this.#isPidAlive(proc.pid),
1082
+ signal: options.signal,
1083
+ }),
1084
+ processFailure,
1085
+ ]);
1086
+ bootReady = true;
1087
+ } finally {
1088
+ options.signal?.removeEventListener('abort', cancelEmulator);
1089
+ }
587
1090
 
588
1091
  writeState(this.userId, { bootstrapped: true, starting: false, startupPhase: null, lastStartError: null });
589
1092
  console.log(`[Android] Emulator ready on ${this.adbSerial}`);
590
1093
 
591
1094
  // Set wallpaper — best-effort, never fails the boot sequence.
592
- this.#setWallpaper(this.adbSerial).catch(err => {
593
- console.warn(`[Android] Wallpaper not set: ${err.message}`);
594
- });
1095
+ try {
1096
+ await this.#setWallpaper(this.adbSerial, { signal: options.signal });
1097
+ } catch (error) {
1098
+ if (options.signal?.aborted) throw error;
1099
+ console.warn(`[Android] Wallpaper not set: ${error.message}`);
1100
+ }
1101
+ if (!this.#isPidAlive(proc.pid)) {
1102
+ throw new Error('Android emulator exited immediately after boot completed.');
1103
+ }
595
1104
  }
596
1105
 
597
- async #waitForBoot({ timeoutMs = 10 * 60 * 1000, isAlive = () => true } = {}) {
1106
+ async #waitForBoot({ timeoutMs = 10 * 60 * 1000, isAlive = () => true, signal } = {}) {
598
1107
  const adb = adbBin(this.sdkDir);
599
1108
  const deadline = Date.now() + timeoutMs;
600
1109
  while (Date.now() < deadline) {
@@ -602,41 +1111,65 @@ class AndroidController {
602
1111
  throw new Error('Emulator process exited before Android finished booting (check virtualization/KVM support and the system image).');
603
1112
  }
604
1113
  try {
605
- const r = spawnSync(adb, ['-s', this.adbSerial, 'shell', 'getprop', 'sys.boot_completed'], { encoding: 'utf8', timeout: 5000 });
606
- if (r.stdout?.trim() === '1') return;
607
- } catch {}
608
- await new Promise(r => setTimeout(r, 3000));
1114
+ const result = await runProcess(
1115
+ adb,
1116
+ ['-s', this.adbSerial, 'shell', 'getprop', 'sys.boot_completed'],
1117
+ { timeoutMs: 5000, maxOutputBytes: 64 * 1024, signal },
1118
+ );
1119
+ if (result.stdout.trim() === '1') return;
1120
+ } catch (error) {
1121
+ if (signal?.aborted) throw error;
1122
+ }
1123
+ await delay(3000, signal);
609
1124
  }
610
1125
  throw new Error('Emulator did not boot within timeout');
611
1126
  }
612
1127
 
613
- async #setWallpaper(serial) {
1128
+ async #setWallpaper(serial, options = {}) {
614
1129
  if (!fs.existsSync(LOGO_PATH)) return;
615
1130
  const adb = adbBin(this.sdkDir);
616
1131
 
617
1132
  // Try to gain root access (works on AOSP default images).
618
- spawnSync(adb, ['-s', serial, 'root'], { timeout: 5000 });
619
- await new Promise(r => setTimeout(r, 1500));
1133
+ await runProcess(adb, ['-s', serial, 'root'], {
1134
+ timeoutMs: 5000,
1135
+ maxOutputBytes: 64 * 1024,
1136
+ signal: options.signal,
1137
+ }).catch(() => {});
1138
+ await delay(1500, options.signal);
620
1139
 
621
1140
  // Push PNG to device sdcard.
622
- const push = spawnSync(adb, ['-s', serial, 'push', LOGO_PATH, '/sdcard/neoagent-wallpaper.png'], { timeout: 15000 });
623
- if (push.status !== 0) throw new Error('adb push logo failed');
1141
+ await runProcess(adb, ['-s', serial, 'push', LOGO_PATH, '/sdcard/neoagent-wallpaper.png'], {
1142
+ timeoutMs: 15_000,
1143
+ maxOutputBytes: 1024 * 1024,
1144
+ signal: options.signal,
1145
+ });
624
1146
 
625
1147
  // cmd wallpaper set-stream reads PNG from stdin (Android 7.1+).
626
1148
  const logoData = fs.readFileSync(LOGO_PATH);
627
- const r = spawnSync(adb, ['-s', serial, 'shell', 'cmd', 'wallpaper', 'set-stream'], {
628
- input: logoData, timeout: 15000,
629
- });
630
- if (r.status === 0) {
1149
+ try {
1150
+ await runProcess(adb, ['-s', serial, 'shell', 'cmd', 'wallpaper', 'set-stream'], {
1151
+ input: logoData,
1152
+ timeoutMs: 15_000,
1153
+ maxOutputBytes: 1024 * 1024,
1154
+ signal: options.signal,
1155
+ });
631
1156
  console.log('[Android] Wallpaper set');
632
1157
  return;
633
- }
1158
+ } catch {}
634
1159
 
635
1160
  // Fallback: direct file copy for rooted images (Android 11 AOSP).
636
- spawnSync(adb, ['-s', serial, 'shell', 'cp /sdcard/neoagent-wallpaper.png /data/system/users/0/wallpaper'], { timeout: 5000 });
637
- spawnSync(adb, ['-s', serial, 'shell', 'chmod 600 /data/system/users/0/wallpaper'], { timeout: 5000 });
638
- spawnSync(adb, ['-s', serial, 'shell', 'chown system:system /data/system/users/0/wallpaper'], { timeout: 5000 });
639
- spawnSync(adb, ['-s', serial, 'shell', 'am broadcast -a android.intent.action.WALLPAPER_CHANGED'], { timeout: 5000 });
1161
+ for (const command of [
1162
+ 'cp /sdcard/neoagent-wallpaper.png /data/system/users/0/wallpaper',
1163
+ 'chmod 600 /data/system/users/0/wallpaper',
1164
+ 'chown system:system /data/system/users/0/wallpaper',
1165
+ 'am broadcast -a android.intent.action.WALLPAPER_CHANGED',
1166
+ ]) {
1167
+ await runProcess(adb, ['-s', serial, 'shell', command], {
1168
+ timeoutMs: 5000,
1169
+ maxOutputBytes: 1024 * 1024,
1170
+ signal: options.signal,
1171
+ });
1172
+ }
640
1173
  console.log('[Android] Wallpaper set via direct copy');
641
1174
  }
642
1175
  }