camoufox-cli 0.7.2__tar.gz → 0.7.6__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 (119) hide show
  1. {camoufox_cli-0.7.2 → camoufox_cli-0.7.6}/.gitignore +1 -0
  2. {camoufox_cli-0.7.2 → camoufox_cli-0.7.6}/PKG-INFO +9 -2
  3. {camoufox_cli-0.7.2 → camoufox_cli-0.7.6}/README.md +7 -0
  4. {camoufox_cli-0.7.2 → camoufox_cli-0.7.6}/pyproject.toml +13 -1
  5. {camoufox_cli-0.7.2 → camoufox_cli-0.7.6}/src/camoufox_cli/cli.py +74 -16
  6. {camoufox_cli-0.7.2 → camoufox_cli-0.7.6}/src/camoufox_cli/commands.py +75 -11
  7. {camoufox_cli-0.7.2 → camoufox_cli-0.7.6}/src/camoufox_cli/install.py +36 -7
  8. {camoufox_cli-0.7.2/.claude/worktrees/cli-version-command-8852a1 → camoufox_cli-0.7.6}/src/camoufox_cli/refs.py +38 -11
  9. {camoufox_cli-0.7.2/.claude/worktrees/cli-version-command-8852a1 → camoufox_cli-0.7.6}/src/camoufox_cli/server.py +6 -0
  10. camoufox_cli-0.7.2/.claude/worktrees/cli-version-command-8852a1/.claude-plugin/marketplace.json +0 -20
  11. camoufox_cli-0.7.2/.claude/worktrees/cli-version-command-8852a1/.gitignore +0 -30
  12. camoufox_cli-0.7.2/.claude/worktrees/cli-version-command-8852a1/Makefile +0 -30
  13. camoufox_cli-0.7.2/.claude/worktrees/cli-version-command-8852a1/README.md +0 -272
  14. camoufox_cli-0.7.2/.claude/worktrees/cli-version-command-8852a1/js/.gitignore +0 -2
  15. camoufox_cli-0.7.2/.claude/worktrees/cli-version-command-8852a1/js/LICENSE +0 -21
  16. camoufox_cli-0.7.2/.claude/worktrees/cli-version-command-8852a1/js/package-lock.json +0 -2785
  17. camoufox_cli-0.7.2/.claude/worktrees/cli-version-command-8852a1/js/package.json +0 -47
  18. camoufox_cli-0.7.2/.claude/worktrees/cli-version-command-8852a1/js/pnpm-lock.yaml +0 -1599
  19. camoufox_cli-0.7.2/.claude/worktrees/cli-version-command-8852a1/js/scripts/postinstall.js +0 -11
  20. camoufox_cli-0.7.2/.claude/worktrees/cli-version-command-8852a1/js/src/browser.ts +0 -371
  21. camoufox_cli-0.7.2/.claude/worktrees/cli-version-command-8852a1/js/src/cli.ts +0 -538
  22. camoufox_cli-0.7.2/.claude/worktrees/cli-version-command-8852a1/js/src/commands.ts +0 -364
  23. camoufox_cli-0.7.2/.claude/worktrees/cli-version-command-8852a1/js/src/config.ts +0 -140
  24. camoufox_cli-0.7.2/.claude/worktrees/cli-version-command-8852a1/js/src/daemon.ts +0 -27
  25. camoufox_cli-0.7.2/.claude/worktrees/cli-version-command-8852a1/js/src/identity.ts +0 -206
  26. camoufox_cli-0.7.2/.claude/worktrees/cli-version-command-8852a1/js/src/install.ts +0 -126
  27. camoufox_cli-0.7.2/.claude/worktrees/cli-version-command-8852a1/js/src/protocol.ts +0 -32
  28. camoufox_cli-0.7.2/.claude/worktrees/cli-version-command-8852a1/js/src/proxy.ts +0 -40
  29. camoufox_cli-0.7.2/.claude/worktrees/cli-version-command-8852a1/js/src/refs.ts +0 -65
  30. camoufox_cli-0.7.2/.claude/worktrees/cli-version-command-8852a1/js/src/server.ts +0 -200
  31. camoufox_cli-0.7.2/.claude/worktrees/cli-version-command-8852a1/js/tests/browser.test.ts +0 -98
  32. camoufox_cli-0.7.2/.claude/worktrees/cli-version-command-8852a1/js/tests/cli.test.ts +0 -475
  33. camoufox_cli-0.7.2/.claude/worktrees/cli-version-command-8852a1/js/tests/commands.test.ts +0 -189
  34. camoufox_cli-0.7.2/.claude/worktrees/cli-version-command-8852a1/js/tests/e2e.test.ts +0 -294
  35. camoufox_cli-0.7.2/.claude/worktrees/cli-version-command-8852a1/js/tests/install.test.ts +0 -153
  36. camoufox_cli-0.7.2/.claude/worktrees/cli-version-command-8852a1/js/tests/protocol.test.ts +0 -98
  37. camoufox_cli-0.7.2/.claude/worktrees/cli-version-command-8852a1/js/tests/proxy.test.ts +0 -55
  38. camoufox_cli-0.7.2/.claude/worktrees/cli-version-command-8852a1/js/tests/refs.test.ts +0 -150
  39. camoufox_cli-0.7.2/.claude/worktrees/cli-version-command-8852a1/js/tests/server.test.ts +0 -257
  40. camoufox_cli-0.7.2/.claude/worktrees/cli-version-command-8852a1/js/tsconfig.json +0 -14
  41. camoufox_cli-0.7.2/.claude/worktrees/cli-version-command-8852a1/js/vitest.config.ts +0 -7
  42. camoufox_cli-0.7.2/.claude/worktrees/cli-version-command-8852a1/pyproject.toml +0 -46
  43. camoufox_cli-0.7.2/.claude/worktrees/cli-version-command-8852a1/skills/camoufox-cli/SKILL.md +0 -386
  44. camoufox_cli-0.7.2/.claude/worktrees/cli-version-command-8852a1/skills/camoufox-cli/references/commands.md +0 -150
  45. camoufox_cli-0.7.2/.claude/worktrees/cli-version-command-8852a1/skills/camoufox-cli/references/snapshot-refs.md +0 -221
  46. camoufox_cli-0.7.2/.claude/worktrees/cli-version-command-8852a1/src/camoufox_cli/cli.py +0 -550
  47. camoufox_cli-0.7.2/.claude/worktrees/cli-version-command-8852a1/src/camoufox_cli/commands.py +0 -406
  48. camoufox_cli-0.7.2/.claude/worktrees/cli-version-command-8852a1/src/camoufox_cli/install.py +0 -127
  49. camoufox_cli-0.7.2/.claude/worktrees/cli-version-command-8852a1/tests/__init__.py +0 -0
  50. camoufox_cli-0.7.2/.claude/worktrees/cli-version-command-8852a1/tests/fixture.html +0 -22
  51. camoufox_cli-0.7.2/.claude/worktrees/cli-version-command-8852a1/tests/test_cli.py +0 -418
  52. camoufox_cli-0.7.2/.claude/worktrees/cli-version-command-8852a1/tests/test_commands.py +0 -556
  53. camoufox_cli-0.7.2/.claude/worktrees/cli-version-command-8852a1/tests/test_e2e.py +0 -302
  54. camoufox_cli-0.7.2/.claude/worktrees/cli-version-command-8852a1/tests/test_install.py +0 -150
  55. camoufox_cli-0.7.2/.claude/worktrees/cli-version-command-8852a1/tests/test_protocol.py +0 -63
  56. camoufox_cli-0.7.2/.claude/worktrees/cli-version-command-8852a1/tests/test_proxy.py +0 -37
  57. camoufox_cli-0.7.2/.claude/worktrees/cli-version-command-8852a1/tests/test_refs.py +0 -112
  58. camoufox_cli-0.7.2/.claude/worktrees/cli-version-command-8852a1/tests/test_server.py +0 -101
  59. camoufox_cli-0.7.2/.claude-plugin/marketplace.json +0 -20
  60. camoufox_cli-0.7.2/LICENSE +0 -21
  61. camoufox_cli-0.7.2/Makefile +0 -30
  62. camoufox_cli-0.7.2/js/.gitignore +0 -2
  63. camoufox_cli-0.7.2/js/LICENSE +0 -21
  64. camoufox_cli-0.7.2/js/package-lock.json +0 -2785
  65. camoufox_cli-0.7.2/js/package.json +0 -47
  66. camoufox_cli-0.7.2/js/pnpm-lock.yaml +0 -1599
  67. camoufox_cli-0.7.2/js/scripts/postinstall.js +0 -11
  68. camoufox_cli-0.7.2/js/src/browser.ts +0 -401
  69. camoufox_cli-0.7.2/js/src/cli.ts +0 -588
  70. camoufox_cli-0.7.2/js/src/commands.ts +0 -364
  71. camoufox_cli-0.7.2/js/src/config.ts +0 -140
  72. camoufox_cli-0.7.2/js/src/daemon.ts +0 -27
  73. camoufox_cli-0.7.2/js/src/identity.ts +0 -206
  74. camoufox_cli-0.7.2/js/src/install.ts +0 -179
  75. camoufox_cli-0.7.2/js/src/protocol.ts +0 -32
  76. camoufox_cli-0.7.2/js/src/proxy.ts +0 -40
  77. camoufox_cli-0.7.2/js/src/refs.ts +0 -65
  78. camoufox_cli-0.7.2/js/src/server.ts +0 -214
  79. camoufox_cli-0.7.2/js/tests/browser.test.ts +0 -122
  80. camoufox_cli-0.7.2/js/tests/cli.test.ts +0 -475
  81. camoufox_cli-0.7.2/js/tests/commands.test.ts +0 -189
  82. camoufox_cli-0.7.2/js/tests/e2e.test.ts +0 -294
  83. camoufox_cli-0.7.2/js/tests/install.test.ts +0 -205
  84. camoufox_cli-0.7.2/js/tests/protocol.test.ts +0 -98
  85. camoufox_cli-0.7.2/js/tests/proxy.test.ts +0 -55
  86. camoufox_cli-0.7.2/js/tests/refs.test.ts +0 -150
  87. camoufox_cli-0.7.2/js/tests/server.test.ts +0 -300
  88. camoufox_cli-0.7.2/js/tsconfig.json +0 -14
  89. camoufox_cli-0.7.2/js/vitest.config.ts +0 -7
  90. camoufox_cli-0.7.2/skills/camoufox-cli/SKILL.md +0 -386
  91. camoufox_cli-0.7.2/skills/camoufox-cli/references/commands.md +0 -150
  92. camoufox_cli-0.7.2/skills/camoufox-cli/references/snapshot-refs.md +0 -221
  93. camoufox_cli-0.7.2/src/camoufox_cli/__init__.py +0 -0
  94. camoufox_cli-0.7.2/src/camoufox_cli/__main__.py +0 -38
  95. camoufox_cli-0.7.2/src/camoufox_cli/browser.py +0 -316
  96. camoufox_cli-0.7.2/src/camoufox_cli/config.py +0 -130
  97. camoufox_cli-0.7.2/src/camoufox_cli/identity.py +0 -215
  98. camoufox_cli-0.7.2/src/camoufox_cli/protocol.py +0 -24
  99. camoufox_cli-0.7.2/src/camoufox_cli/proxy.py +0 -30
  100. camoufox_cli-0.7.2/src/camoufox_cli/refs.py +0 -83
  101. camoufox_cli-0.7.2/src/camoufox_cli/server.py +0 -189
  102. camoufox_cli-0.7.2/tests/__init__.py +0 -0
  103. camoufox_cli-0.7.2/tests/fixture.html +0 -22
  104. camoufox_cli-0.7.2/tests/test_cli.py +0 -418
  105. camoufox_cli-0.7.2/tests/test_commands.py +0 -556
  106. camoufox_cli-0.7.2/tests/test_e2e.py +0 -302
  107. camoufox_cli-0.7.2/tests/test_install.py +0 -201
  108. camoufox_cli-0.7.2/tests/test_protocol.py +0 -63
  109. camoufox_cli-0.7.2/tests/test_proxy.py +0 -37
  110. camoufox_cli-0.7.2/tests/test_refs.py +0 -112
  111. camoufox_cli-0.7.2/tests/test_server.py +0 -101
  112. {camoufox_cli-0.7.2/.claude/worktrees/cli-version-command-8852a1 → camoufox_cli-0.7.6}/LICENSE +0 -0
  113. {camoufox_cli-0.7.2/.claude/worktrees/cli-version-command-8852a1 → camoufox_cli-0.7.6}/src/camoufox_cli/__init__.py +0 -0
  114. {camoufox_cli-0.7.2/.claude/worktrees/cli-version-command-8852a1 → camoufox_cli-0.7.6}/src/camoufox_cli/__main__.py +0 -0
  115. {camoufox_cli-0.7.2/.claude/worktrees/cli-version-command-8852a1 → camoufox_cli-0.7.6}/src/camoufox_cli/browser.py +0 -0
  116. {camoufox_cli-0.7.2/.claude/worktrees/cli-version-command-8852a1 → camoufox_cli-0.7.6}/src/camoufox_cli/config.py +0 -0
  117. {camoufox_cli-0.7.2/.claude/worktrees/cli-version-command-8852a1 → camoufox_cli-0.7.6}/src/camoufox_cli/identity.py +0 -0
  118. {camoufox_cli-0.7.2/.claude/worktrees/cli-version-command-8852a1 → camoufox_cli-0.7.6}/src/camoufox_cli/protocol.py +0 -0
  119. {camoufox_cli-0.7.2/.claude/worktrees/cli-version-command-8852a1 → camoufox_cli-0.7.6}/src/camoufox_cli/proxy.py +0 -0
