navvi 2.0.0
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.
- package/LICENSE +201 -0
- package/README.md +179 -0
- package/bin/navvi.js +150 -0
- package/container/Dockerfile +63 -0
- package/container/marionette.py +147 -0
- package/container/navvi-server.py +652 -0
- package/container/requirements.txt +2 -0
- package/container/start.sh +126 -0
- package/docs/navvi-logo.png +0 -0
- package/mcp/server.mjs +1278 -0
- package/package.json +41 -0
- package/personas/default.yaml +5 -0
- package/personas/dev.yaml +15 -0
- package/personas/fry.yaml +18 -0
|
@@ -0,0 +1,652 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Navvi API Server — FastAPI REST endpoints for browser automation.
|
|
3
|
+
|
|
4
|
+
Runs inside the container. Controls Firefox via:
|
|
5
|
+
- xdotool (OS-level mouse/keyboard — isTrusted: true events)
|
|
6
|
+
- scrot (screenshots)
|
|
7
|
+
- marionette.py (navigate, getURL, getTitle, executeJS via Firefox Marionette)
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import asyncio
|
|
12
|
+
import base64
|
|
13
|
+
import os
|
|
14
|
+
import re
|
|
15
|
+
import shlex
|
|
16
|
+
import subprocess
|
|
17
|
+
import tempfile
|
|
18
|
+
|
|
19
|
+
from fastapi import FastAPI, HTTPException
|
|
20
|
+
from fastapi.responses import JSONResponse
|
|
21
|
+
from pydantic import BaseModel
|
|
22
|
+
from typing import Optional
|
|
23
|
+
|
|
24
|
+
from marionette import Marionette, MarionetteError
|
|
25
|
+
|
|
26
|
+
app = FastAPI(title="Navvi Server", version="2.0.0")
|
|
27
|
+
|
|
28
|
+
# --- Globals ---
|
|
29
|
+
|
|
30
|
+
marionette: Optional[Marionette] = None
|
|
31
|
+
display: str = ":1"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# --- Pydantic models ---
|
|
35
|
+
|
|
36
|
+
class NavigateRequest(BaseModel):
|
|
37
|
+
url: str
|
|
38
|
+
|
|
39
|
+
class ClickRequest(BaseModel):
|
|
40
|
+
x: int
|
|
41
|
+
y: int
|
|
42
|
+
|
|
43
|
+
class TypeRequest(BaseModel):
|
|
44
|
+
text: str
|
|
45
|
+
delay: int = 12 # ms between chars
|
|
46
|
+
|
|
47
|
+
class KeyRequest(BaseModel):
|
|
48
|
+
key: str
|
|
49
|
+
|
|
50
|
+
class MouseRequest(BaseModel):
|
|
51
|
+
x: int
|
|
52
|
+
y: int
|
|
53
|
+
|
|
54
|
+
class DragRequest(BaseModel):
|
|
55
|
+
x1: int
|
|
56
|
+
y1: int
|
|
57
|
+
x2: int
|
|
58
|
+
y2: int
|
|
59
|
+
steps: int = 20
|
|
60
|
+
duration: float = 0.3 # seconds for the full drag
|
|
61
|
+
|
|
62
|
+
class ScrollRequest(BaseModel):
|
|
63
|
+
direction: str = "down" # up, down, left, right
|
|
64
|
+
amount: int = 3
|
|
65
|
+
|
|
66
|
+
class ExecuteJSRequest(BaseModel):
|
|
67
|
+
script: str
|
|
68
|
+
args: list = []
|
|
69
|
+
|
|
70
|
+
class FindRequest(BaseModel):
|
|
71
|
+
selector: str
|
|
72
|
+
all: bool = False # return all matches vs just the first
|
|
73
|
+
|
|
74
|
+
class CredsAutofillRequest(BaseModel):
|
|
75
|
+
entry: str # gopass entry path, e.g. "navvi/default/tuta"
|
|
76
|
+
username_selector: str = "input[type=email], input[type=text], input[name*=user i], input[name*=email i], input[name*=login i]"
|
|
77
|
+
password_selector: str = "input[type=password]"
|
|
78
|
+
|
|
79
|
+
class CredsGetRequest(BaseModel):
|
|
80
|
+
entry: str
|
|
81
|
+
field: str # e.g. "username", "url", "email" — NOT "password"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
# --- Helpers ---
|
|
85
|
+
|
|
86
|
+
def run_xdotool(args: str, timeout: float = 5.0) -> str:
|
|
87
|
+
"""Run an xdotool command and return stdout."""
|
|
88
|
+
env = os.environ.copy()
|
|
89
|
+
env["DISPLAY"] = display
|
|
90
|
+
result = subprocess.run(
|
|
91
|
+
f"xdotool {args}",
|
|
92
|
+
shell=True,
|
|
93
|
+
capture_output=True,
|
|
94
|
+
text=True,
|
|
95
|
+
timeout=timeout,
|
|
96
|
+
env=env,
|
|
97
|
+
)
|
|
98
|
+
if result.returncode != 0 and result.stderr:
|
|
99
|
+
raise RuntimeError(f"xdotool error: {result.stderr.strip()}")
|
|
100
|
+
return result.stdout.strip()
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def get_marionette() -> Marionette:
|
|
104
|
+
"""Get or reconnect the Marionette client."""
|
|
105
|
+
global marionette
|
|
106
|
+
if marionette is None:
|
|
107
|
+
marionette = Marionette()
|
|
108
|
+
marionette.connect()
|
|
109
|
+
marionette.new_session()
|
|
110
|
+
return marionette
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def reconnect_marionette() -> Marionette:
|
|
114
|
+
"""Force reconnect (e.g. after Firefox restart)."""
|
|
115
|
+
global marionette
|
|
116
|
+
if marionette:
|
|
117
|
+
marionette.close()
|
|
118
|
+
marionette = None
|
|
119
|
+
return get_marionette()
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
# xdotool key name mapping (browser key names → xdotool names)
|
|
123
|
+
KEY_MAP = {
|
|
124
|
+
"Enter": "Return",
|
|
125
|
+
"Backspace": "BackSpace",
|
|
126
|
+
"ArrowUp": "Up",
|
|
127
|
+
"ArrowDown": "Down",
|
|
128
|
+
"ArrowLeft": "Left",
|
|
129
|
+
"ArrowRight": "Right",
|
|
130
|
+
"Escape": "Escape",
|
|
131
|
+
"Tab": "Tab",
|
|
132
|
+
"Delete": "Delete",
|
|
133
|
+
"Home": "Home",
|
|
134
|
+
"End": "End",
|
|
135
|
+
"PageUp": "Prior",
|
|
136
|
+
"PageDown": "Next",
|
|
137
|
+
"Space": "space",
|
|
138
|
+
" ": "space",
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
# --- Endpoints ---
|
|
143
|
+
|
|
144
|
+
@app.get("/health")
|
|
145
|
+
async def health():
|
|
146
|
+
"""Check Firefox + Xvfb are alive."""
|
|
147
|
+
checks = {"xvfb": False, "firefox": False, "marionette": False}
|
|
148
|
+
|
|
149
|
+
# Check Xvfb
|
|
150
|
+
try:
|
|
151
|
+
result = subprocess.run(
|
|
152
|
+
["xdpyinfo", "-display", display],
|
|
153
|
+
capture_output=True, timeout=3
|
|
154
|
+
)
|
|
155
|
+
checks["xvfb"] = result.returncode == 0
|
|
156
|
+
except (subprocess.TimeoutExpired, FileNotFoundError):
|
|
157
|
+
# xdpyinfo may not be installed; check if Xvfb process exists
|
|
158
|
+
try:
|
|
159
|
+
subprocess.run(
|
|
160
|
+
["pgrep", "-f", f"Xvfb {display}"],
|
|
161
|
+
capture_output=True, timeout=3
|
|
162
|
+
)
|
|
163
|
+
checks["xvfb"] = True
|
|
164
|
+
except Exception:
|
|
165
|
+
pass
|
|
166
|
+
|
|
167
|
+
# Check Firefox process
|
|
168
|
+
try:
|
|
169
|
+
result = subprocess.run(
|
|
170
|
+
["pgrep", "-f", "firefox"],
|
|
171
|
+
capture_output=True, timeout=3
|
|
172
|
+
)
|
|
173
|
+
checks["firefox"] = result.returncode == 0
|
|
174
|
+
except Exception:
|
|
175
|
+
pass
|
|
176
|
+
|
|
177
|
+
# Check Marionette connection
|
|
178
|
+
try:
|
|
179
|
+
m = get_marionette()
|
|
180
|
+
m.get_url()
|
|
181
|
+
checks["marionette"] = True
|
|
182
|
+
except Exception:
|
|
183
|
+
pass
|
|
184
|
+
|
|
185
|
+
ok = all(checks.values())
|
|
186
|
+
return {"ok": ok, **checks}
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
@app.post("/navigate")
|
|
190
|
+
async def navigate(req: NavigateRequest):
|
|
191
|
+
"""Navigate to a URL via Marionette."""
|
|
192
|
+
try:
|
|
193
|
+
m = get_marionette()
|
|
194
|
+
m.navigate(req.url)
|
|
195
|
+
# Give the page a moment to start loading
|
|
196
|
+
await asyncio.sleep(1.0)
|
|
197
|
+
url = m.get_url()
|
|
198
|
+
title = m.get_title()
|
|
199
|
+
return {"ok": True, "url": url, "title": title}
|
|
200
|
+
except MarionetteError as e:
|
|
201
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
@app.get("/url")
|
|
205
|
+
async def get_url():
|
|
206
|
+
"""Get current page URL."""
|
|
207
|
+
try:
|
|
208
|
+
m = get_marionette()
|
|
209
|
+
return {"url": m.get_url()}
|
|
210
|
+
except MarionetteError as e:
|
|
211
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
@app.get("/title")
|
|
215
|
+
async def get_title():
|
|
216
|
+
"""Get current page title."""
|
|
217
|
+
try:
|
|
218
|
+
m = get_marionette()
|
|
219
|
+
return {"title": m.get_title()}
|
|
220
|
+
except MarionetteError as e:
|
|
221
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
@app.post("/click")
|
|
225
|
+
async def click(req: ClickRequest):
|
|
226
|
+
"""Click at (x, y) using xdotool."""
|
|
227
|
+
run_xdotool(f"mousemove --sync {req.x} {req.y}")
|
|
228
|
+
await asyncio.sleep(0.05)
|
|
229
|
+
run_xdotool("click 1")
|
|
230
|
+
return {"ok": True, "x": req.x, "y": req.y}
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
@app.post("/type")
|
|
234
|
+
async def type_text(req: TypeRequest):
|
|
235
|
+
"""Type text using xdotool, chunked at 50 chars."""
|
|
236
|
+
text = req.text
|
|
237
|
+
chunk_size = 50
|
|
238
|
+
for i in range(0, len(text), chunk_size):
|
|
239
|
+
chunk = text[i:i + chunk_size]
|
|
240
|
+
# Escape for shell safety
|
|
241
|
+
safe_chunk = shlex.quote(chunk)
|
|
242
|
+
run_xdotool(f"type --delay {req.delay} -- {safe_chunk}", timeout=30.0)
|
|
243
|
+
return {"ok": True, "length": len(text)}
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
@app.post("/key")
|
|
247
|
+
async def press_key(req: KeyRequest):
|
|
248
|
+
"""Press a key using xdotool."""
|
|
249
|
+
key = KEY_MAP.get(req.key, req.key)
|
|
250
|
+
run_xdotool(f"key {shlex.quote(key)}")
|
|
251
|
+
return {"ok": True, "key": key}
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
@app.post("/mousedown")
|
|
255
|
+
async def mousedown(req: MouseRequest):
|
|
256
|
+
"""Move to (x, y) and press mouse button down."""
|
|
257
|
+
run_xdotool(f"mousemove --sync {req.x} {req.y}")
|
|
258
|
+
await asyncio.sleep(0.05)
|
|
259
|
+
run_xdotool("mousedown 1")
|
|
260
|
+
return {"ok": True, "x": req.x, "y": req.y}
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
@app.post("/mouseup")
|
|
264
|
+
async def mouseup(req: MouseRequest):
|
|
265
|
+
"""Move to (x, y) and release mouse button."""
|
|
266
|
+
run_xdotool(f"mousemove --sync {req.x} {req.y}")
|
|
267
|
+
await asyncio.sleep(0.05)
|
|
268
|
+
run_xdotool("mouseup 1")
|
|
269
|
+
return {"ok": True, "x": req.x, "y": req.y}
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
@app.post("/mousemove")
|
|
273
|
+
async def mousemove(req: MouseRequest):
|
|
274
|
+
"""Move mouse to (x, y)."""
|
|
275
|
+
run_xdotool(f"mousemove --sync {req.x} {req.y}")
|
|
276
|
+
return {"ok": True, "x": req.x, "y": req.y}
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
@app.post("/drag")
|
|
280
|
+
async def drag(req: DragRequest):
|
|
281
|
+
"""Drag from (x1,y1) to (x2,y2) with interpolated mouse moves."""
|
|
282
|
+
steps = max(req.steps, 2)
|
|
283
|
+
step_delay = req.duration / steps
|
|
284
|
+
|
|
285
|
+
# Move to start and press
|
|
286
|
+
run_xdotool(f"mousemove --sync {req.x1} {req.y1}")
|
|
287
|
+
await asyncio.sleep(0.05)
|
|
288
|
+
run_xdotool("mousedown 1")
|
|
289
|
+
await asyncio.sleep(0.05)
|
|
290
|
+
|
|
291
|
+
# Interpolate path
|
|
292
|
+
for i in range(1, steps + 1):
|
|
293
|
+
t = i / steps
|
|
294
|
+
cx = int(req.x1 + (req.x2 - req.x1) * t)
|
|
295
|
+
cy = int(req.y1 + (req.y2 - req.y1) * t)
|
|
296
|
+
run_xdotool(f"mousemove --sync {cx} {cy}")
|
|
297
|
+
await asyncio.sleep(step_delay)
|
|
298
|
+
|
|
299
|
+
# Release
|
|
300
|
+
run_xdotool("mouseup 1")
|
|
301
|
+
return {"ok": True, "from": [req.x1, req.y1], "to": [req.x2, req.y2]}
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
@app.post("/scroll")
|
|
305
|
+
async def scroll(req: ScrollRequest):
|
|
306
|
+
"""Scroll using xdotool button clicks (4=up, 5=down, 6=left, 7=right)."""
|
|
307
|
+
button_map = {"up": 4, "down": 5, "left": 6, "right": 7}
|
|
308
|
+
button = button_map.get(req.direction)
|
|
309
|
+
if button is None:
|
|
310
|
+
raise HTTPException(
|
|
311
|
+
status_code=400,
|
|
312
|
+
detail=f"Invalid direction: {req.direction}. Use up/down/left/right."
|
|
313
|
+
)
|
|
314
|
+
run_xdotool(f"click --repeat {req.amount} {button}")
|
|
315
|
+
return {"ok": True, "direction": req.direction, "amount": req.amount}
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
@app.get("/screenshot")
|
|
319
|
+
async def screenshot():
|
|
320
|
+
"""Take a screenshot with scrot and return base64 PNG."""
|
|
321
|
+
env = os.environ.copy()
|
|
322
|
+
env["DISPLAY"] = display
|
|
323
|
+
tmp_path = os.path.join(tempfile.gettempdir(), "navvi-shot.png")
|
|
324
|
+
|
|
325
|
+
result = subprocess.run(
|
|
326
|
+
["scrot", "-o", "-p", tmp_path],
|
|
327
|
+
capture_output=True, timeout=5, env=env
|
|
328
|
+
)
|
|
329
|
+
if result.returncode != 0:
|
|
330
|
+
raise HTTPException(
|
|
331
|
+
status_code=500,
|
|
332
|
+
detail=f"scrot failed: {result.stderr.decode().strip()}"
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
with open(tmp_path, "rb") as f:
|
|
336
|
+
img_data = f.read()
|
|
337
|
+
|
|
338
|
+
b64 = base64.b64encode(img_data).decode("ascii")
|
|
339
|
+
return {"ok": True, "base64": b64, "size": len(img_data)}
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
@app.get("/cursor")
|
|
343
|
+
async def cursor():
|
|
344
|
+
"""Get current mouse position."""
|
|
345
|
+
output = run_xdotool("getmouselocation --shell")
|
|
346
|
+
# Parse: X=123\nY=456\nSCREEN=0\nWINDOW=...
|
|
347
|
+
pos = {}
|
|
348
|
+
for line in output.splitlines():
|
|
349
|
+
if "=" in line:
|
|
350
|
+
k, v = line.split("=", 1)
|
|
351
|
+
pos[k.lower()] = int(v) if v.isdigit() else v
|
|
352
|
+
return {"ok": True, "x": pos.get("x", 0), "y": pos.get("y", 0)}
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
@app.post("/execute")
|
|
356
|
+
async def execute_js(req: ExecuteJSRequest):
|
|
357
|
+
"""Execute JavaScript in Firefox via Marionette."""
|
|
358
|
+
try:
|
|
359
|
+
m = get_marionette()
|
|
360
|
+
result = m.execute_script(req.script, req.args)
|
|
361
|
+
return {"ok": True, "value": result}
|
|
362
|
+
except MarionetteError as e:
|
|
363
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def get_viewport_offset():
|
|
367
|
+
"""Get the pixel offset from screen origin to Firefox's content viewport.
|
|
368
|
+
|
|
369
|
+
JS getBoundingClientRect() returns coords relative to the viewport.
|
|
370
|
+
xdotool works with absolute screen coords. The difference is the
|
|
371
|
+
browser chrome (tab bar, address bar, notification banners).
|
|
372
|
+
|
|
373
|
+
Firefox exposes this via mozInnerScreenX/Y — the screen position
|
|
374
|
+
of the top-left corner of the viewport.
|
|
375
|
+
"""
|
|
376
|
+
try:
|
|
377
|
+
m = get_marionette()
|
|
378
|
+
result = m.execute_script(
|
|
379
|
+
"return { x: window.mozInnerScreenX || 0, y: window.mozInnerScreenY || 0 }"
|
|
380
|
+
)
|
|
381
|
+
return (int(result.get("x", 0)), int(result.get("y", 0)))
|
|
382
|
+
except Exception:
|
|
383
|
+
return (0, 0)
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
@app.get("/viewport")
|
|
387
|
+
async def viewport():
|
|
388
|
+
"""Get viewport offset — the translation between JS coordinates and screen coordinates.
|
|
389
|
+
|
|
390
|
+
Returns the pixel offset from screen origin to the browser content area.
|
|
391
|
+
Add these values to any getBoundingClientRect() coordinates before
|
|
392
|
+
passing them to click/mousedown/etc.
|
|
393
|
+
"""
|
|
394
|
+
offset_x, offset_y = get_viewport_offset()
|
|
395
|
+
try:
|
|
396
|
+
m = get_marionette()
|
|
397
|
+
dims = m.execute_script(
|
|
398
|
+
"return { innerWidth: window.innerWidth, innerHeight: window.innerHeight }"
|
|
399
|
+
)
|
|
400
|
+
except Exception:
|
|
401
|
+
dims = {}
|
|
402
|
+
return {
|
|
403
|
+
"ok": True,
|
|
404
|
+
"offset_x": offset_x,
|
|
405
|
+
"offset_y": offset_y,
|
|
406
|
+
"viewport_width": dims.get("innerWidth", 0),
|
|
407
|
+
"viewport_height": dims.get("innerHeight", 0),
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
@app.post("/find")
|
|
412
|
+
async def find_element(req: FindRequest):
|
|
413
|
+
"""Find element(s) by CSS selector and return screen-ready coordinates.
|
|
414
|
+
|
|
415
|
+
Unlike raw executeJS + getBoundingClientRect(), this endpoint
|
|
416
|
+
auto-translates viewport coordinates to screen coordinates by adding
|
|
417
|
+
the browser chrome offset. The returned x/y can be passed directly
|
|
418
|
+
to /click, /mousedown, etc.
|
|
419
|
+
"""
|
|
420
|
+
try:
|
|
421
|
+
m = get_marionette()
|
|
422
|
+
offset_x, offset_y = get_viewport_offset()
|
|
423
|
+
|
|
424
|
+
if req.all:
|
|
425
|
+
script = """
|
|
426
|
+
const els = document.querySelectorAll(arguments[0]);
|
|
427
|
+
return Array.from(els).slice(0, 50).map(el => {
|
|
428
|
+
const r = el.getBoundingClientRect();
|
|
429
|
+
return {
|
|
430
|
+
tag: el.tagName,
|
|
431
|
+
id: el.id || '',
|
|
432
|
+
name: el.name || el.getAttribute('name') || '',
|
|
433
|
+
type: el.type || '',
|
|
434
|
+
role: el.getAttribute('role') || '',
|
|
435
|
+
text: (el.textContent || '').trim().slice(0, 80),
|
|
436
|
+
value: (el.value || '').slice(0, 80),
|
|
437
|
+
placeholder: el.placeholder || '',
|
|
438
|
+
ariaLabel: el.getAttribute('aria-label') || '',
|
|
439
|
+
visible: r.width > 0 && r.height > 0,
|
|
440
|
+
vx: Math.round(r.x + r.width / 2),
|
|
441
|
+
vy: Math.round(r.y + r.height / 2),
|
|
442
|
+
width: Math.round(r.width),
|
|
443
|
+
height: Math.round(r.height),
|
|
444
|
+
};
|
|
445
|
+
});
|
|
446
|
+
"""
|
|
447
|
+
elements = m.execute_script(script, [req.selector])
|
|
448
|
+
if not elements:
|
|
449
|
+
return {"ok": True, "found": False, "elements": []}
|
|
450
|
+
# Apply screen offset
|
|
451
|
+
for el in elements:
|
|
452
|
+
el["x"] = el.pop("vx") + offset_x
|
|
453
|
+
el["y"] = el.pop("vy") + offset_y
|
|
454
|
+
return {"ok": True, "found": True, "count": len(elements), "elements": elements}
|
|
455
|
+
else:
|
|
456
|
+
script = """
|
|
457
|
+
const el = document.querySelector(arguments[0]);
|
|
458
|
+
if (!el) return null;
|
|
459
|
+
const r = el.getBoundingClientRect();
|
|
460
|
+
return {
|
|
461
|
+
tag: el.tagName,
|
|
462
|
+
id: el.id || '',
|
|
463
|
+
name: el.name || el.getAttribute('name') || '',
|
|
464
|
+
type: el.type || '',
|
|
465
|
+
role: el.getAttribute('role') || '',
|
|
466
|
+
text: (el.textContent || '').trim().slice(0, 80),
|
|
467
|
+
value: (el.value || '').slice(0, 80),
|
|
468
|
+
placeholder: el.placeholder || '',
|
|
469
|
+
ariaLabel: el.getAttribute('aria-label') || '',
|
|
470
|
+
visible: r.width > 0 && r.height > 0,
|
|
471
|
+
vx: Math.round(r.x + r.width / 2),
|
|
472
|
+
vy: Math.round(r.y + r.height / 2),
|
|
473
|
+
width: Math.round(r.width),
|
|
474
|
+
height: Math.round(r.height),
|
|
475
|
+
};
|
|
476
|
+
"""
|
|
477
|
+
el = m.execute_script(script, [req.selector])
|
|
478
|
+
if not el:
|
|
479
|
+
return {"ok": True, "found": False}
|
|
480
|
+
el["x"] = el.pop("vx") + offset_x
|
|
481
|
+
el["y"] = el.pop("vy") + offset_y
|
|
482
|
+
return {"ok": True, "found": True, **el}
|
|
483
|
+
except MarionetteError as e:
|
|
484
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
# --- Credential management (gopass) ---
|
|
488
|
+
|
|
489
|
+
def run_gopass(args: str, timeout: float = 5.0) -> str:
|
|
490
|
+
"""Run a gopass command and return stdout."""
|
|
491
|
+
result = subprocess.run(
|
|
492
|
+
f"gopass {args}",
|
|
493
|
+
shell=True,
|
|
494
|
+
capture_output=True,
|
|
495
|
+
text=True,
|
|
496
|
+
timeout=timeout,
|
|
497
|
+
)
|
|
498
|
+
if result.returncode != 0:
|
|
499
|
+
raise RuntimeError(result.stderr.strip() or f"gopass failed: {result.returncode}")
|
|
500
|
+
return result.stdout.strip()
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
@app.get("/creds/list")
|
|
504
|
+
async def creds_list():
|
|
505
|
+
"""List all credential entries (names only, no secrets)."""
|
|
506
|
+
try:
|
|
507
|
+
output = run_gopass("ls --flat")
|
|
508
|
+
entries = [e for e in output.splitlines() if e.strip()]
|
|
509
|
+
return {"ok": True, "entries": entries, "count": len(entries)}
|
|
510
|
+
except RuntimeError as e:
|
|
511
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
@app.post("/creds/get")
|
|
515
|
+
async def creds_get(req: CredsGetRequest):
|
|
516
|
+
"""Get a specific non-secret field from a gopass entry.
|
|
517
|
+
|
|
518
|
+
Returns metadata fields like username, url, email.
|
|
519
|
+
Refuses to return the password field — use /creds/autofill instead.
|
|
520
|
+
"""
|
|
521
|
+
blocked = {"password", "pass", "secret", "token", "key", "recovery"}
|
|
522
|
+
if req.field.lower() in blocked:
|
|
523
|
+
raise HTTPException(
|
|
524
|
+
status_code=403,
|
|
525
|
+
detail=f"Field '{req.field}' is a secret — use /creds/autofill to fill it into the browser without exposing it."
|
|
526
|
+
)
|
|
527
|
+
try:
|
|
528
|
+
value = run_gopass(f"show {shlex.quote(req.entry)} {shlex.quote(req.field)}")
|
|
529
|
+
return {"ok": True, "entry": req.entry, "field": req.field, "value": value}
|
|
530
|
+
except RuntimeError as e:
|
|
531
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
@app.post("/creds/autofill")
|
|
535
|
+
async def creds_autofill(req: CredsAutofillRequest):
|
|
536
|
+
"""Autofill a login form using gopass credentials.
|
|
537
|
+
|
|
538
|
+
Reads username and password from gopass, finds the form fields
|
|
539
|
+
via CSS selectors, and types them using xdotool. The password
|
|
540
|
+
NEVER appears in the API response — it goes directly from
|
|
541
|
+
gopass → xdotool → browser.
|
|
542
|
+
"""
|
|
543
|
+
try:
|
|
544
|
+
# Read credentials from gopass (server-side only, never returned)
|
|
545
|
+
# Try email first (most login forms want full email), fall back to username
|
|
546
|
+
username = ""
|
|
547
|
+
for field in ["email", "username", "login", "user"]:
|
|
548
|
+
try:
|
|
549
|
+
username = run_gopass(f"show {shlex.quote(req.entry)} {field}")
|
|
550
|
+
if username:
|
|
551
|
+
break
|
|
552
|
+
except RuntimeError:
|
|
553
|
+
continue
|
|
554
|
+
password = run_gopass(f"show -o {shlex.quote(req.entry)}")
|
|
555
|
+
except RuntimeError as e:
|
|
556
|
+
raise HTTPException(status_code=500, detail=f"gopass error: {e}")
|
|
557
|
+
|
|
558
|
+
if not username or not password:
|
|
559
|
+
raise HTTPException(status_code=404, detail=f"Entry '{req.entry}' missing username/email or password")
|
|
560
|
+
|
|
561
|
+
# Find form fields
|
|
562
|
+
try:
|
|
563
|
+
m = get_marionette()
|
|
564
|
+
offset_x, offset_y = get_viewport_offset()
|
|
565
|
+
|
|
566
|
+
find_script = """
|
|
567
|
+
const el = document.querySelector(arguments[0]);
|
|
568
|
+
if (!el) return null;
|
|
569
|
+
const r = el.getBoundingClientRect();
|
|
570
|
+
return {
|
|
571
|
+
x: Math.round(r.x + r.width / 2),
|
|
572
|
+
y: Math.round(r.y + r.height / 2),
|
|
573
|
+
visible: r.width > 0 && r.height > 0,
|
|
574
|
+
};
|
|
575
|
+
"""
|
|
576
|
+
username_el = m.execute_script(find_script, [req.username_selector])
|
|
577
|
+
password_el = m.execute_script(find_script, [req.password_selector])
|
|
578
|
+
|
|
579
|
+
if not username_el or not username_el.get("visible"):
|
|
580
|
+
raise HTTPException(status_code=404, detail=f"Username field not found: {req.username_selector}")
|
|
581
|
+
if not password_el or not password_el.get("visible"):
|
|
582
|
+
raise HTTPException(status_code=404, detail=f"Password field not found: {req.password_selector}")
|
|
583
|
+
|
|
584
|
+
# Apply viewport offset
|
|
585
|
+
ux, uy = username_el["x"] + offset_x, username_el["y"] + offset_y
|
|
586
|
+
px, py = password_el["x"] + offset_x, password_el["y"] + offset_y
|
|
587
|
+
except MarionetteError as e:
|
|
588
|
+
raise HTTPException(status_code=500, detail=f"Browser error: {e}")
|
|
589
|
+
|
|
590
|
+
# Fill username
|
|
591
|
+
run_xdotool(f"mousemove --sync {ux} {uy}")
|
|
592
|
+
await asyncio.sleep(0.05)
|
|
593
|
+
run_xdotool("click 1")
|
|
594
|
+
await asyncio.sleep(0.1)
|
|
595
|
+
run_xdotool("key ctrl+a")
|
|
596
|
+
await asyncio.sleep(0.05)
|
|
597
|
+
safe_user = shlex.quote(username)
|
|
598
|
+
run_xdotool(f"type --delay 15 -- {safe_user}", timeout=15.0)
|
|
599
|
+
|
|
600
|
+
await asyncio.sleep(0.3)
|
|
601
|
+
|
|
602
|
+
# Fill password (never logged, never returned)
|
|
603
|
+
run_xdotool(f"mousemove --sync {px} {py}")
|
|
604
|
+
await asyncio.sleep(0.05)
|
|
605
|
+
run_xdotool("click 1")
|
|
606
|
+
await asyncio.sleep(0.1)
|
|
607
|
+
run_xdotool("key ctrl+a")
|
|
608
|
+
await asyncio.sleep(0.05)
|
|
609
|
+
safe_pass = shlex.quote(password)
|
|
610
|
+
run_xdotool(f"type --delay 15 -- {safe_pass}", timeout=15.0)
|
|
611
|
+
|
|
612
|
+
# Scrub password from memory
|
|
613
|
+
del password
|
|
614
|
+
del safe_pass
|
|
615
|
+
|
|
616
|
+
return {
|
|
617
|
+
"ok": True,
|
|
618
|
+
"entry": req.entry,
|
|
619
|
+
"username_filled": True,
|
|
620
|
+
"password_filled": True,
|
|
621
|
+
"username_at": [ux, uy],
|
|
622
|
+
"password_at": [px, py],
|
|
623
|
+
"note": "Password was typed directly into the browser — it never appeared in this response."
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
# --- Startup ---
|
|
628
|
+
|
|
629
|
+
def main():
|
|
630
|
+
import uvicorn
|
|
631
|
+
|
|
632
|
+
parser = argparse.ArgumentParser(description="Navvi API Server")
|
|
633
|
+
parser.add_argument("--port", type=int, default=8024)
|
|
634
|
+
parser.add_argument("--display", type=str, default=":1")
|
|
635
|
+
args = parser.parse_args()
|
|
636
|
+
|
|
637
|
+
global display
|
|
638
|
+
display = args.display
|
|
639
|
+
|
|
640
|
+
# Connect to Marionette at startup
|
|
641
|
+
try:
|
|
642
|
+
get_marionette()
|
|
643
|
+
print(f"[navvi-server] Marionette connected")
|
|
644
|
+
except Exception as e:
|
|
645
|
+
print(f"[navvi-server] WARNING: Marionette not ready yet: {e}")
|
|
646
|
+
print("[navvi-server] Will retry on first request")
|
|
647
|
+
|
|
648
|
+
uvicorn.run(app, host="0.0.0.0", port=args.port, log_level="warning")
|
|
649
|
+
|
|
650
|
+
|
|
651
|
+
if __name__ == "__main__":
|
|
652
|
+
main()
|