qoder-autopilot 0.2.2__tar.gz → 0.3.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. {qoder_autopilot-0.2.2 → qoder_autopilot-0.3.1}/PKG-INFO +46 -10
  2. {qoder_autopilot-0.2.2 → qoder_autopilot-0.3.1}/README.md +38 -9
  3. {qoder_autopilot-0.2.2 → qoder_autopilot-0.3.1}/pyproject.toml +11 -2
  4. {qoder_autopilot-0.2.2 → qoder_autopilot-0.3.1}/src/qoder_autopilot/__init__.py +1 -1
  5. qoder_autopilot-0.3.1/src/qoder_autopilot/browser/window_tiler.py +246 -0
  6. {qoder_autopilot-0.2.2 → qoder_autopilot-0.3.1}/src/qoder_autopilot/cli.py +55 -3
  7. {qoder_autopilot-0.2.2 → qoder_autopilot-0.3.1}/src/qoder_autopilot/config.py +13 -0
  8. qoder_autopilot-0.3.1/src/qoder_autopilot/credentials.py +77 -0
  9. {qoder_autopilot-0.2.2 → qoder_autopilot-0.3.1}/src/qoder_autopilot/ninerouter.py +17 -0
  10. qoder_autopilot-0.3.1/src/qoder_autopilot/relay.py +416 -0
  11. {qoder_autopilot-0.2.2 → qoder_autopilot-0.3.1}/src/qoder_autopilot/user_config.py +18 -0
  12. qoder_autopilot-0.3.1/tests/test_relay.py +196 -0
  13. qoder_autopilot-0.2.2/src/qoder_autopilot/browser/window_tiler.py +0 -117
  14. qoder_autopilot-0.2.2/src/qoder_autopilot/credentials.py +0 -44
  15. {qoder_autopilot-0.2.2 → qoder_autopilot-0.3.1}/.gitignore +0 -0
  16. {qoder_autopilot-0.2.2 → qoder_autopilot-0.3.1}/LICENSE +0 -0
  17. {qoder_autopilot-0.2.2 → qoder_autopilot-0.3.1}/docs/architecture.md +0 -0
  18. {qoder_autopilot-0.2.2 → qoder_autopilot-0.3.1}/examples/basic_usage.py +0 -0
  19. {qoder_autopilot-0.2.2 → qoder_autopilot-0.3.1}/examples/parallel_mode.py +0 -0
  20. {qoder_autopilot-0.2.2 → qoder_autopilot-0.3.1}/src/qoder_autopilot/__main__.py +0 -0
  21. {qoder_autopilot-0.2.2 → qoder_autopilot-0.3.1}/src/qoder_autopilot/browser/__init__.py +0 -0
  22. {qoder_autopilot-0.2.2 → qoder_autopilot-0.3.1}/src/qoder_autopilot/browser/camoufox.py +0 -0
  23. {qoder_autopilot-0.2.2 → qoder_autopilot-0.3.1}/src/qoder_autopilot/captcha/__init__.py +0 -0
  24. {qoder_autopilot-0.2.2 → qoder_autopilot-0.3.1}/src/qoder_autopilot/captcha/ai_vision.py +0 -0
  25. {qoder_autopilot-0.2.2 → qoder_autopilot-0.3.1}/src/qoder_autopilot/captcha/manual.py +0 -0
  26. {qoder_autopilot-0.2.2 → qoder_autopilot-0.3.1}/src/qoder_autopilot/captcha/opencv_detect.py +0 -0
  27. {qoder_autopilot-0.2.2 → qoder_autopilot-0.3.1}/src/qoder_autopilot/captcha/slider.py +0 -0
  28. {qoder_autopilot-0.2.2 → qoder_autopilot-0.3.1}/src/qoder_autopilot/captcha/solver.py +0 -0
  29. {qoder_autopilot-0.2.2 → qoder_autopilot-0.3.1}/src/qoder_autopilot/deploy.py +0 -0
  30. {qoder_autopilot-0.2.2 → qoder_autopilot-0.3.1}/src/qoder_autopilot/errors.py +0 -0
  31. {qoder_autopilot-0.2.2 → qoder_autopilot-0.3.1}/src/qoder_autopilot/first_run.py +0 -0
  32. {qoder_autopilot-0.2.2 → qoder_autopilot-0.3.1}/src/qoder_autopilot/identity.py +0 -0
  33. {qoder_autopilot-0.2.2 → qoder_autopilot-0.3.1}/src/qoder_autopilot/logger.py +0 -0
  34. {qoder_autopilot-0.2.2 → qoder_autopilot-0.3.1}/src/qoder_autopilot/oauth.py +0 -0
  35. {qoder_autopilot-0.2.2 → qoder_autopilot-0.3.1}/src/qoder_autopilot/otp.py +0 -0
  36. {qoder_autopilot-0.2.2 → qoder_autopilot-0.3.1}/src/qoder_autopilot/register.py +0 -0
  37. {qoder_autopilot-0.2.2 → qoder_autopilot-0.3.1}/src/qoder_autopilot/temp_mail.py +0 -0
  38. {qoder_autopilot-0.2.2 → qoder_autopilot-0.3.1}/src/qoder_autopilot/worker_template/package.json +0 -0
  39. {qoder_autopilot-0.2.2 → qoder_autopilot-0.3.1}/src/qoder_autopilot/worker_template/schema.sql +0 -0
  40. {qoder_autopilot-0.2.2 → qoder_autopilot-0.3.1}/src/qoder_autopilot/worker_template/scripts/setup.sh +0 -0
  41. {qoder_autopilot-0.2.2 → qoder_autopilot-0.3.1}/src/qoder_autopilot/worker_template/src/config.js +0 -0
  42. {qoder_autopilot-0.2.2 → qoder_autopilot-0.3.1}/src/qoder_autopilot/worker_template/src/handlers/api.js +0 -0
  43. {qoder_autopilot-0.2.2 → qoder_autopilot-0.3.1}/src/qoder_autopilot/worker_template/src/handlers/email.js +0 -0
  44. {qoder_autopilot-0.2.2 → qoder_autopilot-0.3.1}/src/qoder_autopilot/worker_template/src/index.js +0 -0
  45. {qoder_autopilot-0.2.2 → qoder_autopilot-0.3.1}/src/qoder_autopilot/worker_template/src/utils.js +0 -0
  46. {qoder_autopilot-0.2.2 → qoder_autopilot-0.3.1}/src/qoder_autopilot/worker_template/wrangler.toml.example +0 -0
  47. {qoder_autopilot-0.2.2 → qoder_autopilot-0.3.1}/tests/conftest.py +0 -0
  48. {qoder_autopilot-0.2.2 → qoder_autopilot-0.3.1}/tests/test_config.py +0 -0
  49. {qoder_autopilot-0.2.2 → qoder_autopilot-0.3.1}/tests/test_errors.py +0 -0
  50. {qoder_autopilot-0.2.2 → qoder_autopilot-0.3.1}/tests/test_identity.py +0 -0
  51. {qoder_autopilot-0.2.2 → qoder_autopilot-0.3.1}/tests/test_oauth.py +0 -0
  52. {qoder_autopilot-0.2.2 → qoder_autopilot-0.3.1}/tests/test_otp.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: qoder-autopilot