@@ -14,6 +14,7 @@ node_modules/
14
14
  js/README.md
15
15
 
16
16
  # IDE
17
+ .claude/
17
18
  .idea/
18
19
  .vscode/
19
20
  *.swp
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: camoufox-cli
3
- Version: 0.7.2
3
+ Version: 0.7.6
4
4
  Summary: Anti-detect browser CLI & Skills for AI agents, powered by Camoufox
5
5
  Project-URL: Homepage, https://github.com/Bin-Huang/camoufox-cli
6
6
  Project-URL: Repository, https://github.com/Bin-Huang/camoufox-cli
@@ -122,10 +122,16 @@ camoufox-cli type @e3 "append text" # Type without clearing
122
122
  camoufox-cli select @e5 "option text" # Select dropdown option
123
123
  camoufox-cli check @e6 # Toggle checkbox
124
124
  camoufox-cli hover @e2 # Hover over element
125
+ camoufox-cli mouse click 660 380 # Click at viewport coordinates
125
126
  camoufox-cli press Enter # Press keyboard key
126
127
  camoufox-cli press "Control+a" # Key combination
127
128
  ```
128
129
 
130
+ `mouse click` reaches elements that have no ref, such as a checkbox inside a
131
+ cross-origin iframe (for example a Cloudflare Turnstile widget). Coordinates are
132
+ viewport CSS pixels. At the default `devicePixelRatio` of 1, they equal the pixels
133
+ of a non-`--full` screenshot.
134
+
129
135
  ### Data Extraction
130
136
 
131
137
  ```bash
