mobster-cli 0.4.3__py3-none-any.whl

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 (1009) hide show
  1. mobile_agent/__init__.py +4 -0
  2. mobile_agent/__main__.py +598 -0
  3. mobile_agent/accounts/__init__.py +1 -0
  4. mobile_agent/accounts/context.py +80 -0
  5. mobile_agent/accounts/mcp.py +55 -0
  6. mobile_agent/accounts/pacing.py +318 -0
  7. mobile_agent/accounts/register.py +78 -0
  8. mobile_agent/accounts/routes.py +212 -0
  9. mobile_agent/accounts/routing.py +69 -0
  10. mobile_agent/accounts/screen.py +273 -0
  11. mobile_agent/accounts/stats.py +54 -0
  12. mobile_agent/accounts/store.py +394 -0
  13. mobile_agent/agent_hooks.py +123 -0
  14. mobile_agent/ai_plans/__init__.py +7 -0
  15. mobile_agent/ai_plans/agents.py +96 -0
  16. mobile_agent/ai_plans/claude_code.py +208 -0
  17. mobile_agent/ai_plans/cli.py +400 -0
  18. mobile_agent/ai_plans/codex.py +258 -0
  19. mobile_agent/ai_plans/compat_chat.py +307 -0
  20. mobile_agent/ai_plans/device_login.py +164 -0
  21. mobile_agent/ai_plans/draft_client.py +68 -0
  22. mobile_agent/ai_plans/gemini.py +137 -0
  23. mobile_agent/ai_plans/instructions.py +65 -0
  24. mobile_agent/ai_plans/mcp_http.py +160 -0
  25. mobile_agent/ai_plans/phone_tools.py +892 -0
  26. mobile_agent/ai_plans/providers.py +235 -0
  27. mobile_agent/ai_plans/routes.py +160 -0
  28. mobile_agent/ai_plans/runner.py +400 -0
  29. mobile_agent/ai_plans/vault.py +114 -0
  30. mobile_agent/alerts.py +337 -0
  31. mobile_agent/android/__init__.py +18 -0
  32. mobile_agent/android/adb.py +268 -0
  33. mobile_agent/android/conditions.py +128 -0
  34. mobile_agent/android/doctor.py +54 -0
  35. mobile_agent/android/driver.py +137 -0
  36. mobile_agent/android/emulator.py +216 -0
  37. mobile_agent/android/logs.py +85 -0
  38. mobile_agent/android/manager.py +225 -0
  39. mobile_agent/android/records.py +31 -0
  40. mobile_agent/android/tree.py +96 -0
  41. mobile_agent/answer_shapes.py +230 -0
  42. mobile_agent/api_devices.py +333 -0
  43. mobile_agent/api_errors.py +17 -0
  44. mobile_agent/api_get.py +249 -0
  45. mobile_agent/api_http.py +467 -0
  46. mobile_agent/api_post.py +453 -0
  47. mobile_agent/api_routes.py +173 -0
  48. mobile_agent/app_api/__init__.py +4 -0
  49. mobile_agent/app_api/cli.py +151 -0
  50. mobile_agent/app_api/endpoints.py +193 -0
  51. mobile_agent/app_api/mcp.py +54 -0
  52. mobile_agent/app_api/openapi.py +146 -0
  53. mobile_agent/app_api/reading.py +172 -0
  54. mobile_agent/app_api/register.py +10 -0
  55. mobile_agent/app_api/screen.py +216 -0
  56. mobile_agent/app_api/service.py +382 -0
  57. mobile_agent/app_api/sessions.py +404 -0
  58. mobile_agent/app_api/tokens.py +167 -0
  59. mobile_agent/app_choice.py +131 -0
  60. mobile_agent/app_icons.py +183 -0
  61. mobile_agent/app_killer.py +69 -0
  62. mobile_agent/artifacts.py +606 -0
  63. mobile_agent/attach/__init__.py +13 -0
  64. mobile_agent/attach/cli.py +46 -0
  65. mobile_agent/attach/flutter.py +28 -0
  66. mobile_agent/attach/score.py +119 -0
  67. mobile_agent/attach/web.py +13 -0
  68. mobile_agent/attachments/__init__.py +1 -0
  69. mobile_agent/attachments/agent.py +418 -0
  70. mobile_agent/attachments/extract.py +217 -0
  71. mobile_agent/attachments/mcp.py +104 -0
  72. mobile_agent/attachments/register.py +73 -0
  73. mobile_agent/attachments/routes.py +326 -0
  74. mobile_agent/attachments/service.py +170 -0
  75. mobile_agent/attachments/sniff.py +152 -0
  76. mobile_agent/attachments/store.py +441 -0
  77. mobile_agent/audit_verify.py +436 -0
  78. mobile_agent/bench/README.md +410 -0
  79. mobile_agent/bench/__init__.py +0 -0
  80. mobile_agent/bench/__main__.py +394 -0
  81. mobile_agent/bench/ab.py +168 -0
  82. mobile_agent/bench/actuation.py +368 -0
  83. mobile_agent/bench/agents/__init__.py +9 -0
  84. mobile_agent/bench/agents/base.py +105 -0
  85. mobile_agent/bench/agents/plan_clients.py +245 -0
  86. mobile_agent/bench/agents/smart.py +533 -0
  87. mobile_agent/bench/checks.py +358 -0
  88. mobile_agent/bench/decide_gate.py +214 -0
  89. mobile_agent/bench/diagnose.py +181 -0
  90. mobile_agent/bench/evals/__init__.py +14 -0
  91. mobile_agent/bench/evals/build.py +565 -0
  92. mobile_agent/bench/evals/checkpoint.py +214 -0
  93. mobile_agent/bench/evals/irt.py +219 -0
  94. mobile_agent/bench/evals/l0.py +647 -0
  95. mobile_agent/bench/evals/l1.py +866 -0
  96. mobile_agent/bench/evals/models.py +124 -0
  97. mobile_agent/bench/evals/pairwise.py +189 -0
  98. mobile_agent/bench/evals/proxy.py +90 -0
  99. mobile_agent/bench/evals/sets.py +149 -0
  100. mobile_agent/bench/evals/traces.py +446 -0
  101. mobile_agent/bench/fixtures.py +176 -0
  102. mobile_agent/bench/guard_replay.py +129 -0
  103. mobile_agent/bench/h2-manifest.json +20 -0
  104. mobile_agent/bench/h3-manifest.json +20 -0
  105. mobile_agent/bench/heldout-manifest.json +32 -0
  106. mobile_agent/bench/heldout.py +375 -0
  107. mobile_agent/bench/iosworld.py +901 -0
  108. mobile_agent/bench/iosworld_stats.py +510 -0
  109. mobile_agent/bench/llm_units.py +116 -0
  110. mobile_agent/bench/manifest.json +93 -0
  111. mobile_agent/bench/metrics.py +66 -0
  112. mobile_agent/bench/phone.py +44 -0
  113. mobile_agent/bench/replay.py +172 -0
  114. mobile_agent/bench/report.py +207 -0
  115. mobile_agent/bench/runner.py +456 -0
  116. mobile_agent/bench/safety.py +310 -0
  117. mobile_agent/bench/stream_timing.py +213 -0
  118. mobile_agent/bench/suite.py +444 -0
  119. mobile_agent/bench/sweep.py +176 -0
  120. mobile_agent/bench/tape.py +798 -0
  121. mobile_agent/bench/tape_replay.py +88 -0
  122. mobile_agent/bench/trace_evals.py +128 -0
  123. mobile_agent/bench/trace_view.py +81 -0
  124. mobile_agent/bench/truth/iphone15pro.json +138 -0
  125. mobile_agent/bench/truth.py +340 -0
  126. mobile_agent/bench/tuning.py +543 -0
  127. mobile_agent/bench/tuning_analysis.py +656 -0
  128. mobile_agent/bench/vague-heldout-manifest.json +12 -0
  129. mobile_agent/bench/vague_rubric.py +135 -0
  130. mobile_agent/bench/vague_run.py +243 -0
  131. mobile_agent/brand.py +454 -0
  132. mobile_agent/brand_fonts.py +756 -0
  133. mobile_agent/build_identity.py +21 -0
  134. mobile_agent/cable_bt.py +623 -0
  135. mobile_agent/cable_bt_runtime.py +128 -0
  136. mobile_agent/catalog.py +69 -0
  137. mobile_agent/chart_svg.py +415 -0
  138. mobile_agent/chat_here.py +189 -0
  139. mobile_agent/cli_constants.py +5 -0
  140. mobile_agent/cli_parser.py +611 -0
  141. mobile_agent/cli_run.py +115 -0
  142. mobile_agent/compiled_read/__init__.py +21 -0
  143. mobile_agent/compiled_read/delta.py +89 -0
  144. mobile_agent/compiled_read/extractors.py +202 -0
  145. mobile_agent/compiled_read/routes.py +56 -0
  146. mobile_agent/completion.py +267 -0
  147. mobile_agent/compose.py +48 -0
  148. mobile_agent/config.py +114 -0
  149. mobile_agent/connectors/__init__.py +1 -0
  150. mobile_agent/connectors/api_session.py +76 -0
  151. mobile_agent/connectors/callback_page.py +146 -0
  152. mobile_agent/connectors/client.py +582 -0
  153. mobile_agent/connectors/github_app.py +55 -0
  154. mobile_agent/connectors/graph_api.py +396 -0
  155. mobile_agent/connectors/imports.py +170 -0
  156. mobile_agent/connectors/issue.py +66 -0
  157. mobile_agent/connectors/keystore.py +85 -0
  158. mobile_agent/connectors/marks.py +222 -0
  159. mobile_agent/connectors/oauth.py +476 -0
  160. mobile_agent/connectors/picker.py +228 -0
  161. mobile_agent/connectors/presets.py +591 -0
  162. mobile_agent/connectors/register.py +48 -0
  163. mobile_agent/connectors/routes.py +153 -0
  164. mobile_agent/connectors/service.py +966 -0
  165. mobile_agent/connectors/shared.py +136 -0
  166. mobile_agent/connectors/skill.py +446 -0
  167. mobile_agent/connectors/slack_api.py +269 -0
  168. mobile_agent/connectors/steps.py +92 -0
  169. mobile_agent/connectors/store.py +144 -0
  170. mobile_agent/connectors/vault.py +72 -0
  171. mobile_agent/console.py +178 -0
  172. mobile_agent/content_queue/__init__.py +1 -0
  173. mobile_agent/content_queue/client.py +263 -0
  174. mobile_agent/content_queue/importing.py +153 -0
  175. mobile_agent/content_queue/media_check.py +224 -0
  176. mobile_agent/content_queue/prompt.py +64 -0
  177. mobile_agent/content_queue/register.py +91 -0
  178. mobile_agent/content_queue/routes.py +370 -0
  179. mobile_agent/content_queue/runner.py +475 -0
  180. mobile_agent/content_queue/store.py +521 -0
  181. mobile_agent/contract.py +996 -0
  182. mobile_agent/contract_asks.py +248 -0
  183. mobile_agent/contract_prompt.py +45 -0
  184. mobile_agent/contract_screen.py +981 -0
  185. mobile_agent/contract_view.py +84 -0
  186. mobile_agent/corpus/__init__.py +26 -0
  187. mobile_agent/corpus/cli.py +63 -0
  188. mobile_agent/corpus/consent.py +55 -0
  189. mobile_agent/corpus/listener.py +76 -0
  190. mobile_agent/corpus/register.py +9 -0
  191. mobile_agent/corpus/skeleton.py +72 -0
  192. mobile_agent/corpus/store.py +143 -0
  193. mobile_agent/costs.py +161 -0
  194. mobile_agent/decider_scope.py +40 -0
  195. mobile_agent/decision_provider.py +189 -0
  196. mobile_agent/deeplinks.py +352 -0
  197. mobile_agent/device_info.py +116 -0
  198. mobile_agent/device_manager.py +978 -0
  199. mobile_agent/device_status.py +195 -0
  200. mobile_agent/device_targets.py +217 -0
  201. mobile_agent/devices.py +676 -0
  202. mobile_agent/devkit/__init__.py +64 -0
  203. mobile_agent/devkit/account_cli.py +90 -0
  204. mobile_agent/devkit/asc.py +562 -0
  205. mobile_agent/devkit/asc_cli.py +214 -0
  206. mobile_agent/devkit/ci.py +737 -0
  207. mobile_agent/devkit/ci_cli.py +196 -0
  208. mobile_agent/devkit/ci_hosts.py +194 -0
  209. mobile_agent/devkit/es256.py +197 -0
  210. mobile_agent/devkit/expo.py +440 -0
  211. mobile_agent/devkit/expo_cli.py +405 -0
  212. mobile_agent/devkit/expo_dev.py +203 -0
  213. mobile_agent/devkit/expo_routes.py +90 -0
  214. mobile_agent/devkit/fixtures_cli.py +133 -0
  215. mobile_agent/devkit/fixtures_routes.py +82 -0
  216. mobile_agent/devkit/links.py +375 -0
  217. mobile_agent/devkit/links_cli.py +123 -0
  218. mobile_agent/devkit/links_routes.py +83 -0
  219. mobile_agent/devkit/nightly.py +152 -0
  220. mobile_agent/devkit/shots.py +203 -0
  221. mobile_agent/devkit/testflight.py +155 -0
  222. mobile_agent/devkit/testflight_cli.py +127 -0
  223. mobile_agent/devkit/testflight_routes.py +111 -0
  224. mobile_agent/devkit/xcode.py +236 -0
  225. mobile_agent/devkit/xcode_cli.py +101 -0
  226. mobile_agent/devtools.py +419 -0
  227. mobile_agent/doctor.py +509 -0
  228. mobile_agent/doctor_fix.py +467 -0
  229. mobile_agent/downloads.py +55 -0
  230. mobile_agent/drivers.py +793 -0
  231. mobile_agent/dry_run.py +102 -0
  232. mobile_agent/effect_ledger.py +83 -0
  233. mobile_agent/engine_health.py +378 -0
  234. mobile_agent/engine_hooks.py +96 -0
  235. mobile_agent/engines.py +408 -0
  236. mobile_agent/env_file.py +63 -0
  237. mobile_agent/env_secrets.py +149 -0
  238. mobile_agent/errors.py +15 -0
  239. mobile_agent/evals/__init__.py +3 -0
  240. mobile_agent/evals/app_survey.py +149 -0
  241. mobile_agent/evals/development/__init__.py +1 -0
  242. mobile_agent/evals/development/score.py +276 -0
  243. mobile_agent/evals/latency_report.py +312 -0
  244. mobile_agent/evals/offline_report.py +374 -0
  245. mobile_agent/evals/oracles.py +298 -0
  246. mobile_agent/evals/redteam/__init__.py +15 -0
  247. mobile_agent/evals/redteam/__main__.py +72 -0
  248. mobile_agent/evals/redteam/cases.py +730 -0
  249. mobile_agent/evals/redteam/harness.py +604 -0
  250. mobile_agent/evals/redteam/live.py +139 -0
  251. mobile_agent/evals/redteam/plain.py +85 -0
  252. mobile_agent/evals/vision/__init__.py +0 -0
  253. mobile_agent/evals/vision/images/dog_001.jpg +0 -0
  254. mobile_agent/evals/vision/images/dog_002.jpg +0 -0
  255. mobile_agent/evals/vision/images/dog_003.jpg +0 -0
  256. mobile_agent/evals/vision/images/dog_004.jpg +0 -0
  257. mobile_agent/evals/vision/images/dog_005.jpg +0 -0
  258. mobile_agent/evals/vision/images/dog_006.jpg +0 -0
  259. mobile_agent/evals/vision/images/dog_007.jpg +0 -0
  260. mobile_agent/evals/vision/images/dog_010.jpg +0 -0
  261. mobile_agent/evals/vision/images/dog_011.jpg +0 -0
  262. mobile_agent/evals/vision/images/dog_013.jpg +0 -0
  263. mobile_agent/evals/vision/images/dog_014.jpg +0 -0
  264. mobile_agent/evals/vision/images/dog_015.jpg +0 -0
  265. mobile_agent/evals/vision/images/dog_016.jpg +0 -0
  266. mobile_agent/evals/vision/images/dog_017.jpg +0 -0
  267. mobile_agent/evals/vision/images/dog_019.jpg +0 -0
  268. mobile_agent/evals/vision/images/dog_020.jpg +0 -0
  269. mobile_agent/evals/vision/images/dog_021.jpg +0 -0
  270. mobile_agent/evals/vision/images/dog_022.jpg +0 -0
  271. mobile_agent/evals/vision/images/dog_023.jpg +0 -0
  272. mobile_agent/evals/vision/images/dog_024.jpg +0 -0
  273. mobile_agent/evals/vision/images/dog_026.jpg +0 -0
  274. mobile_agent/evals/vision/images/dog_027.jpg +0 -0
  275. mobile_agent/evals/vision/images/dog_034.jpg +0 -0
  276. mobile_agent/evals/vision/images/dog_035.jpg +0 -0
  277. mobile_agent/evals/vision/images/dog_038.jpg +0 -0
  278. mobile_agent/evals/vision/images/dog_042.jpg +0 -0
  279. mobile_agent/evals/vision/images/dog_047.jpg +0 -0
  280. mobile_agent/evals/vision/images/dog_052.jpg +0 -0
  281. mobile_agent/evals/vision/images/dog_058.jpg +0 -0
  282. mobile_agent/evals/vision/images/dog_062.jpg +0 -0
  283. mobile_agent/evals/vision/images/eyes_000.jpg +0 -0
  284. mobile_agent/evals/vision/images/eyes_010.jpg +0 -0
  285. mobile_agent/evals/vision/images/eyes_011.jpg +0 -0
  286. mobile_agent/evals/vision/images/eyes_015.jpg +0 -0
  287. mobile_agent/evals/vision/images/eyes_016.jpg +0 -0
  288. mobile_agent/evals/vision/images/eyes_017.jpg +0 -0
  289. mobile_agent/evals/vision/images/eyes_019.jpg +0 -0
  290. mobile_agent/evals/vision/images/eyes_021.jpg +0 -0
  291. mobile_agent/evals/vision/images/eyes_025.jpg +0 -0
  292. mobile_agent/evals/vision/images/eyes_026.jpg +0 -0
  293. mobile_agent/evals/vision/images/eyes_027.jpg +0 -0
  294. mobile_agent/evals/vision/images/eyes_030.jpg +0 -0
  295. mobile_agent/evals/vision/images/eyes_032.jpg +0 -0
  296. mobile_agent/evals/vision/images/eyes_033.jpg +0 -0
  297. mobile_agent/evals/vision/images/eyes_034.jpg +0 -0
  298. mobile_agent/evals/vision/images/eyes_035.jpg +0 -0
  299. mobile_agent/evals/vision/images/eyes_036.jpg +0 -0
  300. mobile_agent/evals/vision/images/eyes_037.jpg +0 -0
  301. mobile_agent/evals/vision/images/eyes_039.jpg +0 -0
  302. mobile_agent/evals/vision/images/eyes_040.jpg +0 -0
  303. mobile_agent/evals/vision/images/eyes_041.jpg +0 -0
  304. mobile_agent/evals/vision/images/eyes_042.jpg +0 -0
  305. mobile_agent/evals/vision/images/eyes_043.jpg +0 -0
  306. mobile_agent/evals/vision/images/eyes_044.jpg +0 -0
  307. mobile_agent/evals/vision/images/eyes_046.jpg +0 -0
  308. mobile_agent/evals/vision/images/eyes_047.jpg +0 -0
  309. mobile_agent/evals/vision/images/eyes_048.jpg +0 -0
  310. mobile_agent/evals/vision/images/eyes_049.jpg +0 -0
  311. mobile_agent/evals/vision/images/eyes_051.jpg +0 -0
  312. mobile_agent/evals/vision/images/eyes_053.jpg +0 -0
  313. mobile_agent/evals/vision/images/eyes_054.jpg +0 -0
  314. mobile_agent/evals/vision/images/eyes_055.jpg +0 -0
  315. mobile_agent/evals/vision/images/eyes_058.jpg +0 -0
  316. mobile_agent/evals/vision/images/eyes_059.jpg +0 -0
  317. mobile_agent/evals/vision/images/receipt_002.jpg +0 -0
  318. mobile_agent/evals/vision/images/receipt_003.jpg +0 -0
  319. mobile_agent/evals/vision/images/receipt_004.jpg +0 -0
  320. mobile_agent/evals/vision/images/receipt_007.jpg +0 -0
  321. mobile_agent/evals/vision/images/receipt_009.jpg +0 -0
  322. mobile_agent/evals/vision/images/receipt_010.jpg +0 -0
  323. mobile_agent/evals/vision/images/receipt_011.jpg +0 -0
  324. mobile_agent/evals/vision/images/receipt_012.jpg +0 -0
  325. mobile_agent/evals/vision/images/receipt_013.jpg +0 -0
  326. mobile_agent/evals/vision/images/receipt_014.jpg +0 -0
  327. mobile_agent/evals/vision/images/receipt_016.jpg +0 -0
  328. mobile_agent/evals/vision/images/receipt_017.jpg +0 -0
  329. mobile_agent/evals/vision/images/receipt_023.jpg +0 -0
  330. mobile_agent/evals/vision/images/receipt_027.jpg +0 -0
  331. mobile_agent/evals/vision/images/receipt_029.jpg +0 -0
  332. mobile_agent/evals/vision/images/receipt_032.jpg +0 -0
  333. mobile_agent/evals/vision/images/receipt_033.jpg +0 -0
  334. mobile_agent/evals/vision/images/receipt_036.jpg +0 -0
  335. mobile_agent/evals/vision/images/receipt_041.jpg +0 -0
  336. mobile_agent/evals/vision/images/receipt_043.jpg +0 -0
  337. mobile_agent/evals/vision/images/receipt_047.jpg +0 -0
  338. mobile_agent/evals/vision/images/receipt_049.jpg +0 -0
  339. mobile_agent/evals/vision/images/receipt_051.jpg +0 -0
  340. mobile_agent/evals/vision/images/receipt_053.jpg +0 -0
  341. mobile_agent/evals/vision/images/receipt_056.jpg +0 -0
  342. mobile_agent/evals/vision/images/receipt_057.jpg +0 -0
  343. mobile_agent/evals/vision/images/receipt_058.jpg +0 -0
  344. mobile_agent/evals/vision/images/receipt_059.jpg +0 -0
  345. mobile_agent/evals/vision/images/receipt_060.jpg +0 -0
  346. mobile_agent/evals/vision/images/receipt_061.jpg +0 -0
  347. mobile_agent/evals/vision/images/receipt_062.jpg +0 -0
  348. mobile_agent/evals/vision/images/receipt_063.jpg +0 -0
  349. mobile_agent/evals/vision/images/receipt_064.jpg +0 -0
  350. mobile_agent/evals/vision/images/receipt_065.jpg +0 -0
  351. mobile_agent/evals/vision/images/receipt_066.jpg +0 -0
  352. mobile_agent/evals/vision/images/receipt_067.jpg +0 -0
  353. mobile_agent/evals/vision/images/receipt_068.jpg +0 -0
  354. mobile_agent/evals/vision/images/receipt_069.jpg +0 -0
  355. mobile_agent/evals/vision/images/receipt_070.jpg +0 -0
  356. mobile_agent/evals/vision/images/receipt_071.jpg +0 -0
  357. mobile_agent/evals/vision/images/sunglasses_000.jpg +0 -0
  358. mobile_agent/evals/vision/images/sunglasses_001.jpg +0 -0
  359. mobile_agent/evals/vision/images/sunglasses_002.jpg +0 -0
  360. mobile_agent/evals/vision/images/sunglasses_004.jpg +0 -0
  361. mobile_agent/evals/vision/images/sunglasses_005.jpg +0 -0
  362. mobile_agent/evals/vision/images/sunglasses_008.jpg +0 -0
  363. mobile_agent/evals/vision/images/sunglasses_009.jpg +0 -0
  364. mobile_agent/evals/vision/images/sunglasses_010.jpg +0 -0
  365. mobile_agent/evals/vision/images/sunglasses_012.jpg +0 -0
  366. mobile_agent/evals/vision/images/sunglasses_013.jpg +0 -0
  367. mobile_agent/evals/vision/images/sunglasses_015.jpg +0 -0
  368. mobile_agent/evals/vision/images/sunglasses_016.jpg +0 -0
  369. mobile_agent/evals/vision/images/sunglasses_017.jpg +0 -0
  370. mobile_agent/evals/vision/images/sunglasses_018.jpg +0 -0
  371. mobile_agent/evals/vision/images/sunglasses_021.jpg +0 -0
  372. mobile_agent/evals/vision/images/sunglasses_022.jpg +0 -0
  373. mobile_agent/evals/vision/images/sunglasses_023.jpg +0 -0
  374. mobile_agent/evals/vision/images/sunglasses_024.jpg +0 -0
  375. mobile_agent/evals/vision/images/sunglasses_030.jpg +0 -0
  376. mobile_agent/evals/vision/images/sunglasses_031.jpg +0 -0
  377. mobile_agent/evals/vision/images/sunglasses_032.jpg +0 -0
  378. mobile_agent/evals/vision/images/sunglasses_033.jpg +0 -0
  379. mobile_agent/evals/vision/images/sunglasses_034.jpg +0 -0
  380. mobile_agent/evals/vision/images/sunglasses_036.jpg +0 -0
  381. mobile_agent/evals/vision/images/sunglasses_038.jpg +0 -0
  382. mobile_agent/evals/vision/images/sunglasses_039.jpg +0 -0
  383. mobile_agent/evals/vision/images/sunglasses_040.jpg +0 -0
  384. mobile_agent/evals/vision/images/sunglasses_041.jpg +0 -0
  385. mobile_agent/evals/vision/manifest.json +2355 -0
  386. mobile_agent/eventbus.py +110 -0
  387. mobile_agent/explain.py +34 -0
  388. mobile_agent/failure_line.py +197 -0
  389. mobile_agent/fanout.py +663 -0
  390. mobile_agent/feedback/__init__.py +2 -0
  391. mobile_agent/feedback/bundle.py +204 -0
  392. mobile_agent/feedback/outbox.py +196 -0
  393. mobile_agent/feedback/register.py +9 -0
  394. mobile_agent/feedback/routes.py +177 -0
  395. mobile_agent/fleet.py +841 -0
  396. mobile_agent/fleet_health.py +729 -0
  397. mobile_agent/fleet_phone.py +186 -0
  398. mobile_agent/frontier/__init__.py +79 -0
  399. mobile_agent/frontier/act.py +380 -0
  400. mobile_agent/frontier/actions.py +70 -0
  401. mobile_agent/frontier/agent.py +256 -0
  402. mobile_agent/frontier/approvals.py +366 -0
  403. mobile_agent/frontier/hooks.py +199 -0
  404. mobile_agent/frontier/interrupts.py +125 -0
  405. mobile_agent/frontier/observe.py +331 -0
  406. mobile_agent/frontier/phases.py +381 -0
  407. mobile_agent/frontier/pipeline.py +61 -0
  408. mobile_agent/frontier/prompt.py +384 -0
  409. mobile_agent/frontier/protocol.py +91 -0
  410. mobile_agent/frontier/safety.py +467 -0
  411. mobile_agent/frontier/saved.py +375 -0
  412. mobile_agent/frontier/screen.py +419 -0
  413. mobile_agent/frontier/skills.py +147 -0
  414. mobile_agent/frontier/stages/__init__.py +1 -0
  415. mobile_agent/frontier/stages/after.py +192 -0
  416. mobile_agent/frontier/stages/approve.py +138 -0
  417. mobile_agent/frontier/stages/controls.py +115 -0
  418. mobile_agent/frontier/stages/ends.py +102 -0
  419. mobile_agent/frontier/stages/gate.py +92 -0
  420. mobile_agent/frontier/stages/guards.py +132 -0
  421. mobile_agent/frontier/stages/points.py +155 -0
  422. mobile_agent/frontier/stages/send.py +177 -0
  423. mobile_agent/frontier/turn.py +151 -0
  424. mobile_agent/full_disk_access.py +83 -0
  425. mobile_agent/gemini.py +69 -0
  426. mobile_agent/genuine.py +120 -0
  427. mobile_agent/graph/__init__.py +4 -0
  428. mobile_agent/graph/catalog.py +789 -0
  429. mobile_agent/graph/chain.py +306 -0
  430. mobile_agent/graph/dispatch.py +98 -0
  431. mobile_agent/graph/draft.py +373 -0
  432. mobile_agent/graph/edit.py +76 -0
  433. mobile_agent/graph/estimate.py +189 -0
  434. mobile_agent/graph/expr.py +562 -0
  435. mobile_agent/graph/functions.py +271 -0
  436. mobile_agent/graph/infer.py +144 -0
  437. mobile_agent/graph/nodes.py +936 -0
  438. mobile_agent/graph/phone_steps.py +178 -0
  439. mobile_agent/graph/rerun.py +327 -0
  440. mobile_agent/graph/run.py +899 -0
  441. mobile_agent/graph/schema.py +842 -0
  442. mobile_agent/graph/state.py +161 -0
  443. mobile_agent/graph/values.py +198 -0
  444. mobile_agent/harness/__init__.py +2 -0
  445. mobile_agent/harness/checkpoints.py +468 -0
  446. mobile_agent/harness/control.py +101 -0
  447. mobile_agent/harness/prewarm.py +213 -0
  448. mobile_agent/harness/register.py +43 -0
  449. mobile_agent/harness/routes.py +114 -0
  450. mobile_agent/harness/settings.py +66 -0
  451. mobile_agent/harness/tools/__init__.py +67 -0
  452. mobile_agent/harness/tools/ask_user.py +102 -0
  453. mobile_agent/harness/tools/watch.py +120 -0
  454. mobile_agent/harness_api.py +815 -0
  455. mobile_agent/hedge.py +203 -0
  456. mobile_agent/help_topics.py +305 -0
  457. mobile_agent/hints.py +248 -0
  458. mobile_agent/history.py +69 -0
  459. mobile_agent/home.py +214 -0
  460. mobile_agent/http_pool.py +175 -0
  461. mobile_agent/image_policy.py +139 -0
  462. mobile_agent/inbox.py +148 -0
  463. mobile_agent/installed_apps.py +344 -0
  464. mobile_agent/integrations/__init__.py +1 -0
  465. mobile_agent/integrations/cli.py +472 -0
  466. mobile_agent/integrations/client_files.py +65 -0
  467. mobile_agent/integrations/clients.py +998 -0
  468. mobile_agent/integrations/doctor.py +185 -0
  469. mobile_agent/integrations/jsonc.py +383 -0
  470. mobile_agent/integrations/routes.py +135 -0
  471. mobile_agent/integrations/rules.py +112 -0
  472. mobile_agent/integrations/server_options.py +46 -0
  473. mobile_agent/integrations/skill.py +152 -0
  474. mobile_agent/integrations/skill_files.py +10 -0
  475. mobile_agent/integrations/textedit.py +309 -0
  476. mobile_agent/intents/__init__.py +17 -0
  477. mobile_agent/intents/atlas.py +216 -0
  478. mobile_agent/intents/callback.py +44 -0
  479. mobile_agent/intents/cli.py +55 -0
  480. mobile_agent/intents/harvest.py +91 -0
  481. mobile_agent/intents/plan.py +93 -0
  482. mobile_agent/ios/__init__.py +1 -0
  483. mobile_agent/ios/guide.py +16 -0
  484. mobile_agent/ios/kb.py +212 -0
  485. mobile_agent/ios/kb_data.py +1588 -0
  486. mobile_agent/ios/mcp.py +44 -0
  487. mobile_agent/ios/register.py +8 -0
  488. mobile_agent/ios/system.py +369 -0
  489. mobile_agent/journal.py +531 -0
  490. mobile_agent/keychain_acl.py +374 -0
  491. mobile_agent/keys.py +395 -0
  492. mobile_agent/latency_overlap.py +36 -0
  493. mobile_agent/latency_trace.py +170 -0
  494. mobile_agent/learned/__init__.py +15 -0
  495. mobile_agent/learned/app_path.py +160 -0
  496. mobile_agent/learned/code_read.py +38 -0
  497. mobile_agent/learned/parts.py +546 -0
  498. mobile_agent/learned/programs.py +691 -0
  499. mobile_agent/learned/register.py +171 -0
  500. mobile_agent/learned/routes.py +61 -0
  501. mobile_agent/learned/taught.py +329 -0
  502. mobile_agent/learned/teach.py +538 -0
  503. mobile_agent/learned/workflows.py +647 -0
  504. mobile_agent/learned/zero.py +41 -0
  505. mobile_agent/links.py +37 -0
  506. mobile_agent/listen.py +607 -0
  507. mobile_agent/lock_spike.py +278 -0
  508. mobile_agent/lockscreen.py +526 -0
  509. mobile_agent/login.py +354 -0
  510. mobile_agent/longform/__init__.py +15 -0
  511. mobile_agent/longform/ads.py +191 -0
  512. mobile_agent/longform/blocks.py +117 -0
  513. mobile_agent/longform/budget.py +265 -0
  514. mobile_agent/longform/context.py +285 -0
  515. mobile_agent/longform/costs.py +93 -0
  516. mobile_agent/longform/digest.py +145 -0
  517. mobile_agent/longform/extract.py +508 -0
  518. mobile_agent/longform/feed.py +505 -0
  519. mobile_agent/longform/followup.py +168 -0
  520. mobile_agent/longform/frames.py +82 -0
  521. mobile_agent/longform/identity.py +87 -0
  522. mobile_agent/longform/inbox.py +221 -0
  523. mobile_agent/longform/items.py +425 -0
  524. mobile_agent/longform/models.py +115 -0
  525. mobile_agent/longform/notebook.py +329 -0
  526. mobile_agent/longform/plan.py +614 -0
  527. mobile_agent/longform/recall.py +195 -0
  528. mobile_agent/longform/receipts.py +81 -0
  529. mobile_agent/longform/register.py +124 -0
  530. mobile_agent/longform/report.py +343 -0
  531. mobile_agent/longform/routes.py +102 -0
  532. mobile_agent/longform/session.py +644 -0
  533. mobile_agent/longform/store.py +146 -0
  534. mobile_agent/longform/synthesis.py +347 -0
  535. mobile_agent/mac_tools.py +223 -0
  536. mobile_agent/manual_control.py +143 -0
  537. mobile_agent/maps.py +149 -0
  538. mobile_agent/marks.py +92 -0
  539. mobile_agent/mcp_server/__init__.py +3 -0
  540. mobile_agent/mcp_server/__main__.py +29 -0
  541. mobile_agent/mcp_server/action_journal.py +24 -0
  542. mobile_agent/mcp_server/action_tools.py +638 -0
  543. mobile_agent/mcp_server/approvals.py +219 -0
  544. mobile_agent/mcp_server/catalog.py +92 -0
  545. mobile_agent/mcp_server/cli.py +194 -0
  546. mobile_agent/mcp_server/codes.py +147 -0
  547. mobile_agent/mcp_server/device_tools.py +306 -0
  548. mobile_agent/mcp_server/direct.py +272 -0
  549. mobile_agent/mcp_server/expo_tools.py +105 -0
  550. mobile_agent/mcp_server/phone_tools.py +510 -0
  551. mobile_agent/mcp_server/protocol.py +364 -0
  552. mobile_agent/mcp_server/registry.py +55 -0
  553. mobile_agent/mcp_server/run_tools.py +531 -0
  554. mobile_agent/mcp_server/schemas.py +425 -0
  555. mobile_agent/mcp_server/screen_tools.py +168 -0
  556. mobile_agent/mcp_server/session.py +469 -0
  557. mobile_agent/mcp_server/support.py +145 -0
  558. mobile_agent/mcp_server/tools.py +493 -0
  559. mobile_agent/memory/__init__.py +9 -0
  560. mobile_agent/memory/cli.py +207 -0
  561. mobile_agent/memory/extract.py +191 -0
  562. mobile_agent/memory/listener.py +100 -0
  563. mobile_agent/memory/model.py +144 -0
  564. mobile_agent/memory/project.py +96 -0
  565. mobile_agent/memory/provider.py +127 -0
  566. mobile_agent/memory/register.py +21 -0
  567. mobile_agent/memory/retrieval.py +172 -0
  568. mobile_agent/memory/routes.py +216 -0
  569. mobile_agent/memory/runs.py +124 -0
  570. mobile_agent/memory/store.py +806 -0
  571. mobile_agent/memory/writer.py +278 -0
  572. mobile_agent/mirroring.py +585 -0
  573. mobile_agent/mirroring_runtime.py +221 -0
  574. mobile_agent/model_clients.py +604 -0
  575. mobile_agent/narrate.py +813 -0
  576. mobile_agent/native_helpers.py +84 -0
  577. mobile_agent/netxray/__init__.py +1 -0
  578. mobile_agent/netxray/analyze.py +322 -0
  579. mobile_agent/netxray/attribution.py +62 -0
  580. mobile_agent/netxray/capture.py +88 -0
  581. mobile_agent/netxray/cli.py +142 -0
  582. mobile_agent/netxray/job.py +219 -0
  583. mobile_agent/netxray/mobster-pcap.c +560 -0
  584. mobile_agent/netxray/packets.py +410 -0
  585. mobile_agent/netxray/pcapng.py +223 -0
  586. mobile_agent/netxray/register.py +93 -0
  587. mobile_agent/netxray/report.py +214 -0
  588. mobile_agent/netxray/store.py +48 -0
  589. mobile_agent/netxray/summary.py +157 -0
  590. mobile_agent/netxray/target.py +67 -0
  591. mobile_agent/netxray/tool.py +66 -0
  592. mobile_agent/netxray/trackers.py +194 -0
  593. mobile_agent/notify_triggers/__init__.py +1 -0
  594. mobile_agent/notify_triggers/rules.py +39 -0
  595. mobile_agent/notify_triggers/sources.py +315 -0
  596. mobile_agent/notify_triggers/watch.py +135 -0
  597. mobile_agent/offers.py +86 -0
  598. mobile_agent/orchestrate.py +215 -0
  599. mobile_agent/outlets/__init__.py +1 -0
  600. mobile_agent/outlets/connect.py +249 -0
  601. mobile_agent/outlets/folder.py +180 -0
  602. mobile_agent/outlets/hooks.py +453 -0
  603. mobile_agent/outlets/inbound.py +472 -0
  604. mobile_agent/outlets/inbound_reply.py +57 -0
  605. mobile_agent/outlets/inbound_routes.py +81 -0
  606. mobile_agent/outlets/kinds.py +308 -0
  607. mobile_agent/outlets/mail.py +110 -0
  608. mobile_agent/outlets/net.py +86 -0
  609. mobile_agent/outlets/notion.py +244 -0
  610. mobile_agent/outlets/register.py +56 -0
  611. mobile_agent/outlets/routes.py +135 -0
  612. mobile_agent/outlets/service.py +580 -0
  613. mobile_agent/outlets/sheets.py +169 -0
  614. mobile_agent/outlets/slack.py +177 -0
  615. mobile_agent/outlets/store.py +194 -0
  616. mobile_agent/outlets/vault.py +80 -0
  617. mobile_agent/outlets/webhook.py +316 -0
  618. mobile_agent/output_contract.py +141 -0
  619. mobile_agent/owner_presence.py +211 -0
  620. mobile_agent/ownership.py +219 -0
  621. mobile_agent/packs/__init__.py +1 -0
  622. mobile_agent/packs/calendar.py +243 -0
  623. mobile_agent/packs/context.py +68 -0
  624. mobile_agent/packs/core.py +757 -0
  625. mobile_agent/packs/front_door.py +316 -0
  626. mobile_agent/packs/heal.py +214 -0
  627. mobile_agent/packs/health.py +122 -0
  628. mobile_agent/packs/insights.py +76 -0
  629. mobile_agent/packs/instagram.py +640 -0
  630. mobile_agent/packs/linkedin.py +106 -0
  631. mobile_agent/packs/maps.py +196 -0
  632. mobile_agent/packs/mcp.py +149 -0
  633. mobile_agent/packs/messages.py +270 -0
  634. mobile_agent/packs/notes.py +226 -0
  635. mobile_agent/packs/photos.py +166 -0
  636. mobile_agent/packs/pins.py +47 -0
  637. mobile_agent/packs/reddit.py +110 -0
  638. mobile_agent/packs/register.py +276 -0
  639. mobile_agent/packs/rows.py +108 -0
  640. mobile_agent/packs/safari.py +143 -0
  641. mobile_agent/packs/settings.py +253 -0
  642. mobile_agent/packs/social.py +300 -0
  643. mobile_agent/packs/threads.py +97 -0
  644. mobile_agent/packs/tiktok.py +455 -0
  645. mobile_agent/packs/tools.py +290 -0
  646. mobile_agent/packs/x.py +127 -0
  647. mobile_agent/packs/youtube.py +199 -0
  648. mobile_agent/parity.py +270 -0
  649. mobile_agent/paths.py +99 -0
  650. mobile_agent/perception.py +470 -0
  651. mobile_agent/permissions/__init__.py +32 -0
  652. mobile_agent/permissions/consequence.py +535 -0
  653. mobile_agent/permissions/exceptions.py +99 -0
  654. mobile_agent/permissions/gate.py +316 -0
  655. mobile_agent/permissions/outside.py +316 -0
  656. mobile_agent/permissions/policy.py +288 -0
  657. mobile_agent/permissions/project_policy.py +411 -0
  658. mobile_agent/permissions/register.py +148 -0
  659. mobile_agent/permissions/rules.py +291 -0
  660. mobile_agent/phone_io/__init__.py +146 -0
  661. mobile_agent/phone_io/cli.py +427 -0
  662. mobile_agent/phone_io/clipboard.py +78 -0
  663. mobile_agent/phone_io/controls.py +78 -0
  664. mobile_agent/phone_io/files.py +289 -0
  665. mobile_agent/phone_io/handoff.py +123 -0
  666. mobile_agent/phone_io/icloud.py +118 -0
  667. mobile_agent/phone_io/install.py +188 -0
  668. mobile_agent/phone_io/network_capture.py +188 -0
  669. mobile_agent/phone_io/network_cli.py +138 -0
  670. mobile_agent/phone_io/network_profiles.py +138 -0
  671. mobile_agent/phone_io/photos.py +512 -0
  672. mobile_agent/phone_io/recording.py +115 -0
  673. mobile_agent/phone_io/secrets.py +146 -0
  674. mobile_agent/phone_io/transfer.py +895 -0
  675. mobile_agent/phone_io/tunnel.py +129 -0
  676. mobile_agent/phone_ready.py +91 -0
  677. mobile_agent/pinning.py +158 -0
  678. mobile_agent/plan_gate.py +807 -0
  679. mobile_agent/plugins/__init__.py +3 -0
  680. mobile_agent/plugins/install.py +163 -0
  681. mobile_agent/plugins/manifest.py +242 -0
  682. mobile_agent/plugins/register.py +15 -0
  683. mobile_agent/plugins/routes.py +82 -0
  684. mobile_agent/plugins/service.py +322 -0
  685. mobile_agent/plugins/skills.py +76 -0
  686. mobile_agent/plugins/store.py +79 -0
  687. mobile_agent/pool.py +285 -0
  688. mobile_agent/power_watch.py +275 -0
  689. mobile_agent/premium_model.py +22 -0
  690. mobile_agent/project_env.py +291 -0
  691. mobile_agent/project_env_cli.py +74 -0
  692. mobile_agent/project_env_routes.py +55 -0
  693. mobile_agent/projects/__init__.py +27 -0
  694. mobile_agent/projects/ci.py +332 -0
  695. mobile_agent/projects/clone.py +339 -0
  696. mobile_agent/projects/drafts.py +158 -0
  697. mobile_agent/projects/folder.py +436 -0
  698. mobile_agent/projects/github.py +131 -0
  699. mobile_agent/projects/register.py +31 -0
  700. mobile_agent/projects/revisions.py +253 -0
  701. mobile_agent/projects/routes.py +234 -0
  702. mobile_agent/projects/runs.py +297 -0
  703. mobile_agent/projects/service.py +695 -0
  704. mobile_agent/projects/setup.py +84 -0
  705. mobile_agent/projects/store.py +379 -0
  706. mobile_agent/projects/sync.py +380 -0
  707. mobile_agent/projects/workflow_files.py +270 -0
  708. mobile_agent/replay_core/__init__.py +26 -0
  709. mobile_agent/replay_core/checks.py +594 -0
  710. mobile_agent/replay_core/engine.py +596 -0
  711. mobile_agent/replay_core/heal.py +364 -0
  712. mobile_agent/replay_core/path.py +240 -0
  713. mobile_agent/replay_core/recorder.py +206 -0
  714. mobile_agent/replay_core/store.py +171 -0
  715. mobile_agent/result_blocks.py +941 -0
  716. mobile_agent/run.py +642 -0
  717. mobile_agent/run_delivery.py +123 -0
  718. mobile_agent/run_executor.py +200 -0
  719. mobile_agent/run_export.py +284 -0
  720. mobile_agent/run_gif.py +891 -0
  721. mobile_agent/runner_hints.py +86 -0
  722. mobile_agent/runner_renewal.py +205 -0
  723. mobile_agent/runtime_create.py +549 -0
  724. mobile_agent/runtime_driver.py +458 -0
  725. mobile_agent/runtime_limits.py +319 -0
  726. mobile_agent/runtime_queue.py +394 -0
  727. mobile_agent/runtime_runs.py +383 -0
  728. mobile_agent/runtime_status.py +181 -0
  729. mobile_agent/runtime_teach.py +295 -0
  730. mobile_agent/runtime_work.py +599 -0
  731. mobile_agent/screen_text.py +101 -0
  732. mobile_agent/secret_filter.py +113 -0
  733. mobile_agent/serve.py +160 -0
  734. mobile_agent/server.py +363 -0
  735. mobile_agent/service_support.py +42 -0
  736. mobile_agent/settle/__init__.py +1 -0
  737. mobile_agent/settle/predict.py +102 -0
  738. mobile_agent/settle/replay.py +92 -0
  739. mobile_agent/settle/store.py +93 -0
  740. mobile_agent/settle/stream.py +43 -0
  741. mobile_agent/setup_cli.py +225 -0
  742. mobile_agent/setup_errors.py +87 -0
  743. mobile_agent/setup_service.py +527 -0
  744. mobile_agent/share.py +104 -0
  745. mobile_agent/signing.py +184 -0
  746. mobile_agent/sim/__init__.py +6 -0
  747. mobile_agent/sim/__main__.py +26 -0
  748. mobile_agent/sim/api.py +952 -0
  749. mobile_agent/sim/apps.py +172 -0
  750. mobile_agent/sim/biometrics.py +84 -0
  751. mobile_agent/sim/cli.py +271 -0
  752. mobile_agent/sim/doctor.py +293 -0
  753. mobile_agent/sim/governor.py +90 -0
  754. mobile_agent/sim/holders.py +88 -0
  755. mobile_agent/sim/models.py +62 -0
  756. mobile_agent/sim/registry.py +161 -0
  757. mobile_agent/sim/simctl.py +269 -0
  758. mobile_agent/sim/wda.py +618 -0
  759. mobile_agent/sim/xcode.py +74 -0
  760. mobile_agent/skills/__init__.py +12 -0
  761. mobile_agent/skills/codes.py +597 -0
  762. mobile_agent/skills/notifications.py +281 -0
  763. mobile_agent/skills/open_url.py +127 -0
  764. mobile_agent/smart_events.py +588 -0
  765. mobile_agent/smart_result.py +490 -0
  766. mobile_agent/smart_run.py +413 -0
  767. mobile_agent/state.py +662 -0
  768. mobile_agent/states.py +217 -0
  769. mobile_agent/steering.py +70 -0
  770. mobile_agent/style.py +178 -0
  771. mobile_agent/supervised.py +234 -0
  772. mobile_agent/swift/helper_bt_hid.swift +534 -0
  773. mobile_agent/swift/helper_doc_text.swift +184 -0
  774. mobile_agent/swift/helper_draft_reply.swift +170 -0
  775. mobile_agent/swift/helper_map.swift +110 -0
  776. mobile_agent/swift/helper_mirroring.swift +1099 -0
  777. mobile_agent/swift/helper_mjpeg_mp4.swift +103 -0
  778. mobile_agent/swift/helper_pdf.swift +100 -0
  779. mobile_agent/swift/helper_phone_audio.swift +993 -0
  780. mobile_agent/swift/helper_phone_screen.swift +416 -0
  781. mobile_agent/swift/helper_screen_text.swift +24 -0
  782. mobile_agent/swift/helper_transfer.swift +246 -0
  783. mobile_agent/swift/ocr.swift +22 -0
  784. mobile_agent/task_policy.py +337 -0
  785. mobile_agent/team_signing/__init__.py +34 -0
  786. mobile_agent/team_signing/apple_ca.py +37 -0
  787. mobile_agent/team_signing/bundle.py +136 -0
  788. mobile_agent/team_signing/cli.py +208 -0
  789. mobile_agent/team_signing/identity.py +248 -0
  790. mobile_agent/team_signing/ledger.py +105 -0
  791. mobile_agent/team_signing/prebuilt.py +61 -0
  792. mobile_agent/team_signing/resign.py +256 -0
  793. mobile_agent/team_signing/service.py +382 -0
  794. mobile_agent/team_signing/tooling.py +243 -0
  795. mobile_agent/team_signing/writes.py +268 -0
  796. mobile_agent/terminal_image.py +81 -0
  797. mobile_agent/testkit/__init__.py +1 -0
  798. mobile_agent/testkit/a11y_baseline.py +132 -0
  799. mobile_agent/testkit/api.py +403 -0
  800. mobile_agent/testkit/capsule.py +400 -0
  801. mobile_agent/testkit/capsule_cli.py +57 -0
  802. mobile_agent/testkit/cli.py +948 -0
  803. mobile_agent/testkit/cli_arguments.py +193 -0
  804. mobile_agent/testkit/comparison.py +288 -0
  805. mobile_agent/testkit/comparison_cli.py +41 -0
  806. mobile_agent/testkit/discover.py +257 -0
  807. mobile_agent/testkit/editor.py +243 -0
  808. mobile_agent/testkit/execute.py +515 -0
  809. mobile_agent/testkit/github_checks.py +248 -0
  810. mobile_agent/testkit/gitref.py +66 -0
  811. mobile_agent/testkit/heal_review.py +176 -0
  812. mobile_agent/testkit/health.py +480 -0
  813. mobile_agent/testkit/history.py +255 -0
  814. mobile_agent/testkit/hosted_results.py +321 -0
  815. mobile_agent/testkit/input_files.py +173 -0
  816. mobile_agent/testkit/jobs.py +285 -0
  817. mobile_agent/testkit/junit_verdict.py +33 -0
  818. mobile_agent/testkit/maestro.py +802 -0
  819. mobile_agent/testkit/mcp.py +562 -0
  820. mobile_agent/testkit/mcp_comparison.py +88 -0
  821. mobile_agent/testkit/minimize.py +471 -0
  822. mobile_agent/testkit/minimizer_cli.py +48 -0
  823. mobile_agent/testkit/notify.py +212 -0
  824. mobile_agent/testkit/phone_video.py +191 -0
  825. mobile_agent/testkit/pr.py +491 -0
  826. mobile_agent/testkit/pr_report.py +562 -0
  827. mobile_agent/testkit/prbuild.py +148 -0
  828. mobile_agent/testkit/proof_report.py +55 -0
  829. mobile_agent/testkit/provenance.py +119 -0
  830. mobile_agent/testkit/prrun.py +244 -0
  831. mobile_agent/testkit/recent.py +166 -0
  832. mobile_agent/testkit/recipes.py +146 -0
  833. mobile_agent/testkit/record.py +204 -0
  834. mobile_agent/testkit/register.py +288 -0
  835. mobile_agent/testkit/reports.py +998 -0
  836. mobile_agent/testkit/repro.py +432 -0
  837. mobile_agent/testkit/scenario.py +440 -0
  838. mobile_agent/testkit/scenario_cli.py +37 -0
  839. mobile_agent/testkit/snapshots_cli.py +60 -0
  840. mobile_agent/testkit/suite.py +670 -0
  841. mobile_agent/testkit/targets.py +497 -0
  842. mobile_agent/testkit/xctest.py +378 -0
  843. mobile_agent/threads/__init__.py +1 -0
  844. mobile_agent/threads/chat_once.py +264 -0
  845. mobile_agent/threads/cli.py +740 -0
  846. mobile_agent/threads/client.py +326 -0
  847. mobile_agent/threads/context.py +246 -0
  848. mobile_agent/threads/mcp.py +739 -0
  849. mobile_agent/threads/recent.py +34 -0
  850. mobile_agent/threads/register.py +54 -0
  851. mobile_agent/threads/routes.py +188 -0
  852. mobile_agent/threads/service.py +975 -0
  853. mobile_agent/threads/store.py +361 -0
  854. mobile_agent/threads/summary.py +117 -0
  855. mobile_agent/threads/term.py +101 -0
  856. mobile_agent/threads/workflow_cli.py +104 -0
  857. mobile_agent/tls.py +36 -0
  858. mobile_agent/traces.py +271 -0
  859. mobile_agent/tracks.py +235 -0
  860. mobile_agent/transport.py +271 -0
  861. mobile_agent/triggers/__init__.py +1 -0
  862. mobile_agent/triggers/actions.py +204 -0
  863. mobile_agent/triggers/model.py +146 -0
  864. mobile_agent/triggers/register.py +29 -0
  865. mobile_agent/triggers/routes.py +101 -0
  866. mobile_agent/triggers/service.py +387 -0
  867. mobile_agent/triggers/store.py +142 -0
  868. mobile_agent/tui/__init__.py +89 -0
  869. mobile_agent/tui/app.py +582 -0
  870. mobile_agent/tui/chrome.py +321 -0
  871. mobile_agent/tui/commands.py +375 -0
  872. mobile_agent/tui/demo_phone.py +369 -0
  873. mobile_agent/tui/images.py +29 -0
  874. mobile_agent/tui/keys.py +64 -0
  875. mobile_agent/tui/prompt.py +195 -0
  876. mobile_agent/tui/render.py +395 -0
  877. mobile_agent/tui/screens.py +180 -0
  878. mobile_agent/tui/session.py +308 -0
  879. mobile_agent/tui/tasks.py +287 -0
  880. mobile_agent/tui/theme.py +118 -0
  881. mobile_agent/tui/widgets.py +272 -0
  882. mobile_agent/update_check.py +213 -0
  883. mobile_agent/usage.py +184 -0
  884. mobile_agent/verify/__init__.py +0 -0
  885. mobile_agent/verify/__main__.py +41 -0
  886. mobile_agent/verify/a11y.py +189 -0
  887. mobile_agent/verify/actions.py +290 -0
  888. mobile_agent/verify/assertions.py +570 -0
  889. mobile_agent/verify/audit.py +312 -0
  890. mobile_agent/verify/business.py +221 -0
  891. mobile_agent/verify/business_run.py +131 -0
  892. mobile_agent/verify/business_state.py +168 -0
  893. mobile_agent/verify/check_schema.py +398 -0
  894. mobile_agent/verify/checks.py +614 -0
  895. mobile_agent/verify/cli.py +423 -0
  896. mobile_agent/verify/conditions.py +483 -0
  897. mobile_agent/verify/crashes.py +321 -0
  898. mobile_agent/verify/device_audit.py +246 -0
  899. mobile_agent/verify/dialogs.py +287 -0
  900. mobile_agent/verify/evidence.py +705 -0
  901. mobile_agent/verify/fixtures.py +305 -0
  902. mobile_agent/verify/flow.py +213 -0
  903. mobile_agent/verify/l10n.py +141 -0
  904. mobile_agent/verify/netcerts.py +67 -0
  905. mobile_agent/verify/netproxy.py +356 -0
  906. mobile_agent/verify/netrun.py +81 -0
  907. mobile_agent/verify/netshim.m +28 -0
  908. mobile_agent/verify/netshim.py +48 -0
  909. mobile_agent/verify/netstubs.py +132 -0
  910. mobile_agent/verify/overlay.py +174 -0
  911. mobile_agent/verify/perf.py +239 -0
  912. mobile_agent/verify/proof.py +192 -0
  913. mobile_agent/verify/replay_admission.py +48 -0
  914. mobile_agent/verify/report.py +952 -0
  915. mobile_agent/verify/run_cleanup.py +67 -0
  916. mobile_agent/verify/run_links.py +48 -0
  917. mobile_agent/verify/run_perf.py +44 -0
  918. mobile_agent/verify/run_secrets.py +39 -0
  919. mobile_agent/verify/runner.py +976 -0
  920. mobile_agent/verify/runs.py +57 -0
  921. mobile_agent/verify/screen_coverage.py +181 -0
  922. mobile_agent/verify/smart.py +106 -0
  923. mobile_agent/verify/snapshot_run.py +114 -0
  924. mobile_agent/verify/snapshots.py +306 -0
  925. mobile_agent/verify/sound.py +335 -0
  926. mobile_agent/verify/source_observation.py +130 -0
  927. mobile_agent/verify/system_surfaces/__init__.py +7 -0
  928. mobile_agent/verify/system_surfaces/play.py +106 -0
  929. mobile_agent/verify/system_surfaces/spec.py +139 -0
  930. mobile_agent/verify/system_surfaces/stage.py +138 -0
  931. mobile_agent/verify/system_surfaces/surfaces.py +121 -0
  932. mobile_agent/verify/tree.py +223 -0
  933. mobile_agent/verify/verdict.py +377 -0
  934. mobile_agent/verify/wire.py +392 -0
  935. mobile_agent/verify/wirestore.py +176 -0
  936. mobile_agent/waiting/__init__.py +1 -0
  937. mobile_agent/waiting/contacts.py +73 -0
  938. mobile_agent/waiting/drafts.py +136 -0
  939. mobile_agent/waiting/job.py +145 -0
  940. mobile_agent/waiting/messages.py +174 -0
  941. mobile_agent/waiting/rank.py +115 -0
  942. mobile_agent/waiting/register.py +26 -0
  943. mobile_agent/watch.py +502 -0
  944. mobile_agent/wda/__init__.py +2 -0
  945. mobile_agent/wda/frame_clock.py +646 -0
  946. mobile_agent/wda/frame_source.py +325 -0
  947. mobile_agent/wda/frame_store.py +115 -0
  948. mobile_agent/wda/gate.py +163 -0
  949. mobile_agent/wda/gestures.py +291 -0
  950. mobile_agent/wda/guards.py +94 -0
  951. mobile_agent/wda/identity.py +55 -0
  952. mobile_agent/wda/observe.py +884 -0
  953. mobile_agent/wda/patches.py +856 -0
  954. mobile_agent/wda/point_gestures.py +150 -0
  955. mobile_agent/wda/source.py +32 -0
  956. mobile_agent/wda/system_dialogs.py +571 -0
  957. mobile_agent/wda/text.py +694 -0
  958. mobile_agent/wda/timeouts.py +77 -0
  959. mobile_agent/wda/video.py +505 -0
  960. mobile_agent/wda/watchdog.py +243 -0
  961. mobile_agent/wireless/__init__.py +15 -0
  962. mobile_agent/wireless/api.py +354 -0
  963. mobile_agent/wireless/cli.py +287 -0
  964. mobile_agent/wireless/devicectl.py +229 -0
  965. mobile_agent/wireless/exposure.py +33 -0
  966. mobile_agent/wireless/mobster-wifi-pair.c +122 -0
  967. mobile_agent/wireless/monitor.py +332 -0
  968. mobile_agent/wireless/network.py +334 -0
  969. mobile_agent/wireless/onboarding.py +293 -0
  970. mobile_agent/wireless/pair.py +124 -0
  971. mobile_agent/wireless/probe.py +322 -0
  972. mobile_agent/wireless/register.py +8 -0
  973. mobile_agent/wireless/relay.py +179 -0
  974. mobile_agent/wireless/result.py +87 -0
  975. mobile_agent/wireless/store.py +85 -0
  976. mobile_agent/wireless/switch.py +77 -0
  977. mobile_agent/wireless/transport.py +118 -0
  978. mobile_agent/wireless/words.py +81 -0
  979. mobile_agent/wireless/xctestrun.py +83 -0
  980. mobile_agent/workflow_tools.py +59 -0
  981. mobile_agent/workflows/__init__.py +2 -0
  982. mobile_agent/workflows/brief.py +201 -0
  983. mobile_agent/workflows/cron.py +110 -0
  984. mobile_agent/workflows/health.py +257 -0
  985. mobile_agent/workflows/history.py +126 -0
  986. mobile_agent/workflows/records.py +442 -0
  987. mobile_agent/workflows/runs.py +1000 -0
  988. mobile_agent/workflows/scheduler.py +134 -0
  989. mobile_agent/workflows/service.py +973 -0
  990. mobile_agent/workflows/starters.py +285 -0
  991. mobile_agent/workflows/triggers.py +248 -0
  992. mobile_agent/xray/__init__.py +1 -0
  993. mobile_agent/xray/app_cli.py +116 -0
  994. mobile_agent/xray/cli.py +21 -0
  995. mobile_agent/xray/crawl.py +754 -0
  996. mobile_agent/xray/diff.py +171 -0
  997. mobile_agent/xray/diff_cli.py +43 -0
  998. mobile_agent/xray/job.py +184 -0
  999. mobile_agent/xray/paywall.py +188 -0
  1000. mobile_agent/xray/policy.py +305 -0
  1001. mobile_agent/xray/register.py +43 -0
  1002. mobile_agent/xray/report.py +283 -0
  1003. mobile_agent/xray/screens.py +117 -0
  1004. mobster_cli-0.4.3.dist-info/METADATA +93 -0
  1005. mobster_cli-0.4.3.dist-info/RECORD +1009 -0
  1006. mobster_cli-0.4.3.dist-info/WHEEL +5 -0
  1007. mobster_cli-0.4.3.dist-info/entry_points.txt +3 -0
  1008. mobster_cli-0.4.3.dist-info/licenses/LICENSE +21 -0
  1009. mobster_cli-0.4.3.dist-info/top_level.txt +1 -0
