uber-mcp 0.1.2__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.
- uber_login/__init__.py +9 -0
- uber_login/__main__.py +3 -0
- uber_login/app.py +218 -0
- uber_login/logins.py +215 -0
- uber_login/window.py +147 -0
- uber_mcp/__init__.py +9 -0
- uber_mcp/__main__.py +10 -0
- uber_mcp/account.py +100 -0
- uber_mcp/auth.py +44 -0
- uber_mcp/booking.py +390 -0
- uber_mcp/cli.py +228 -0
- uber_mcp/config.py +157 -0
- uber_mcp/errors.py +115 -0
- uber_mcp/fsio.py +102 -0
- uber_mcp/login_client.py +86 -0
- uber_mcp/logs.py +57 -0
- uber_mcp/operations.json +19 -0
- uber_mcp/places.py +196 -0
- uber_mcp/rides.py +201 -0
- uber_mcp/server.py +451 -0
- uber_mcp/session.py +313 -0
- uber_mcp/state.py +176 -0
- uber_mcp/trips.py +78 -0
- uber_mcp/uber.py +216 -0
- uber_mcp-0.1.2.dist-info/METADATA +355 -0
- uber_mcp-0.1.2.dist-info/RECORD +30 -0
- uber_mcp-0.1.2.dist-info/WHEEL +4 -0
- uber_mcp-0.1.2.dist-info/entry_points.txt +2 -0
- uber_mcp-0.1.2.dist-info/licenses/LICENSE +21 -0
- uber_mcp-0.1.2.dist-info/licenses/THIRD_PARTY_NOTICES +35 -0
uber_login/__init__.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""uber-login: the browser a person signs in to Uber with, shown to them through a remote-desktop view.
|
|
2
|
+
|
|
3
|
+
The person sees Uber's own sign-in page, running in a Chromium next to the server, through noVNC. They sign in
|
|
4
|
+
exactly as on uber.com (code, Google, Apple or QR code); this service automates nothing on the sign-in
|
|
5
|
+
(``tests/test_login_safety.py`` pins that). It only notices when Uber says signed in, opens Uber's trips site once
|
|
6
|
+
(Uber's single sign-on for it), checks both from the same browser, hands the session to uber-mcp once and closes
|
|
7
|
+
the browser. It holds no session of its own and no key; only uber-mcp can call it (a bearer).
|
|
8
|
+
``window.sign_in`` is the same window on the person's own screen, for ``uber-mcp login``.
|
|
9
|
+
"""
|
uber_login/__main__.py
ADDED
uber_login/app.py
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"""The login service's API. uber-mcp (``uber-mcp admin login``) holds its bearer; the person, one view token.
|
|
2
|
+
|
|
3
|
+
POST /logins {"layout": "phone"|"desktop"} -> 201 {id, state, view_token, ...} | 503 busy (bearer)
|
|
4
|
+
GET /logins/{id} -> {id, state, host, error, layout} (bearer)
|
|
5
|
+
POST /logins/{id}/result -> the proven session once {account, user_agent, cookies} | 409 not yet (bearer)
|
|
6
|
+
DELETE /logins/{id} -> 204 (bearer)
|
|
7
|
+
GET /view/{id}?t=TOKEN -> the person's sign-in page: Uber's page through noVNC (view token)
|
|
8
|
+
GET /view/{id}/state?t= -> {state, host} (view token)
|
|
9
|
+
WS /logins/{id}/vnc?t= -> the login's remote-desktop stream (RFB), for noVNC (view token)
|
|
10
|
+
GET /novnc/... -> noVNC's own files (the viewer) (open)
|
|
11
|
+
GET /health -> open
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import asyncio
|
|
17
|
+
import hmac
|
|
18
|
+
import logging
|
|
19
|
+
import os
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
from starlette.applications import Starlette
|
|
23
|
+
from starlette.middleware import Middleware
|
|
24
|
+
from starlette.requests import Request
|
|
25
|
+
from starlette.responses import HTMLResponse, JSONResponse, Response
|
|
26
|
+
from starlette.routing import Mount, Route, WebSocketRoute
|
|
27
|
+
from starlette.staticfiles import StaticFiles
|
|
28
|
+
from starlette.types import ASGIApp, Receive, Scope, Send
|
|
29
|
+
from starlette.websockets import WebSocket, WebSocketDisconnect
|
|
30
|
+
|
|
31
|
+
from .logins import Busy, Logins
|
|
32
|
+
|
|
33
|
+
log = logging.getLogger("uber-login")
|
|
34
|
+
|
|
35
|
+
VIEW_PAGE = """<!doctype html><meta name=viewport content="width=device-width,initial-scale=1">
|
|
36
|
+
<meta name=referrer content=no-referrer>
|
|
37
|
+
<title>Sign in to Uber</title>
|
|
38
|
+
<style>
|
|
39
|
+
html,body{margin:0;height:100%;font:15px system-ui;background:#f4f5f4}
|
|
40
|
+
header{padding:.6em 1em;background:#fff;border-bottom:1px solid #ddd}
|
|
41
|
+
header b{font-size:1.05em} .url{color:#2a7a2a;font-size:.85em;margin-top:.2em}
|
|
42
|
+
#screen{position:absolute;top:4.2em;bottom:2.4em;left:0;right:0;background:#fff}
|
|
43
|
+
footer{position:absolute;bottom:0;left:0;right:0;padding:.5em 1em;font-size:.8em;color:#555;background:#fff;
|
|
44
|
+
border-top:1px solid #ddd}
|
|
45
|
+
#done{display:none;padding:3em 1em;text-align:center;font-size:1.2em}
|
|
46
|
+
</style>
|
|
47
|
+
<header><b>Sign in to Uber for uber-mcp</b><div class=url id=url>auth.uber.com</div></header>
|
|
48
|
+
<div id=screen></div><div id=done></div>
|
|
49
|
+
<footer id=status>Sign in as you normally do (code, Google, Apple or QR code). This page finishes by itself.</footer>
|
|
50
|
+
<script type=module>
|
|
51
|
+
import RFB from '/novnc/core/rfb.js';
|
|
52
|
+
const id = location.pathname.split('/')[2], t = new URLSearchParams(location.search).get('t');
|
|
53
|
+
history.replaceState(null, '', location.pathname); // keep the token out of the address bar
|
|
54
|
+
const ws = (location.protocol === 'https:' ? 'wss' : 'ws') + '://' + location.host;
|
|
55
|
+
const rfb = new RFB(document.getElementById('screen'), `${ws}/logins/${id}/vnc?t=${encodeURIComponent(t)}`);
|
|
56
|
+
rfb.scaleViewport = true; rfb.resizeSession = false; rfb.focusOnClick = true;
|
|
57
|
+
const status = document.getElementById('status');
|
|
58
|
+
async function poll() {
|
|
59
|
+
let s;
|
|
60
|
+
try { s = await (await fetch(`/view/${id}/state?t=${encodeURIComponent(t)}`)).json(); } catch (e) { s = {}; }
|
|
61
|
+
if (s.host) document.getElementById('url').textContent = s.host;
|
|
62
|
+
if (s.state === 'signed_in' || s.state === 'closed') {
|
|
63
|
+
rfb.disconnect(); document.getElementById('screen').remove();
|
|
64
|
+
const d = document.getElementById('done'); d.style.display = 'block';
|
|
65
|
+
d.textContent = 'Signed in. You can close this page.'; status.textContent = ''; return;
|
|
66
|
+
}
|
|
67
|
+
if (['failed', 'expired', 'unknown'].includes(s.state)) {
|
|
68
|
+
status.textContent = 'That did not work (' + s.state + ').'; return;
|
|
69
|
+
}
|
|
70
|
+
setTimeout(poll, 1000);
|
|
71
|
+
}
|
|
72
|
+
poll();
|
|
73
|
+
</script>"""
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class BearerOnly:
|
|
77
|
+
"""Every route but /health, /novnc and the person's view (its own token) needs the bearer, checked before any
|
|
78
|
+
WebSocket handshake too."""
|
|
79
|
+
|
|
80
|
+
def __init__(self, app: ASGIApp, token: str) -> None:
|
|
81
|
+
self.app, self.expected = app, f"Bearer {token}".encode()
|
|
82
|
+
|
|
83
|
+
@staticmethod
|
|
84
|
+
def _open(scope: Scope) -> bool:
|
|
85
|
+
path = scope.get("path") or ""
|
|
86
|
+
if scope["type"] == "websocket":
|
|
87
|
+
return path.startswith("/logins/") and path.endswith("/vnc") # the view token is checked there
|
|
88
|
+
return path == "/health" or path.startswith(("/novnc/", "/view/"))
|
|
89
|
+
|
|
90
|
+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
91
|
+
if scope["type"] in ("http", "websocket") and not self._open(scope):
|
|
92
|
+
given = dict(scope.get("headers") or []).get(b"authorization", b"")
|
|
93
|
+
if not hmac.compare_digest(given, self.expected):
|
|
94
|
+
if scope["type"] == "websocket":
|
|
95
|
+
await send({"type": "websocket.close", "code": 4401})
|
|
96
|
+
else:
|
|
97
|
+
await Response(status_code=401)(scope, receive, send)
|
|
98
|
+
return
|
|
99
|
+
await self.app(scope, receive, send)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def build_app(logins: Logins, *, token: str, novnc_dir: Path) -> Starlette:
|
|
103
|
+
async def health(_: Request) -> JSONResponse:
|
|
104
|
+
return JSONResponse({"status": "healthy"})
|
|
105
|
+
|
|
106
|
+
async def start(request: Request) -> JSONResponse:
|
|
107
|
+
try:
|
|
108
|
+
body = await request.json()
|
|
109
|
+
except ValueError:
|
|
110
|
+
body = {}
|
|
111
|
+
layout = body.get("layout") if isinstance(body, dict) else None
|
|
112
|
+
try:
|
|
113
|
+
login = await logins.start(layout if layout in ("phone", "desktop") else "desktop")
|
|
114
|
+
except Busy:
|
|
115
|
+
return JSONResponse({"error": "busy"}, status_code=503)
|
|
116
|
+
return JSONResponse({**login.public(), "view_token": login.view_token}, status_code=201)
|
|
117
|
+
|
|
118
|
+
async def state(request: Request) -> JSONResponse:
|
|
119
|
+
login = logins.get(request.path_params["id"])
|
|
120
|
+
if login is None:
|
|
121
|
+
return JSONResponse({"error": "unknown"}, status_code=404)
|
|
122
|
+
return JSONResponse(login.public())
|
|
123
|
+
|
|
124
|
+
async def result(request: Request) -> JSONResponse:
|
|
125
|
+
login_id = request.path_params["id"]
|
|
126
|
+
if logins.get(login_id) is None:
|
|
127
|
+
return JSONResponse({"error": "unknown"}, status_code=404)
|
|
128
|
+
session = logins.take(login_id)
|
|
129
|
+
if session is None:
|
|
130
|
+
return JSONResponse({"error": "not_signed_in"}, status_code=409)
|
|
131
|
+
return JSONResponse(session)
|
|
132
|
+
|
|
133
|
+
async def close(request: Request) -> Response:
|
|
134
|
+
await logins.close(request.path_params["id"])
|
|
135
|
+
return Response(status_code=204)
|
|
136
|
+
|
|
137
|
+
def _viewable(login_id: str, view_token: str): # type: ignore[no-untyped-def]
|
|
138
|
+
login = logins.get(login_id)
|
|
139
|
+
return login if login is not None and login.view_ok(view_token) else None
|
|
140
|
+
|
|
141
|
+
async def view(request: Request) -> Response:
|
|
142
|
+
if _viewable(request.path_params["id"], request.query_params.get("t", "")) is None:
|
|
143
|
+
return Response(status_code=404)
|
|
144
|
+
return HTMLResponse(VIEW_PAGE, headers={"Cache-Control": "no-store", "X-Frame-Options": "DENY"})
|
|
145
|
+
|
|
146
|
+
async def view_state(request: Request) -> JSONResponse:
|
|
147
|
+
login = _viewable(request.path_params["id"], request.query_params.get("t", ""))
|
|
148
|
+
if login is None:
|
|
149
|
+
return JSONResponse({"state": "unknown"})
|
|
150
|
+
return JSONResponse({"state": login.state, "host": login.host})
|
|
151
|
+
|
|
152
|
+
async def vnc(ws: WebSocket) -> None:
|
|
153
|
+
login = _viewable(ws.path_params["id"], ws.query_params.get("t", ""))
|
|
154
|
+
if login is None or login.state not in ("starting", "waiting"):
|
|
155
|
+
await ws.close(code=4404)
|
|
156
|
+
return
|
|
157
|
+
await ws.accept(subprotocol="binary" if "binary" in ws.scope.get("subprotocols", []) else None)
|
|
158
|
+
try:
|
|
159
|
+
reader, writer = await asyncio.open_connection("127.0.0.1", login.vnc_port)
|
|
160
|
+
except OSError:
|
|
161
|
+
await ws.close(code=1011)
|
|
162
|
+
return
|
|
163
|
+
|
|
164
|
+
async def upstream() -> None:
|
|
165
|
+
try:
|
|
166
|
+
while True:
|
|
167
|
+
message = await ws.receive()
|
|
168
|
+
if message["type"] == "websocket.disconnect":
|
|
169
|
+
return
|
|
170
|
+
writer.write(message.get("bytes") or (message.get("text") or "").encode())
|
|
171
|
+
await writer.drain()
|
|
172
|
+
except (WebSocketDisconnect, ConnectionError):
|
|
173
|
+
return
|
|
174
|
+
|
|
175
|
+
async def downstream() -> None:
|
|
176
|
+
try:
|
|
177
|
+
while data := await reader.read(65536):
|
|
178
|
+
await ws.send_bytes(data)
|
|
179
|
+
except (WebSocketDisconnect, ConnectionError, RuntimeError):
|
|
180
|
+
return
|
|
181
|
+
|
|
182
|
+
tasks = [asyncio.create_task(upstream()), asyncio.create_task(downstream())]
|
|
183
|
+
await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
|
|
184
|
+
for task in tasks:
|
|
185
|
+
task.cancel()
|
|
186
|
+
writer.close()
|
|
187
|
+
|
|
188
|
+
routes = [
|
|
189
|
+
Route("/health", health),
|
|
190
|
+
Route("/logins", start, methods=["POST"]),
|
|
191
|
+
Route("/logins/{id}", state, methods=["GET"]),
|
|
192
|
+
Route("/logins/{id}", close, methods=["DELETE"]),
|
|
193
|
+
Route("/logins/{id}/result", result, methods=["POST"]),
|
|
194
|
+
Route("/view/{id}", view, methods=["GET"]),
|
|
195
|
+
Route("/view/{id}/state", view_state, methods=["GET"]),
|
|
196
|
+
WebSocketRoute("/logins/{id}/vnc", vnc),
|
|
197
|
+
]
|
|
198
|
+
if novnc_dir.is_dir():
|
|
199
|
+
routes.append(Mount("/novnc", app=StaticFiles(directory=novnc_dir)))
|
|
200
|
+
return Starlette(routes=routes, middleware=[Middleware(BearerOnly, token=token)])
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def main() -> None:
|
|
204
|
+
import uvicorn
|
|
205
|
+
|
|
206
|
+
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(name)s %(levelname)s %(message)s")
|
|
207
|
+
token = (os.environ.get("UBER_LOGIN_TOKEN") or "").strip()
|
|
208
|
+
if len(token) < 32:
|
|
209
|
+
raise SystemExit("UBER_LOGIN_TOKEN (at least 32 characters) is required")
|
|
210
|
+
logins = Logins(
|
|
211
|
+
slots=int(os.environ.get("UBER_LOGIN_SLOTS") or 1),
|
|
212
|
+
minutes=int(os.environ.get("UBER_LOGIN_MINUTES") or 20),
|
|
213
|
+
xvfb=os.environ.get("UBER_LOGIN_XVFB") or "Xvfb",
|
|
214
|
+
x11vnc=os.environ.get("UBER_LOGIN_X11VNC") or "x11vnc",
|
|
215
|
+
)
|
|
216
|
+
novnc = Path(os.environ.get("UBER_LOGIN_NOVNC") or "/usr/share/novnc")
|
|
217
|
+
app = build_app(logins, token=token, novnc_dir=novnc)
|
|
218
|
+
uvicorn.run(app, host="0.0.0.0", port=int(os.environ.get("PORT") or 8080), log_level="warning")
|
uber_login/logins.py
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
"""One login = one virtual display, one remote-desktop server on loopback and one throwaway Chromium profile.
|
|
2
|
+
|
|
3
|
+
States: ``starting`` -> ``waiting`` (Uber's sign-in page is up; the person is signing in) -> ``signed_in``
|
|
4
|
+
(proven; the session waits to be taken once) -> ``closed``; or ``failed`` / ``expired`` / ``closed`` early.
|
|
5
|
+
Nothing here clicks, types or fills: the person does every step of the sign-in. Each login has its own view token,
|
|
6
|
+
which only the caller that started it gets: the person's viewer page and remote-desktop stream need it.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import contextlib
|
|
13
|
+
import logging
|
|
14
|
+
import os
|
|
15
|
+
import secrets
|
|
16
|
+
import shutil
|
|
17
|
+
import subprocess
|
|
18
|
+
import tempfile
|
|
19
|
+
import time
|
|
20
|
+
from collections.abc import Awaitable, Callable
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
from .window import DOCUMENT_HOSTS, LAYOUTS, LOGIN_URL, allowed_document, sign_in
|
|
25
|
+
|
|
26
|
+
__all__ = ["DOCUMENT_HOSTS", "LOGIN_URL", "Busy", "Login", "Logins", "allowed_document"]
|
|
27
|
+
|
|
28
|
+
log = logging.getLogger("uber-login")
|
|
29
|
+
|
|
30
|
+
RESULT_TTL = 5 * 60 # a proven session waits this long to be taken, then it is dropped
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class Busy(Exception):
|
|
34
|
+
"""Every login slot is in use."""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class Login:
|
|
39
|
+
id: str
|
|
40
|
+
layout: str
|
|
41
|
+
slot: int
|
|
42
|
+
created: float
|
|
43
|
+
state: str = "starting"
|
|
44
|
+
host: str = ""
|
|
45
|
+
error: str | None = None
|
|
46
|
+
result: dict[str, Any] | None = None
|
|
47
|
+
result_at: float | None = None
|
|
48
|
+
procs: list[subprocess.Popen] = field(default_factory=list)
|
|
49
|
+
profile: str | None = None
|
|
50
|
+
task: asyncio.Task | None = None
|
|
51
|
+
view_token: str = field(default_factory=lambda: secrets.token_urlsafe(24))
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def display(self) -> str:
|
|
55
|
+
return f":{90 + self.slot}"
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def vnc_port(self) -> int:
|
|
59
|
+
return 5990 + self.slot
|
|
60
|
+
|
|
61
|
+
def public(self) -> dict[str, Any]:
|
|
62
|
+
return {"id": self.id, "state": self.state, "host": self.host, "error": self.error, "layout": self.layout}
|
|
63
|
+
|
|
64
|
+
def view_ok(self, token: str) -> bool:
|
|
65
|
+
return secrets.compare_digest(token or "", self.view_token)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
Browser = Callable[[Login, "Logins"], Awaitable[None]]
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class Logins:
|
|
72
|
+
def __init__(
|
|
73
|
+
self,
|
|
74
|
+
*,
|
|
75
|
+
slots: int = 2,
|
|
76
|
+
minutes: int = 15,
|
|
77
|
+
xvfb: str = "Xvfb",
|
|
78
|
+
x11vnc: str = "x11vnc",
|
|
79
|
+
browser: Browser | None = None,
|
|
80
|
+
clock: Callable[[], float] = time.monotonic,
|
|
81
|
+
) -> None:
|
|
82
|
+
self.slots, self.limit = slots, minutes * 60
|
|
83
|
+
self.xvfb, self.x11vnc = xvfb, x11vnc
|
|
84
|
+
self._browser = browser or run_browser
|
|
85
|
+
self._clock = clock
|
|
86
|
+
self._logins: dict[str, Login] = {}
|
|
87
|
+
self._lock = asyncio.Lock()
|
|
88
|
+
|
|
89
|
+
async def start(self, layout: str) -> Login:
|
|
90
|
+
if layout not in LAYOUTS:
|
|
91
|
+
raise ValueError("layout")
|
|
92
|
+
async with self._lock:
|
|
93
|
+
self.reap()
|
|
94
|
+
used = {login.slot for login in self._logins.values()}
|
|
95
|
+
free = [slot for slot in range(self.slots) if slot not in used]
|
|
96
|
+
if not free:
|
|
97
|
+
raise Busy()
|
|
98
|
+
login = Login(secrets.token_urlsafe(18), layout, free[0], self._clock())
|
|
99
|
+
self._logins[login.id] = login
|
|
100
|
+
try:
|
|
101
|
+
await asyncio.to_thread(self._start_display, login)
|
|
102
|
+
except Exception:
|
|
103
|
+
await self.close(login.id, "failed", "display")
|
|
104
|
+
raise
|
|
105
|
+
login.task = asyncio.create_task(self._run(login))
|
|
106
|
+
return login
|
|
107
|
+
|
|
108
|
+
def get(self, login_id: str) -> Login | None:
|
|
109
|
+
self.reap()
|
|
110
|
+
return self._logins.get(login_id)
|
|
111
|
+
|
|
112
|
+
def take(self, login_id: str) -> dict[str, Any] | None:
|
|
113
|
+
"""The proven session, once; the login is over after that."""
|
|
114
|
+
login = self.get(login_id)
|
|
115
|
+
if login is None or login.state != "signed_in" or login.result is None:
|
|
116
|
+
return None
|
|
117
|
+
result, login.result = login.result, None
|
|
118
|
+
self._forget(login, "closed")
|
|
119
|
+
return result
|
|
120
|
+
|
|
121
|
+
async def close(self, login_id: str, state: str = "closed", error: str | None = None) -> None:
|
|
122
|
+
login = self._logins.get(login_id)
|
|
123
|
+
if login is None:
|
|
124
|
+
return
|
|
125
|
+
if login.task and not login.task.done() and login.task is not asyncio.current_task():
|
|
126
|
+
login.task.cancel()
|
|
127
|
+
with contextlib.suppress(asyncio.CancelledError, Exception):
|
|
128
|
+
await login.task
|
|
129
|
+
login.error = error or login.error
|
|
130
|
+
self._forget(login, state)
|
|
131
|
+
|
|
132
|
+
def reap(self) -> None:
|
|
133
|
+
"""Logins past the time limit and proven sessions nobody took are ended."""
|
|
134
|
+
now = self._clock()
|
|
135
|
+
for login in list(self._logins.values()):
|
|
136
|
+
if login.state in ("starting", "waiting") and now - login.created > self.limit:
|
|
137
|
+
if login.task:
|
|
138
|
+
login.task.cancel()
|
|
139
|
+
self._forget(login, "expired")
|
|
140
|
+
elif login.state == "signed_in" and login.result_at and now - login.result_at > RESULT_TTL:
|
|
141
|
+
login.result = None
|
|
142
|
+
self._forget(login, "expired")
|
|
143
|
+
|
|
144
|
+
def _forget(self, login: Login, state: str) -> None:
|
|
145
|
+
login.state = state
|
|
146
|
+
self._stop_display(login)
|
|
147
|
+
self._logins.pop(login.id, None)
|
|
148
|
+
log.info("login %s ended: %s%s", login.id[:6], state, f" ({login.error})" if login.error else "")
|
|
149
|
+
|
|
150
|
+
# -- the display and its remote-desktop view ----------------------------------------------
|
|
151
|
+
|
|
152
|
+
def _start_display(self, login: Login) -> None:
|
|
153
|
+
w, h = LAYOUTS[login.layout]["size"]
|
|
154
|
+
login.procs.append(
|
|
155
|
+
subprocess.Popen(
|
|
156
|
+
[self.xvfb, login.display, "-screen", "0", f"{w}x{h}x24", "-nolisten", "tcp"],
|
|
157
|
+
stdout=subprocess.DEVNULL,
|
|
158
|
+
stderr=subprocess.DEVNULL,
|
|
159
|
+
)
|
|
160
|
+
)
|
|
161
|
+
time.sleep(0.8)
|
|
162
|
+
# Loopback only: the service's WebSocket bridge is the one way in, behind its bearer.
|
|
163
|
+
login.procs.append(
|
|
164
|
+
subprocess.Popen(
|
|
165
|
+
[self.x11vnc, "-display", login.display, "-localhost", "-rfbport", str(login.vnc_port), "-nopw",
|
|
166
|
+
"-forever", "-shared", "-quiet", "-noxdamage"],
|
|
167
|
+
stdout=subprocess.DEVNULL,
|
|
168
|
+
stderr=subprocess.DEVNULL,
|
|
169
|
+
)
|
|
170
|
+
) # fmt: skip
|
|
171
|
+
time.sleep(0.8)
|
|
172
|
+
if any(proc.poll() is not None for proc in login.procs):
|
|
173
|
+
raise RuntimeError("Xvfb or x11vnc exited at start")
|
|
174
|
+
|
|
175
|
+
def _stop_display(self, login: Login) -> None:
|
|
176
|
+
for proc in reversed(login.procs):
|
|
177
|
+
proc.terminate()
|
|
178
|
+
for proc in login.procs:
|
|
179
|
+
with contextlib.suppress(subprocess.TimeoutExpired):
|
|
180
|
+
proc.wait(timeout=5)
|
|
181
|
+
login.procs.clear()
|
|
182
|
+
if login.profile:
|
|
183
|
+
shutil.rmtree(login.profile, ignore_errors=True)
|
|
184
|
+
login.profile = None
|
|
185
|
+
|
|
186
|
+
async def _run(self, login: Login) -> None:
|
|
187
|
+
try:
|
|
188
|
+
await self._browser(login, self)
|
|
189
|
+
except asyncio.CancelledError:
|
|
190
|
+
raise
|
|
191
|
+
except Exception as exc:
|
|
192
|
+
log.warning("login %s failed: %s", login.id[:6], type(exc).__name__)
|
|
193
|
+
if login.id in self._logins and login.state != "signed_in":
|
|
194
|
+
self._forget(login, "failed")
|
|
195
|
+
|
|
196
|
+
def proven(self, login: Login, result: dict[str, Any]) -> None:
|
|
197
|
+
login.result, login.result_at, login.state = result, self._clock(), "signed_in"
|
|
198
|
+
log.info("login %s signed in", login.id[:6])
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
async def run_browser(login: Login, logins: Logins) -> None:
|
|
202
|
+
"""Uber's sign-in page in a fresh profile on the login's display; returns once signed in and proven."""
|
|
203
|
+
login.profile = tempfile.mkdtemp(prefix="uber-login-")
|
|
204
|
+
|
|
205
|
+
def waiting(host: str) -> None:
|
|
206
|
+
login.state, login.host = "waiting", host
|
|
207
|
+
|
|
208
|
+
result = await sign_in(
|
|
209
|
+
profile=login.profile,
|
|
210
|
+
layout=login.layout,
|
|
211
|
+
env={**os.environ, "DISPLAY": login.display},
|
|
212
|
+
extra_args=("--window-position=0,0",),
|
|
213
|
+
on_page=waiting,
|
|
214
|
+
)
|
|
215
|
+
logins.proven(login, result)
|
uber_login/window.py
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""Uber's own sign-in page in a real browser window, until the person has signed in; then the proven session.
|
|
2
|
+
|
|
3
|
+
Used by the login service (a window on a virtual display, shown through noVNC) and by ``uber-mcp login`` (a window
|
|
4
|
+
on the person's own screen). Nothing here clicks, types or fills: the person does every step of the sign-in, Uber's
|
|
5
|
+
own checks included (``tests/test_login_safety.py`` pins that). Once Uber lands on m.uber.com, the window opens
|
|
6
|
+
Uber's trips site once (riders.uber.com signs in through Uber's single sign-on on first visit), reads both as the
|
|
7
|
+
rider with the same cookies, and returns. Only Uber's pages (and Google's or Apple's, for their sign-in buttons)
|
|
8
|
+
may be shown.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import asyncio
|
|
14
|
+
import json
|
|
15
|
+
import re
|
|
16
|
+
from collections.abc import Callable, Mapping
|
|
17
|
+
from typing import Any
|
|
18
|
+
from urllib.parse import urlsplit
|
|
19
|
+
|
|
20
|
+
from uber_mcp.session import clean_cookies
|
|
21
|
+
from uber_mcp.uber import ENDPOINTS, operations
|
|
22
|
+
|
|
23
|
+
LOGIN_URL = "https://m.uber.com/go/login-redirect?previousPathname=%2Fgo%2Fhome"
|
|
24
|
+
RIDERS_URL = "https://riders.uber.com/trips"
|
|
25
|
+
RIDES_LANDED = re.compile(r"https://m\.uber\.com/go/(?!login)")
|
|
26
|
+
RIDERS_LANDED = re.compile(r"https://riders\.uber\.com/")
|
|
27
|
+
# Pages the window may show; anything else is refused (images, scripts and frames load freely).
|
|
28
|
+
DOCUMENT_HOSTS = ("uber.com", "accounts.google.com", "appleid.apple.com")
|
|
29
|
+
LAYOUTS = {
|
|
30
|
+
"desktop": {"size": (1280, 860), "user_agent": None},
|
|
31
|
+
# A phone-sized window gets Uber's mobile pages only with a mobile browser identity.
|
|
32
|
+
"phone": {
|
|
33
|
+
"size": (430, 900),
|
|
34
|
+
"user_agent": "Mozilla/5.0 (Linux; Android 15; Pixel 9) AppleWebKit/537.36 (KHTML, like Gecko) "
|
|
35
|
+
"Chrome/149.0.0.0 Mobile Safari/537.36",
|
|
36
|
+
},
|
|
37
|
+
}
|
|
38
|
+
WATCH_EVERY = 1.5
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def allowed_document(url: str) -> bool:
|
|
42
|
+
if url.startswith(("about:", "data:", "blob:")):
|
|
43
|
+
return True
|
|
44
|
+
parts = urlsplit(url)
|
|
45
|
+
host = (parts.hostname or "").lower()
|
|
46
|
+
return parts.scheme == "https" and any(host == h or host.endswith("." + h) for h in DOCUMENT_HOSTS)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class WindowClosed(Exception):
|
|
50
|
+
"""The person closed the window before signing in."""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class NotProven(Exception):
|
|
54
|
+
"""Uber's pages said signed in, but reading the account with the same cookies did not work."""
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
async def sign_in(
|
|
58
|
+
*,
|
|
59
|
+
profile: str,
|
|
60
|
+
layout: str = "desktop",
|
|
61
|
+
env: Mapping[str, str] | None = None,
|
|
62
|
+
channel: str | None = None,
|
|
63
|
+
extra_args: tuple[str, ...] = (),
|
|
64
|
+
on_page: Callable[[str], None] | None = None,
|
|
65
|
+
) -> dict[str, Any]:
|
|
66
|
+
"""Open Uber's sign-in page in a browser using ``profile`` and wait until the person has signed in.
|
|
67
|
+
|
|
68
|
+
Returns ``{account: {uuid, name}, user_agent, cookies}``. ``channel="chrome"`` uses the installed Google Chrome;
|
|
69
|
+
None uses the browser patchright installed. ``on_page`` hears the host of the page shown, every look.
|
|
70
|
+
"""
|
|
71
|
+
from patchright.async_api import async_playwright
|
|
72
|
+
|
|
73
|
+
width, height = LAYOUTS[layout]["size"]
|
|
74
|
+
async with async_playwright() as p:
|
|
75
|
+
ctx = await p.chromium.launch_persistent_context(
|
|
76
|
+
profile,
|
|
77
|
+
channel=channel,
|
|
78
|
+
headless=False,
|
|
79
|
+
no_viewport=True,
|
|
80
|
+
user_agent=LAYOUTS[layout]["user_agent"],
|
|
81
|
+
env=dict(env) if env is not None else None,
|
|
82
|
+
args=[f"--window-size={width},{height}", *extra_args, "--no-first-run", "--disable-features=Translate"],
|
|
83
|
+
)
|
|
84
|
+
try:
|
|
85
|
+
|
|
86
|
+
async def guard(route: Any) -> None:
|
|
87
|
+
request = route.request
|
|
88
|
+
top = request.is_navigation_request() and request.frame.parent_frame is None
|
|
89
|
+
if top and not allowed_document(request.url):
|
|
90
|
+
await route.abort()
|
|
91
|
+
return
|
|
92
|
+
await route.continue_()
|
|
93
|
+
|
|
94
|
+
await ctx.route("**/*", guard)
|
|
95
|
+
page = ctx.pages[0] if ctx.pages else await ctx.new_page()
|
|
96
|
+
await page.goto(LOGIN_URL, wait_until="domcontentloaded", timeout=60_000)
|
|
97
|
+
while True:
|
|
98
|
+
open_pages = [pg for pg in ctx.pages if not pg.is_closed()]
|
|
99
|
+
if not open_pages:
|
|
100
|
+
raise WindowClosed()
|
|
101
|
+
if on_page is not None:
|
|
102
|
+
on_page(urlsplit(open_pages[0].url).hostname or "")
|
|
103
|
+
landed = next((pg for pg in open_pages if RIDES_LANDED.match(pg.url)), None)
|
|
104
|
+
if landed is not None:
|
|
105
|
+
return await _hop_and_prove(ctx, landed)
|
|
106
|
+
await asyncio.sleep(WATCH_EVERY)
|
|
107
|
+
finally:
|
|
108
|
+
await ctx.close()
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
async def _hop_and_prove(ctx: Any, page: Any) -> dict[str, Any]:
|
|
112
|
+
await page.goto(RIDERS_URL, wait_until="domcontentloaded", timeout=60_000)
|
|
113
|
+
for _ in range(40):
|
|
114
|
+
if RIDERS_LANDED.match(page.url):
|
|
115
|
+
break
|
|
116
|
+
await asyncio.sleep(0.5)
|
|
117
|
+
user = (await _graphql(ctx, "riders", "CurrentUserRidersWeb", {})).get("currentUser") or {}
|
|
118
|
+
status = (await _graphql(ctx, "rides", "GetStatus", {"latitude": 0, "longitude": 0})).get("status") or {}
|
|
119
|
+
if not user.get("uuid") or not isinstance(status, dict) or "clientStatus" not in status:
|
|
120
|
+
raise NotProven()
|
|
121
|
+
return {
|
|
122
|
+
"account": {"uuid": user["uuid"], "name": str(user.get("firstName") or "")},
|
|
123
|
+
"user_agent": await page.evaluate("() => navigator.userAgent"),
|
|
124
|
+
"cookies": clean_cookies(await ctx.cookies()),
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
async def _graphql(ctx: Any, surface: str, operation: str, variables: dict[str, Any]) -> dict[str, Any]:
|
|
129
|
+
url = ENDPOINTS[surface]
|
|
130
|
+
origin = "https://" + (urlsplit(url).hostname or "")
|
|
131
|
+
response = await ctx.request.post(
|
|
132
|
+
url,
|
|
133
|
+
data=json.dumps({"operationName": operation, "query": operations()[operation], "variables": variables}),
|
|
134
|
+
headers={
|
|
135
|
+
"content-type": "application/json",
|
|
136
|
+
"accept": "application/json",
|
|
137
|
+
"x-csrf-token": "x",
|
|
138
|
+
"origin": origin,
|
|
139
|
+
"referer": origin + "/",
|
|
140
|
+
}, # fmt: skip
|
|
141
|
+
)
|
|
142
|
+
try:
|
|
143
|
+
payload = await response.json()
|
|
144
|
+
except Exception:
|
|
145
|
+
return {}
|
|
146
|
+
data = payload.get("data") if isinstance(payload, dict) else None
|
|
147
|
+
return data if isinstance(data, dict) else {}
|
uber_mcp/__init__.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""uber-mcp: unofficial MCP access to an Uber rider account: places, live fares, rides behind a confirm step."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
# The release tool bumps pyproject.toml only; read the installed distribution.
|
|
7
|
+
__version__ = version("uber-mcp")
|
|
8
|
+
except PackageNotFoundError: # pragma: no cover - source tree without an install
|
|
9
|
+
__version__ = "0+unknown"
|