@@ -289,6 +295,7 @@ The CLI sends JSON commands to a long-running daemon process via Unix socket. Th
289
295
  - [reddit-ads-cli](https://github.com/Bin-Huang/reddit-ads-cli) -- Reddit Ads CLI & Skills for AI agents (and humans)
290
296
  - [spotify-ads-cli](https://github.com/Bin-Huang/spotify-ads-cli) -- Spotify Ads CLI & Skills for AI agents (and humans)
291
297
  - [apple-ads-cli](https://github.com/Bin-Huang/apple-ads-cli) -- Apple Ads CLI & Skills for AI agents (and humans)
298
+
292
299
  ## License
293
300
 
294
301
  MIT
@@ -100,10 +100,16 @@ camoufox-cli type @e3 "append text" # Type without clearing
100
100
  camoufox-cli select @e5 "option text" # Select dropdown option
101
101
  camoufox-cli check @e6 # Toggle checkbox
102
102
  camoufox-cli hover @e2 # Hover over element
103
+ camoufox-cli mouse click 660 380 # Click at viewport coordinates
103
104
  camoufox-cli press Enter # Press keyboard key
104
105
  camoufox-cli press "Control+a" # Key combination
105
106
  ```
106
107
 
108
+ `mouse click` reaches elements that have no ref, such as a checkbox inside a
109
+ cross-origin iframe (for example a Cloudflare Turnstile widget). Coordinates are
110
+ viewport CSS pixels. At the default `devicePixelRatio` of 1, they equal the pixels
111
+ of a non-`--full` screenshot.
112
+
107
113
  ### Data Extraction
108
114
 
109
115
  ```bash
@@ -267,6 +273,7 @@ The CLI sends JSON commands to a long-running daemon process via Unix socket. Th
267
273
  - [reddit-ads-cli](https://github.com/Bin-Huang/reddit-ads-cli) -- Reddit Ads CLI & Skills for AI agents (and humans)
268
274
  - [spotify-ads-cli](https://github.com/Bin-Huang/spotify-ads-cli) -- Spotify Ads CLI & Skills for AI agents (and humans)
269
275
  - [apple-ads-cli](https://github.com/Bin-Huang/apple-ads-cli) -- Apple Ads CLI & Skills for AI agents (and humans)
276
+
270
277
  ## License
271
278
 
272
279
  MIT
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "camoufox-cli"
3
- version = "0.7.2"
3
+ version = "0.7.6"
4
4
  description = "Anti-detect browser CLI & Skills for AI agents, powered by Camoufox"
5
5
  requires-python = ">=3.10"
6
6
  license = "MIT"
@@ -37,6 +37,18 @@ camoufox-cli = "camoufox_cli.cli:main"
37
37
  requires = ["hatchling"]
38
38
  build-backend = "hatchling.build"
39
39
 
40
+ [tool.hatch.build.targets.sdist]
41
+ # List the sdist contents explicitly. By default hatchling packs every file
42
+ # that the repo .gitignore does not exclude, which picks up local untracked
43
+ # files and the JS package. A leading "/" anchors each pattern to the repo
44
+ # root, so js/LICENSE does not match.
45
+ include = [
46
+ "/src/camoufox_cli",
47
+ "/README.md",
48
+ "/LICENSE",
49
+ "/pyproject.toml",
50
+ ]
51
+
40
52
  [tool.hatch.build.targets.wheel]
41
53
  packages = ["src/camoufox_cli"]
42
54
 
@@ -7,18 +7,38 @@ import os
7
7
  import socket
8
8
  import subprocess
9
9
  import sys
10
+ import tempfile
10
11
  import time
11
12
 
12
13
  from .config import load_defaults
13
14
 
14
15
 
15
16
  SOCKET_PREFIX = "/tmp/camoufox-cli-"
17
+ # A unix socket path must fit in sun_path: 104 bytes on macOS (108 on Linux),
18
+ # including the terminating NUL.
19
+ _MAX_SOCKET_PATH_BYTES = 103
16
20
 
17
21
 
18
22
  def get_socket_path(session: str) -> str:
19
23
  return f"{SOCKET_PREFIX}{session}.sock"
20
24
 
21
25
 
26
+ def session_name_error(session: str) -> str | None:
27
+ """Why ``session`` cannot be used as a session name, or None if it can.
28
+
29
+ The name becomes part of the daemon's /tmp socket, pid and lock file names
30
+ and of the default --persistent profile path.
31
+ """
32
+ if session in ("", ".", ".."):
33
+ return "must be a non-empty name other than '.' or '..'"
34
+ if "/" in session:
35
+ return "must not contain '/'"
36
+ max_bytes = _MAX_SOCKET_PATH_BYTES - len(get_socket_path("").encode())
37
+ if len(session.encode()) > max_bytes:
38
+ return f"must be at most {max_bytes} bytes (unix socket path limit)"
39
+ return None
40
+
41
+
22
42
  def send_command(sock_path: str, command: dict) -> dict:
23
43
  s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
24
44
  s.connect(sock_path)
@@ -47,21 +67,30 @@ def spawn_daemon(session: str, headed: bool, timeout: int, persistent: str | Non
47
67
  if locale:
48
68
  cmd.extend(["--locale", locale])
49
69
 
50
- subprocess.Popen(
51
- cmd,
52
- stdin=subprocess.DEVNULL,
53
- stdout=subprocess.DEVNULL,
54
- stderr=subprocess.DEVNULL,
55
- start_new_session=True,
56
- )
70
+ # The daemon writes stderr to an anonymous temp file, so a failed start can
71
+ # report its real cause instead of only a timeout.
72
+ with tempfile.TemporaryFile() as err:
73
+ subprocess.Popen(
74
+ cmd,
75
+ stdin=subprocess.DEVNULL,
76
+ stdout=subprocess.DEVNULL,
77
+ stderr=err,
78
+ start_new_session=True,
79
+ )
80
+
81
+ sock_path = get_socket_path(session)
82
+ for _ in range(50):
83
+ if os.path.exists(sock_path):
84
+ return
85
+ time.sleep(0.1)
57
86
 
58
- sock_path = get_socket_path(session)
59
- for _ in range(50):
60
- if os.path.exists(sock_path):
61
- return
62
- time.sleep(0.1)
87
+ # pread: the daemon shares this file's offset, so do not seek it.
88
+ output = os.pread(err.fileno(), os.fstat(err.fileno()).st_size, 0)
63
89
 
64
90
  print("Error: Daemon did not start within 5 seconds", file=sys.stderr)
91
+ output_lines = output.decode(errors="replace").strip().splitlines()
92
+ if output_lines:
93
+ print("Daemon output:\n" + "\n".join(output_lines[-20:]), file=sys.stderr)
65
94
  sys.exit(1)
66
95
 
67
96
 
@@ -180,6 +209,10 @@ def parse_args(args: list[str]) -> tuple[dict, dict]:
180
209
 
181
210
  # session selects which config block applies, so it comes only from the CLI.
182
211
  session = cli.get("session", builtin["session"])
212
+ error = session_name_error(session)
213
+ if error:
214
+ print(f"Error: invalid --session {session!r}: {error}", file=sys.stderr)
215
+ sys.exit(1)
183
216
  flags = {**builtin, **load_defaults(session), **cli}
184
217
 
185
218
  action = rest[0]
@@ -244,6 +277,18 @@ def build_command(action: str, rest: list[str]) -> dict:
244
277
  case "hover":
245
278
  ref = _require(rest, 1, "Usage: camoufox-cli hover @e1")
246
279
  return {"id": "r1", "action": "hover", "params": {"ref": ref}}
280
+ case "mouse":
281
+ usage = "Usage: camoufox-cli mouse click <x> <y>"
282
+ if _require(rest, 1, usage) != "click":
283
+ print(usage, file=sys.stderr)
284
+ sys.exit(1)
285
+ try:
286
+ x = float(_require(rest, 2, usage))
287
+ y = float(_require(rest, 3, usage))
288
+ except ValueError:
289
+ print(usage, file=sys.stderr)
290
+ sys.exit(1)
291
+ return {"id": "r1", "action": "mouse_click", "params": {"x": x, "y": y}}
247
292
  case "press":
248
293
  key = _require(rest, 1, "Usage: camoufox-cli press Enter")
249
294
  return {"id": "r1", "action": "press", "params": {"key": key}}
@@ -408,6 +453,18 @@ def _resolve_apt_deps(deps: list[str]) -> list[str]:
408
453
  ]
409
454
 
410
455
 
456
+ def _run_as_root(argv: list[str]) -> None:
457
+ """Run a privileged package-manager command.
458
+
459
+ Prefer bare invocation when already root (Docker/CI images rarely ship
460
+ sudo). Fall back to sudo for unprivileged interactive installs.
461
+ """
462
+ if os.geteuid() == 0:
463
+ subprocess.run(argv, check=True)
464
+ else:
465
+ subprocess.run(["sudo", *argv], check=True)
466
+
467
+
411
468
  def _install_system_deps() -> None:
412
469
  import platform
413
470
  import shutil
@@ -419,14 +476,14 @@ def _install_system_deps() -> None:
419
476
  print("[camoufox-cli] Installing system dependencies...", file=sys.stderr)
420
477
 
421
478
  if shutil.which("apt-get"):
422
- subprocess.run(["sudo", "apt-get", "update", "-y"], check=True)
479
+ _run_as_root(["apt-get", "update", "-y"])
423
480
  # Resolve AFTER update: dry-run resolution needs a populated apt cache.
424
481
  deps = _resolve_apt_deps(_APT_DEPS)
425
- subprocess.run(["sudo", "apt-get", "install", "-y", *deps], check=True)
482
+ _run_as_root(["apt-get", "install", "-y", *deps])
426
483
  elif shutil.which("dnf"):
427
- subprocess.run(["sudo", "dnf", "install", "-y", *_DNF_DEPS], check=True)
484
+ _run_as_root(["dnf", "install", "-y", *_DNF_DEPS])
428
485
  elif shutil.which("yum"):
429
- subprocess.run(["sudo", "yum", "install", "-y", *_YUM_DEPS], check=True)
486
+ _run_as_root(["yum", "install", "-y", *_YUM_DEPS])
430
487
  else:
431
488
  print("[camoufox-cli] Could not detect a supported package manager (apt-get, dnf, yum).", file=sys.stderr)
432
489
  sys.exit(1)
@@ -547,6 +604,7 @@ Interaction:
547
604
  select @ref "option" Select dropdown option
548
605
  check @ref Toggle checkbox
549
606
  hover @ref Hover over element
607
+ mouse click <x> <y> Click at viewport coordinates (no ref needed)
550
608
  press <key> Press key (e.g. Enter, Control+a)
551
609
 
552
610
  Data:
@@ -5,9 +5,11 @@ from __future__ import annotations
5
5
  import base64
6
6
  import io
7
7
  import json
8
+ import math
8
9
 
9
10
  from .browser import BrowserManager, TabView
10
11
  from .protocol import ok_response, error_response
12
+ from .refs import UNNAMED_RE
11
13
 
12
14
 
13
15
  def execute(manager: BrowserManager, command: dict) -> dict:
@@ -41,10 +43,22 @@ def _resolve_ref(manager: TabView, ref_str: str):
41
43
  if entry is None:
42
44
  raise ValueError(f"Ref @{ref_str.lstrip('@')} not found. Run 'camoufox-cli snapshot' to refresh refs.")
43
45
  page = manager.get_page()
44
- if entry.name:
45
- locator = page.get_by_role(entry.role, name=entry.name, exact=True) # type: ignore[arg-type]
46
- else:
47
- locator = page.get_by_role(entry.role) # type: ignore[arg-type]
46
+
47
+ # Always filter by name, even for an unnamed ref: nth counts only elements
48
+ # that the snapshot shows with this name, so an unfiltered role query
49
+ # would pick the wrong element when named and unnamed elements share a role.
50
+ name = entry.name or UNNAMED_RE
51
+
52
+ def by_role(root):
53
+ return root.get_by_role(entry.role, name=name, exact=True) # type: ignore[arg-type]
54
+
55
+ locator = by_role(page)
56
+ scope = manager.refs.scope
57
+ if scope:
58
+ # nth was counted within the scoped snapshot, which includes the scope
59
+ # element itself, so resolve within the same subtree.
60
+ root = page.locator(scope)
61
+ locator = root.and_(locator).or_(by_role(root))
48
62
  return locator.nth(entry.nth)
49
63
 
50
64
 
@@ -140,7 +154,7 @@ def _cmd_snapshot(manager: TabView, cmd_id: str, params: dict) -> dict:
140
154
 
141
155
  target = page.locator(selector) if selector else page.locator("body")
142
156
  aria_text = target.aria_snapshot()
143
- annotated = manager.refs.build_from_snapshot(aria_text, interactive_only=interactive)
157
+ annotated = manager.refs.build_from_snapshot(aria_text, interactive_only=interactive, scope=selector)
144
158
  return ok_response(cmd_id, {"snapshot": annotated})
145
159
 
146
160
 
@@ -148,6 +162,39 @@ def _cmd_snapshot(manager: TabView, cmd_id: str, params: dict) -> dict:
148
162
  # Interaction
149
163
  # ---------------------------------------------------------------------------
150
164
 
165
+ # DOM-click the element and return the href the caller must navigate to, or
166
+ # null. For links, a window-level listener runs after the page's own handlers:
167
+ # if none of them called preventDefault() (onclick, SPA routers), it cancels
168
+ # the native navigation and returns the href. If a page handler stops
169
+ # propagation (e.g. React's e.stopPropagation()), that listener never runs and
170
+ # the native navigation is not cancelled, so the href is still returned when
171
+ # the click was not prevented: page.goto() then replaces that navigation.
172
+ # closest() finds the <a> when the locator resolves to a child element.
173
+ _CLICK_JS = """el => {
174
+ const link = el.closest('a');
175
+ if (!(link instanceof HTMLAnchorElement) || !link.href || link.protocol === 'javascript:') {
176
+ el.click();
177
+ return null;
178
+ }
179
+ let event = null;
180
+ let href = null;
181
+ const capture = e => { event = e; };
182
+ const probe = e => {
183
+ if (!e.defaultPrevented) { e.preventDefault(); href = link.href; }
184
+ };
185
+ window.addEventListener('click', capture, true);
186
+ window.addEventListener('click', probe);
187
+ try {
188
+ el.click();
189
+ } finally {
190
+ window.removeEventListener('click', capture, true);
191
+ window.removeEventListener('click', probe);
192
+ }
193
+ if (href === null && event && !event.defaultPrevented) href = link.href;
194
+ return href;
195
+ }"""
196
+
197
+
151
198
  def _cmd_click(manager: TabView, cmd_id: str, params: dict) -> dict:
152
199
  ref_str = params.get("ref", "")
153
200
  if not ref_str:
@@ -156,16 +203,15 @@ def _cmd_click(manager: TabView, cmd_id: str, params: dict) -> dict:
156
203
  page = manager.get_page()
157
204
  url_before = page.url
158
205
 
159
- # Navigate via page.goto() for links, el.click() for other elements.
160
- # This avoids two Camoufox issues:
206
+ # Click via el.click() and navigate links via page.goto(). This avoids two
207
+ # Camoufox issues:
161
208
  # 1. Playwright's .click() times out when sticky headers/overlays intercept pointer events
162
209
  # 2. Camoufox ignores target="_blank" clicks (both .click() and el.click() silently fail)
163
- # Walk up the DOM to find <a> ancestor since the locator may resolve to a child element.
164
- link_href = locator.evaluate("el => { while (el) { if (el.tagName === 'A') return el.href; el = el.parentElement; } return null; }")
210
+ # Links still get the click first, so page handlers that take it over
211
+ # (href="#" + onclick, SPA routing) work; see _CLICK_JS.
212
+ link_href = locator.evaluate(_CLICK_JS)
165
213
  if link_href:
166
214
  page.goto(link_href, wait_until="domcontentloaded")
167
- else:
168
- locator.evaluate("el => el.click()")
169
215
 
170
216
  url_after = page.url
171
217
  if url_after != url_before:
@@ -220,6 +266,23 @@ def _cmd_hover(manager: TabView, cmd_id: str, params: dict) -> dict:
220
266
  return ok_response(cmd_id)
221
267
 
222
268
 
269
+ def _cmd_mouse_click(manager: TabView, cmd_id: str, params: dict) -> dict:
270
+ """Click at viewport coordinates.
271
+
272
+ Reaches targets that have no ref, such as a checkbox inside a
273
+ cross-origin iframe (e.g. a Cloudflare Turnstile widget).
274
+ """
275
+ try:
276
+ x = float(params["x"])
277
+ y = float(params["y"])
278
+ except (KeyError, TypeError, ValueError):
279
+ return error_response(cmd_id, "Missing or invalid 'x'/'y' parameters")
280
+ if not (math.isfinite(x) and math.isfinite(y)):
281
+ return error_response(cmd_id, "Missing or invalid 'x'/'y' parameters")
282
+ manager.get_page().mouse.click(x, y)
283
+ return ok_response(cmd_id)
284
+
285
+
223
286
  def _cmd_press(manager: TabView, cmd_id: str, params: dict) -> dict:
224
287
  key = params.get("key", "")
225
288
  if not key:
@@ -389,6 +452,7 @@ _HANDLERS = {
389
452
  "select": _cmd_select,
390
453
  "check": _cmd_check,
391
454
  "hover": _cmd_hover,
455
+ "mouse_click": _cmd_mouse_click,
392
456
  "press": _cmd_press,
393
457
  # Data extraction
394
458
  "text": _cmd_text,
@@ -148,9 +148,22 @@ def ensure_mmdb() -> None:
148
148
  print(f"[camoufox-cli] GeoIP database download failed ({e}).", file=sys.stderr)
149
149
 
150
150
 
151
+ def _browser_present() -> bool:
152
+ """True when the browser's launch executable is actually on disk."""
153
+ from camoufox.exceptions import CamoufoxNotInstalled
154
+ from camoufox.pkgman import launch_path
155
+
156
+ try:
157
+ launch_path()
158
+ return True
159
+ except (CamoufoxNotInstalled, FileNotFoundError):
160
+ return False
161
+
162
+
151
163
  def install_browser() -> None:
152
- """Download and install the Camoufox browser and GeoIP database."""
153
- from camoufox.pkgman import CamoufoxFetcher, launch_path
164
+ """Download and install the Camoufox browser, GeoIP database and addons."""
165
+ from camoufox.addons import DefaultAddons, maybe_download_addons
166
+ from camoufox.pkgman import CamoufoxFetcher, installed_verstr, launch_path
154
167
 
155
168
  class ResilientFetcher(CamoufoxFetcher):
156
169
  def get_asset(self):
@@ -159,9 +172,25 @@ def install_browser() -> None:
159
172
  return data
160
173
  self.missing_asset_error()
161
174
 
162
- ResilientFetcher().install()
163
- # A broken release asset (e.g. one missing the browser binary) would
164
- # otherwise "install" successfully, write version.json, and mask the
165
- # failure forever. Fail loudly here instead of at the first `open`.
166
- launch_path() # raises CamoufoxNotInstalled if the executable is absent
175
+ fetcher = ResilientFetcher()
176
+ # Match the JS installer: skip the ~600MB download when version.json
177
+ # already matches AND the binary is actually present. Without the
178
+ # presence check a broken install (version file, no binary) would be
179
+ # masked forever; without the version check every `install` re-downloads.
180
+ try:
181
+ installed = installed_verstr()
182
+ except Exception:
183
+ installed = None
184
+ if installed == fetcher.verstr and _browser_present():
185
+ print(f"[camoufox-cli] Camoufox v{installed} is already up to date.", file=sys.stderr)
186
+ else:
187
+ fetcher.install()
188
+ # A broken release asset (e.g. one missing the browser binary) would
189
+ # otherwise "install" successfully, write version.json, and mask the
190
+ # failure forever. Fail loudly here instead of at the first `open`.
191
+ launch_path() # raises CamoufoxNotInstalled if the executable is absent
192
+
167
193
  ensure_mmdb()
194
+ # Default UBO addon — same as `camoufox fetch`. Without this, first open
195
+ # either downloads lazily or fails if an empty addons/ dir was left behind.
196
+ maybe_download_addons(list(DefaultAddons))
@@ -2,6 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import json
5
6
  import re
6
7
  from dataclasses import dataclass
7
8
 
@@ -22,24 +23,51 @@ INTERACTIVE_ROLES = frozenset({
22
23
  "select", "listbox", "searchbox",
23
24
  })
24
25
 
25
- # Pattern to match aria snapshot lines like: - link "About"
26
- # handles nested indentation and optional attributes
27
- _ARIA_LINE_RE = re.compile(
28
- r'^(\s*-\s+)' # leading indent + dash
29
- r'(\w+)' # role
30
- r'(?:\s+"([^"]*)")?' # optional quoted name
26
+ # Playwright renders each node as `- role "name" [attrs]`, with the name
27
+ # JSON-encoded, except that a name which starts and ends with "/" (including
28
+ # "/" itself) is written as is. If the key contains YAML-special text (e.g.
29
+ # ": " or " #"), the whole key is wrapped in YAML single quotes, with ' escaped
30
+ # as ''. A plain key never contains ":" followed by whitespace, so that marks
31
+ # where its value starts.
32
+ _ARIA_ITEM_RE = re.compile(
33
+ r"^\s*-\s+" # leading indent + dash
34
+ r"(?:'((?:[^']|'')*)'|((?:[^:]|:(?!\s|$))*))" # single-quoted key, or plain key
31
35
  )
36
+ _ARIA_KEY_RE = re.compile(
37
+ r'^(\w+)' # role
38
+ r'(?:\s+("(?:[^"\\]|\\.)*"' # optional JSON-encoded name,
39
+ r'|/(?:.*/)?(?=(?:\s\[[^\]]*\])*$)))?' # or a "/.../" or "/" name before the attrs
40
+ )
41
+
42
+ # Playwright omits names longer than 900 characters, so an unnamed node is an
43
+ # element whose accessible name is empty or over that limit.
44
+ UNNAMED_RE = re.compile(r"^$|^[\s\S]{901,}$")
45
+
46
+
47
+ def _parse_aria_line(line: str) -> tuple[str, str] | None:
48
+ """Return (role, name) for an aria snapshot node line, or None."""
49
+ item = _ARIA_ITEM_RE.match(line)
50
+ if not item:
51
+ return None
52
+ key = item.group(1).replace("''", "'") if item.group(1) is not None else item.group(2)
53
+ m = _ARIA_KEY_RE.match(key)
54
+ if not m:
55
+ return None
56
+ name = m.group(2) or ""
57
+ return m.group(1), json.loads(name) if name.startswith('"') else name
32
58
 
33
59
 
34
60
  class RefRegistry:
35
61
  def __init__(self):
36
62
  self._entries: dict[str, RefEntry] = {} # ref_str -> RefEntry
37
63
  self._counter = 0
64
+ self.scope: str | None = None # selector of a scoped snapshot; refs resolve within it
38
65
 
39
- def build_from_snapshot(self, aria_text: str, interactive_only: bool = False) -> str:
66
+ def build_from_snapshot(self, aria_text: str, interactive_only: bool = False, scope: str | None = None) -> str:
40
67
  """Parse aria snapshot text, assign refs, return annotated text."""
41
68
  self._entries.clear()
42
69
  self._counter = 0
70
+ self.scope = scope
43
71
 
44
72
  # Track role+name occurrences for nth disambiguation
45
73
  seen: dict[tuple[str, str], int] = {}
@@ -47,14 +75,13 @@ class RefRegistry:
47
75
  result_lines = []
48
76
 
49
77
  for line in lines:
50
- m = _ARIA_LINE_RE.match(line)
51
- if not m:
78
+ parsed = _parse_aria_line(line)
79
+ if parsed is None:
52
80
  if not interactive_only:
53
81
  result_lines.append(line)
54
82
  continue
55
83
 
56
- role = m.group(2)
57
- name = m.group(3) or ""
84
+ role, name = parsed
58
85
 
59
86
  if interactive_only and role not in INTERACTIVE_ROLES:
60
87
  continue
@@ -14,6 +14,11 @@ from .browser import BrowserManager
14
14
  from .commands import execute
15
15
  from .protocol import parse_command, serialize_response
16
16
 
17
+ # Max seconds a client connection may take to send its command (and to take
18
+ # the response). This daemon serves one connection at a time, so a client
19
+ # that connects but never sends would otherwise block every other tab.
20
+ CONNECTION_TIMEOUT = 10.0
21
+
17
22
 
18
23
  class DaemonServer:
19
24
  def __init__(self, session: str = "default", headless: bool = True, timeout: int = 1800, persistent: str | None = None, proxy: str | None = None, geoip: bool = True, locale: str | None = None):
@@ -74,6 +79,7 @@ class DaemonServer:
74
79
  self._shutdown()
75
80
 
76
81
  def _handle_connection(self, conn: socket.socket) -> None:
82
+ conn.settimeout(CONNECTION_TIMEOUT)
77
83
  data = b""
78
84
  while True:
79
85
  chunk = conn.recv(4096)
@@ -1,20 +0,0 @@
1
- {
2
- "name": "camoufox-cli",
3
- "owner": {
4
- "name": "Benn Huang"
5
- },
6
- "metadata": {
7
- "description": "Anti-detect browser automation CLI & Skills for AI agents, powered by Camoufox",
8
- "version": "0.1.0"
9
- },
10
- "plugins": [
11
- {
12
- "name": "camoufox-cli",
13
- "description": "Anti-detect browser automation skill with fingerprint spoofing for AI agents",
14
- "source": "./",
15
- "skills": [
16
- "./skills/camoufox-cli"
17
- ]
18
- }
19
- ]
20
- }
@@ -1,30 +0,0 @@
1
- # Python
2
- __pycache__/
3
- *.pyc
4
- *.pyo
5
- *.egg-info/
6
- dist/
7
- build/
8
- .venv/
9
- .mypy_cache/
10
- uv.lock
11
-
12
- # Node
13
- node_modules/
14
- js/README.md
15
-
16
- # IDE
17
- .idea/
18
- .vscode/
19
- *.swp
20
- *.swo
21
-
22
- # OS
23
- .DS_Store
24
-
25
- # Env
26
- .env
27
-
28
- # Runtime
29
- *.sock
30
- *.pid
@@ -1,30 +0,0 @@
1
- .PHONY: test test-unit test-integration test-e2e build publish publish-pip publish-npm clean
2
-
3
- test:
4
- python -m pytest tests/ -v
5
-
6
- test-unit:
7
- python -m pytest tests/ -v -m "not integration"
8
-
9
- test-integration:
10
- python -m pytest tests/ -v -m integration
11
-
12
- test-e2e:
13
- python -m pytest tests/test_e2e.py -v
14
- cd js && npx vitest run tests/e2e.test.ts
15
-
16
- build: clean
17
- uv build
18
- cd js && npm run build
19
-
20
- publish: publish-pip publish-npm
21
-
22
- publish-pip: build
23
- uvx twine upload dist/*
24
-
25
- publish-npm: build
26
- cd js && npm publish --access public
27
-
28
- clean:
29
- rm -rf dist/
30
- rm -rf js/dist/