smooth-py 0.4.4.dev20260324__tar.gz → 0.4.4.dev20260423__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.
- {smooth_py-0.4.4.dev20260324 → smooth_py-0.4.4.dev20260423}/PKG-INFO +1 -1
- {smooth_py-0.4.4.dev20260324 → smooth_py-0.4.4.dev20260423}/pyproject.toml +1 -1
- {smooth_py-0.4.4.dev20260324 → smooth_py-0.4.4.dev20260423}/src/smooth/__init__.py +2 -0
- {smooth_py-0.4.4.dev20260324 → smooth_py-0.4.4.dev20260423}/src/smooth/_client.py +39 -2
- {smooth_py-0.4.4.dev20260324 → smooth_py-0.4.4.dev20260423}/src/smooth/_interface.py +5 -1
- {smooth_py-0.4.4.dev20260324 → smooth_py-0.4.4.dev20260423}/src/smooth/_proxy.py +93 -34
- {smooth_py-0.4.4.dev20260324 → smooth_py-0.4.4.dev20260423}/src/smooth/cli.py +1 -1
- {smooth_py-0.4.4.dev20260324 → smooth_py-0.4.4.dev20260423}/src/smooth/models/__init__.py +24 -1
- {smooth_py-0.4.4.dev20260324 → smooth_py-0.4.4.dev20260423}/README.md +0 -0
- {smooth_py-0.4.4.dev20260324 → smooth_py-0.4.4.dev20260423}/src/smooth/_config.py +0 -0
- {smooth_py-0.4.4.dev20260324 → smooth_py-0.4.4.dev20260423}/src/smooth/_exceptions.py +0 -0
- {smooth_py-0.4.4.dev20260324 → smooth_py-0.4.4.dev20260423}/src/smooth/_telemetry.py +0 -0
- {smooth_py-0.4.4.dev20260324 → smooth_py-0.4.4.dev20260423}/src/smooth/_tools.py +0 -0
- {smooth_py-0.4.4.dev20260324 → smooth_py-0.4.4.dev20260423}/src/smooth/_utils.py +0 -0
|
@@ -14,6 +14,7 @@ from ._tools import (
|
|
|
14
14
|
AsyncSmoothTool,
|
|
15
15
|
SmoothTool,
|
|
16
16
|
)
|
|
17
|
+
from .models import Secret
|
|
17
18
|
|
|
18
19
|
# Export public API
|
|
19
20
|
__all__ = [
|
|
@@ -28,6 +29,7 @@ __all__ = [
|
|
|
28
29
|
"BadRequestError",
|
|
29
30
|
"TimeoutError",
|
|
30
31
|
"ToolCallError",
|
|
32
|
+
"Secret",
|
|
31
33
|
# Deprecated
|
|
32
34
|
"TaskHandle",
|
|
33
35
|
"AsyncTaskHandle",
|
|
@@ -5,6 +5,7 @@ import io
|
|
|
5
5
|
import os
|
|
6
6
|
import secrets
|
|
7
7
|
import threading
|
|
8
|
+
import warnings
|
|
8
9
|
from pathlib import Path
|
|
9
10
|
from types import TracebackType
|
|
10
11
|
from typing import Any, Callable, Coroutine, Generator, Sequence, Type, TypedDict, TypeVar, cast
|
|
@@ -177,6 +178,7 @@ class SmoothClient(BaseClient):
|
|
|
177
178
|
profile_id: str | None = None,
|
|
178
179
|
profile_read_only: bool = False,
|
|
179
180
|
stealth_mode: bool = False,
|
|
181
|
+
use_stealth: bool = True,
|
|
180
182
|
proxy_server: str | None = None,
|
|
181
183
|
proxy_username: str | None = None,
|
|
182
184
|
proxy_password: str | None = None,
|
|
@@ -190,6 +192,12 @@ class SmoothClient(BaseClient):
|
|
|
190
192
|
show_cursor: bool = False,
|
|
191
193
|
) -> SessionHandle:
|
|
192
194
|
"""Opens a browser session."""
|
|
195
|
+
if stealth_mode:
|
|
196
|
+
warnings.warn(
|
|
197
|
+
"'stealth_mode' is deprecated and ignored, use 'use_stealth' instead (defaults to True)",
|
|
198
|
+
DeprecationWarning,
|
|
199
|
+
stacklevel=2,
|
|
200
|
+
)
|
|
193
201
|
# Handle proxy_server="self" - auto-start local proxy tunnel
|
|
194
202
|
self_proxy = proxy_server == "self"
|
|
195
203
|
if self_proxy and not proxy_password:
|
|
@@ -206,6 +214,7 @@ class SmoothClient(BaseClient):
|
|
|
206
214
|
profile_id=profile_id,
|
|
207
215
|
profile_read_only=profile_read_only,
|
|
208
216
|
stealth_mode=stealth_mode,
|
|
217
|
+
use_stealth=use_stealth,
|
|
209
218
|
proxy_server=proxy_server,
|
|
210
219
|
proxy_username=proxy_username,
|
|
211
220
|
proxy_password=proxy_password,
|
|
@@ -252,6 +261,7 @@ class SmoothClient(BaseClient):
|
|
|
252
261
|
profile_id: str | None = None,
|
|
253
262
|
profile_read_only: bool = False,
|
|
254
263
|
stealth_mode: bool = False,
|
|
264
|
+
use_stealth: bool = True,
|
|
255
265
|
proxy_server: str | None = None,
|
|
256
266
|
proxy_username: str | None = None,
|
|
257
267
|
proxy_password: str | None = None,
|
|
@@ -285,7 +295,8 @@ class SmoothClient(BaseClient):
|
|
|
285
295
|
session_id: (Deprecated, now `profile_id`) Browser session ID to use.
|
|
286
296
|
profile_id: Browser profile ID to use. Each profile maintains its own state, such as cookies and login credentials.
|
|
287
297
|
profile_read_only: If true, the profile specified by `profile_id` will be loaded in read-only mode.
|
|
288
|
-
stealth_mode:
|
|
298
|
+
stealth_mode: (Deprecated, ignored) Use `use_stealth` instead.
|
|
299
|
+
use_stealth: Run the browser in stealth mode. Default is True.
|
|
289
300
|
proxy_server: Proxy server address to route browser traffic through.
|
|
290
301
|
proxy_username: Proxy server username.
|
|
291
302
|
proxy_password: Proxy server password.
|
|
@@ -307,6 +318,12 @@ class SmoothClient(BaseClient):
|
|
|
307
318
|
Raises:
|
|
308
319
|
ApiException: If the API request fails.
|
|
309
320
|
"""
|
|
321
|
+
if stealth_mode:
|
|
322
|
+
warnings.warn(
|
|
323
|
+
"'stealth_mode' is deprecated and ignored, use 'use_stealth' instead (defaults to True)",
|
|
324
|
+
DeprecationWarning,
|
|
325
|
+
stacklevel=2,
|
|
326
|
+
)
|
|
310
327
|
custom_tools_ = (
|
|
311
328
|
[tool if isinstance(tool, SmoothTool) else SmoothTool(**tool) for tool in custom_tools] if custom_tools else None
|
|
312
329
|
)
|
|
@@ -332,6 +349,7 @@ class SmoothClient(BaseClient):
|
|
|
332
349
|
profile_id=profile_id or session_id,
|
|
333
350
|
profile_read_only=profile_read_only,
|
|
334
351
|
stealth_mode=stealth_mode,
|
|
352
|
+
use_stealth=use_stealth,
|
|
335
353
|
proxy_server=proxy_server,
|
|
336
354
|
proxy_username=proxy_username,
|
|
337
355
|
proxy_password=proxy_password,
|
|
@@ -628,6 +646,7 @@ class SmoothAsyncClient(BaseClient):
|
|
|
628
646
|
profile_id: str | None = None,
|
|
629
647
|
profile_read_only: bool = False,
|
|
630
648
|
stealth_mode: bool = False,
|
|
649
|
+
use_stealth: bool = True,
|
|
631
650
|
proxy_server: str | None = None,
|
|
632
651
|
proxy_username: str | None = None,
|
|
633
652
|
proxy_password: str | None = None,
|
|
@@ -651,6 +670,12 @@ class SmoothAsyncClient(BaseClient):
|
|
|
651
670
|
async with client.session(...) as session:
|
|
652
671
|
...
|
|
653
672
|
"""
|
|
673
|
+
if stealth_mode:
|
|
674
|
+
warnings.warn(
|
|
675
|
+
"'stealth_mode' is deprecated and ignored, use 'use_stealth' instead (defaults to True)",
|
|
676
|
+
DeprecationWarning,
|
|
677
|
+
stacklevel=2,
|
|
678
|
+
)
|
|
654
679
|
return _AsyncSessionContextManager(self._session_coro(
|
|
655
680
|
url=None,
|
|
656
681
|
files=files,
|
|
@@ -661,6 +686,7 @@ class SmoothAsyncClient(BaseClient):
|
|
|
661
686
|
profile_id=profile_id,
|
|
662
687
|
profile_read_only=profile_read_only,
|
|
663
688
|
stealth_mode=stealth_mode,
|
|
689
|
+
use_stealth=use_stealth,
|
|
664
690
|
proxy_server=proxy_server,
|
|
665
691
|
proxy_username=proxy_username,
|
|
666
692
|
proxy_password=proxy_password,
|
|
@@ -695,6 +721,7 @@ class SmoothAsyncClient(BaseClient):
|
|
|
695
721
|
profile_id: str | None = None,
|
|
696
722
|
profile_read_only: bool = False,
|
|
697
723
|
stealth_mode: bool = False,
|
|
724
|
+
use_stealth: bool = True,
|
|
698
725
|
proxy_server: str | None = None,
|
|
699
726
|
proxy_username: str | None = None,
|
|
700
727
|
proxy_password: str | None = None,
|
|
@@ -724,6 +751,7 @@ class SmoothAsyncClient(BaseClient):
|
|
|
724
751
|
profile_id=profile_id,
|
|
725
752
|
profile_read_only=profile_read_only,
|
|
726
753
|
stealth_mode=stealth_mode,
|
|
754
|
+
use_stealth=use_stealth,
|
|
727
755
|
proxy_server=proxy_server,
|
|
728
756
|
proxy_username=proxy_username,
|
|
729
757
|
proxy_password=proxy_password,
|
|
@@ -783,6 +811,7 @@ class SmoothAsyncClient(BaseClient):
|
|
|
783
811
|
profile_id: str | None = None,
|
|
784
812
|
profile_read_only: bool = False,
|
|
785
813
|
stealth_mode: bool = False,
|
|
814
|
+
use_stealth: bool = True,
|
|
786
815
|
proxy_server: str | None = None,
|
|
787
816
|
proxy_username: str | None = None,
|
|
788
817
|
proxy_password: str | None = None,
|
|
@@ -815,7 +844,8 @@ class SmoothAsyncClient(BaseClient):
|
|
|
815
844
|
session_id: (Deprecated, now `profile_id`) Browser session ID to use.
|
|
816
845
|
profile_id: Browser profile ID to use. Each profile maintains its own state, such as cookies and login credentials.
|
|
817
846
|
profile_read_only: If true, the profile specified by `profile_id` will be loaded in read-only mode.
|
|
818
|
-
stealth_mode:
|
|
847
|
+
stealth_mode: (Deprecated, ignored) Use `use_stealth` instead.
|
|
848
|
+
use_stealth: Run the browser in stealth mode. Default is True.
|
|
819
849
|
proxy_server: Proxy server address to route browser traffic through.
|
|
820
850
|
proxy_username: Proxy server username.
|
|
821
851
|
proxy_password: Proxy server password.
|
|
@@ -837,6 +867,12 @@ class SmoothAsyncClient(BaseClient):
|
|
|
837
867
|
Raises:
|
|
838
868
|
ApiException: If the API request fails.
|
|
839
869
|
"""
|
|
870
|
+
if stealth_mode:
|
|
871
|
+
warnings.warn(
|
|
872
|
+
"'stealth_mode' is deprecated and ignored, use 'use_stealth' instead (defaults to True)",
|
|
873
|
+
DeprecationWarning,
|
|
874
|
+
stacklevel=2,
|
|
875
|
+
)
|
|
840
876
|
if proxy_server == "self" and task is not None:
|
|
841
877
|
raise BadRequestError(
|
|
842
878
|
'proxy_server="self" is not supported in run(). '
|
|
@@ -864,6 +900,7 @@ class SmoothAsyncClient(BaseClient):
|
|
|
864
900
|
profile_id=profile_id or session_id,
|
|
865
901
|
profile_read_only=profile_read_only,
|
|
866
902
|
stealth_mode=stealth_mode,
|
|
903
|
+
use_stealth=use_stealth,
|
|
867
904
|
proxy_server=proxy_server,
|
|
868
905
|
proxy_username=proxy_username,
|
|
869
906
|
proxy_password=proxy_password, # pyright: ignore[reportArgumentType]
|
|
@@ -19,6 +19,7 @@ from .models import (
|
|
|
19
19
|
ActionGotoResponse,
|
|
20
20
|
ActionRunTaskResponse,
|
|
21
21
|
BrowserSessionResponse,
|
|
22
|
+
Secret,
|
|
22
23
|
TaskEvent,
|
|
23
24
|
TaskResponse,
|
|
24
25
|
TaskUpdateRequest,
|
|
@@ -566,6 +567,7 @@ class AsyncSessionHandle(AsyncTaskHandleEx):
|
|
|
566
567
|
response_model: dict[str, Any] | Type[BaseModel] | None = None,
|
|
567
568
|
url: str | None = None,
|
|
568
569
|
metadata: dict[str, Any] | None = None,
|
|
570
|
+
secrets: dict[str, Secret] | None = None,
|
|
569
571
|
):
|
|
570
572
|
"""Extracts from the given URL."""
|
|
571
573
|
if response_model is not None and not isinstance(response_model, dict):
|
|
@@ -580,6 +582,7 @@ class AsyncSessionHandle(AsyncTaskHandleEx):
|
|
|
580
582
|
"response_model": response_model,
|
|
581
583
|
"url": url,
|
|
582
584
|
"metadata": metadata,
|
|
585
|
+
"secrets": {k: v.model_dump(context={"reveal_secrets": True}) for k, v in secrets.items()} if secrets else None,
|
|
583
586
|
},
|
|
584
587
|
},
|
|
585
588
|
)
|
|
@@ -721,9 +724,10 @@ class SessionHandle(TaskHandleEx):
|
|
|
721
724
|
response_model: dict[str, Any] | Type[BaseModel] | None = None,
|
|
722
725
|
url: str | None = None,
|
|
723
726
|
metadata: dict[str, Any] | None = None,
|
|
727
|
+
secrets: dict[str, Secret] | None = None,
|
|
724
728
|
):
|
|
725
729
|
"""Extracts from the given URL."""
|
|
726
|
-
return self._run_async(self._async_handle.run_task(task, max_steps, response_model, url, metadata))
|
|
730
|
+
return self._run_async(self._async_handle.run_task(task, max_steps, response_model, url, metadata, secrets))
|
|
727
731
|
|
|
728
732
|
def result(self, timeout: int | None = None, poll_interval: float | None = None) -> "TaskResponse":
|
|
729
733
|
"""Waits for the session to close and returns the result."""
|
|
@@ -9,7 +9,11 @@ The proxy connects to a remote FRP server and exposes a local SOCKS5 proxy
|
|
|
9
9
|
that can be used by the browser session.
|
|
10
10
|
"""
|
|
11
11
|
|
|
12
|
+
import collections
|
|
13
|
+
import contextlib
|
|
14
|
+
import fcntl
|
|
12
15
|
import logging
|
|
16
|
+
import os
|
|
13
17
|
import platform
|
|
14
18
|
import shutil
|
|
15
19
|
import subprocess
|
|
@@ -24,14 +28,28 @@ from dataclasses import dataclass, field
|
|
|
24
28
|
from pathlib import Path
|
|
25
29
|
from typing import Any
|
|
26
30
|
|
|
31
|
+
_frpc_logger = logging.getLogger("smooth.frpc")
|
|
32
|
+
|
|
27
33
|
# FRP version to use
|
|
28
34
|
FRP_VERSION = "0.66.0"
|
|
29
35
|
|
|
30
36
|
# Directory to store FRP binaries and configs
|
|
31
37
|
FRP_DIR = Path.home() / ".smooth" / "frp"
|
|
32
38
|
|
|
33
|
-
|
|
34
|
-
|
|
39
|
+
@contextlib.contextmanager
|
|
40
|
+
def _file_lock(lock_path: Path):
|
|
41
|
+
"""Cross-process file lock using fcntl (Unix) or msvcrt (Windows)."""
|
|
42
|
+
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
|
43
|
+
fd = open(lock_path, "w")
|
|
44
|
+
try:
|
|
45
|
+
if platform.system().lower() == "windows":
|
|
46
|
+
import msvcrt
|
|
47
|
+
msvcrt.locking(fd.fileno(), msvcrt.LK_LOCK, 1)
|
|
48
|
+
else:
|
|
49
|
+
fcntl.flock(fd, fcntl.LOCK_EX)
|
|
50
|
+
yield
|
|
51
|
+
finally:
|
|
52
|
+
fd.close()
|
|
35
53
|
|
|
36
54
|
|
|
37
55
|
@dataclass
|
|
@@ -48,9 +66,11 @@ class ProxyConfig:
|
|
|
48
66
|
class _ProxyState:
|
|
49
67
|
"""Internal state for a proxy instance."""
|
|
50
68
|
|
|
51
|
-
process: subprocess.Popen[
|
|
69
|
+
process: subprocess.Popen[str] | None = None
|
|
52
70
|
config_file: Path | None = None
|
|
53
71
|
lock: threading.Lock = field(default_factory=threading.Lock)
|
|
72
|
+
log_tail: collections.deque[str] = field(default_factory=lambda: collections.deque(maxlen=200))
|
|
73
|
+
drain_thread: threading.Thread | None = None
|
|
54
74
|
|
|
55
75
|
|
|
56
76
|
class FRPProxy:
|
|
@@ -114,16 +134,16 @@ class FRPProxy:
|
|
|
114
134
|
bin_name = "frpc.exe" if os_name == "windows" else "frpc"
|
|
115
135
|
bin_path = FRP_DIR / bin_name
|
|
116
136
|
|
|
117
|
-
#
|
|
137
|
+
# Fast path: binary already installed
|
|
118
138
|
if bin_path.exists():
|
|
119
139
|
return bin_path
|
|
120
140
|
|
|
121
|
-
|
|
122
|
-
|
|
141
|
+
# Cross-process lock: only one process downloads at a time
|
|
142
|
+
with _file_lock(FRP_DIR / ".install.lock"):
|
|
143
|
+
# Re-check after acquiring lock (another process may have installed it)
|
|
123
144
|
if bin_path.exists():
|
|
124
145
|
return bin_path
|
|
125
146
|
|
|
126
|
-
# Construct download URL
|
|
127
147
|
folder_name = f"frp_{FRP_VERSION}_{os_name}_{arch}"
|
|
128
148
|
filename = f"{folder_name}.{ext}"
|
|
129
149
|
url = f"https://github.com/fatedier/frp/releases/download/v{FRP_VERSION}/{filename}"
|
|
@@ -149,11 +169,14 @@ class FRPProxy:
|
|
|
149
169
|
if attempt == max_retries - 1:
|
|
150
170
|
raise
|
|
151
171
|
backoff = 2 ** attempt
|
|
152
|
-
logging.getLogger("smooth").warning(
|
|
172
|
+
logging.getLogger("smooth").warning(
|
|
173
|
+
"FRP download failed (attempt %d/%d), retrying in %ds: %s",
|
|
174
|
+
attempt + 1, max_retries, backoff, e,
|
|
175
|
+
)
|
|
153
176
|
time.sleep(backoff)
|
|
154
177
|
|
|
155
|
-
# Extract to a unique temp dir to avoid
|
|
156
|
-
extract_dir = FRP_DIR / f"extract_tmp_{
|
|
178
|
+
# Extract to a unique temp dir (PID-based to avoid cross-process collisions)
|
|
179
|
+
extract_dir = FRP_DIR / f"extract_tmp_{os.getpid()}"
|
|
157
180
|
extract_dir.mkdir(exist_ok=True)
|
|
158
181
|
|
|
159
182
|
if ext == "zip":
|
|
@@ -163,23 +186,26 @@ class FRPProxy:
|
|
|
163
186
|
with tarfile.open(tmp_path, "r:gz") as t:
|
|
164
187
|
t.extractall(extract_dir)
|
|
165
188
|
|
|
166
|
-
# Move binary
|
|
189
|
+
# Move binary: write to temp name, chmod, then atomic rename
|
|
167
190
|
src = extract_dir / folder_name / bin_name
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
191
|
+
tmp_bin = FRP_DIR / f".{bin_name}.tmp.{os.getpid()}"
|
|
192
|
+
shutil.move(str(src), str(tmp_bin))
|
|
193
|
+
|
|
194
|
+
if os_name != "windows":
|
|
195
|
+
tmp_bin.chmod(0o755)
|
|
196
|
+
|
|
197
|
+
os.rename(str(tmp_bin), str(bin_path))
|
|
171
198
|
|
|
172
199
|
# Cleanup
|
|
173
|
-
tmp_path.unlink()
|
|
200
|
+
tmp_path.unlink(missing_ok=True)
|
|
174
201
|
shutil.rmtree(extract_dir, ignore_errors=True)
|
|
175
202
|
|
|
176
|
-
# Make executable on Unix
|
|
177
|
-
if os_name != "windows":
|
|
178
|
-
bin_path.chmod(0o755)
|
|
179
|
-
|
|
180
203
|
return bin_path
|
|
181
204
|
|
|
182
205
|
except Exception as e:
|
|
206
|
+
# Clean up partial artifacts on failure
|
|
207
|
+
tmp_bin = FRP_DIR / f".{bin_name}.tmp.{os.getpid()}"
|
|
208
|
+
tmp_bin.unlink(missing_ok=True)
|
|
183
209
|
raise RuntimeError(f"Failed to install FRP: {e}") from e
|
|
184
210
|
|
|
185
211
|
def _create_config(self) -> Path:
|
|
@@ -201,7 +227,8 @@ auth:
|
|
|
201
227
|
token: "{self.config.token}"
|
|
202
228
|
|
|
203
229
|
log:
|
|
204
|
-
|
|
230
|
+
to: "console"
|
|
231
|
+
level: "info"
|
|
205
232
|
|
|
206
233
|
transport:
|
|
207
234
|
protocol: "wss"
|
|
@@ -236,27 +263,52 @@ proxies:
|
|
|
236
263
|
# Build command
|
|
237
264
|
cmd = [str(self._bin_path), "-c", str(self._state.config_file)]
|
|
238
265
|
|
|
239
|
-
#
|
|
266
|
+
# Pipe stdout/stderr so we can forward frpc's own logs through Python's
|
|
267
|
+
# logging system (captured by pytest/log handlers) and keep a bounded
|
|
268
|
+
# tail for error reporting.
|
|
240
269
|
self._state.process = subprocess.Popen(
|
|
241
270
|
cmd,
|
|
242
|
-
stdout=subprocess.
|
|
243
|
-
stderr=subprocess.
|
|
271
|
+
stdout=subprocess.PIPE,
|
|
272
|
+
stderr=subprocess.STDOUT,
|
|
273
|
+
bufsize=1,
|
|
274
|
+
text=True,
|
|
244
275
|
)
|
|
245
276
|
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
277
|
+
session_id = self.config.session_id
|
|
278
|
+
proc = self._state.process
|
|
279
|
+
tail = self._state.log_tail
|
|
280
|
+
|
|
281
|
+
def _drain() -> None:
|
|
282
|
+
assert proc.stdout is not None
|
|
283
|
+
try:
|
|
284
|
+
for line in proc.stdout:
|
|
285
|
+
line = line.rstrip()
|
|
286
|
+
if not line:
|
|
287
|
+
continue
|
|
288
|
+
tail.append(line)
|
|
289
|
+
_frpc_logger.info("[%s] %s", session_id, line)
|
|
290
|
+
except Exception:
|
|
291
|
+
pass
|
|
250
292
|
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
raise RuntimeError(f"FRP process exited immediately: {stderr}. Output: {stdout}")
|
|
293
|
+
self._state.drain_thread = threading.Thread(
|
|
294
|
+
target=_drain, name=f"frpc-drain-{session_id}", daemon=True,
|
|
295
|
+
)
|
|
296
|
+
self._state.drain_thread.start()
|
|
256
297
|
|
|
298
|
+
# Give it a moment to start and check if it failed immediately.
|
|
299
|
+
try:
|
|
300
|
+
self._state.process.wait(timeout=1.0)
|
|
301
|
+
rc = self._state.process.returncode
|
|
302
|
+
# Let the drain thread flush what it has.
|
|
303
|
+
self._state.drain_thread.join(timeout=0.5)
|
|
304
|
+
tail_str = "\n".join(tail)
|
|
305
|
+
self._cleanup()
|
|
306
|
+
raise RuntimeError(
|
|
307
|
+
f"FRP process exited immediately (rc={rc}). frpc output:\n{tail_str}"
|
|
308
|
+
)
|
|
257
309
|
except subprocess.TimeoutExpired:
|
|
258
|
-
|
|
259
|
-
|
|
310
|
+
# Process is still running after 1 second — expected happy path.
|
|
311
|
+
pass
|
|
260
312
|
|
|
261
313
|
except Exception as e:
|
|
262
314
|
self._cleanup()
|
|
@@ -283,6 +335,13 @@ proxies:
|
|
|
283
335
|
pass
|
|
284
336
|
self._state.process = None
|
|
285
337
|
|
|
338
|
+
if self._state.drain_thread is not None:
|
|
339
|
+
try:
|
|
340
|
+
self._state.drain_thread.join(timeout=1.0)
|
|
341
|
+
except Exception:
|
|
342
|
+
pass
|
|
343
|
+
self._state.drain_thread = None
|
|
344
|
+
|
|
286
345
|
if self._state.config_file is not None:
|
|
287
346
|
try:
|
|
288
347
|
if self._state.config_file.exists():
|
|
@@ -288,7 +288,7 @@ async def start_session(args: argparse.Namespace):
|
|
|
288
288
|
device=args.device,
|
|
289
289
|
allowed_urls=allowed_urls,
|
|
290
290
|
enable_recording=True, # Enabled by default
|
|
291
|
-
|
|
291
|
+
use_stealth=True, # Enabled by default
|
|
292
292
|
use_adblock=True, # Enabled by default
|
|
293
293
|
proxy_server=proxy_server,
|
|
294
294
|
proxy_username=proxy_username,
|
|
@@ -21,6 +21,12 @@ SensitiveStr = Annotated[SecretStr, PlainSerializer(_serialize_secret, when_used
|
|
|
21
21
|
|
|
22
22
|
DeviceType = Literal["desktop", "mobile", "desktop-lg"]
|
|
23
23
|
|
|
24
|
+
class Secret(BaseModel):
|
|
25
|
+
"""A secret value with URL-based access control."""
|
|
26
|
+
|
|
27
|
+
value: SensitiveStr = Field(description="The secret value.")
|
|
28
|
+
allowed_urls: list[str] = Field(description="URL patterns where this secret can be used (e.g. 'https://github.com/*').")
|
|
29
|
+
|
|
24
30
|
|
|
25
31
|
class Certificate(BaseModel):
|
|
26
32
|
"""Client certificate for accessing secure websites.
|
|
@@ -112,7 +118,12 @@ class TaskRequest(BaseModel):
|
|
|
112
118
|
"Changes made during the task will not be saved back to the profile."
|
|
113
119
|
),
|
|
114
120
|
)
|
|
115
|
-
stealth_mode: bool = Field(
|
|
121
|
+
stealth_mode: bool = Field(
|
|
122
|
+
default=False,
|
|
123
|
+
description="(Deprecated, ignored) Use `use_stealth` instead. Stealth is on by default.",
|
|
124
|
+
deprecated=True,
|
|
125
|
+
)
|
|
126
|
+
use_stealth: bool = Field(default=True, description="Run the browser in stealth mode. Default is True.")
|
|
116
127
|
proxy_server: str | None = Field(
|
|
117
128
|
default=None,
|
|
118
129
|
description=("Proxy server url to route browser traffic through."),
|
|
@@ -361,6 +372,7 @@ class BrowserSessionRequest(BaseModel):
|
|
|
361
372
|
proxy_username: str | None = Field(default=None, description="Proxy server username.")
|
|
362
373
|
proxy_password: SensitiveStr | None = Field(default=None, description="Proxy server password.")
|
|
363
374
|
extensions: list[str] | None = Field(default=None, description="List of extensions to install for the task.")
|
|
375
|
+
use_stealth: bool = Field(default=True, description="Run the browser in stealth mode. Default is True.")
|
|
364
376
|
|
|
365
377
|
@model_validator(mode="before")
|
|
366
378
|
@classmethod
|
|
@@ -374,6 +386,17 @@ class BrowserSessionRequest(BaseModel):
|
|
|
374
386
|
data["profile_id"] = data.pop("session_id") # pyright: ignore[reportUnknownMemberType]
|
|
375
387
|
return data # pyright: ignore[reportUnknownVariableType]
|
|
376
388
|
|
|
389
|
+
@model_validator(mode="before")
|
|
390
|
+
@classmethod
|
|
391
|
+
def _handle_deprecated_stealth_mode(cls, data: Any) -> Any:
|
|
392
|
+
if isinstance(data, dict) and "stealth_mode" in data:
|
|
393
|
+
warnings.warn(
|
|
394
|
+
"'stealth_mode' is deprecated and ignored, use 'use_stealth' instead (defaults to True)",
|
|
395
|
+
DeprecationWarning,
|
|
396
|
+
stacklevel=2,
|
|
397
|
+
)
|
|
398
|
+
return data # pyright: ignore[reportUnknownVariableType]
|
|
399
|
+
|
|
377
400
|
@computed_field(return_type=str | None)
|
|
378
401
|
@property
|
|
379
402
|
def session_id(self):
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|