3
- Version: 0.2.2
3
+ Version: 0.3.1
4
4
  Summary: Automated Qoder account registration with anti-detect browser, captcha solving (AI + OpenCV), and 9Router OAuth integration.
5
5
  Project-URL: Homepage, https://github.com/Daivageralda/qoder-autopilot
6
6
  Project-URL: Repository, https://github.com/Daivageralda/qoder-autopilot
@@ -30,14 +30,17 @@ Requires-Dist: pydantic>=2.0
30
30
  Requires-Dist: python-dotenv>=1.0
31
31
  Requires-Dist: requests>=2.28
32
32
  Provides-Extra: all
33
+ Requires-Dist: fastapi>=0.100; extra == 'all'
33
34
  Requires-Dist: numpy>=1.24; extra == 'all'
34
35
  Requires-Dist: openai>=1.0; extra == 'all'
35
36
  Requires-Dist: opencv-python-headless>=4.8; extra == 'all'
37
+ Requires-Dist: uvicorn[standard]>=0.23; extra == 'all'
36
38
  Provides-Extra: captcha
37
39
  Requires-Dist: numpy>=1.24; extra == 'captcha'
38
40
  Requires-Dist: openai>=1.0; extra == 'captcha'
39
41
  Requires-Dist: opencv-python-headless>=4.8; extra == 'captcha'
40
42
  Provides-Extra: dev
43
+ Requires-Dist: fastapi>=0.100; extra == 'dev'
41
44
  Requires-Dist: mypy>=1.0; extra == 'dev'
42
45
  Requires-Dist: numpy>=1.24; extra == 'dev'
43
46
  Requires-Dist: openai>=1.0; extra == 'dev'
@@ -46,6 +49,10 @@ Requires-Dist: pytest-asyncio>=0.21; extra == 'dev'
46
49
  Requires-Dist: pytest-cov>=4.0; extra == 'dev'
47
50
  Requires-Dist: pytest>=7.0; extra == 'dev'
48
51
  Requires-Dist: ruff>=0.4; extra == 'dev'
52
+ Requires-Dist: uvicorn[standard]>=0.23; extra == 'dev'
53
+ Provides-Extra: relay
54
+ Requires-Dist: fastapi>=0.100; extra == 'relay'
55
+ Requires-Dist: uvicorn[standard]>=0.23; extra == 'relay'
49
56
  Description-Content-Type: text/markdown