@@ -0,0 +1,966 @@
1
+ """Connectors and their live sessions: one ConnectorService per Runtime (harness_api.register_service)."""
2
+
3
+ import hashlib
4
+ import json
5
+ import logging
6
+ import os
7
+ import re
8
+ import shutil
9
+ import threading
10
+ import time
11
+ import uuid
12
+ from dataclasses import asdict
13
+
14
+ from ..api_errors import APIError
15
+ from ..service_support import ServiceRegistry, ms
16
+ from . import client as mcp
17
+ from . import oauth, picker, shared
18
+ from . import presets as preset_list
19
+ from . import store as connector_store
20
+ from . import vault as connector_vault
21
+
22
+ log = logging.getLogger("mobster.connectors")
23
+
24
+ IDLE_SECONDS = 300.0
25
+ NAME = re.compile(r"[^\s].{0,39}", re.S)
26
+ ENV_NAME = re.compile(r"[A-Z_][A-Z0-9_]{0,63}")
27
+ HOST = re.compile(r"[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+(?::\d{1,5})?")
28
+ MAX_ARGS, ARG_CHARS, MAX_ENV, ENV_CHARS, TOKEN_CHARS = 40, 1000, 20, 4000, 8000
29
+ DESCRIPTION_CHARS = 4000 # what is kept of a tool's description (the list shows a line; a lookup all of it)
30
+ SCHEMA_CHARS = 24_000 # a tool's input schema up to this is kept whole, a bigger one without its words
31
+ LEAN_KEYS = frozenset({"description", "examples", "title", "$comment", "$schema"})
32
+ NAMED_SCHEMAS = frozenset({"properties", "patternProperties", "$defs", "definitions"})
33
+ EXTRA_PATH = ("/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin", "/usr/sbin", "/sbin")
34
+ TEST_CONNECT, TEST_LIST = 9.0, 5.0
35
+ KEYCHAIN = ("Mobster couldn't read this connector's sign-in from the Keychain. Unlock your Mac's "
36
+ "Keychain, then try again.")
37
+
38
+ _services = ServiceRegistry()
39
+
40
+
41
+ def service_for(runtime):
42
+ return _services.get(runtime)
43
+
44
+
45
+ def start(runtime):
46
+ _services.add(runtime, ConnectorService(runtime))
47
+
48
+
49
+ def close(runtime):
50
+ service = _services.pop(runtime)
51
+ if service is not None:
52
+ service.close()
53
+
54
+
55
+ def slug(name):
56
+ """How the model names a connector in CALL_TOOL: lower case, words joined by "_"
57
+ ("Google Drive" -> google_drive)."""
58
+ return re.sub(r"[^a-z0-9]+", "_", str(name).casefold()).strip("_")[:40] or "server"
59
+
60
+
61
+ def lean_schema(schema):
62
+ """A JSON schema without its words (descriptions, examples, titles): the shape a call must match, whole."""
63
+ if isinstance(schema, list):
64
+ return [lean_schema(item) for item in schema]
65
+ if not isinstance(schema, dict):
66
+ return schema
67
+ lean = {}
68
+ for key, value in schema.items():
69
+ if key in NAMED_SCHEMAS and isinstance(value, dict):
70
+ lean[key] = {name: lean_schema(sub) for name, sub in value.items()}
71
+ elif key not in LEAN_KEYS:
72
+ lean[key] = lean_schema(value)
73
+ return lean
74
+
75
+
76
+ def trim_tool(tool):
77
+ """A tools/list entry as the store keeps it: name, description, input schema, read-only hint."""
78
+ schema = tool.get("inputSchema") if isinstance(tool.get("inputSchema"), dict) else {"type": "object"}
79
+ if len(json.dumps(schema)) > SCHEMA_CHARS:
80
+ schema = lean_schema(schema)
81
+ if len(json.dumps(schema)) > SCHEMA_CHARS:
82
+ properties = schema.get("properties") if isinstance(schema.get("properties"), dict) else {}
83
+ schema = {"type": "object", "required": [r for r in schema.get("required") or () if isinstance(r, str)][:20],
84
+ "properties": {k: {"type": (v or {}).get("type", "string") if isinstance(v, dict) else "string"}
85
+ for k, v in list(properties.items())[:40]}}
86
+ annotations = tool.get("annotations") if isinstance(tool.get("annotations"), dict) else {}
87
+ title = tool.get("title") or annotations.get("title")
88
+ return {"name": str(tool["name"])[:128], "title": " ".join(str(title).split())[:80] if title else None,
89
+ "description": " ".join(str(tool.get("description") or "").split())[:DESCRIPTION_CHARS],
90
+ "inputSchema": schema, "readOnly": annotations.get("readOnlyHint") is True}
91
+
92
+
93
+ def stdio_env(saved_env):
94
+ """The environment a stdio server starts with: a few basics and its own saved values, nothing of Mobster's."""
95
+ folders = dict.fromkeys([*os.environ.get("PATH", "").split(os.pathsep), *EXTRA_PATH])
96
+ path = os.pathsep.join(folder for folder in folders if folder)
97
+ env = {"PATH": path}
98
+ for name in ("HOME", "LANG", "USER", "TMPDIR", "LC_ALL"):
99
+ if os.environ.get(name):
100
+ env[name] = os.environ[name]
101
+ env.update({k: str(v) for k, v in (saved_env or {}).items() if ENV_NAME.fullmatch(str(k))})
102
+ return env
103
+
104
+
105
+ def validate_name(value):
106
+ if not isinstance(value, str) or not NAME.fullmatch(value.strip()):
107
+ raise ValueError("A connector's name is 1 to 40 characters")
108
+ if slug(value) == "tools":
109
+ # CALL_TOOL tools.search is the agent's tool search (skill.SEARCH).
110
+ raise ValueError("Mobster keeps the name Tools for its tool search. Give this connector another name")
111
+ return " ".join(value.split())
112
+
113
+
114
+ def validate_command(command, args):
115
+ if not isinstance(command, str) or not command.strip() or len(command) > ARG_CHARS or "\n" in command:
116
+ raise ValueError("A server's command is the program to run, such as npx")
117
+ if args is None:
118
+ args = []
119
+ if not isinstance(args, list) or len(args) > MAX_ARGS or not all(
120
+ isinstance(a, str) and len(a) <= ARG_CHARS and "\n" not in a for a in args):
121
+ raise ValueError(f"A server's arguments are at most {MAX_ARGS} short words")
122
+ return command.strip(), list(args)
123
+
124
+
125
+ def validate_env(env):
126
+ if env is None:
127
+ return {}
128
+ if not isinstance(env, dict) or len(env) > MAX_ENV or not all(
129
+ isinstance(k, str) and ENV_NAME.fullmatch(k) and isinstance(v, str) and len(v) <= ENV_CHARS
130
+ for k, v in env.items()):
131
+ raise ValueError(f"A server's environment is at most {MAX_ENV} NAME=value pairs, names in capitals")
132
+ return dict(env)
133
+
134
+
135
+ def preset_url(preset, host):
136
+ """Where a preset connects: its own URL, or ``host``, your own server of that vendor, at the preset's
137
+ ``instance_path`` (GitLab self-managed)."""
138
+ if host is None:
139
+ return preset["url"]
140
+ if not preset.get("instance_path"):
141
+ raise ValueError(f"{preset['name']} has one address. Leave the host out")
142
+ if not isinstance(host, str):
143
+ raise ValueError("That isn't a server's address. Type the host alone, such as git.example.com")
144
+ text = host.strip().lower()
145
+ if text.startswith("http://"):
146
+ raise ValueError("That address is plain http, and Mobster signs in only over https. Type the host alone, such "
147
+ "as git.example.com")
148
+ text = text.removeprefix("https://").rstrip("/")
149
+ if len(text) > 253 or not HOST.fullmatch(text):
150
+ raise ValueError("That isn't a server's address. Type the host alone, such as git.example.com")
151
+ return mcp.validate_url(f"https://{text}{preset['instance_path']}")
152
+
153
+
154
+ def validate_token(token):
155
+ if token is None:
156
+ return None
157
+ if not isinstance(token, str) or len(token.strip()) > TOKEN_CHARS or any(c in token.strip() for c in "\r\n "):
158
+ raise ValueError("That token isn't valid. Paste it again")
159
+ return token.strip() or None
160
+
161
+
162
+ class ToolGone(mcp.NotSent):
163
+ """The server no longer lists the tool: nothing was sent, and the connection is fine."""
164
+
165
+
166
+ class ConnectorService:
167
+ def __init__(self, runtime, *, vault=None, clock=time.time, connect=mcp.connect, sign_in=oauth.SignIn,
168
+ refresh=oauth.refresh, revoke=oauth.revoke, import_env=None):
169
+ self.runtime = runtime
170
+ self.apps = shared.Apps() # Mobster's own OAuth apps (Slack, Google Drive)
171
+ self.import_env = import_env # integrations.clients.Env for imports.py (tests); None: this Mac's
172
+ self.store = connector_store.ConnectorStore(runtime.journal)
173
+ self.vault = vault if vault is not None else connector_vault.backend()
174
+ self.clock, self.connect, self.sign_in_class, self.refresh_tokens = clock, connect, sign_in, refresh
175
+ self.revoke_tokens = revoke
176
+ self._sessions = {} # connector id -> [session, last used]
177
+ self._locks = {} # connector id -> threading.Lock (one request at a time per session)
178
+ self._refreshing = {} # connector id -> threading.Lock (one token renewal at a time)
179
+ self._lock = threading.Lock()
180
+ self._credential_versions = {} # connector id -> generation of its current credentials/browser attempt
181
+ # connector id -> {"state", "message", "flow"}. state: waiting (for the browser), done, failed (message says
182
+ # why) or awaiting_approval (the browser never came back from a vendor whose admin approves each client).
183
+ self._sign_ins = {}
184
+ self._connecting = set() # connector ids a test or reconnect is connecting now
185
+ self._closed = False
186
+
187
+ def close(self):
188
+ with self._lock:
189
+ self._closed = True
190
+ sessions, self._sessions = list(self._sessions.values()), {}
191
+ flows = [s.get("flow") for s in self._sign_ins.values()]
192
+ for session, _ in sessions:
193
+ try:
194
+ session.close()
195
+ except Exception: # noqa: BLE001
196
+ pass
197
+ for flow in flows:
198
+ if flow is not None:
199
+ flow.cancel()
200
+
201
+ def _credentials_current(self, connector_id, version):
202
+ # Called under _lock; network work never owns credentials across a sign-out or replacement.
203
+ if (self._closed or self._credential_versions.get(connector_id, 0) != version
204
+ or not self.store.get(connector_id)):
205
+ raise mcp.NotSent("This connector's sign-in changed. Try again.")
206
+
207
+ def _invalidate_credentials(self, connector_id):
208
+ self._credential_versions[connector_id] = self._credential_versions.get(connector_id, 0) + 1
209
+ previous = self._sign_ins.pop(connector_id, None)
210
+ return previous.get("flow") if previous else None, self._sessions.pop(connector_id, None)
211
+
212
+ @staticmethod
213
+ def _close_session(entry):
214
+ if entry is not None:
215
+ try:
216
+ entry[0].close()
217
+ except Exception: # noqa: BLE001
218
+ pass
219
+
220
+ def _drop(self, connector_id):
221
+ with self._lock:
222
+ entry = self._sessions.pop(connector_id, None)
223
+ self._close_session(entry)
224
+
225
+ def _reap(self):
226
+ now = time.monotonic()
227
+ with self._lock:
228
+ idle = [cid for cid, (_, used) in self._sessions.items() if now - used > IDLE_SECONDS]
229
+ for connector_id in idle:
230
+ self._drop(connector_id)
231
+
232
+ def public(self, row):
233
+ test = row.get("last_test") if isinstance(row.get("last_test"), dict) else None
234
+ flow = self._sign_ins.get(row["id"])
235
+ preset = preset_list.preset(row["preset"]) if row.get("preset") else None
236
+ needs = row["auth"] == "none" and (preset is not None or bool(test and test.get("code") == "auth"))
237
+ return {
238
+ "id": row["id"], "name": row["name"], "slug": slug(row["name"]), "transport": row["transport"],
239
+ "url": mcp.hint(row["url"]) if row.get("url") else None,
240
+ "command": row.get("command") if row["transport"] == "stdio" else None,
241
+ "args": row.get("args") if row["transport"] == "stdio" else None,
242
+ "auth": row["auth"], "needsSignIn": needs, "enabled": row["enabled"], "preset": row.get("preset"),
243
+ "plugin": row.get("plugin"), "picksFiles": bool((preset or {}).get("picker")),
244
+ "tools": [{"name": t["name"], "title": t.get("title"), "description": t.get("description", "")[:300],
245
+ "mode": connector_store.mode_of(row, t["name"]), "readOnly": bool(t.get("readOnly"))}
246
+ for t in row.get("tools") or ()],
247
+ "toolsAt": ms(row.get("tools_at")),
248
+ "lastTest": {"ok": bool(test.get("ok")), "at": ms(test.get("at")), "message": test.get("message"),
249
+ "code": test.get("code")} if test else None,
250
+ "signIn": {"state": flow["state"], "message": flow.get("message")} if flow else None,
251
+ "health": self.health(row, needs, test, flow),
252
+ "createdAt": ms(row["created_at"]),
253
+ }
254
+
255
+ def health(self, row, needs_sign_in, test, flow):
256
+ """One word for the row's dot: ``connecting`` (signing in or reconnecting), ``needs_sign_in``, ``connected`` (a
257
+ live session answers), ``failed`` (the last connect or call didn't work), or ``idle`` (no session open now;
258
+ Mobster connects when a task needs it)."""
259
+ if (flow and flow.get("state") == "waiting") or row["id"] in self._connecting:
260
+ return "connecting"
261
+ if needs_sign_in or (test and not test.get("ok") and test.get("code") == "auth"):
262
+ return "needs_sign_in"
263
+ with self._lock:
264
+ entry = self._sessions.get(row["id"])
265
+ if entry is not None and entry[0].ready:
266
+ return "connected"
267
+ if test and not test.get("ok"):
268
+ return "failed"
269
+ return "idle"
270
+
271
+ def list(self):
272
+ return [self.public(row) for row in self.store.all()]
273
+
274
+ def presets(self):
275
+ return preset_list.public(self.apps)
276
+
277
+ def presets_not_yet(self):
278
+ return preset_list.not_yet(self.apps)
279
+
280
+ def _get(self, connector_id):
281
+ row = self.store.get(connector_id) if isinstance(connector_id, str) else None
282
+ if row is None:
283
+ raise APIError("That connector was removed.", 404, "not_found")
284
+ return row
285
+
286
+ def find(self, wanted):
287
+ """A connector by id, name or slug (case ignored). ValueError when none or several match."""
288
+ if not isinstance(wanted, str) or not wanted.strip():
289
+ raise ValueError("Name a connector, or give its id")
290
+ wanted = wanted.strip()
291
+ rows = self.store.all()
292
+ found = [r for r in rows if r["id"] == wanted.lower()] or [
293
+ r for r in rows if r["name"].casefold() == " ".join(wanted.split()).casefold()
294
+ or slug(r["name"]) == slug(wanted)]
295
+ if not found:
296
+ raise ValueError(f"No connector is called {wanted[:40]}. Add it in Settings › Connectors.")
297
+ if len(found) > 1:
298
+ raise ValueError(f"Several connectors are called {wanted[:40]}. Use its id.")
299
+ return found[0]
300
+
301
+ def _unique(self, name, but=None):
302
+ for row in self.store.all():
303
+ if row["id"] != but and (row["name"].casefold() == name.casefold() or slug(row["name"]) == slug(name)):
304
+ raise ValueError(f"You already have a connector called {row['name']}. Give this one another name.")
305
+
306
+ def add(self, body, *, plugin=None):
307
+ """``body``: {"preset", "host"?} or {"name", "url"} or {"name", "command", "args"?}, with "token"?, "env"?,
308
+ "clientId"?, "clientSecret"? and "modes"?. Secrets go to the Keychain; the row never holds one."""
309
+ allowed = {"name", "preset", "host", "url", "command", "args", "env", "token", "clientId", "clientSecret",
310
+ "modes", "enabled"}
311
+ if not isinstance(body, dict) or set(body) - allowed:
312
+ raise ValueError("A connector has a name and a url or a command, and optionally a token or env values")
313
+ preset = None
314
+ if body.get("preset") is not None:
315
+ preset = preset_list.preset(body["preset"])
316
+ if preset is None:
317
+ raise ValueError("There's no such preset")
318
+ refused = preset_list.refusal(preset, self.apps)
319
+ if refused:
320
+ raise ValueError(refused)
321
+ name = validate_name(body.get("name") or (preset or {}).get("name"))
322
+ if preset is not None:
323
+ transport, url, command, args = "http", preset_url(preset, body.get("host")), None, []
324
+ elif body.get("host") is not None:
325
+ raise ValueError("A host goes with a preset")
326
+ elif body.get("url") is not None:
327
+ transport, url, command, args = "http", mcp.validate_url(body["url"]), None, []
328
+ if body.get("command") is not None:
329
+ raise ValueError("A connector has a url or a command, not both")
330
+ elif body.get("command") is not None:
331
+ transport, url = "stdio", None
332
+ command, args = validate_command(body["command"], body.get("args"))
333
+ else:
334
+ raise ValueError("Give the server's URL, or the command that starts it")
335
+ env = validate_env(body.get("env")) if transport == "stdio" else {}
336
+ if transport == "http" and body.get("env"):
337
+ raise ValueError("Only a command takes environment values")
338
+ token = validate_token(body.get("token"))
339
+ if token and transport == "stdio":
340
+ raise ValueError("A command's keys go in its environment values")
341
+ client_id = validate_token(body.get("clientId"))
342
+ client_secret = validate_token(body.get("clientSecret"))
343
+ modes = self._clean_modes(body.get("modes"))
344
+ enabled = body.get("enabled", True)
345
+ if type(enabled) is not bool:
346
+ raise ValueError("enabled is true or false")
347
+ self._unique(name)
348
+ if transport == "stdio" and plugin is None:
349
+ self._must_be_mcp(name, command, args, env)
350
+ if self.store.count() >= connector_store.MAX_CONNECTORS:
351
+ raise APIError(f"You have {connector_store.MAX_CONNECTORS} connectors. Remove one first.", 409,
352
+ "connector_limit")
353
+ connector_id = uuid.uuid4().hex[:12]
354
+ secrets = {"token": token, "env": env or None,
355
+ "client": {"client_id": client_id, "client_secret": client_secret} if client_id else None}
356
+ try:
357
+ self.vault.put(connector_id, secrets)
358
+ except connector_vault.VaultError as error:
359
+ raise APIError(f"{error}. Nothing was saved.", 503, "keychain_unavailable") from None
360
+ now = self.clock()
361
+ try:
362
+ row = self.store.add({"id": connector_id, "name": name, "transport": transport, "command": command,
363
+ "args": args, "url": url, "auth": "token" if token else "none", "enabled": enabled,
364
+ "preset": preset["key"] if preset else None, "plugin": plugin, "tools": [],
365
+ "modes": modes, "created_at": now, "updated_at": now})
366
+ except Exception:
367
+ self._forget_secrets(connector_id)
368
+ raise
369
+ return self.public(row)
370
+
371
+ def importable(self):
372
+ from . import imports
373
+ return imports.public(imports.found(self.import_env), self.store.all())
374
+
375
+ def add_import(self, body):
376
+ """{"source", "key", "name"?}: add one server another app has, its env values and token read from that
377
+ app's file now (never from the request)."""
378
+ from . import imports
379
+ if (not isinstance(body, dict) or set(body) - {"source", "key", "name"}
380
+ or not isinstance(body.get("source"), str) \
381
+ or not isinstance(body.get("key"), str)):
382
+ raise ValueError("Name the app and the server to import")
383
+ name = body.get("name")
384
+ if name is not None and not isinstance(name, str):
385
+ raise ValueError("A connector's name is 1 to 40 characters")
386
+ draft = imports.body_for(imports.found(self.import_env), body["source"], body["key"], name)
387
+ return self.add(draft)
388
+
389
+ def _clean_modes(self, modes):
390
+ if modes is None:
391
+ return {}
392
+ if not isinstance(modes, dict) or len(modes) > mcp.MAX_TOOLS or not all(
393
+ isinstance(k, str) and 0 < len(k) <= 128 and v in connector_store.MODES for k, v in modes.items()):
394
+ raise ValueError('modes is {"tool name": "read" | "ask" | "off"}')
395
+ return dict(modes)
396
+
397
+ def update(self, connector_id, body):
398
+ """{"name"?, "enabled"?, "url"? or "command" and "args"? (your own server's), "token"? (null signs out), "env"?,
399
+ "modes"? (merged), "clientId"?, "clientSecret"?}."""
400
+ row = self._get(connector_id)
401
+ allowed = {"name", "enabled", "url", "command", "args", "token", "env", "modes", "clientId", "clientSecret"}
402
+ if not isinstance(body, dict) or set(body) - allowed:
403
+ raise ValueError("A connector's name, address, enabled, token, env and modes can change")
404
+ address = self._new_address(row, body)
405
+ if address and row["transport"] == "stdio":
406
+ env = validate_env(body["env"]) if "env" in body else self._secrets(row).get("env")
407
+ self._must_be_mcp(row["name"], address["command"], address["args"], env)
408
+ changes = {}
409
+ if "name" in body:
410
+ name = validate_name(body["name"])
411
+ self._unique(name, but=connector_id)
412
+ changes["name"] = name
413
+ if "enabled" in body:
414
+ if type(body["enabled"]) is not bool:
415
+ raise ValueError("enabled is true or false")
416
+ changes["enabled"] = body["enabled"]
417
+ if "modes" in body:
418
+ changes["modes"] = {**row["modes"], **self._clean_modes(body["modes"])}
419
+ secret_keys = {"token", "env", "clientId", "clientSecret"} & set(body)
420
+ flow, session, issued = None, None, None
421
+ if secret_keys or address:
422
+ try:
423
+ with self._lock:
424
+ row = self._get(connector_id)
425
+ if self._closed:
426
+ raise APIError("Mobster is quitting.", 503, "unavailable")
427
+ try:
428
+ saved = self.vault.get(connector_id)
429
+ except connector_vault.VaultError:
430
+ raise APIError(KEYCHAIN, 503, "keychain_unavailable") from None
431
+ address = self._new_address(row, body) # again, under the lock: another change may have landed
432
+ if address:
433
+ # A new address ends the session and sign-in. The old server issued its OAuth tokens: revoked
434
+ # there below, and never sent to the new one. On a new host, a pasted token and OAuth app go
435
+ # too.
436
+ issued = saved.pop("oauth", None)
437
+ if row["transport"] == "http" and shared.origin(address["url"]) != shared.origin(row["url"]):
438
+ saved["token"], saved["client"] = None, None
439
+ changes.update(address, last_test=None, auth="token" if saved.get("token") else "none")
440
+ if "token" in body:
441
+ if row["transport"] == "stdio" and body["token"]:
442
+ raise ValueError("A command's keys go in its environment values")
443
+ saved["token"] = validate_token(body["token"])
444
+ saved.pop("oauth", None)
445
+ changes["auth"] = "token" if saved["token"] else "none"
446
+ if "env" in body:
447
+ if row["transport"] != "stdio":
448
+ raise ValueError("Only a command takes environment values")
449
+ saved["env"] = validate_env(body["env"]) or None
450
+ if "clientId" in body or "clientSecret" in body:
451
+ client = dict(saved.get("client") or {})
452
+ if "clientId" in body:
453
+ client["client_id"] = validate_token(body["clientId"])
454
+ if "clientSecret" in body:
455
+ client["client_secret"] = validate_token(body["clientSecret"])
456
+ saved["client"] = client if client.get("client_id") else None
457
+ try:
458
+ self.vault.put(connector_id, saved)
459
+ except connector_vault.VaultError:
460
+ raise APIError(KEYCHAIN, 503, "keychain_unavailable") from None
461
+ flow, session = self._invalidate_credentials(connector_id)
462
+ if changes:
463
+ changes["updated_at"] = self.clock()
464
+ row = self.store.update(connector_id, **changes)
465
+ changes = {}
466
+ finally:
467
+ self._close_session(session)
468
+ if flow is not None:
469
+ flow.cancel()
470
+ try:
471
+ if issued:
472
+ self.revoke_tokens(issued)
473
+ except Exception: # noqa: BLE001 -- moved on this Mac whatever the old server says
474
+ pass
475
+ if changes:
476
+ changes["updated_at"] = self.clock()
477
+ row = self.store.update(connector_id, **changes)
478
+ if changes.get("enabled") is False:
479
+ self._drop(connector_id)
480
+ return self.public(row)
481
+
482
+ @staticmethod
483
+ def _new_address(row, body):
484
+ """The url, or command and args, ``body`` moves your own server to, or None; a preset's or plugin's stay put."""
485
+ if not {"url", "command", "args"} & set(body):
486
+ return None
487
+ if row.get("preset") or row.get("plugin"):
488
+ raise ValueError("Only your own server's address can change")
489
+ if row["transport"] == "http":
490
+ if "command" in body or "args" in body:
491
+ raise ValueError("A server with a URL can't take a command")
492
+ url = mcp.validate_url(body["url"])
493
+ return {"url": url} if url != row["url"] else None
494
+ if "url" in body:
495
+ raise ValueError("A command's server can't take a URL")
496
+ command, args = validate_command(body.get("command", row["command"]), body.get("args", row.get("args")))
497
+ unchanged = (command, args) == (row["command"], row.get("args") or [])
498
+ return None if unchanged else {"command": command, "args": args}
499
+
500
+ def remove(self, connector_id):
501
+ flow, session, tokens = None, None, None
502
+ try:
503
+ with self._lock:
504
+ self._get(connector_id)
505
+ flow, session = self._invalidate_credentials(connector_id)
506
+ try:
507
+ tokens = self.vault.get(connector_id).get("oauth")
508
+ except connector_vault.VaultError:
509
+ tokens = None
510
+ self._forget_secrets(connector_id)
511
+ self.store.delete(connector_id)
512
+ finally:
513
+ self._close_session(session)
514
+ if flow is not None:
515
+ flow.cancel()
516
+ # Like Disconnect: gone here first, then the server that issued the sign-in is asked to revoke it.
517
+ try:
518
+ if tokens:
519
+ self.revoke_tokens(tokens)
520
+ except Exception: # noqa: BLE001 -- removed on this Mac whatever the server says
521
+ pass
522
+ return connector_id
523
+
524
+ def _forget_secrets(self, connector_id):
525
+ try:
526
+ self.vault.delete(connector_id)
527
+ except connector_vault.VaultError:
528
+ log.warning("connectors: couldn't remove a connector's Keychain item")
529
+
530
+ def _secrets(self, row):
531
+ try:
532
+ return self.vault.get(row["id"])
533
+ except connector_vault.VaultError:
534
+ raise mcp.NotSent(KEYCHAIN) from None
535
+
536
+ def _token(self, row, saved, force_refresh=False):
537
+ """Read current credentials; serialize renewals without blocking fresh tokens on an older refresh."""
538
+ connector_id = row["id"]
539
+ with self._lock:
540
+ version = self._credential_versions.get(connector_id, 0)
541
+ self._credentials_current(connector_id, version)
542
+ original = saved.get("oauth") or {}
543
+ current = self._secrets(row)
544
+ saved.clear()
545
+ saved.update(current)
546
+ if saved.get("token"):
547
+ return saved["token"]
548
+ tokens = saved.get("oauth")
549
+ if not tokens:
550
+ if original:
551
+ raise mcp.AuthNeeded(f"{row['name']} isn't signed in.")
552
+ return None
553
+ renewed = tokens.get("access_token") != original.get("access_token")
554
+ if not oauth.expiring(tokens, self.clock) and (not force_refresh or renewed):
555
+ return tokens.get("access_token")
556
+ with self._refresh_lock(connector_id):
557
+ with self._lock:
558
+ self._credentials_current(connector_id, version)
559
+ stored = self._secrets(row).get("oauth")
560
+ if not stored:
561
+ raise mcp.AuthNeeded(f"{row['name']} isn't signed in.")
562
+ # A sibling may have renewed while this caller waited for the refresh lock.
563
+ if stored != tokens and not oauth.expiring(stored, self.clock):
564
+ saved["oauth"] = stored
565
+ return stored.get("access_token")
566
+ tokens = stored
567
+ try:
568
+ refreshed = self.refresh_tokens(tokens, self.clock)
569
+ except oauth.OAuthError as error:
570
+ if error.retry:
571
+ raise mcp.NotSent(f"Mobster couldn't renew {row['name']}'s sign-in. {error.message}") from None
572
+ raise mcp.AuthNeeded(f"{row['name']} needs you to sign in again.") from None
573
+ with self._lock:
574
+ self._credentials_current(connector_id, version)
575
+ current = self._secrets(row)
576
+ if current.get("oauth") != tokens:
577
+ raise mcp.NotSent("This connector's sign-in changed. Try again.")
578
+ current["oauth"] = refreshed
579
+ try:
580
+ self.vault.put(connector_id, current)
581
+ except connector_vault.VaultError:
582
+ raise mcp.NotSent(KEYCHAIN) from None
583
+ saved.clear()
584
+ saved.update(current)
585
+ return refreshed.get("access_token")
586
+
587
+ def _refresh_lock(self, connector_id):
588
+ with self._lock:
589
+ return self._refreshing.setdefault(connector_id, threading.Lock())
590
+
591
+ def bearer(self, connector_id, refresh=False):
592
+ """The access token a signed-in connector sends, renewed first when it's expiring or ``refresh``: a Slack
593
+ destination posts with the Slack connector's sign-in (outlets/slack.py)."""
594
+ row = self._get(connector_id)
595
+ saved = self._secrets(row)
596
+ if not saved.get("oauth"):
597
+ raise mcp.AuthNeeded(f"{row['name']} isn't signed in.")
598
+ return self._token(row, saved, force_refresh=refresh)
599
+
600
+ def preset_bearer(self, preset):
601
+ """The bearer token of the first enabled connector made from ``preset`` that has one (a pasted token, or its
602
+ sign-in, refreshed when it's expiring), or None."""
603
+ for row in self.store.all():
604
+ if row.get("preset") != preset or not row["enabled"]:
605
+ continue
606
+ try:
607
+ token = self._token(row, self._secrets(row))
608
+ except mcp.AuthNeeded:
609
+ continue
610
+ except mcp.NotSent as error:
611
+ raise APIError(str(error), 503, "keychain") from None
612
+ if token:
613
+ return token
614
+ return None
615
+
616
+ @staticmethod
617
+ def _stdio_spec(command, args, saved_env):
618
+ """The spec and environment that start a command: a bare name is looked up on the PATH the server will get."""
619
+ env = stdio_env(saved_env)
620
+ if os.sep not in command:
621
+ found = shutil.which(command, path=env["PATH"])
622
+ if found is None:
623
+ hint = " Install Node.js from nodejs.org, then test again." if command in ("npx", "node") else \
624
+ " Install it, or check the command."
625
+ raise mcp.NotSent(f"Mobster couldn't find {command}.{hint}")
626
+ command = found
627
+ return {"transport": "stdio", "command": command, "args": list(args), "url": None}, env
628
+
629
+ def _must_be_mcp(self, name, command, args, env):
630
+ """Starts a command you typed and runs the MCP handshake before it is saved."""
631
+ try:
632
+ spec, stdio = self._stdio_spec(command, args, env)
633
+ session = self.connect(spec, env=stdio, name=name, timeout=TEST_CONNECT)
634
+ except mcp.SlowToAnswer:
635
+ return
636
+ except mcp.ConnectorError as error:
637
+ raise APIError(f"{error.message} Mobster saved nothing. Check the command, then try again.", 422,
638
+ "not_an_mcp_server") from None
639
+ self._close_session([session, 0])
640
+
641
+ def _open(self, row, timeout=mcp.CONNECT_TIMEOUT, force_refresh=False):
642
+ """A new initialized session for ``row``. A 401 with OAuth tokens refreshes once (nothing was sent)."""
643
+ saved = self._secrets(row)
644
+ spec = {"transport": row["transport"], "command": row.get("command"), "args": row.get("args") or [],
645
+ "url": row.get("url")}
646
+ if row["transport"] == "stdio":
647
+ spec, env = self._stdio_spec(row["command"], row.get("args") or [], saved.get("env"))
648
+ return self.connect(spec, env=env, name=row["name"], timeout=timeout)
649
+ try:
650
+ return self.connect(spec, token=self._token(row, saved, force_refresh=force_refresh and bool(
651
+ (saved.get("oauth") or {}).get("refresh_token"))), name=row["name"], timeout=timeout)
652
+ except mcp.AuthNeeded:
653
+ if force_refresh:
654
+ raise
655
+ if not saved.get("oauth") or not saved["oauth"].get("refresh_token"):
656
+ raise
657
+ return self.connect(spec, token=self._token(row, saved, force_refresh=True), name=row["name"],
658
+ timeout=timeout)
659
+
660
+ def _lock_for(self, connector_id):
661
+ with self._lock:
662
+ return self._locks.setdefault(connector_id, threading.Lock())
663
+
664
+ def _session(self, row, timeout=mcp.CONNECT_TIMEOUT, force_refresh=False):
665
+ self._reap()
666
+ with self._lock:
667
+ version = self._credential_versions.get(row["id"], 0)
668
+ self._credentials_current(row["id"], version)
669
+ entry = self._sessions.get(row["id"])
670
+ if entry is not None and entry[0].ready:
671
+ entry[1] = time.monotonic()
672
+ return entry[0]
673
+ session = self._open(row, timeout, force_refresh=force_refresh)
674
+ try:
675
+ with self._lock:
676
+ self._credentials_current(row["id"], version)
677
+ self._sessions[row["id"]] = [session, time.monotonic()]
678
+ except mcp.NotSent:
679
+ self._close_session([session, 0])
680
+ raise
681
+ return session
682
+
683
+ def _remember_tools(self, row, tools):
684
+ trimmed = [trim_tool(t) for t in tools]
685
+ return self.store.update(row["id"], tools=trimmed, tools_at=self.clock(), updated_at=self.clock())
686
+
687
+ def test(self, connector_id, keep=False, *, credential_version=None):
688
+ """Connect (a fresh session), ping and cache tools. ``keep`` leaves the session open (Reconnect)."""
689
+ try:
690
+ with self._lock:
691
+ version = (self._credential_versions.get(connector_id, 0) if credential_version is None
692
+ else credential_version)
693
+ self._credentials_current(connector_id, version)
694
+ with self._lock_for(connector_id):
695
+ with self._lock:
696
+ self._credentials_current(connector_id, version)
697
+ row = self._get(connector_id)
698
+ entry = self._sessions.pop(connector_id, None)
699
+ self._connecting.add(connector_id)
700
+ self._close_session(entry)
701
+ try:
702
+ # Within the app's 15 s request: a command that needs longer the first time answers the next test.
703
+ session = self._session(row, TEST_CONNECT)
704
+ session.ping(timeout=TEST_LIST)
705
+ tools = session.list_tools(timeout=TEST_LIST)
706
+ session._tools_seen = True
707
+ session.tools_changed = False
708
+ except mcp.ConnectorError as error:
709
+ with self._lock:
710
+ self._credentials_current(connector_id, version)
711
+ entry = self._sessions.pop(connector_id, None)
712
+ row = self._failed(connector_id, error)
713
+ self._connecting.discard(connector_id)
714
+ self._close_session(entry)
715
+ return {"ok": False, "message": error.message, "connector": self.public(row)}
716
+ finally:
717
+ self._connecting.discard(connector_id)
718
+ with self._lock:
719
+ self._credentials_current(connector_id, version)
720
+ entry = self._sessions.pop(connector_id, None) if not keep else None
721
+ row = self._remember_tools(row, tools)
722
+ message = f"Connected. {len(row['tools'])} tool{'s' if len(row['tools']) != 1 else ''}."
723
+ row = self.store.update(connector_id,
724
+ last_test={"ok": True, "at": self.clock(), "message": message})
725
+ self._close_session(entry)
726
+ return {"ok": True, "message": message, "connector": self.public(row)}
727
+
728
+ except mcp.NotSent as error:
729
+ raise APIError(error.message, 409, "sign_in_changed") from None
730
+
731
+ def reconnect(self, connector_id):
732
+ """Close the session, connect again and keep it open: Reconnect on the row. {ok, message, connector}."""
733
+ return self.test(connector_id, keep=True)
734
+
735
+ def _failed(self, connector_id, error):
736
+ """Record a failed connect or call on the row (its health says so until one works again)."""
737
+ code = "auth" if isinstance(error, mcp.AuthNeeded) else "failed"
738
+ return self.store.update(connector_id, last_test={"ok": False, "at": self.clock(), "message": error.message,
739
+ "code": code})
740
+
741
+ def _worked(self, row):
742
+ """A call worked after the row said it failed: the row says connected again."""
743
+ test = row.get("last_test") if isinstance(row.get("last_test"), dict) else None
744
+ if test and not test.get("ok"):
745
+ count = len(row.get("tools") or ())
746
+ self.store.update(row["id"], last_test={"ok": True, "at": self.clock(),
747
+ "message": f"Connected. {count} tool{'s' if count != 1 else ''}."})
748
+
749
+ def begin_sign_in(self, connector_id):
750
+ """Start OAuth for an HTTP connector: {"authorizeUrl"} for the Mac app to open in the browser."""
751
+ row = self._get(connector_id)
752
+ if row["transport"] != "http":
753
+ raise ValueError("Only a server with a URL signs in. A command takes its keys as environment values")
754
+ with self._lock:
755
+ if self._closed:
756
+ raise APIError("Mobster is quitting.", 503, "unavailable")
757
+ self._get(connector_id)
758
+ try:
759
+ saved = self.vault.get(connector_id)
760
+ except connector_vault.VaultError:
761
+ raise APIError(KEYCHAIN, 503, "keychain_unavailable") from None
762
+ previous, session = self._invalidate_credentials(connector_id)
763
+ version = self._credential_versions[connector_id]
764
+ attempt = {"state": "waiting", "flow": None, "message": None}
765
+ self._sign_ins[connector_id] = attempt
766
+ self._close_session(session)
767
+ if previous is not None:
768
+ previous.cancel()
769
+ flow = None
770
+ try:
771
+ client = saved.get("client") or {}
772
+ preset = preset_list.preset(row["preset"]) if row.get("preset") else None
773
+ # The person's own app first, then Mobster's own for a vendor that lets no client register itself, else the
774
+ # server registers Mobster now.
775
+ if client.get("client_id"):
776
+ app = oauth.OwnApp(client["client_id"], client.get("client_secret"))
777
+ elif preset and preset["auth"] == "mobster_app" and row["url"] == preset["url"]:
778
+ # Mobster's own app is registered at the vendor's own service; a server of your own (GitLab
779
+ # self-managed) is another one, where the server registers Mobster itself.
780
+ app = self.apps.get(preset["key"])
781
+ if isinstance(app, shared.Unavailable) and preset.get("needs_app"):
782
+ raise ValueError(f"{shared.why(preset['name'], app.reason)}. Try again later")
783
+ if isinstance(app, shared.Unavailable):
784
+ own = ("an OAuth app of your own" if preset["fallback"] == "oauth_client"
785
+ else f"a {preset['token_label']}")
786
+ raise ValueError(f"{shared.why(preset['name'], app.reason)}. Remove {row['name']}, then connect it "
787
+ f"again with {own}, or try again later")
788
+ # Mobster's app trades the sign-in through your Mobster account: without one, the browser's sign-in
789
+ # would come back to a refusal (ICP walkthrough, 24 Oct: "HTTP 401" and no way to sign in).
790
+ if not shared.has_account():
791
+ raise APIError(f"{preset['name']} connects through Mobster's own app, which needs your Mobster "
792
+ f"account. Sign in to Mobster, then connect {row['name']} again.", 409,
793
+ "account_needed")
794
+ else:
795
+ app = None
796
+ resource_metadata = None if isinstance(app, oauth.MobsterApp) else self._resource_metadata(row)
797
+
798
+ # A vendor whose admin approves each client (the preset's ``approval``) gets the client it approved again.
799
+ approval = (preset or {}).get("approval")
800
+ registration = oauth.Registration.parse(saved.get("registration")) if approval else None
801
+
802
+ def done(ended, tokens, message):
803
+ self._signed_in(connector_id, version, attempt, ended, tokens, message)
804
+
805
+ # Google Drive goes on to Google's Picker in the same tab, so the files you pick are there from the start.
806
+ then = {"then": picker.after_sign_in(self, connector_id)} if (preset or {}).get("picker") and \
807
+ isinstance(app, oauth.MobsterApp) else {}
808
+ flow = self.sign_in_class(row["url"], done, name=row["name"], app=app, scopes=(preset or {}).get("scopes"),
809
+ redirect_host=(preset or {}).get("redirect_host", oauth.LOOPBACK_HOSTS[0]),
810
+ resource_metadata=resource_metadata, registration=registration, **then)
811
+ with self._lock:
812
+ self._credentials_current(connector_id, version)
813
+ attempt["flow"] = flow
814
+ attempt["message"] = (
815
+ f"Finish signing in in your browser. If {row['name']} asks for an admin's approval, they approve "
816
+ f"Mobster in {approval}.") if approval else None
817
+ url = flow.begin()
818
+ with self._lock:
819
+ self._credentials_current(connector_id, version)
820
+ if approval and flow.registration is not None and flow.registration != registration:
821
+ # The callback may already have saved tokens; merge the registration into the current item.
822
+ saved = self.vault.get(connector_id)
823
+ self.vault.put(connector_id, {**saved, "registration": asdict(flow.registration)})
824
+ except (ValueError, oauth.OAuthError, connector_vault.VaultError, mcp.NotSent) as error:
825
+ with self._lock:
826
+ if self._credential_versions.get(connector_id) == version and not self._closed:
827
+ attempt.update(state="failed", message=KEYCHAIN if isinstance(error, connector_vault.VaultError)
828
+ else str(error), flow=None)
829
+ if flow is not None:
830
+ flow.cancel()
831
+ if isinstance(error, ValueError):
832
+ raise
833
+ if isinstance(error, connector_vault.VaultError):
834
+ raise APIError(KEYCHAIN, 503, "keychain_unavailable") from None
835
+ raise APIError(str(error), 502, "sign_in_failed") from None
836
+ return {"authorizeUrl": url}
837
+
838
+ @staticmethod
839
+ def _resource_metadata(row):
840
+ """Where the server's 401 says its sign-in is described (MCP discovery), or None."""
841
+ probe = mcp.HttpSession(row["url"], name=row["name"])
842
+ try:
843
+ probe.initialize(timeout=5)
844
+ probe.close()
845
+ except mcp.AuthNeeded:
846
+ return probe.resource_metadata
847
+ except mcp.ConnectorError:
848
+ pass
849
+ return None
850
+
851
+ def _signed_in(self, connector_id, version, attempt, ended, tokens, message):
852
+ with self._lock:
853
+ if (self._closed or self._credential_versions.get(connector_id) != version
854
+ or self._sign_ins.get(connector_id) is not attempt or attempt["state"] != "waiting"):
855
+ return
856
+ row = self.store.get(connector_id)
857
+ if row is None:
858
+ return
859
+ if tokens is None:
860
+ approval = (preset_list.preset(row["preset"]) or {}).get("approval") if row.get("preset") else None
861
+ if ended is oauth.Ended.UNANSWERED and approval:
862
+ attempt.update(state="awaiting_approval", flow=None, message=(
863
+ f"{row['name']} didn't send you back. If it asked an admin to approve Mobster, connect again "
864
+ f"once they approve it in {approval}."))
865
+ else:
866
+ attempt.update(state="failed", message=message, flow=None)
867
+ return
868
+ try:
869
+ saved = self.vault.get(connector_id)
870
+ saved.pop("token", None)
871
+ saved["oauth"] = tokens
872
+ self.vault.put(connector_id, saved)
873
+ self.store.update(connector_id, auth="oauth", updated_at=self.clock())
874
+ except Exception: # noqa: BLE001 -- don't let a failed Keychain/store write escape the browser callback
875
+ attempt.update(state="failed", message=KEYCHAIN, flow=None)
876
+ return
877
+ attempt.update(state="done", message=None, flow=None)
878
+ try:
879
+ self.test(connector_id, credential_version=version)
880
+ except Exception: # noqa: BLE001
881
+ log.warning("connectors: the test after sign-in failed")
882
+
883
+ def sign_out(self, connector_id):
884
+ """Disconnect: the tokens leave the Keychain and the session closes, then the server that issued them
885
+ is asked to revoke them, when it offers that (oauth.revoke)."""
886
+ flow, session, tokens = None, None, None
887
+ try:
888
+ with self._lock:
889
+ row = self._get(connector_id)
890
+ try:
891
+ saved = self.vault.get(connector_id)
892
+ saved.pop("token", None)
893
+ tokens = saved.pop("oauth", None)
894
+ self.vault.put(connector_id, saved)
895
+ except connector_vault.VaultError:
896
+ raise APIError(KEYCHAIN, 503, "keychain_unavailable") from None
897
+ flow, session = self._invalidate_credentials(connector_id)
898
+ row = self.store.update(row["id"], auth="none", updated_at=self.clock())
899
+ finally:
900
+ self._close_session(session)
901
+ if flow is not None:
902
+ flow.cancel()
903
+ public = self.public(row)
904
+ try:
905
+ revoked = self.revoke_tokens(tokens) if tokens else None
906
+ except Exception: # noqa: BLE001 -- disconnected on this Mac whatever the server says
907
+ revoked = False
908
+ return public, revoked
909
+
910
+ def call(self, row, tool, arguments, timeout=None):
911
+ """One tools/call on ``row``'s live session. Reconnects at most once, and only when nothing ran (NotSent, or
912
+ AuthNeeded after a refresh): OutcomeUnknown and a server's own error are never sent again."""
913
+ with self._lock_for(row["id"]):
914
+ for attempt in (1, 2):
915
+ with self._lock:
916
+ reused = row["id"] in self._sessions
917
+ try:
918
+ return self._call_once(row, tool, arguments, timeout, refresh=attempt == 2)
919
+ except ToolGone:
920
+ raise
921
+ except mcp.AuthNeeded as error:
922
+ self._drop(row["id"])
923
+ saved = self._saved_quietly(row)
924
+ # A fresh session already refreshed once while connecting (_open); a session that was open
925
+ # meets an expired token here.
926
+ if attempt == 1 and reused and saved.get("oauth") and saved["oauth"].get("refresh_token"):
927
+ continue
928
+ self._failed(row["id"], error)
929
+ raise
930
+ except mcp.NotSent as error:
931
+ self._drop(row["id"])
932
+ if attempt == 1 and reused:
933
+ continue
934
+ self._failed(row["id"], error)
935
+ raise
936
+ except mcp.OutcomeUnknown:
937
+ self._drop(row["id"])
938
+ raise
939
+
940
+ def _saved_quietly(self, row):
941
+ try:
942
+ return self.vault.get(row["id"]) or {}
943
+ except connector_vault.VaultError:
944
+ return {}
945
+
946
+ def _call_once(self, row, tool, arguments, timeout, refresh=False):
947
+ if refresh:
948
+ self._drop(row["id"])
949
+ session = self._session(row, force_refresh=refresh)
950
+ if not getattr(session, "_tools_seen", False) or session.tools_changed:
951
+ # A new session lists its tools first (and again after the server said they changed), so the cache
952
+ # follows what the server offers now.
953
+ session.tools_changed = False
954
+ tools = session.list_tools()
955
+ session._tools_seen = True
956
+ row = self._remember_tools(row, tools)
957
+ if not any(t["name"] == tool for t in row["tools"]):
958
+ raise ToolGone(f"{row['name']} no longer has a tool called {tool}.")
959
+ result = session.call_tool(tool, arguments, timeout=timeout or mcp.CALL_TIMEOUT)
960
+ self._worked(row)
961
+ return result
962
+
963
+
964
+ def fingerprint(connector_id, tool, arguments):
965
+ raw = json.dumps([connector_id, tool, arguments], sort_keys=True, separators=(",", ":"), default=str)
966
+ return hashlib.sha256(raw.encode()).hexdigest()