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.
- {qoder_autopilot-0.2.2 → qoder_autopilot-0.3.1}/PKG-INFO +46 -10
- {qoder_autopilot-0.2.2 → qoder_autopilot-0.3.1}/README.md +38 -9
- {qoder_autopilot-0.2.2 → qoder_autopilot-0.3.1}/pyproject.toml +11 -2
- {qoder_autopilot-0.2.2 → qoder_autopilot-0.3.1}/src/qoder_autopilot/__init__.py +1 -1
- qoder_autopilot-0.3.1/src/qoder_autopilot/browser/window_tiler.py +246 -0
- {qoder_autopilot-0.2.2 → qoder_autopilot-0.3.1}/src/qoder_autopilot/cli.py +55 -3
- {qoder_autopilot-0.2.2 → qoder_autopilot-0.3.1}/src/qoder_autopilot/config.py +13 -0
- qoder_autopilot-0.3.1/src/qoder_autopilot/credentials.py +77 -0
- {qoder_autopilot-0.2.2 → qoder_autopilot-0.3.1}/src/qoder_autopilot/ninerouter.py +17 -0
- qoder_autopilot-0.3.1/src/qoder_autopilot/relay.py +416 -0
- {qoder_autopilot-0.2.2 → qoder_autopilot-0.3.1}/src/qoder_autopilot/user_config.py +18 -0
- qoder_autopilot-0.3.1/tests/test_relay.py +196 -0
- qoder_autopilot-0.2.2/src/qoder_autopilot/browser/window_tiler.py +0 -117
- qoder_autopilot-0.2.2/src/qoder_autopilot/credentials.py +0 -44
- {qoder_autopilot-0.2.2 → qoder_autopilot-0.3.1}/.gitignore +0 -0
- {qoder_autopilot-0.2.2 → qoder_autopilot-0.3.1}/LICENSE +0 -0
- {qoder_autopilot-0.2.2 → qoder_autopilot-0.3.1}/docs/architecture.md +0 -0
- {qoder_autopilot-0.2.2 → qoder_autopilot-0.3.1}/examples/basic_usage.py +0 -0
- {qoder_autopilot-0.2.2 → qoder_autopilot-0.3.1}/examples/parallel_mode.py +0 -0
- {qoder_autopilot-0.2.2 → qoder_autopilot-0.3.1}/src/qoder_autopilot/__main__.py +0 -0
- {qoder_autopilot-0.2.2 → qoder_autopilot-0.3.1}/src/qoder_autopilot/browser/__init__.py +0 -0
- {qoder_autopilot-0.2.2 → qoder_autopilot-0.3.1}/src/qoder_autopilot/browser/camoufox.py +0 -0
- {qoder_autopilot-0.2.2 → qoder_autopilot-0.3.1}/src/qoder_autopilot/captcha/__init__.py +0 -0
- {qoder_autopilot-0.2.2 → qoder_autopilot-0.3.1}/src/qoder_autopilot/captcha/ai_vision.py +0 -0
- {qoder_autopilot-0.2.2 → qoder_autopilot-0.3.1}/src/qoder_autopilot/captcha/manual.py +0 -0
- {qoder_autopilot-0.2.2 → qoder_autopilot-0.3.1}/src/qoder_autopilot/captcha/opencv_detect.py +0 -0
- {qoder_autopilot-0.2.2 → qoder_autopilot-0.3.1}/src/qoder_autopilot/captcha/slider.py +0 -0
- {qoder_autopilot-0.2.2 → qoder_autopilot-0.3.1}/src/qoder_autopilot/captcha/solver.py +0 -0
- {qoder_autopilot-0.2.2 → qoder_autopilot-0.3.1}/src/qoder_autopilot/deploy.py +0 -0
- {qoder_autopilot-0.2.2 → qoder_autopilot-0.3.1}/src/qoder_autopilot/errors.py +0 -0
- {qoder_autopilot-0.2.2 → qoder_autopilot-0.3.1}/src/qoder_autopilot/first_run.py +0 -0
- {qoder_autopilot-0.2.2 → qoder_autopilot-0.3.1}/src/qoder_autopilot/identity.py +0 -0
- {qoder_autopilot-0.2.2 → qoder_autopilot-0.3.1}/src/qoder_autopilot/logger.py +0 -0
- {qoder_autopilot-0.2.2 → qoder_autopilot-0.3.1}/src/qoder_autopilot/oauth.py +0 -0
- {qoder_autopilot-0.2.2 → qoder_autopilot-0.3.1}/src/qoder_autopilot/otp.py +0 -0
- {qoder_autopilot-0.2.2 → qoder_autopilot-0.3.1}/src/qoder_autopilot/register.py +0 -0
- {qoder_autopilot-0.2.2 → qoder_autopilot-0.3.1}/src/qoder_autopilot/temp_mail.py +0 -0
- {qoder_autopilot-0.2.2 → qoder_autopilot-0.3.1}/src/qoder_autopilot/worker_template/package.json +0 -0
- {qoder_autopilot-0.2.2 → qoder_autopilot-0.3.1}/src/qoder_autopilot/worker_template/schema.sql +0 -0
- {qoder_autopilot-0.2.2 → qoder_autopilot-0.3.1}/src/qoder_autopilot/worker_template/scripts/setup.sh +0 -0
- {qoder_autopilot-0.2.2 → qoder_autopilot-0.3.1}/src/qoder_autopilot/worker_template/src/config.js +0 -0
- {qoder_autopilot-0.2.2 → qoder_autopilot-0.3.1}/src/qoder_autopilot/worker_template/src/handlers/api.js +0 -0
- {qoder_autopilot-0.2.2 → qoder_autopilot-0.3.1}/src/qoder_autopilot/worker_template/src/handlers/email.js +0 -0
- {qoder_autopilot-0.2.2 → qoder_autopilot-0.3.1}/src/qoder_autopilot/worker_template/src/index.js +0 -0
- {qoder_autopilot-0.2.2 → qoder_autopilot-0.3.1}/src/qoder_autopilot/worker_template/src/utils.js +0 -0
- {qoder_autopilot-0.2.2 → qoder_autopilot-0.3.1}/src/qoder_autopilot/worker_template/wrangler.toml.example +0 -0
- {qoder_autopilot-0.2.2 → qoder_autopilot-0.3.1}/tests/conftest.py +0 -0
- {qoder_autopilot-0.2.2 → qoder_autopilot-0.3.1}/tests/test_config.py +0 -0
- {qoder_autopilot-0.2.2 → qoder_autopilot-0.3.1}/tests/test_errors.py +0 -0
- {qoder_autopilot-0.2.2 → qoder_autopilot-0.3.1}/tests/test_identity.py +0 -0
- {qoder_autopilot-0.2.2 → qoder_autopilot-0.3.1}/tests/test_oauth.py +0 -0
- {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.
|
|
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
|
[](https://www.python.org/downloads/)
|
|
65
|
+
[](https://pypi.org/project/qoder-autopilot/)
|
|
58
66
|
[](https://opensource.org/licenses/MIT)
|
|
59
67
|
[](https://camoufox.com/)
|
|
68
|
+
[](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
|
-
###
|
|
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
|
|
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
|
[](https://www.python.org/downloads/)
|
|
8
|
+
[](https://pypi.org/project/qoder-autopilot/)
|
|
8
9
|
[](https://opensource.org/licenses/MIT)
|
|
9
10
|
[](https://camoufox.com/)
|
|
11
|
+
[](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
|
-
###
|
|
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
|
|
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.
|
|
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",
|
|
@@ -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: {
|
|
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
|
-
|
|
379
|
-
|
|
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 []
|