50
57
 
51
58
  # 🤖 Qoder Autopilot
@@ -55,11 +62,17 @@ Automated [Qoder](https://qoder.com) account registration with anti-detect brows
55
62
  > Register Qoder accounts → solve captchas → verify OTP → auto-connect to 9Router. All in one command.
56
63
 
57
64
  [![Python 3.10+](https://img.shields.io/badge/python-3.10%2B-blue.svg)](https://www.python.org/downloads/)
65
+ [![PyPI version](https://img.shields.io/pypi/v/qoder-autopilot.svg)](https://pypi.org/project/qoder-autopilot/)
58
66
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
59
67
  [![Camoufox](https://img.shields.io/badge/browser-Camoufox-orange.svg)](https://camoufox.com/)
68
+ [![Tests](https://github.com/Daivageralda/qoder-autopilot/actions/workflows/test.yml/badge.svg)](https://github.com/Daivageralda/qoder-autopilot/actions/workflows/test.yml)
60
69
 
61
70
  ---
62
71
 
72
+ > **📦 Published on PyPI** — [pypi.org/project/qoder-autopilot](https://pypi.org/project/qoder-autopilot/)
73
+ >
74
+ > Install with `pip install qoder-autopilot` — no clone needed.
75
+
63
76
  ## ✨ Features
64
77
 
65
78
  - **🦊 Anti-detect Browser** — Uses [Camoufox](https://camoufox.com/) (stealth Firefox fork) with C++-level fingerprinting to bypass bot detection
@@ -76,13 +89,26 @@ Automated [Qoder](https://qoder.com) account registration with anti-detect brows
76
89
 
77
90
  ## 📦 Installation
78
91
 
79
- ### From source (recommended)
92
+ ### Via pip (recommended)
93
+
94
+ ```bash
95
+ # Basic install (manual captcha only)
96
+ pip install qoder-autopilot
97
+
98
+ # With AI captcha solver support
99
+ pip install qoder-autopilot[captcha]
100
+
101
+ # Full install with all extras
102
+ pip install qoder-autopilot[full]
103
+ ```
104
+
105
+ ### From source (development)
80
106
 
81
107
  ```bash
82
108
  git clone https://github.com/Daivageralda/qoder-autopilot.git
83
109
  cd qoder-autopilot
84
110
 
85
- # Basic install (manual captcha only)
111
+ # Basic install
86
112
  pip install -e .
87
113
 
88
114
  # With AI captcha solver
@@ -92,12 +118,6 @@ pip install -e ".[captcha]"
92
118
  pip install -e ".[dev]"
93
119
  ```
94
120
 
95
- ### Via pip
96
-
97
- ```bash
98
- pip install qoder-autopilot
99
- ```
100
-
101
121
  ### Post-install
102
122
 
103
123
  ```bash
@@ -213,7 +233,7 @@ qoder-autopilot config reset
213
233
  | `otp-timeout` | Max seconds to wait for OTP | `20` |
214
234
  | `captcha-timeout` | Max seconds for manual captcha | `120` |
215
235
  | `parallel-delay` | Delay between parallel accounts (sec) | `30` |
216
- | `ninerouter-db` | Path to 9Router SQLite DB | `~/.9router/db/data.sqlite` |
236
+ | `ninerouter-db` | Path to 9Router SQLite DB | OS-aware: `~/.9router/db/data.sqlite` (macOS/Linux), `%APPDATA%/9router/db/data.sqlite` (Windows) |
217
237
 
218
238
  ### Via environment variables
219
239
 
@@ -285,6 +305,22 @@ qoder-autopilot/
285
305
  └── README.md
286
306
  ```
287
307
 
308
+ ## 🔒 Security
309
+
310
+ qoder-autopilot takes security seriously:
311
+
312
+ - **Credential files** — `qoder_accounts.json` saved with `chmod 600` (owner-only)
313
+ - **Config files** — `~/.qoder-autopilot/config.json` and `relay.json` restricted to `600`
314
+ - **Password masking** — passwords never logged to stdout (masked as `••••••••`)
315
+ - **Sensitive field masking** — `config show` masks API keys, tokens, and passwords
316
+ - **File locking** — concurrent credential writes are atomic (safe in `--parallel` mode)
317
+ - **Timing-safe auth** — relay token comparison uses `hmac.compare_digest()`
318
+ - **Rate limiting** — relay server limits to 30 requests/60s per IP
319
+ - **Secure default binding** — relay defaults to `127.0.0.1` (localhost only)
320
+ - **HTTPS warning** — startup warns when relay runs without TLS
321
+
322
+ > **Recommendation:** For production relay deployments, always use a reverse proxy with HTTPS (nginx/caddy) or an SSH tunnel.
323
+
288
324
  ## 🔗 Related
289
325
 
290
326
  - [**cf-mail-worker**](https://github.com/Daivageralda/cf-mail-worker) — Self-hosted temp mail API (Cloudflare Workers + D1)
@@ -5,11 +5,17 @@ Automated [Qoder](https://qoder.com) account registration with anti-detect brows
5
5
  > Register Qoder accounts → solve captchas → verify OTP → auto-connect to 9Router. All in one command.
6
6
 
7
7
  [![Python 3.10+](https://img.shields.io/badge/python-3.10%2B-blue.svg)](https://www.python.org/downloads/)
8
+ [![PyPI version](https://img.shields.io/pypi/v/qoder-autopilot.svg)](https://pypi.org/project/qoder-autopilot/)
8
9
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
9
10
  [![Camoufox](https://img.shields.io/badge/browser-Camoufox-orange.svg)](https://camoufox.com/)
11
+ [![Tests](https://github.com/Daivageralda/qoder-autopilot/actions/workflows/test.yml/badge.svg)](https://github.com/Daivageralda/qoder-autopilot/actions/workflows/test.yml)
10
12
 
11
13
  ---
12
14
 
15
+ > **📦 Published on PyPI** — [pypi.org/project/qoder-autopilot](https://pypi.org/project/qoder-autopilot/)
16
+ >
17
+ > Install with `pip install qoder-autopilot` — no clone needed.
18
+
13
19
  ## ✨ Features
14
20
 
15
21
  - **🦊 Anti-detect Browser** — Uses [Camoufox](https://camoufox.com/) (stealth Firefox fork) with C++-level fingerprinting to bypass bot detection
@@ -26,13 +32,26 @@ Automated [Qoder](https://qoder.com) account registration with anti-detect brows
26
32
 
27
33
  ## 📦 Installation
28
34
 
29
- ### From source (recommended)
35
+ ### Via pip (recommended)
36
+
37
+ ```bash
38
+ # Basic install (manual captcha only)
39
+ pip install qoder-autopilot
40
+
41
+ # With AI captcha solver support
42
+ pip install qoder-autopilot[captcha]
43
+
44
+ # Full install with all extras
45
+ pip install qoder-autopilot[full]
46
+ ```
47
+
48
+ ### From source (development)
30
49
 
31
50
  ```bash
32
51
  git clone https://github.com/Daivageralda/qoder-autopilot.git
33
52
  cd qoder-autopilot
34
53
 
35
- # Basic install (manual captcha only)
54
+ # Basic install
36
55
  pip install -e .
37
56
 
38
57
  # With AI captcha solver
@@ -42,12 +61,6 @@ pip install -e ".[captcha]"
42
61
  pip install -e ".[dev]"
43
62
  ```
44
63
 
45
- ### Via pip
46
-
47
- ```bash
48
- pip install qoder-autopilot
49
- ```
50
-
51
64
  ### Post-install
52
65
 
53
66
  ```bash
@@ -163,7 +176,7 @@ qoder-autopilot config reset
163
176
  | `otp-timeout` | Max seconds to wait for OTP | `20` |
164
177
  | `captcha-timeout` | Max seconds for manual captcha | `120` |
165
178
  | `parallel-delay` | Delay between parallel accounts (sec) | `30` |
166
- | `ninerouter-db` | Path to 9Router SQLite DB | `~/.9router/db/data.sqlite` |
179
+ | `ninerouter-db` | Path to 9Router SQLite DB | OS-aware: `~/.9router/db/data.sqlite` (macOS/Linux), `%APPDATA%/9router/db/data.sqlite` (Windows) |
167
180
 
168
181
  ### Via environment variables
169
182
 
@@ -235,6 +248,22 @@ qoder-autopilot/
235
248
  └── README.md
236
249
  ```
237
250
 
251
+ ## 🔒 Security
252
+
253
+ qoder-autopilot takes security seriously:
254
+
255
+ - **Credential files** — `qoder_accounts.json` saved with `chmod 600` (owner-only)
256
+ - **Config files** — `~/.qoder-autopilot/config.json` and `relay.json` restricted to `600`
257
+ - **Password masking** — passwords never logged to stdout (masked as `••••••••`)
258
+ - **Sensitive field masking** — `config show` masks API keys, tokens, and passwords
259
+ - **File locking** — concurrent credential writes are atomic (safe in `--parallel` mode)
260
+ - **Timing-safe auth** — relay token comparison uses `hmac.compare_digest()`
261
+ - **Rate limiting** — relay server limits to 30 requests/60s per IP
262
+ - **Secure default binding** — relay defaults to `127.0.0.1` (localhost only)
263
+ - **HTTPS warning** — startup warns when relay runs without TLS
264
+
265
+ > **Recommendation:** For production relay deployments, always use a reverse proxy with HTTPS (nginx/caddy) or an SSH tunnel.
266
+
238
267
  ## 🔗 Related
239
268
 
240
269
  - [**cf-mail-worker**](https://github.com/Daivageralda/cf-mail-worker) — Self-hosted temp mail API (Cloudflare Workers + D1)
@@ -9,7 +9,7 @@ build-backend = "hatchling.build"
9
9
  # ── Project Metadata ──────────────────────────────
10
10
  [project]
11
11
  name = "qoder-autopilot"
12
- version = "0.2.2"
12
+ version = "0.3.1"
13
13
  description = "Automated Qoder account registration with anti-detect browser, captcha solving (AI + OpenCV), and 9Router OAuth integration."
14
14
  readme = "README.md"
15
15
  license = "MIT"
@@ -53,9 +53,14 @@ captcha = [
53
53
  "numpy>=1.24",
54
54
  "openai>=1.0",
55
55
  ]
56
+ # Relay server for remote 9Router integration
57
+ relay = [
58
+ "fastapi>=0.100",
59
+ "uvicorn[standard]>=0.23",
60
+ ]
56
61
  # Install everything
57
62
  all = [
58
- "qoder-autopilot[captcha]",
63
+ "qoder-autopilot[captcha,relay]",
59
64
  ]
60
65
  # Development dependencies
61
66
  dev = [
@@ -122,6 +127,10 @@ known-first-party = ["qoder_autopilot"]
122
127
  [tool.pytest.ini_options]
123
128
  testpaths = ["tests"]
124
129
  asyncio_mode = "auto"
130
+ norecursedirs = ["src", "node_modules", ".venv", "*.egg-info"]
131
+ python_files = ["test_*.py"]
132
+ python_classes = ["Test*"]
133
+ python_functions = ["test_*"]
125
134
  markers = [
126
135
  "slow: marks tests as slow (deselect with '-m \"not slow\"')",
127
136
  "integration: marks tests that require external services",
@@ -12,7 +12,7 @@ Quick start::
12
12
  result = asyncio.run(run_one(manual_captcha=True))
13
13
  """
14
14
 
15
- __version__ = "0.1.0"
15
+ __version__ = "0.3.1"
16
16
 
17
17
  from .captcha import CaptchaSolver
18
18
  from .cli import run_one
@@ -0,0 +1,246 @@
1
+ """
2
+ Qoder Autopilot — Window Grid Tiling
3
+ ======================================
4
+ Tile Camoufox browser windows into a 2×2 grid layout.
5
+ Supports macOS (AppleScript) and Windows (Win32 API via ctypes).
6
+
7
+ Grid layout:
8
+ ┌──────────┬──────────┐
9
+ │ Slot 1 │ Slot 2 │ (top row)
10
+ ├──────────┼──────────┤
11
+ │ Slot 3 │ Slot 4 │ (bottom row)
12
+ └──────────┴──────────┘
13
+
14
+ Windows cycle through slots: 5th window → slot 1, 6th → slot 2, etc.
15
+ """
16
+
17
+ import platform
18
+ import subprocess
19
+
20
+ from ..logger import log
21
+
22
+ _screen_size_cache: tuple[int, int] | None = None
23
+
24
+
25
+ def get_screen_size() -> tuple[int, int]:
26
+ """Get main screen dimensions (cached).
27
+
28
+ macOS: Uses AppleScript (osascript).
29
+ Windows: Uses ctypes Win32 API (GetSystemMetrics).
30
+ Linux: Falls back to 1920×1080.
31
+
32
+ Returns:
33
+ Tuple of (width, height) in pixels.
34
+ """
35
+ global _screen_size_cache
36
+ if _screen_size_cache:
37
+ return _screen_size_cache
38
+
39
+ system = platform.system()
40
+
41
+ if system == "Darwin":
42
+ try:
43
+ result = subprocess.run(
44
+ [
45
+ "osascript",
46
+ "-e",
47
+ 'tell application "Finder" to get bounds of window of desktop',
48
+ ],
49
+ capture_output=True,
50
+ text=True,
51
+ timeout=5,
52
+ )
53
+ parts = [int(x.strip()) for x in result.stdout.strip().split(",")]
54
+ _screen_size_cache = (parts[2], parts[3])
55
+ except Exception:
56
+ _screen_size_cache = (1920, 1080)
57
+
58
+ elif system == "Windows":
59
+ try:
60
+ import ctypes
61
+
62
+ user32 = ctypes.windll.user32 # type: ignore[attr-defined]
63
+ SM_CXSCREEN = 0 # noqa: N806
64
+ SM_CYSCREEN = 1 # noqa: N806
65
+ w = user32.GetSystemMetrics(SM_CXSCREEN)
66
+ h = user32.GetSystemMetrics(SM_CYSCREEN)
67
+ _screen_size_cache = (w, h)
68
+ except Exception:
69
+ _screen_size_cache = (1920, 1080)
70
+
71
+ else:
72
+ # Linux / other — fallback
73
+ _screen_size_cache = (1920, 1080)
74
+
75
+ return _screen_size_cache
76
+
77
+
78
+ def _tile_macos(sw: int, sh: int) -> None:
79
+ """Tile Camoufox windows on macOS using AppleScript."""
80
+ half_w = sw // 2
81
+ half_h = sh // 2
82
+ menubar = 25
83
+
84
+ # Grid positions: (left, top, right, bottom)
85
+ g = [
86
+ (0, menubar, half_w, half_h + menubar),
87
+ (half_w, menubar, sw, half_h + menubar),
88
+ (0, half_h + menubar, half_w, sh),
89
+ (half_w, half_h + menubar, sw, sh),
90
+ ]
91
+
92
+ grid_str = (
93
+ "{"
94
+ + "{"
95
+ + f"{g[0][0]}, {g[0][1]}, {g[0][2]}, {g[0][3]}"
96
+ + "}, "
97
+ + "{"
98
+ + f"{g[1][0]}, {g[1][1]}, {g[1][2]}, {g[1][3]}"
99
+ + "}, "
100
+ + "{"
101
+ + f"{g[2][0]}, {g[2][1]}, {g[2][2]}, {g[2][3]}"
102
+ + "}, "
103
+ + "{"
104
+ + f"{g[3][0]}, {g[3][1]}, {g[3][2]}, {g[3][3]}"
105
+ + "}"
106
+ + "}"
107
+ )
108
+
109
+ script = f"""
110
+ tell application "camoufox"
111
+ set winCount to count of windows
112
+ set grid to {grid_str}
113
+ repeat with i from 1 to winCount
114
+ set posIdx to ((i - 1) mod 4) + 1
115
+ set bounds of window i to item posIdx of grid
116
+ end repeat
117
+ return winCount
118
+ end tell
119
+ """
120
+
121
+ try:
122
+ result = subprocess.run(
123
+ ["osascript", "-e", script],
124
+ capture_output=True,
125
+ text=True,
126
+ timeout=10,
127
+ )
128
+ count = result.stdout.strip()
129
+ log(f" 🪟 Tiled {count} Camoufox windows into 2×2 grid")
130
+ except Exception as e:
131
+ log(f" ⚠️ Grid tiling failed: {e}", "WARN")
132
+
133
+
134
+ def _tile_windows(sw: int, sh: int) -> None:
135
+ """Tile Camoufox windows on Windows using Win32 API via ctypes.
136
+
137
+ Enumerates all top-level windows, filters by title containing 'camoufox'
138
+ or 'firefox' (Camoufox is Firefox-based), then positions them in a 2×2 grid.
139
+ """
140
+ import ctypes
141
+ from ctypes import wintypes
142
+
143
+ user32 = ctypes.windll.user32 # type: ignore[attr-defined]
144
+
145
+ # Win32 constants
146
+ SWP_NOZORDER = 0x0004 # noqa: N806
147
+ SWP_NOACTIVATE = 0x0010 # noqa: N806
148
+ GW_OWNER = 4 # noqa: N806
149
+ WS_VISIBLE = 0x10000000 # noqa: N806
150
+
151
+ # Grid positions
152
+ half_w = sw // 2
153
+ half_h = sh // 2
154
+ taskbar_h = 48 # approximate Windows taskbar height
155
+
156
+ grid = [
157
+ (0, 0, half_w, half_h), # top-left
158
+ (half_w, 0, half_w, half_h), # top-right
159
+ (0, half_h, half_w, half_h - taskbar_h), # bottom-left
160
+ (half_w, half_h, half_w, half_h - taskbar_h), # bottom-right
161
+ ]
162
+
163
+ # Callback type for EnumWindows
164
+ WNDENUMPROC = ctypes.WINFUNCTYPE( # type: ignore[attr-defined] # noqa: N806
165
+ wintypes.BOOL,
166
+ wintypes.HWND,
167
+ wintypes.LPARAM,
168
+ )
169
+
170
+ found_windows: list[int] = []
171
+
172
+ def enum_callback(hwnd: int, _lparam: int) -> bool:
173
+ """Collect visible top-level windows with Camoufox/Firefox in title."""
174
+ # Skip invisible windows
175
+ style = user32.GetWindowLongW(hwnd, -16) # GWL_STYLE
176
+ if not (style & WS_VISIBLE):
177
+ return True
178
+
179
+ # Skip child/owned windows (only want top-level)
180
+ owner = user32.GetWindow(hwnd, GW_OWNER)
181
+ if owner:
182
+ return True
183
+
184
+ # Get window title
185
+ length = user32.GetWindowTextLengthW(hwnd)
186
+ if length == 0:
187
+ return True
188
+ buf = ctypes.create_unicode_buffer(length + 1)
189
+ user32.GetWindowTextW(hwnd, buf, length + 1)
190
+ title = buf.value.lower()
191
+
192
+ # Match Camoufox windows (title contains 'camoufox' or 'firefox')
193
+ if "camoufox" in title or "firefox" in title:
194
+ found_windows.append(hwnd)
195
+
196
+ return True
197
+
198
+ try:
199
+ # Enumerate all windows
200
+ user32.EnumWindows(WNDENUMPROC(enum_callback), 0)
201
+
202
+ if not found_windows:
203
+ log(" ⚠️ No Camoufox windows found for tiling", "WARN")
204
+ return
205
+
206
+ # Position each window in the grid
207
+ count = 0
208
+ for i, hwnd in enumerate(found_windows):
209
+ slot = i % 4
210
+ x, y, w, h = grid[slot]
211
+
212
+ # SetWindowPos: move and resize
213
+ user32.SetWindowPos(
214
+ hwnd,
215
+ None,
216
+ x,
217
+ y,
218
+ w,
219
+ h,
220
+ SWP_NOZORDER | SWP_NOACTIVATE,
221
+ )
222
+ count += 1
223
+
224
+ log(f" 🪟 Tiled {count} Camoufox windows into 2×2 grid (Windows)")
225
+
226
+ except Exception as e:
227
+ log(f" ⚠️ Windows grid tiling failed: {e}", "WARN")
228
+
229
+
230
+ def tile_all_camoufox_windows() -> None:
231
+ """Tile ALL open Camoufox windows into a 2×2 grid.
232
+
233
+ Call this after all browsers have been launched.
234
+ Supports macOS (AppleScript) and Windows (Win32 API).
235
+ Linux is not supported (falls back to no-op).
236
+ """
237
+ system = platform.system()
238
+ if system not in ("Darwin", "Windows"):
239
+ return
240
+
241
+ sw, sh = get_screen_size()
242
+
243
+ if system == "Darwin":
244
+ _tile_macos(sw, sh)
245
+ elif system == "Windows":
246
+ _tile_windows(sw, sh)
@@ -70,7 +70,7 @@ async def run_one(
70
70
  # 2. Generate identity
71
71
  log("📋 Step 2/4: Generating identity...")
72
72
  ident = gen_identity()
73
- log_ok(f"{ident['display_name']} | pw: {ident['password']}")
73
+ log_ok(f"{ident['display_name']} | pw: {'' * 8}")
74
74
 
75
75
  # 3. Register + verify
76
76
  log("📋 Step 3/4: OAuth register + device token flow...")
@@ -266,6 +266,10 @@ def main() -> None:
266
266
  deploy_worker()
267
267
  return
268
268
 
269
+ if sub == "relay":
270
+ _handle_relay_command(sys.argv[2:])
271
+ return
272
+
269
273
  # ── First-run wizard ──
270
274
  from .first_run import is_first_run, run_first_run_wizard
271
275
 
@@ -375,8 +379,10 @@ def _handle_config_command(argv: list[str]) -> None:
375
379
  else:
376
380
  source = "default"
377
381
  val_str = str(current) if current else "(empty)"
378
- if key.endswith("api_key") and val_str and val_str != "(empty)":
379
- val_str = val_str[:8] + "..." + val_str[-4:] if len(val_str) > 12 else "***"
382
+ # Mask sensitive fields (API keys, tokens, passwords)
383
+ sensitive_suffixes = ("api_key", "token", "password", "secret")
384
+ if any(key.endswith(s) for s in sensitive_suffixes) and val_str not in ("(empty)", ""):
385
+ val_str = val_str[:4] + "••••" + val_str[-4:] if len(val_str) > 8 else "***"
380
386
  print(f" {info['cli_flag']:<23} {val_str:<50} {source}")
381
387
  print()
382
388
  print(f"Config file: {CONFIG_FILE}")
@@ -429,3 +435,49 @@ def _handle_config_command(argv: list[str]) -> None:
429
435
  print(f"❌ Unknown command: {cmd}")
430
436
  print("Run 'qoder-autopilot config --help' for usage")
431
437
  sys.exit(1)
438
+
439
+
440
+ def _handle_relay_command(argv: list[str]) -> None:
441
+ """Handle 'qoder-autopilot relay' subcommand."""
442
+ import argparse
443
+
444
+ parser = argparse.ArgumentParser(
445
+ prog="qoder-autopilot relay",
446
+ description="Start relay server for remote 9Router integration",
447
+ )
448
+ parser.add_argument(
449
+ "--host",
450
+ default="127.0.0.1",
451
+ help="Bind host (default: 127.0.0.1 — use 0.0.0.0 for external access)",
452
+ )
453
+ parser.add_argument(
454
+ "--port",
455
+ type=int,
456
+ default=8765,
457
+ help="Bind port (default: 8765)",
458
+ )
459
+ parser.add_argument(
460
+ "--token",
461
+ default=None,
462
+ help="Custom auth token (default: auto-generate)",
463
+ )
464
+ parser.add_argument(
465
+ "--db",
466
+ default=None,
467
+ help="Custom 9Router DB path (default: auto-detect)",
468
+ )
469
+
470
+ if argv and argv[0] in ("-h", "--help"):
471
+ parser.print_help()
472
+ return
473
+
474
+ args = parser.parse_args(argv)
475
+
476
+ from .relay import start_relay
477
+
478
+ start_relay(
479
+ host=args.host,
480
+ port=args.port,
481
+ custom_token=args.token,
482
+ custom_db_path=args.db,
483
+ )
@@ -150,6 +150,14 @@ class Settings(BaseSettings):
150
150
  default_factory=_default_ninerouter_db,
151
151
  description="Path to 9Router SQLite database (OS-aware default)",
152
152
  )
153
+ ninerouter_relay_url: str = Field(
154
+ default="",
155
+ description="Remote relay server URL (e.g., http://myvps:8765)",
156
+ )
157
+ ninerouter_relay_token: str = Field(
158
+ default="",
159
+ description="Auth token for relay server",
160
+ )
153
161
 
154
162
  # ── AI Captcha (optional) ─────────────────────────────────────────────
155
163
  ai_api_key: str = Field(
@@ -203,6 +211,11 @@ class Settings(BaseSettings):
203
211
  db_path = os.path.expanduser(self.ninerouter_db)
204
212
  return os.path.exists(db_path)
205
213
 
214
+ @property
215
+ def has_relay(self) -> bool:
216
+ """Check if remote relay is configured."""
217
+ return bool(self.ninerouter_relay_url and self.ninerouter_relay_token)
218
+
206
219
  @property
207
220
  def ninerouter_db_path(self) -> str:
208
221
  """Expanded path to 9Router SQLite database."""
@@ -0,0 +1,77 @@
1
+ """
2
+ Qoder Autopilot — Credential Storage
3
+ ======================================
4
+ Save and load registered account credentials to/from JSON.
5
+
6
+ Security:
7
+ - File permissions restricted to owner-only (chmod 600)
8
+ - File locking for concurrent writes in parallel mode
9
+ """
10
+
11
+ import fcntl
12
+ import json
13
+ import os
14
+ import stat
15
+ from datetime import datetime, timezone
16
+ from pathlib import Path
17
+ from typing import Any
18
+
19
+ from . import config
20
+ from .logger import log
21
+
22
+
23
+ def save_creds(data: dict[str, Any], path: Path | None = None) -> None:
24
+ """Append account credentials to the JSON storage file.
25
+
26
+ Uses file locking to prevent corruption in parallel mode.
27
+ File is saved with owner-only permissions (600).
28
+ """
29
+ path = path or config.CREDENTIALS_FILE
30
+
31
+ # Ensure parent directory exists
32
+ path.parent.mkdir(parents=True, exist_ok=True)
33
+
34
+ # Atomic read-modify-write with file locking
35
+ with open(path, "a+") as f:
36
+ fcntl.flock(f, fcntl.LOCK_EX)
37
+ try:
38
+ f.seek(0)
39
+ content = f.read()
40
+ accounts: list[dict] = []
41
+ if content:
42
+ try:
43
+ accounts = json.loads(content)
44
+ except json.JSONDecodeError:
45
+ accounts = []
46
+
47
+ accounts.append(
48
+ {
49
+ **data,
50
+ "created_at": datetime.now(timezone.utc).isoformat(),
51
+ }
52
+ )
53
+
54
+ f.seek(0)
55
+ f.truncate()
56
+ f.write(json.dumps(accounts, indent=2))
57
+ finally:
58
+ fcntl.flock(f, fcntl.LOCK_UN)
59
+
60
+ # Restrict file permissions: owner read/write only
61
+ try:
62
+ os.chmod(path, stat.S_IRUSR | stat.S_IWUSR) # 0o600
63
+ except OSError:
64
+ pass # Windows may not support
65
+
66
+ log(f" 💾 Saved to {path}")
67
+
68
+
69
+ def load_creds(path: Path | None = None) -> list[dict[str, Any]]:
70
+ """Load all stored credentials from the JSON file."""
71
+ path = path or config.CREDENTIALS_FILE
72
+ if not path.exists():
73
+ return []
74
+ try:
75
+ return json.loads(path.read_text())
76
+ except (json.JSONDecodeError, OSError):
77
+ return []