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.
- mobile_agent/__init__.py +4 -0
- mobile_agent/__main__.py +598 -0
- mobile_agent/accounts/__init__.py +1 -0
- mobile_agent/accounts/context.py +80 -0
- mobile_agent/accounts/mcp.py +55 -0
- mobile_agent/accounts/pacing.py +318 -0
- mobile_agent/accounts/register.py +78 -0
- mobile_agent/accounts/routes.py +212 -0
- mobile_agent/accounts/routing.py +69 -0
- mobile_agent/accounts/screen.py +273 -0
- mobile_agent/accounts/stats.py +54 -0
- mobile_agent/accounts/store.py +394 -0
- mobile_agent/agent_hooks.py +123 -0
- mobile_agent/ai_plans/__init__.py +7 -0
- mobile_agent/ai_plans/agents.py +96 -0
- mobile_agent/ai_plans/claude_code.py +208 -0
- mobile_agent/ai_plans/cli.py +400 -0
- mobile_agent/ai_plans/codex.py +258 -0
- mobile_agent/ai_plans/compat_chat.py +307 -0
- mobile_agent/ai_plans/device_login.py +164 -0
- mobile_agent/ai_plans/draft_client.py +68 -0
- mobile_agent/ai_plans/gemini.py +137 -0
- mobile_agent/ai_plans/instructions.py +65 -0
- mobile_agent/ai_plans/mcp_http.py +160 -0
- mobile_agent/ai_plans/phone_tools.py +892 -0
- mobile_agent/ai_plans/providers.py +235 -0
- mobile_agent/ai_plans/routes.py +160 -0
- mobile_agent/ai_plans/runner.py +400 -0
- mobile_agent/ai_plans/vault.py +114 -0
- mobile_agent/alerts.py +337 -0
- mobile_agent/android/__init__.py +18 -0
- mobile_agent/android/adb.py +268 -0
- mobile_agent/android/conditions.py +128 -0
- mobile_agent/android/doctor.py +54 -0
- mobile_agent/android/driver.py +137 -0
- mobile_agent/android/emulator.py +216 -0
- mobile_agent/android/logs.py +85 -0
- mobile_agent/android/manager.py +225 -0
- mobile_agent/android/records.py +31 -0
- mobile_agent/android/tree.py +96 -0
- mobile_agent/answer_shapes.py +230 -0
- mobile_agent/api_devices.py +333 -0
- mobile_agent/api_errors.py +17 -0
- mobile_agent/api_get.py +249 -0
- mobile_agent/api_http.py +467 -0
- mobile_agent/api_post.py +453 -0
- mobile_agent/api_routes.py +173 -0
- mobile_agent/app_api/__init__.py +4 -0
- mobile_agent/app_api/cli.py +151 -0
- mobile_agent/app_api/endpoints.py +193 -0
- mobile_agent/app_api/mcp.py +54 -0
- mobile_agent/app_api/openapi.py +146 -0
- mobile_agent/app_api/reading.py +172 -0
- mobile_agent/app_api/register.py +10 -0
- mobile_agent/app_api/screen.py +216 -0
- mobile_agent/app_api/service.py +382 -0
- mobile_agent/app_api/sessions.py +404 -0
- mobile_agent/app_api/tokens.py +167 -0
- mobile_agent/app_choice.py +131 -0
- mobile_agent/app_icons.py +183 -0
- mobile_agent/app_killer.py +69 -0
- mobile_agent/artifacts.py +606 -0
- mobile_agent/attach/__init__.py +13 -0
- mobile_agent/attach/cli.py +46 -0
- mobile_agent/attach/flutter.py +28 -0
- mobile_agent/attach/score.py +119 -0
- mobile_agent/attach/web.py +13 -0
- mobile_agent/attachments/__init__.py +1 -0
- mobile_agent/attachments/agent.py +418 -0
- mobile_agent/attachments/extract.py +217 -0
- mobile_agent/attachments/mcp.py +104 -0
- mobile_agent/attachments/register.py +73 -0
- mobile_agent/attachments/routes.py +326 -0
- mobile_agent/attachments/service.py +170 -0
- mobile_agent/attachments/sniff.py +152 -0
- mobile_agent/attachments/store.py +441 -0
- mobile_agent/audit_verify.py +436 -0
- mobile_agent/bench/README.md +410 -0
- mobile_agent/bench/__init__.py +0 -0
- mobile_agent/bench/__main__.py +394 -0
- mobile_agent/bench/ab.py +168 -0
- mobile_agent/bench/actuation.py +368 -0
- mobile_agent/bench/agents/__init__.py +9 -0
- mobile_agent/bench/agents/base.py +105 -0
- mobile_agent/bench/agents/plan_clients.py +245 -0
- mobile_agent/bench/agents/smart.py +533 -0
- mobile_agent/bench/checks.py +358 -0
- mobile_agent/bench/decide_gate.py +214 -0
- mobile_agent/bench/diagnose.py +181 -0
- mobile_agent/bench/evals/__init__.py +14 -0
- mobile_agent/bench/evals/build.py +565 -0
- mobile_agent/bench/evals/checkpoint.py +214 -0
- mobile_agent/bench/evals/irt.py +219 -0
- mobile_agent/bench/evals/l0.py +647 -0
- mobile_agent/bench/evals/l1.py +866 -0
- mobile_agent/bench/evals/models.py +124 -0
- mobile_agent/bench/evals/pairwise.py +189 -0
- mobile_agent/bench/evals/proxy.py +90 -0
- mobile_agent/bench/evals/sets.py +149 -0
- mobile_agent/bench/evals/traces.py +446 -0
- mobile_agent/bench/fixtures.py +176 -0
- mobile_agent/bench/guard_replay.py +129 -0
- mobile_agent/bench/h2-manifest.json +20 -0
- mobile_agent/bench/h3-manifest.json +20 -0
- mobile_agent/bench/heldout-manifest.json +32 -0
- mobile_agent/bench/heldout.py +375 -0
- mobile_agent/bench/iosworld.py +901 -0
- mobile_agent/bench/iosworld_stats.py +510 -0
- mobile_agent/bench/llm_units.py +116 -0
- mobile_agent/bench/manifest.json +93 -0
- mobile_agent/bench/metrics.py +66 -0
- mobile_agent/bench/phone.py +44 -0
- mobile_agent/bench/replay.py +172 -0
- mobile_agent/bench/report.py +207 -0
- mobile_agent/bench/runner.py +456 -0
- mobile_agent/bench/safety.py +310 -0
- mobile_agent/bench/stream_timing.py +213 -0
- mobile_agent/bench/suite.py +444 -0
- mobile_agent/bench/sweep.py +176 -0
- mobile_agent/bench/tape.py +798 -0
- mobile_agent/bench/tape_replay.py +88 -0
- mobile_agent/bench/trace_evals.py +128 -0
- mobile_agent/bench/trace_view.py +81 -0
- mobile_agent/bench/truth/iphone15pro.json +138 -0
- mobile_agent/bench/truth.py +340 -0
- mobile_agent/bench/tuning.py +543 -0
- mobile_agent/bench/tuning_analysis.py +656 -0
- mobile_agent/bench/vague-heldout-manifest.json +12 -0
- mobile_agent/bench/vague_rubric.py +135 -0
- mobile_agent/bench/vague_run.py +243 -0
- mobile_agent/brand.py +454 -0
- mobile_agent/brand_fonts.py +756 -0
- mobile_agent/build_identity.py +21 -0
- mobile_agent/cable_bt.py +623 -0
- mobile_agent/cable_bt_runtime.py +128 -0
- mobile_agent/catalog.py +69 -0
- mobile_agent/chart_svg.py +415 -0
- mobile_agent/chat_here.py +189 -0
- mobile_agent/cli_constants.py +5 -0
- mobile_agent/cli_parser.py +611 -0
- mobile_agent/cli_run.py +115 -0
- mobile_agent/compiled_read/__init__.py +21 -0
- mobile_agent/compiled_read/delta.py +89 -0
- mobile_agent/compiled_read/extractors.py +202 -0
- mobile_agent/compiled_read/routes.py +56 -0
- mobile_agent/completion.py +267 -0
- mobile_agent/compose.py +48 -0
- mobile_agent/config.py +114 -0
- mobile_agent/connectors/__init__.py +1 -0
- mobile_agent/connectors/api_session.py +76 -0
- mobile_agent/connectors/callback_page.py +146 -0
- mobile_agent/connectors/client.py +582 -0
- mobile_agent/connectors/github_app.py +55 -0
- mobile_agent/connectors/graph_api.py +396 -0
- mobile_agent/connectors/imports.py +170 -0
- mobile_agent/connectors/issue.py +66 -0
- mobile_agent/connectors/keystore.py +85 -0
- mobile_agent/connectors/marks.py +222 -0
- mobile_agent/connectors/oauth.py +476 -0
- mobile_agent/connectors/picker.py +228 -0
- mobile_agent/connectors/presets.py +591 -0
- mobile_agent/connectors/register.py +48 -0
- mobile_agent/connectors/routes.py +153 -0
- mobile_agent/connectors/service.py +966 -0
- mobile_agent/connectors/shared.py +136 -0
- mobile_agent/connectors/skill.py +446 -0
- mobile_agent/connectors/slack_api.py +269 -0
- mobile_agent/connectors/steps.py +92 -0
- mobile_agent/connectors/store.py +144 -0
- mobile_agent/connectors/vault.py +72 -0
- mobile_agent/console.py +178 -0
- mobile_agent/content_queue/__init__.py +1 -0
- mobile_agent/content_queue/client.py +263 -0
- mobile_agent/content_queue/importing.py +153 -0
- mobile_agent/content_queue/media_check.py +224 -0
- mobile_agent/content_queue/prompt.py +64 -0
- mobile_agent/content_queue/register.py +91 -0
- mobile_agent/content_queue/routes.py +370 -0
- mobile_agent/content_queue/runner.py +475 -0
- mobile_agent/content_queue/store.py +521 -0
- mobile_agent/contract.py +996 -0
- mobile_agent/contract_asks.py +248 -0
- mobile_agent/contract_prompt.py +45 -0
- mobile_agent/contract_screen.py +981 -0
- mobile_agent/contract_view.py +84 -0
- mobile_agent/corpus/__init__.py +26 -0
- mobile_agent/corpus/cli.py +63 -0
- mobile_agent/corpus/consent.py +55 -0
- mobile_agent/corpus/listener.py +76 -0
- mobile_agent/corpus/register.py +9 -0
- mobile_agent/corpus/skeleton.py +72 -0
- mobile_agent/corpus/store.py +143 -0
- mobile_agent/costs.py +161 -0
- mobile_agent/decider_scope.py +40 -0
- mobile_agent/decision_provider.py +189 -0
- mobile_agent/deeplinks.py +352 -0
- mobile_agent/device_info.py +116 -0
- mobile_agent/device_manager.py +978 -0
- mobile_agent/device_status.py +195 -0
- mobile_agent/device_targets.py +217 -0
- mobile_agent/devices.py +676 -0
- mobile_agent/devkit/__init__.py +64 -0
- mobile_agent/devkit/account_cli.py +90 -0
- mobile_agent/devkit/asc.py +562 -0
- mobile_agent/devkit/asc_cli.py +214 -0
- mobile_agent/devkit/ci.py +737 -0
- mobile_agent/devkit/ci_cli.py +196 -0
- mobile_agent/devkit/ci_hosts.py +194 -0
- mobile_agent/devkit/es256.py +197 -0
- mobile_agent/devkit/expo.py +440 -0
- mobile_agent/devkit/expo_cli.py +405 -0
- mobile_agent/devkit/expo_dev.py +203 -0
- mobile_agent/devkit/expo_routes.py +90 -0
- mobile_agent/devkit/fixtures_cli.py +133 -0
- mobile_agent/devkit/fixtures_routes.py +82 -0
- mobile_agent/devkit/links.py +375 -0
- mobile_agent/devkit/links_cli.py +123 -0
- mobile_agent/devkit/links_routes.py +83 -0
- mobile_agent/devkit/nightly.py +152 -0
- mobile_agent/devkit/shots.py +203 -0
- mobile_agent/devkit/testflight.py +155 -0
- mobile_agent/devkit/testflight_cli.py +127 -0
- mobile_agent/devkit/testflight_routes.py +111 -0
- mobile_agent/devkit/xcode.py +236 -0
- mobile_agent/devkit/xcode_cli.py +101 -0
- mobile_agent/devtools.py +419 -0
- mobile_agent/doctor.py +509 -0
- mobile_agent/doctor_fix.py +467 -0
- mobile_agent/downloads.py +55 -0
- mobile_agent/drivers.py +793 -0
- mobile_agent/dry_run.py +102 -0
- mobile_agent/effect_ledger.py +83 -0
- mobile_agent/engine_health.py +378 -0
- mobile_agent/engine_hooks.py +96 -0
- mobile_agent/engines.py +408 -0
- mobile_agent/env_file.py +63 -0
- mobile_agent/env_secrets.py +149 -0
- mobile_agent/errors.py +15 -0
- mobile_agent/evals/__init__.py +3 -0
- mobile_agent/evals/app_survey.py +149 -0
- mobile_agent/evals/development/__init__.py +1 -0
- mobile_agent/evals/development/score.py +276 -0
- mobile_agent/evals/latency_report.py +312 -0
- mobile_agent/evals/offline_report.py +374 -0
- mobile_agent/evals/oracles.py +298 -0
- mobile_agent/evals/redteam/__init__.py +15 -0
- mobile_agent/evals/redteam/__main__.py +72 -0
- mobile_agent/evals/redteam/cases.py +730 -0
- mobile_agent/evals/redteam/harness.py +604 -0
- mobile_agent/evals/redteam/live.py +139 -0
- mobile_agent/evals/redteam/plain.py +85 -0
- mobile_agent/evals/vision/__init__.py +0 -0
- mobile_agent/evals/vision/images/dog_001.jpg +0 -0
- mobile_agent/evals/vision/images/dog_002.jpg +0 -0
- mobile_agent/evals/vision/images/dog_003.jpg +0 -0
- mobile_agent/evals/vision/images/dog_004.jpg +0 -0
- mobile_agent/evals/vision/images/dog_005.jpg +0 -0
- mobile_agent/evals/vision/images/dog_006.jpg +0 -0
- mobile_agent/evals/vision/images/dog_007.jpg +0 -0
- mobile_agent/evals/vision/images/dog_010.jpg +0 -0
- mobile_agent/evals/vision/images/dog_011.jpg +0 -0
- mobile_agent/evals/vision/images/dog_013.jpg +0 -0
- mobile_agent/evals/vision/images/dog_014.jpg +0 -0
- mobile_agent/evals/vision/images/dog_015.jpg +0 -0
- mobile_agent/evals/vision/images/dog_016.jpg +0 -0
- mobile_agent/evals/vision/images/dog_017.jpg +0 -0
- mobile_agent/evals/vision/images/dog_019.jpg +0 -0
- mobile_agent/evals/vision/images/dog_020.jpg +0 -0
- mobile_agent/evals/vision/images/dog_021.jpg +0 -0
- mobile_agent/evals/vision/images/dog_022.jpg +0 -0
- mobile_agent/evals/vision/images/dog_023.jpg +0 -0
- mobile_agent/evals/vision/images/dog_024.jpg +0 -0
- mobile_agent/evals/vision/images/dog_026.jpg +0 -0
- mobile_agent/evals/vision/images/dog_027.jpg +0 -0
- mobile_agent/evals/vision/images/dog_034.jpg +0 -0
- mobile_agent/evals/vision/images/dog_035.jpg +0 -0
- mobile_agent/evals/vision/images/dog_038.jpg +0 -0
- mobile_agent/evals/vision/images/dog_042.jpg +0 -0
- mobile_agent/evals/vision/images/dog_047.jpg +0 -0
- mobile_agent/evals/vision/images/dog_052.jpg +0 -0
- mobile_agent/evals/vision/images/dog_058.jpg +0 -0
- mobile_agent/evals/vision/images/dog_062.jpg +0 -0
- mobile_agent/evals/vision/images/eyes_000.jpg +0 -0
- mobile_agent/evals/vision/images/eyes_010.jpg +0 -0
- mobile_agent/evals/vision/images/eyes_011.jpg +0 -0
- mobile_agent/evals/vision/images/eyes_015.jpg +0 -0
- mobile_agent/evals/vision/images/eyes_016.jpg +0 -0
- mobile_agent/evals/vision/images/eyes_017.jpg +0 -0
- mobile_agent/evals/vision/images/eyes_019.jpg +0 -0
- mobile_agent/evals/vision/images/eyes_021.jpg +0 -0
- mobile_agent/evals/vision/images/eyes_025.jpg +0 -0
- mobile_agent/evals/vision/images/eyes_026.jpg +0 -0
- mobile_agent/evals/vision/images/eyes_027.jpg +0 -0
- mobile_agent/evals/vision/images/eyes_030.jpg +0 -0
- mobile_agent/evals/vision/images/eyes_032.jpg +0 -0
- mobile_agent/evals/vision/images/eyes_033.jpg +0 -0
- mobile_agent/evals/vision/images/eyes_034.jpg +0 -0
- mobile_agent/evals/vision/images/eyes_035.jpg +0 -0
- mobile_agent/evals/vision/images/eyes_036.jpg +0 -0
- mobile_agent/evals/vision/images/eyes_037.jpg +0 -0
- mobile_agent/evals/vision/images/eyes_039.jpg +0 -0
- mobile_agent/evals/vision/images/eyes_040.jpg +0 -0
- mobile_agent/evals/vision/images/eyes_041.jpg +0 -0
- mobile_agent/evals/vision/images/eyes_042.jpg +0 -0
- mobile_agent/evals/vision/images/eyes_043.jpg +0 -0
- mobile_agent/evals/vision/images/eyes_044.jpg +0 -0
- mobile_agent/evals/vision/images/eyes_046.jpg +0 -0
- mobile_agent/evals/vision/images/eyes_047.jpg +0 -0
- mobile_agent/evals/vision/images/eyes_048.jpg +0 -0
- mobile_agent/evals/vision/images/eyes_049.jpg +0 -0
- mobile_agent/evals/vision/images/eyes_051.jpg +0 -0
- mobile_agent/evals/vision/images/eyes_053.jpg +0 -0
- mobile_agent/evals/vision/images/eyes_054.jpg +0 -0
- mobile_agent/evals/vision/images/eyes_055.jpg +0 -0
- mobile_agent/evals/vision/images/eyes_058.jpg +0 -0
- mobile_agent/evals/vision/images/eyes_059.jpg +0 -0
- mobile_agent/evals/vision/images/receipt_002.jpg +0 -0
- mobile_agent/evals/vision/images/receipt_003.jpg +0 -0
- mobile_agent/evals/vision/images/receipt_004.jpg +0 -0
- mobile_agent/evals/vision/images/receipt_007.jpg +0 -0
- mobile_agent/evals/vision/images/receipt_009.jpg +0 -0
- mobile_agent/evals/vision/images/receipt_010.jpg +0 -0
- mobile_agent/evals/vision/images/receipt_011.jpg +0 -0
- mobile_agent/evals/vision/images/receipt_012.jpg +0 -0
- mobile_agent/evals/vision/images/receipt_013.jpg +0 -0
- mobile_agent/evals/vision/images/receipt_014.jpg +0 -0
- mobile_agent/evals/vision/images/receipt_016.jpg +0 -0
- mobile_agent/evals/vision/images/receipt_017.jpg +0 -0
- mobile_agent/evals/vision/images/receipt_023.jpg +0 -0
- mobile_agent/evals/vision/images/receipt_027.jpg +0 -0
- mobile_agent/evals/vision/images/receipt_029.jpg +0 -0
- mobile_agent/evals/vision/images/receipt_032.jpg +0 -0
- mobile_agent/evals/vision/images/receipt_033.jpg +0 -0
- mobile_agent/evals/vision/images/receipt_036.jpg +0 -0
- mobile_agent/evals/vision/images/receipt_041.jpg +0 -0
- mobile_agent/evals/vision/images/receipt_043.jpg +0 -0
- mobile_agent/evals/vision/images/receipt_047.jpg +0 -0
- mobile_agent/evals/vision/images/receipt_049.jpg +0 -0
- mobile_agent/evals/vision/images/receipt_051.jpg +0 -0
- mobile_agent/evals/vision/images/receipt_053.jpg +0 -0
- mobile_agent/evals/vision/images/receipt_056.jpg +0 -0
- mobile_agent/evals/vision/images/receipt_057.jpg +0 -0
- mobile_agent/evals/vision/images/receipt_058.jpg +0 -0
- mobile_agent/evals/vision/images/receipt_059.jpg +0 -0
- mobile_agent/evals/vision/images/receipt_060.jpg +0 -0
- mobile_agent/evals/vision/images/receipt_061.jpg +0 -0
- mobile_agent/evals/vision/images/receipt_062.jpg +0 -0
- mobile_agent/evals/vision/images/receipt_063.jpg +0 -0
- mobile_agent/evals/vision/images/receipt_064.jpg +0 -0
- mobile_agent/evals/vision/images/receipt_065.jpg +0 -0
- mobile_agent/evals/vision/images/receipt_066.jpg +0 -0
- mobile_agent/evals/vision/images/receipt_067.jpg +0 -0
- mobile_agent/evals/vision/images/receipt_068.jpg +0 -0
- mobile_agent/evals/vision/images/receipt_069.jpg +0 -0
- mobile_agent/evals/vision/images/receipt_070.jpg +0 -0
- mobile_agent/evals/vision/images/receipt_071.jpg +0 -0
- mobile_agent/evals/vision/images/sunglasses_000.jpg +0 -0
- mobile_agent/evals/vision/images/sunglasses_001.jpg +0 -0
- mobile_agent/evals/vision/images/sunglasses_002.jpg +0 -0
- mobile_agent/evals/vision/images/sunglasses_004.jpg +0 -0
- mobile_agent/evals/vision/images/sunglasses_005.jpg +0 -0
- mobile_agent/evals/vision/images/sunglasses_008.jpg +0 -0
- mobile_agent/evals/vision/images/sunglasses_009.jpg +0 -0
- mobile_agent/evals/vision/images/sunglasses_010.jpg +0 -0
- mobile_agent/evals/vision/images/sunglasses_012.jpg +0 -0
- mobile_agent/evals/vision/images/sunglasses_013.jpg +0 -0
- mobile_agent/evals/vision/images/sunglasses_015.jpg +0 -0
- mobile_agent/evals/vision/images/sunglasses_016.jpg +0 -0
- mobile_agent/evals/vision/images/sunglasses_017.jpg +0 -0
- mobile_agent/evals/vision/images/sunglasses_018.jpg +0 -0
- mobile_agent/evals/vision/images/sunglasses_021.jpg +0 -0
- mobile_agent/evals/vision/images/sunglasses_022.jpg +0 -0
- mobile_agent/evals/vision/images/sunglasses_023.jpg +0 -0
- mobile_agent/evals/vision/images/sunglasses_024.jpg +0 -0
- mobile_agent/evals/vision/images/sunglasses_030.jpg +0 -0
- mobile_agent/evals/vision/images/sunglasses_031.jpg +0 -0
- mobile_agent/evals/vision/images/sunglasses_032.jpg +0 -0
- mobile_agent/evals/vision/images/sunglasses_033.jpg +0 -0
- mobile_agent/evals/vision/images/sunglasses_034.jpg +0 -0
- mobile_agent/evals/vision/images/sunglasses_036.jpg +0 -0
- mobile_agent/evals/vision/images/sunglasses_038.jpg +0 -0
- mobile_agent/evals/vision/images/sunglasses_039.jpg +0 -0
- mobile_agent/evals/vision/images/sunglasses_040.jpg +0 -0
- mobile_agent/evals/vision/images/sunglasses_041.jpg +0 -0
- mobile_agent/evals/vision/manifest.json +2355 -0
- mobile_agent/eventbus.py +110 -0
- mobile_agent/explain.py +34 -0
- mobile_agent/failure_line.py +197 -0
- mobile_agent/fanout.py +663 -0
- mobile_agent/feedback/__init__.py +2 -0
- mobile_agent/feedback/bundle.py +204 -0
- mobile_agent/feedback/outbox.py +196 -0
- mobile_agent/feedback/register.py +9 -0
- mobile_agent/feedback/routes.py +177 -0
- mobile_agent/fleet.py +841 -0
- mobile_agent/fleet_health.py +729 -0
- mobile_agent/fleet_phone.py +186 -0
- mobile_agent/frontier/__init__.py +79 -0
- mobile_agent/frontier/act.py +380 -0
- mobile_agent/frontier/actions.py +70 -0
- mobile_agent/frontier/agent.py +256 -0
- mobile_agent/frontier/approvals.py +366 -0
- mobile_agent/frontier/hooks.py +199 -0
- mobile_agent/frontier/interrupts.py +125 -0
- mobile_agent/frontier/observe.py +331 -0
- mobile_agent/frontier/phases.py +381 -0
- mobile_agent/frontier/pipeline.py +61 -0
- mobile_agent/frontier/prompt.py +384 -0
- mobile_agent/frontier/protocol.py +91 -0
- mobile_agent/frontier/safety.py +467 -0
- mobile_agent/frontier/saved.py +375 -0
- mobile_agent/frontier/screen.py +419 -0
- mobile_agent/frontier/skills.py +147 -0
- mobile_agent/frontier/stages/__init__.py +1 -0
- mobile_agent/frontier/stages/after.py +192 -0
- mobile_agent/frontier/stages/approve.py +138 -0
- mobile_agent/frontier/stages/controls.py +115 -0
- mobile_agent/frontier/stages/ends.py +102 -0
- mobile_agent/frontier/stages/gate.py +92 -0
- mobile_agent/frontier/stages/guards.py +132 -0
- mobile_agent/frontier/stages/points.py +155 -0
- mobile_agent/frontier/stages/send.py +177 -0
- mobile_agent/frontier/turn.py +151 -0
- mobile_agent/full_disk_access.py +83 -0
- mobile_agent/gemini.py +69 -0
- mobile_agent/genuine.py +120 -0
- mobile_agent/graph/__init__.py +4 -0
- mobile_agent/graph/catalog.py +789 -0
- mobile_agent/graph/chain.py +306 -0
- mobile_agent/graph/dispatch.py +98 -0
- mobile_agent/graph/draft.py +373 -0
- mobile_agent/graph/edit.py +76 -0
- mobile_agent/graph/estimate.py +189 -0
- mobile_agent/graph/expr.py +562 -0
- mobile_agent/graph/functions.py +271 -0
- mobile_agent/graph/infer.py +144 -0
- mobile_agent/graph/nodes.py +936 -0
- mobile_agent/graph/phone_steps.py +178 -0
- mobile_agent/graph/rerun.py +327 -0
- mobile_agent/graph/run.py +899 -0
- mobile_agent/graph/schema.py +842 -0
- mobile_agent/graph/state.py +161 -0
- mobile_agent/graph/values.py +198 -0
- mobile_agent/harness/__init__.py +2 -0
- mobile_agent/harness/checkpoints.py +468 -0
- mobile_agent/harness/control.py +101 -0
- mobile_agent/harness/prewarm.py +213 -0
- mobile_agent/harness/register.py +43 -0
- mobile_agent/harness/routes.py +114 -0
- mobile_agent/harness/settings.py +66 -0
- mobile_agent/harness/tools/__init__.py +67 -0
- mobile_agent/harness/tools/ask_user.py +102 -0
- mobile_agent/harness/tools/watch.py +120 -0
- mobile_agent/harness_api.py +815 -0
- mobile_agent/hedge.py +203 -0
- mobile_agent/help_topics.py +305 -0
- mobile_agent/hints.py +248 -0
- mobile_agent/history.py +69 -0
- mobile_agent/home.py +214 -0
- mobile_agent/http_pool.py +175 -0
- mobile_agent/image_policy.py +139 -0
- mobile_agent/inbox.py +148 -0
- mobile_agent/installed_apps.py +344 -0
- mobile_agent/integrations/__init__.py +1 -0
- mobile_agent/integrations/cli.py +472 -0
- mobile_agent/integrations/client_files.py +65 -0
- mobile_agent/integrations/clients.py +998 -0
- mobile_agent/integrations/doctor.py +185 -0
- mobile_agent/integrations/jsonc.py +383 -0
- mobile_agent/integrations/routes.py +135 -0
- mobile_agent/integrations/rules.py +112 -0
- mobile_agent/integrations/server_options.py +46 -0
- mobile_agent/integrations/skill.py +152 -0
- mobile_agent/integrations/skill_files.py +10 -0
- mobile_agent/integrations/textedit.py +309 -0
- mobile_agent/intents/__init__.py +17 -0
- mobile_agent/intents/atlas.py +216 -0
- mobile_agent/intents/callback.py +44 -0
- mobile_agent/intents/cli.py +55 -0
- mobile_agent/intents/harvest.py +91 -0
- mobile_agent/intents/plan.py +93 -0
- mobile_agent/ios/__init__.py +1 -0
- mobile_agent/ios/guide.py +16 -0
- mobile_agent/ios/kb.py +212 -0
- mobile_agent/ios/kb_data.py +1588 -0
- mobile_agent/ios/mcp.py +44 -0
- mobile_agent/ios/register.py +8 -0
- mobile_agent/ios/system.py +369 -0
- mobile_agent/journal.py +531 -0
- mobile_agent/keychain_acl.py +374 -0
- mobile_agent/keys.py +395 -0
- mobile_agent/latency_overlap.py +36 -0
- mobile_agent/latency_trace.py +170 -0
- mobile_agent/learned/__init__.py +15 -0
- mobile_agent/learned/app_path.py +160 -0
- mobile_agent/learned/code_read.py +38 -0
- mobile_agent/learned/parts.py +546 -0
- mobile_agent/learned/programs.py +691 -0
- mobile_agent/learned/register.py +171 -0
- mobile_agent/learned/routes.py +61 -0
- mobile_agent/learned/taught.py +329 -0
- mobile_agent/learned/teach.py +538 -0
- mobile_agent/learned/workflows.py +647 -0
- mobile_agent/learned/zero.py +41 -0
- mobile_agent/links.py +37 -0
- mobile_agent/listen.py +607 -0
- mobile_agent/lock_spike.py +278 -0
- mobile_agent/lockscreen.py +526 -0
- mobile_agent/login.py +354 -0
- mobile_agent/longform/__init__.py +15 -0
- mobile_agent/longform/ads.py +191 -0
- mobile_agent/longform/blocks.py +117 -0
- mobile_agent/longform/budget.py +265 -0
- mobile_agent/longform/context.py +285 -0
- mobile_agent/longform/costs.py +93 -0
- mobile_agent/longform/digest.py +145 -0
- mobile_agent/longform/extract.py +508 -0
- mobile_agent/longform/feed.py +505 -0
- mobile_agent/longform/followup.py +168 -0
- mobile_agent/longform/frames.py +82 -0
- mobile_agent/longform/identity.py +87 -0
- mobile_agent/longform/inbox.py +221 -0
- mobile_agent/longform/items.py +425 -0
- mobile_agent/longform/models.py +115 -0
- mobile_agent/longform/notebook.py +329 -0
- mobile_agent/longform/plan.py +614 -0
- mobile_agent/longform/recall.py +195 -0
- mobile_agent/longform/receipts.py +81 -0
- mobile_agent/longform/register.py +124 -0
- mobile_agent/longform/report.py +343 -0
- mobile_agent/longform/routes.py +102 -0
- mobile_agent/longform/session.py +644 -0
- mobile_agent/longform/store.py +146 -0
- mobile_agent/longform/synthesis.py +347 -0
- mobile_agent/mac_tools.py +223 -0
- mobile_agent/manual_control.py +143 -0
- mobile_agent/maps.py +149 -0
- mobile_agent/marks.py +92 -0
- mobile_agent/mcp_server/__init__.py +3 -0
- mobile_agent/mcp_server/__main__.py +29 -0
- mobile_agent/mcp_server/action_journal.py +24 -0
- mobile_agent/mcp_server/action_tools.py +638 -0
- mobile_agent/mcp_server/approvals.py +219 -0
- mobile_agent/mcp_server/catalog.py +92 -0
- mobile_agent/mcp_server/cli.py +194 -0
- mobile_agent/mcp_server/codes.py +147 -0
- mobile_agent/mcp_server/device_tools.py +306 -0
- mobile_agent/mcp_server/direct.py +272 -0
- mobile_agent/mcp_server/expo_tools.py +105 -0
- mobile_agent/mcp_server/phone_tools.py +510 -0
- mobile_agent/mcp_server/protocol.py +364 -0
- mobile_agent/mcp_server/registry.py +55 -0
- mobile_agent/mcp_server/run_tools.py +531 -0
- mobile_agent/mcp_server/schemas.py +425 -0
- mobile_agent/mcp_server/screen_tools.py +168 -0
- mobile_agent/mcp_server/session.py +469 -0
- mobile_agent/mcp_server/support.py +145 -0
- mobile_agent/mcp_server/tools.py +493 -0
- mobile_agent/memory/__init__.py +9 -0
- mobile_agent/memory/cli.py +207 -0
- mobile_agent/memory/extract.py +191 -0
- mobile_agent/memory/listener.py +100 -0
- mobile_agent/memory/model.py +144 -0
- mobile_agent/memory/project.py +96 -0
- mobile_agent/memory/provider.py +127 -0
- mobile_agent/memory/register.py +21 -0
- mobile_agent/memory/retrieval.py +172 -0
- mobile_agent/memory/routes.py +216 -0
- mobile_agent/memory/runs.py +124 -0
- mobile_agent/memory/store.py +806 -0
- mobile_agent/memory/writer.py +278 -0
- mobile_agent/mirroring.py +585 -0
- mobile_agent/mirroring_runtime.py +221 -0
- mobile_agent/model_clients.py +604 -0
- mobile_agent/narrate.py +813 -0
- mobile_agent/native_helpers.py +84 -0
- mobile_agent/netxray/__init__.py +1 -0
- mobile_agent/netxray/analyze.py +322 -0
- mobile_agent/netxray/attribution.py +62 -0
- mobile_agent/netxray/capture.py +88 -0
- mobile_agent/netxray/cli.py +142 -0
- mobile_agent/netxray/job.py +219 -0
- mobile_agent/netxray/mobster-pcap.c +560 -0
- mobile_agent/netxray/packets.py +410 -0
- mobile_agent/netxray/pcapng.py +223 -0
- mobile_agent/netxray/register.py +93 -0
- mobile_agent/netxray/report.py +214 -0
- mobile_agent/netxray/store.py +48 -0
- mobile_agent/netxray/summary.py +157 -0
- mobile_agent/netxray/target.py +67 -0
- mobile_agent/netxray/tool.py +66 -0
- mobile_agent/netxray/trackers.py +194 -0
- mobile_agent/notify_triggers/__init__.py +1 -0
- mobile_agent/notify_triggers/rules.py +39 -0
- mobile_agent/notify_triggers/sources.py +315 -0
- mobile_agent/notify_triggers/watch.py +135 -0
- mobile_agent/offers.py +86 -0
- mobile_agent/orchestrate.py +215 -0
- mobile_agent/outlets/__init__.py +1 -0
- mobile_agent/outlets/connect.py +249 -0
- mobile_agent/outlets/folder.py +180 -0
- mobile_agent/outlets/hooks.py +453 -0
- mobile_agent/outlets/inbound.py +472 -0
- mobile_agent/outlets/inbound_reply.py +57 -0
- mobile_agent/outlets/inbound_routes.py +81 -0
- mobile_agent/outlets/kinds.py +308 -0
- mobile_agent/outlets/mail.py +110 -0
- mobile_agent/outlets/net.py +86 -0
- mobile_agent/outlets/notion.py +244 -0
- mobile_agent/outlets/register.py +56 -0
- mobile_agent/outlets/routes.py +135 -0
- mobile_agent/outlets/service.py +580 -0
- mobile_agent/outlets/sheets.py +169 -0
- mobile_agent/outlets/slack.py +177 -0
- mobile_agent/outlets/store.py +194 -0
- mobile_agent/outlets/vault.py +80 -0
- mobile_agent/outlets/webhook.py +316 -0
- mobile_agent/output_contract.py +141 -0
- mobile_agent/owner_presence.py +211 -0
- mobile_agent/ownership.py +219 -0
- mobile_agent/packs/__init__.py +1 -0
- mobile_agent/packs/calendar.py +243 -0
- mobile_agent/packs/context.py +68 -0
- mobile_agent/packs/core.py +757 -0
- mobile_agent/packs/front_door.py +316 -0
- mobile_agent/packs/heal.py +214 -0
- mobile_agent/packs/health.py +122 -0
- mobile_agent/packs/insights.py +76 -0
- mobile_agent/packs/instagram.py +640 -0
- mobile_agent/packs/linkedin.py +106 -0
- mobile_agent/packs/maps.py +196 -0
- mobile_agent/packs/mcp.py +149 -0
- mobile_agent/packs/messages.py +270 -0
- mobile_agent/packs/notes.py +226 -0
- mobile_agent/packs/photos.py +166 -0
- mobile_agent/packs/pins.py +47 -0
- mobile_agent/packs/reddit.py +110 -0
- mobile_agent/packs/register.py +276 -0
- mobile_agent/packs/rows.py +108 -0
- mobile_agent/packs/safari.py +143 -0
- mobile_agent/packs/settings.py +253 -0
- mobile_agent/packs/social.py +300 -0
- mobile_agent/packs/threads.py +97 -0
- mobile_agent/packs/tiktok.py +455 -0
- mobile_agent/packs/tools.py +290 -0
- mobile_agent/packs/x.py +127 -0
- mobile_agent/packs/youtube.py +199 -0
- mobile_agent/parity.py +270 -0
- mobile_agent/paths.py +99 -0
- mobile_agent/perception.py +470 -0
- mobile_agent/permissions/__init__.py +32 -0
- mobile_agent/permissions/consequence.py +535 -0
- mobile_agent/permissions/exceptions.py +99 -0
- mobile_agent/permissions/gate.py +316 -0
- mobile_agent/permissions/outside.py +316 -0
- mobile_agent/permissions/policy.py +288 -0
- mobile_agent/permissions/project_policy.py +411 -0
- mobile_agent/permissions/register.py +148 -0
- mobile_agent/permissions/rules.py +291 -0
- mobile_agent/phone_io/__init__.py +146 -0
- mobile_agent/phone_io/cli.py +427 -0
- mobile_agent/phone_io/clipboard.py +78 -0
- mobile_agent/phone_io/controls.py +78 -0
- mobile_agent/phone_io/files.py +289 -0
- mobile_agent/phone_io/handoff.py +123 -0
- mobile_agent/phone_io/icloud.py +118 -0
- mobile_agent/phone_io/install.py +188 -0
- mobile_agent/phone_io/network_capture.py +188 -0
- mobile_agent/phone_io/network_cli.py +138 -0
- mobile_agent/phone_io/network_profiles.py +138 -0
- mobile_agent/phone_io/photos.py +512 -0
- mobile_agent/phone_io/recording.py +115 -0
- mobile_agent/phone_io/secrets.py +146 -0
- mobile_agent/phone_io/transfer.py +895 -0
- mobile_agent/phone_io/tunnel.py +129 -0
- mobile_agent/phone_ready.py +91 -0
- mobile_agent/pinning.py +158 -0
- mobile_agent/plan_gate.py +807 -0
- mobile_agent/plugins/__init__.py +3 -0
- mobile_agent/plugins/install.py +163 -0
- mobile_agent/plugins/manifest.py +242 -0
- mobile_agent/plugins/register.py +15 -0
- mobile_agent/plugins/routes.py +82 -0
- mobile_agent/plugins/service.py +322 -0
- mobile_agent/plugins/skills.py +76 -0
- mobile_agent/plugins/store.py +79 -0
- mobile_agent/pool.py +285 -0
- mobile_agent/power_watch.py +275 -0
- mobile_agent/premium_model.py +22 -0
- mobile_agent/project_env.py +291 -0
- mobile_agent/project_env_cli.py +74 -0
- mobile_agent/project_env_routes.py +55 -0
- mobile_agent/projects/__init__.py +27 -0
- mobile_agent/projects/ci.py +332 -0
- mobile_agent/projects/clone.py +339 -0
- mobile_agent/projects/drafts.py +158 -0
- mobile_agent/projects/folder.py +436 -0
- mobile_agent/projects/github.py +131 -0
- mobile_agent/projects/register.py +31 -0
- mobile_agent/projects/revisions.py +253 -0
- mobile_agent/projects/routes.py +234 -0
- mobile_agent/projects/runs.py +297 -0
- mobile_agent/projects/service.py +695 -0
- mobile_agent/projects/setup.py +84 -0
- mobile_agent/projects/store.py +379 -0
- mobile_agent/projects/sync.py +380 -0
- mobile_agent/projects/workflow_files.py +270 -0
- mobile_agent/replay_core/__init__.py +26 -0
- mobile_agent/replay_core/checks.py +594 -0
- mobile_agent/replay_core/engine.py +596 -0
- mobile_agent/replay_core/heal.py +364 -0
- mobile_agent/replay_core/path.py +240 -0
- mobile_agent/replay_core/recorder.py +206 -0
- mobile_agent/replay_core/store.py +171 -0
- mobile_agent/result_blocks.py +941 -0
- mobile_agent/run.py +642 -0
- mobile_agent/run_delivery.py +123 -0
- mobile_agent/run_executor.py +200 -0
- mobile_agent/run_export.py +284 -0
- mobile_agent/run_gif.py +891 -0
- mobile_agent/runner_hints.py +86 -0
- mobile_agent/runner_renewal.py +205 -0
- mobile_agent/runtime_create.py +549 -0
- mobile_agent/runtime_driver.py +458 -0
- mobile_agent/runtime_limits.py +319 -0
- mobile_agent/runtime_queue.py +394 -0
- mobile_agent/runtime_runs.py +383 -0
- mobile_agent/runtime_status.py +181 -0
- mobile_agent/runtime_teach.py +295 -0
- mobile_agent/runtime_work.py +599 -0
- mobile_agent/screen_text.py +101 -0
- mobile_agent/secret_filter.py +113 -0
- mobile_agent/serve.py +160 -0
- mobile_agent/server.py +363 -0
- mobile_agent/service_support.py +42 -0
- mobile_agent/settle/__init__.py +1 -0
- mobile_agent/settle/predict.py +102 -0
- mobile_agent/settle/replay.py +92 -0
- mobile_agent/settle/store.py +93 -0
- mobile_agent/settle/stream.py +43 -0
- mobile_agent/setup_cli.py +225 -0
- mobile_agent/setup_errors.py +87 -0
- mobile_agent/setup_service.py +527 -0
- mobile_agent/share.py +104 -0
- mobile_agent/signing.py +184 -0
- mobile_agent/sim/__init__.py +6 -0
- mobile_agent/sim/__main__.py +26 -0
- mobile_agent/sim/api.py +952 -0
- mobile_agent/sim/apps.py +172 -0
- mobile_agent/sim/biometrics.py +84 -0
- mobile_agent/sim/cli.py +271 -0
- mobile_agent/sim/doctor.py +293 -0
- mobile_agent/sim/governor.py +90 -0
- mobile_agent/sim/holders.py +88 -0
- mobile_agent/sim/models.py +62 -0
- mobile_agent/sim/registry.py +161 -0
- mobile_agent/sim/simctl.py +269 -0
- mobile_agent/sim/wda.py +618 -0
- mobile_agent/sim/xcode.py +74 -0
- mobile_agent/skills/__init__.py +12 -0
- mobile_agent/skills/codes.py +597 -0
- mobile_agent/skills/notifications.py +281 -0
- mobile_agent/skills/open_url.py +127 -0
- mobile_agent/smart_events.py +588 -0
- mobile_agent/smart_result.py +490 -0
- mobile_agent/smart_run.py +413 -0
- mobile_agent/state.py +662 -0
- mobile_agent/states.py +217 -0
- mobile_agent/steering.py +70 -0
- mobile_agent/style.py +178 -0
- mobile_agent/supervised.py +234 -0
- mobile_agent/swift/helper_bt_hid.swift +534 -0
- mobile_agent/swift/helper_doc_text.swift +184 -0
- mobile_agent/swift/helper_draft_reply.swift +170 -0
- mobile_agent/swift/helper_map.swift +110 -0
- mobile_agent/swift/helper_mirroring.swift +1099 -0
- mobile_agent/swift/helper_mjpeg_mp4.swift +103 -0
- mobile_agent/swift/helper_pdf.swift +100 -0
- mobile_agent/swift/helper_phone_audio.swift +993 -0
- mobile_agent/swift/helper_phone_screen.swift +416 -0
- mobile_agent/swift/helper_screen_text.swift +24 -0
- mobile_agent/swift/helper_transfer.swift +246 -0
- mobile_agent/swift/ocr.swift +22 -0
- mobile_agent/task_policy.py +337 -0
- mobile_agent/team_signing/__init__.py +34 -0
- mobile_agent/team_signing/apple_ca.py +37 -0
- mobile_agent/team_signing/bundle.py +136 -0
- mobile_agent/team_signing/cli.py +208 -0
- mobile_agent/team_signing/identity.py +248 -0
- mobile_agent/team_signing/ledger.py +105 -0
- mobile_agent/team_signing/prebuilt.py +61 -0
- mobile_agent/team_signing/resign.py +256 -0
- mobile_agent/team_signing/service.py +382 -0
- mobile_agent/team_signing/tooling.py +243 -0
- mobile_agent/team_signing/writes.py +268 -0
- mobile_agent/terminal_image.py +81 -0
- mobile_agent/testkit/__init__.py +1 -0
- mobile_agent/testkit/a11y_baseline.py +132 -0
- mobile_agent/testkit/api.py +403 -0
- mobile_agent/testkit/capsule.py +400 -0
- mobile_agent/testkit/capsule_cli.py +57 -0
- mobile_agent/testkit/cli.py +948 -0
- mobile_agent/testkit/cli_arguments.py +193 -0
- mobile_agent/testkit/comparison.py +288 -0
- mobile_agent/testkit/comparison_cli.py +41 -0
- mobile_agent/testkit/discover.py +257 -0
- mobile_agent/testkit/editor.py +243 -0
- mobile_agent/testkit/execute.py +515 -0
- mobile_agent/testkit/github_checks.py +248 -0
- mobile_agent/testkit/gitref.py +66 -0
- mobile_agent/testkit/heal_review.py +176 -0
- mobile_agent/testkit/health.py +480 -0
- mobile_agent/testkit/history.py +255 -0
- mobile_agent/testkit/hosted_results.py +321 -0
- mobile_agent/testkit/input_files.py +173 -0
- mobile_agent/testkit/jobs.py +285 -0
- mobile_agent/testkit/junit_verdict.py +33 -0
- mobile_agent/testkit/maestro.py +802 -0
- mobile_agent/testkit/mcp.py +562 -0
- mobile_agent/testkit/mcp_comparison.py +88 -0
- mobile_agent/testkit/minimize.py +471 -0
- mobile_agent/testkit/minimizer_cli.py +48 -0
- mobile_agent/testkit/notify.py +212 -0
- mobile_agent/testkit/phone_video.py +191 -0
- mobile_agent/testkit/pr.py +491 -0
- mobile_agent/testkit/pr_report.py +562 -0
- mobile_agent/testkit/prbuild.py +148 -0
- mobile_agent/testkit/proof_report.py +55 -0
- mobile_agent/testkit/provenance.py +119 -0
- mobile_agent/testkit/prrun.py +244 -0
- mobile_agent/testkit/recent.py +166 -0
- mobile_agent/testkit/recipes.py +146 -0
- mobile_agent/testkit/record.py +204 -0
- mobile_agent/testkit/register.py +288 -0
- mobile_agent/testkit/reports.py +998 -0
- mobile_agent/testkit/repro.py +432 -0
- mobile_agent/testkit/scenario.py +440 -0
- mobile_agent/testkit/scenario_cli.py +37 -0
- mobile_agent/testkit/snapshots_cli.py +60 -0
- mobile_agent/testkit/suite.py +670 -0
- mobile_agent/testkit/targets.py +497 -0
- mobile_agent/testkit/xctest.py +378 -0
- mobile_agent/threads/__init__.py +1 -0
- mobile_agent/threads/chat_once.py +264 -0
- mobile_agent/threads/cli.py +740 -0
- mobile_agent/threads/client.py +326 -0
- mobile_agent/threads/context.py +246 -0
- mobile_agent/threads/mcp.py +739 -0
- mobile_agent/threads/recent.py +34 -0
- mobile_agent/threads/register.py +54 -0
- mobile_agent/threads/routes.py +188 -0
- mobile_agent/threads/service.py +975 -0
- mobile_agent/threads/store.py +361 -0
- mobile_agent/threads/summary.py +117 -0
- mobile_agent/threads/term.py +101 -0
- mobile_agent/threads/workflow_cli.py +104 -0
- mobile_agent/tls.py +36 -0
- mobile_agent/traces.py +271 -0
- mobile_agent/tracks.py +235 -0
- mobile_agent/transport.py +271 -0
- mobile_agent/triggers/__init__.py +1 -0
- mobile_agent/triggers/actions.py +204 -0
- mobile_agent/triggers/model.py +146 -0
- mobile_agent/triggers/register.py +29 -0
- mobile_agent/triggers/routes.py +101 -0
- mobile_agent/triggers/service.py +387 -0
- mobile_agent/triggers/store.py +142 -0
- mobile_agent/tui/__init__.py +89 -0
- mobile_agent/tui/app.py +582 -0
- mobile_agent/tui/chrome.py +321 -0
- mobile_agent/tui/commands.py +375 -0
- mobile_agent/tui/demo_phone.py +369 -0
- mobile_agent/tui/images.py +29 -0
- mobile_agent/tui/keys.py +64 -0
- mobile_agent/tui/prompt.py +195 -0
- mobile_agent/tui/render.py +395 -0
- mobile_agent/tui/screens.py +180 -0
- mobile_agent/tui/session.py +308 -0
- mobile_agent/tui/tasks.py +287 -0
- mobile_agent/tui/theme.py +118 -0
- mobile_agent/tui/widgets.py +272 -0
- mobile_agent/update_check.py +213 -0
- mobile_agent/usage.py +184 -0
- mobile_agent/verify/__init__.py +0 -0
- mobile_agent/verify/__main__.py +41 -0
- mobile_agent/verify/a11y.py +189 -0
- mobile_agent/verify/actions.py +290 -0
- mobile_agent/verify/assertions.py +570 -0
- mobile_agent/verify/audit.py +312 -0
- mobile_agent/verify/business.py +221 -0
- mobile_agent/verify/business_run.py +131 -0
- mobile_agent/verify/business_state.py +168 -0
- mobile_agent/verify/check_schema.py +398 -0
- mobile_agent/verify/checks.py +614 -0
- mobile_agent/verify/cli.py +423 -0
- mobile_agent/verify/conditions.py +483 -0
- mobile_agent/verify/crashes.py +321 -0
- mobile_agent/verify/device_audit.py +246 -0
- mobile_agent/verify/dialogs.py +287 -0
- mobile_agent/verify/evidence.py +705 -0
- mobile_agent/verify/fixtures.py +305 -0
- mobile_agent/verify/flow.py +213 -0
- mobile_agent/verify/l10n.py +141 -0
- mobile_agent/verify/netcerts.py +67 -0
- mobile_agent/verify/netproxy.py +356 -0
- mobile_agent/verify/netrun.py +81 -0
- mobile_agent/verify/netshim.m +28 -0
- mobile_agent/verify/netshim.py +48 -0
- mobile_agent/verify/netstubs.py +132 -0
- mobile_agent/verify/overlay.py +174 -0
- mobile_agent/verify/perf.py +239 -0
- mobile_agent/verify/proof.py +192 -0
- mobile_agent/verify/replay_admission.py +48 -0
- mobile_agent/verify/report.py +952 -0
- mobile_agent/verify/run_cleanup.py +67 -0
- mobile_agent/verify/run_links.py +48 -0
- mobile_agent/verify/run_perf.py +44 -0
- mobile_agent/verify/run_secrets.py +39 -0
- mobile_agent/verify/runner.py +976 -0
- mobile_agent/verify/runs.py +57 -0
- mobile_agent/verify/screen_coverage.py +181 -0
- mobile_agent/verify/smart.py +106 -0
- mobile_agent/verify/snapshot_run.py +114 -0
- mobile_agent/verify/snapshots.py +306 -0
- mobile_agent/verify/sound.py +335 -0
- mobile_agent/verify/source_observation.py +130 -0
- mobile_agent/verify/system_surfaces/__init__.py +7 -0
- mobile_agent/verify/system_surfaces/play.py +106 -0
- mobile_agent/verify/system_surfaces/spec.py +139 -0
- mobile_agent/verify/system_surfaces/stage.py +138 -0
- mobile_agent/verify/system_surfaces/surfaces.py +121 -0
- mobile_agent/verify/tree.py +223 -0
- mobile_agent/verify/verdict.py +377 -0
- mobile_agent/verify/wire.py +392 -0
- mobile_agent/verify/wirestore.py +176 -0
- mobile_agent/waiting/__init__.py +1 -0
- mobile_agent/waiting/contacts.py +73 -0
- mobile_agent/waiting/drafts.py +136 -0
- mobile_agent/waiting/job.py +145 -0
- mobile_agent/waiting/messages.py +174 -0
- mobile_agent/waiting/rank.py +115 -0
- mobile_agent/waiting/register.py +26 -0
- mobile_agent/watch.py +502 -0
- mobile_agent/wda/__init__.py +2 -0
- mobile_agent/wda/frame_clock.py +646 -0
- mobile_agent/wda/frame_source.py +325 -0
- mobile_agent/wda/frame_store.py +115 -0
- mobile_agent/wda/gate.py +163 -0
- mobile_agent/wda/gestures.py +291 -0
- mobile_agent/wda/guards.py +94 -0
- mobile_agent/wda/identity.py +55 -0
- mobile_agent/wda/observe.py +884 -0
- mobile_agent/wda/patches.py +856 -0
- mobile_agent/wda/point_gestures.py +150 -0
- mobile_agent/wda/source.py +32 -0
- mobile_agent/wda/system_dialogs.py +571 -0
- mobile_agent/wda/text.py +694 -0
- mobile_agent/wda/timeouts.py +77 -0
- mobile_agent/wda/video.py +505 -0
- mobile_agent/wda/watchdog.py +243 -0
- mobile_agent/wireless/__init__.py +15 -0
- mobile_agent/wireless/api.py +354 -0
- mobile_agent/wireless/cli.py +287 -0
- mobile_agent/wireless/devicectl.py +229 -0
- mobile_agent/wireless/exposure.py +33 -0
- mobile_agent/wireless/mobster-wifi-pair.c +122 -0
- mobile_agent/wireless/monitor.py +332 -0
- mobile_agent/wireless/network.py +334 -0
- mobile_agent/wireless/onboarding.py +293 -0
- mobile_agent/wireless/pair.py +124 -0
- mobile_agent/wireless/probe.py +322 -0
- mobile_agent/wireless/register.py +8 -0
- mobile_agent/wireless/relay.py +179 -0
- mobile_agent/wireless/result.py +87 -0
- mobile_agent/wireless/store.py +85 -0
- mobile_agent/wireless/switch.py +77 -0
- mobile_agent/wireless/transport.py +118 -0
- mobile_agent/wireless/words.py +81 -0
- mobile_agent/wireless/xctestrun.py +83 -0
- mobile_agent/workflow_tools.py +59 -0
- mobile_agent/workflows/__init__.py +2 -0
- mobile_agent/workflows/brief.py +201 -0
- mobile_agent/workflows/cron.py +110 -0
- mobile_agent/workflows/health.py +257 -0
- mobile_agent/workflows/history.py +126 -0
- mobile_agent/workflows/records.py +442 -0
- mobile_agent/workflows/runs.py +1000 -0
- mobile_agent/workflows/scheduler.py +134 -0
- mobile_agent/workflows/service.py +973 -0
- mobile_agent/workflows/starters.py +285 -0
- mobile_agent/workflows/triggers.py +248 -0
- mobile_agent/xray/__init__.py +1 -0
- mobile_agent/xray/app_cli.py +116 -0
- mobile_agent/xray/cli.py +21 -0
- mobile_agent/xray/crawl.py +754 -0
- mobile_agent/xray/diff.py +171 -0
- mobile_agent/xray/diff_cli.py +43 -0
- mobile_agent/xray/job.py +184 -0
- mobile_agent/xray/paywall.py +188 -0
- mobile_agent/xray/policy.py +305 -0
- mobile_agent/xray/register.py +43 -0
- mobile_agent/xray/report.py +283 -0
- mobile_agent/xray/screens.py +117 -0
- mobster_cli-0.4.3.dist-info/METADATA +93 -0
- mobster_cli-0.4.3.dist-info/RECORD +1009 -0
- mobster_cli-0.4.3.dist-info/WHEEL +5 -0
- mobster_cli-0.4.3.dist-info/entry_points.txt +3 -0
- mobster_cli-0.4.3.dist-info/licenses/LICENSE +21 -0
- 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()
|