camoufox-cli 0.3.0__tar.gz → 0.5.0__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.

Potentially problematic release.


This version of camoufox-cli might be problematic. Click here for more details.

Files changed (108) hide show
  1. camoufox_cli-0.5.0/.claude/worktrees/hungry-mclaren-0aa1c0/.claude-plugin/marketplace.json +20 -0
  2. {camoufox_cli-0.3.0 → camoufox_cli-0.5.0/.claude/worktrees/hungry-mclaren-0aa1c0}/Makefile +2 -2
  3. camoufox_cli-0.3.0/PKG-INFO → camoufox_cli-0.5.0/.claude/worktrees/hungry-mclaren-0aa1c0/README.md +40 -23
  4. {camoufox_cli-0.3.0 → camoufox_cli-0.5.0/.claude/worktrees/hungry-mclaren-0aa1c0}/js/package.json +1 -1
  5. {camoufox_cli-0.3.0 → camoufox_cli-0.5.0/.claude/worktrees/hungry-mclaren-0aa1c0}/js/src/browser.ts +28 -1
  6. {camoufox_cli-0.3.0 → camoufox_cli-0.5.0/.claude/worktrees/hungry-mclaren-0aa1c0}/js/src/cli.ts +13 -7
  7. {camoufox_cli-0.3.0 → camoufox_cli-0.5.0/.claude/worktrees/hungry-mclaren-0aa1c0}/js/src/daemon.ts +2 -1
  8. camoufox_cli-0.5.0/.claude/worktrees/hungry-mclaren-0aa1c0/js/src/identity.ts +206 -0
  9. {camoufox_cli-0.3.0 → camoufox_cli-0.5.0/.claude/worktrees/hungry-mclaren-0aa1c0}/js/src/server.ts +2 -2
  10. {camoufox_cli-0.3.0 → camoufox_cli-0.5.0/.claude/worktrees/hungry-mclaren-0aa1c0}/pyproject.toml +1 -1
  11. {camoufox_cli-0.3.0 → camoufox_cli-0.5.0/.claude/worktrees/hungry-mclaren-0aa1c0}/skills/camoufox-cli/SKILL.md +20 -1
  12. {camoufox_cli-0.3.0 → camoufox_cli-0.5.0/.claude/worktrees/hungry-mclaren-0aa1c0}/skills/camoufox-cli/references/commands.md +8 -1
  13. {camoufox_cli-0.3.0 → camoufox_cli-0.5.0/.claude/worktrees/hungry-mclaren-0aa1c0}/src/camoufox_cli/__main__.py +2 -0
  14. {camoufox_cli-0.3.0 → camoufox_cli-0.5.0/.claude/worktrees/hungry-mclaren-0aa1c0}/src/camoufox_cli/browser.py +27 -1
  15. {camoufox_cli-0.3.0 → camoufox_cli-0.5.0/.claude/worktrees/hungry-mclaren-0aa1c0}/src/camoufox_cli/cli.py +15 -6
  16. camoufox_cli-0.5.0/.claude/worktrees/hungry-mclaren-0aa1c0/src/camoufox_cli/identity.py +215 -0
  17. {camoufox_cli-0.3.0 → camoufox_cli-0.5.0/.claude/worktrees/hungry-mclaren-0aa1c0}/src/camoufox_cli/server.py +2 -2
  18. camoufox_cli-0.5.0/.gitignore +30 -0
  19. camoufox_cli-0.5.0/LICENSE +21 -0
  20. camoufox_cli-0.5.0/Makefile +30 -0
  21. camoufox_cli-0.5.0/PKG-INFO +275 -0
  22. {camoufox_cli-0.3.0 → camoufox_cli-0.5.0}/README.md +67 -1
  23. camoufox_cli-0.5.0/js/.gitignore +2 -0
  24. camoufox_cli-0.5.0/js/LICENSE +21 -0
  25. camoufox_cli-0.5.0/js/package-lock.json +2719 -0
  26. camoufox_cli-0.5.0/js/package.json +47 -0
  27. camoufox_cli-0.5.0/js/pnpm-lock.yaml +1599 -0
  28. camoufox_cli-0.5.0/js/scripts/postinstall.js +11 -0
  29. camoufox_cli-0.5.0/js/src/browser.ts +192 -0
  30. camoufox_cli-0.5.0/js/src/cli.ts +505 -0
  31. camoufox_cli-0.5.0/js/src/commands.ts +352 -0
  32. camoufox_cli-0.5.0/js/src/config.ts +140 -0
  33. camoufox_cli-0.5.0/js/src/daemon.ts +27 -0
  34. camoufox_cli-0.5.0/js/src/identity.ts +206 -0
  35. camoufox_cli-0.5.0/js/src/protocol.ts +32 -0
  36. camoufox_cli-0.5.0/js/src/proxy.ts +40 -0
  37. camoufox_cli-0.5.0/js/src/refs.ts +65 -0
  38. camoufox_cli-0.5.0/js/src/server.ts +131 -0
  39. camoufox_cli-0.5.0/js/tests/browser.test.ts +61 -0
  40. camoufox_cli-0.5.0/js/tests/cli.test.ts +455 -0
  41. camoufox_cli-0.5.0/js/tests/commands.test.ts +194 -0
  42. camoufox_cli-0.5.0/js/tests/e2e.test.ts +216 -0
  43. camoufox_cli-0.5.0/js/tests/protocol.test.ts +98 -0
  44. camoufox_cli-0.5.0/js/tests/proxy.test.ts +55 -0
  45. camoufox_cli-0.5.0/js/tests/refs.test.ts +150 -0
  46. camoufox_cli-0.5.0/js/tests/server.test.ts +193 -0
  47. camoufox_cli-0.5.0/js/tsconfig.json +14 -0
  48. camoufox_cli-0.5.0/js/vitest.config.ts +7 -0
  49. camoufox_cli-0.5.0/pyproject.toml +46 -0
  50. camoufox_cli-0.5.0/skills/camoufox-cli/SKILL.md +351 -0
  51. camoufox_cli-0.5.0/skills/camoufox-cli/references/commands.md +143 -0
  52. camoufox_cli-0.5.0/skills/camoufox-cli/references/snapshot-refs.md +221 -0
  53. camoufox_cli-0.5.0/src/camoufox_cli/__init__.py +0 -0
  54. camoufox_cli-0.5.0/src/camoufox_cli/__main__.py +38 -0
  55. camoufox_cli-0.5.0/src/camoufox_cli/browser.py +190 -0
  56. camoufox_cli-0.5.0/src/camoufox_cli/cli.py +518 -0
  57. camoufox_cli-0.5.0/src/camoufox_cli/commands.py +390 -0
  58. camoufox_cli-0.5.0/src/camoufox_cli/config.py +130 -0
  59. camoufox_cli-0.5.0/src/camoufox_cli/identity.py +215 -0
  60. camoufox_cli-0.5.0/src/camoufox_cli/protocol.py +24 -0
  61. camoufox_cli-0.5.0/src/camoufox_cli/proxy.py +30 -0
  62. camoufox_cli-0.5.0/src/camoufox_cli/refs.py +83 -0
  63. camoufox_cli-0.5.0/src/camoufox_cli/server.py +145 -0
  64. camoufox_cli-0.5.0/tests/__init__.py +0 -0
  65. camoufox_cli-0.5.0/tests/fixture.html +22 -0
  66. camoufox_cli-0.5.0/tests/test_cli.py +392 -0
  67. camoufox_cli-0.5.0/tests/test_commands.py +556 -0
  68. camoufox_cli-0.5.0/tests/test_e2e.py +217 -0
  69. camoufox_cli-0.5.0/tests/test_protocol.py +63 -0
  70. camoufox_cli-0.5.0/tests/test_proxy.py +37 -0
  71. camoufox_cli-0.5.0/tests/test_refs.py +112 -0
  72. camoufox_cli-0.3.0/.claude/settings.local.json +0 -20
  73. {camoufox_cli-0.3.0 → camoufox_cli-0.5.0/.claude/worktrees/hungry-mclaren-0aa1c0}/.gitignore +0 -0
  74. {camoufox_cli-0.3.0 → camoufox_cli-0.5.0/.claude/worktrees/hungry-mclaren-0aa1c0}/LICENSE +0 -0
  75. {camoufox_cli-0.3.0 → camoufox_cli-0.5.0/.claude/worktrees/hungry-mclaren-0aa1c0}/js/.gitignore +0 -0
  76. {camoufox_cli-0.3.0 → camoufox_cli-0.5.0/.claude/worktrees/hungry-mclaren-0aa1c0}/js/LICENSE +0 -0
  77. {camoufox_cli-0.3.0 → camoufox_cli-0.5.0/.claude/worktrees/hungry-mclaren-0aa1c0}/js/package-lock.json +0 -0
  78. {camoufox_cli-0.3.0 → camoufox_cli-0.5.0/.claude/worktrees/hungry-mclaren-0aa1c0}/js/pnpm-lock.yaml +0 -0
  79. {camoufox_cli-0.3.0 → camoufox_cli-0.5.0/.claude/worktrees/hungry-mclaren-0aa1c0}/js/scripts/postinstall.js +0 -0
  80. {camoufox_cli-0.3.0 → camoufox_cli-0.5.0/.claude/worktrees/hungry-mclaren-0aa1c0}/js/src/commands.ts +0 -0
  81. {camoufox_cli-0.3.0 → camoufox_cli-0.5.0/.claude/worktrees/hungry-mclaren-0aa1c0}/js/src/protocol.ts +0 -0
  82. {camoufox_cli-0.3.0 → camoufox_cli-0.5.0/.claude/worktrees/hungry-mclaren-0aa1c0}/js/src/proxy.ts +0 -0
  83. {camoufox_cli-0.3.0 → camoufox_cli-0.5.0/.claude/worktrees/hungry-mclaren-0aa1c0}/js/src/refs.ts +0 -0
  84. {camoufox_cli-0.3.0 → camoufox_cli-0.5.0/.claude/worktrees/hungry-mclaren-0aa1c0}/js/tests/browser.test.ts +0 -0
  85. {camoufox_cli-0.3.0 → camoufox_cli-0.5.0/.claude/worktrees/hungry-mclaren-0aa1c0}/js/tests/cli.test.ts +0 -0
  86. {camoufox_cli-0.3.0 → camoufox_cli-0.5.0/.claude/worktrees/hungry-mclaren-0aa1c0}/js/tests/commands.test.ts +0 -0
  87. {camoufox_cli-0.3.0 → camoufox_cli-0.5.0/.claude/worktrees/hungry-mclaren-0aa1c0}/js/tests/e2e.test.ts +0 -0
  88. {camoufox_cli-0.3.0 → camoufox_cli-0.5.0/.claude/worktrees/hungry-mclaren-0aa1c0}/js/tests/protocol.test.ts +0 -0
  89. {camoufox_cli-0.3.0 → camoufox_cli-0.5.0/.claude/worktrees/hungry-mclaren-0aa1c0}/js/tests/proxy.test.ts +0 -0
  90. {camoufox_cli-0.3.0 → camoufox_cli-0.5.0/.claude/worktrees/hungry-mclaren-0aa1c0}/js/tests/refs.test.ts +0 -0
  91. {camoufox_cli-0.3.0 → camoufox_cli-0.5.0/.claude/worktrees/hungry-mclaren-0aa1c0}/js/tests/server.test.ts +0 -0
  92. {camoufox_cli-0.3.0 → camoufox_cli-0.5.0/.claude/worktrees/hungry-mclaren-0aa1c0}/js/tsconfig.json +0 -0
  93. {camoufox_cli-0.3.0 → camoufox_cli-0.5.0/.claude/worktrees/hungry-mclaren-0aa1c0}/js/vitest.config.ts +0 -0
  94. {camoufox_cli-0.3.0 → camoufox_cli-0.5.0/.claude/worktrees/hungry-mclaren-0aa1c0}/skills/camoufox-cli/references/snapshot-refs.md +0 -0
  95. {camoufox_cli-0.3.0 → camoufox_cli-0.5.0/.claude/worktrees/hungry-mclaren-0aa1c0}/src/camoufox_cli/__init__.py +0 -0
  96. {camoufox_cli-0.3.0 → camoufox_cli-0.5.0/.claude/worktrees/hungry-mclaren-0aa1c0}/src/camoufox_cli/commands.py +0 -0
  97. {camoufox_cli-0.3.0 → camoufox_cli-0.5.0/.claude/worktrees/hungry-mclaren-0aa1c0}/src/camoufox_cli/protocol.py +0 -0
  98. {camoufox_cli-0.3.0 → camoufox_cli-0.5.0/.claude/worktrees/hungry-mclaren-0aa1c0}/src/camoufox_cli/proxy.py +0 -0
  99. {camoufox_cli-0.3.0 → camoufox_cli-0.5.0/.claude/worktrees/hungry-mclaren-0aa1c0}/src/camoufox_cli/refs.py +0 -0
  100. {camoufox_cli-0.3.0 → camoufox_cli-0.5.0/.claude/worktrees/hungry-mclaren-0aa1c0}/tests/__init__.py +0 -0
  101. {camoufox_cli-0.3.0 → camoufox_cli-0.5.0/.claude/worktrees/hungry-mclaren-0aa1c0}/tests/fixture.html +0 -0
  102. {camoufox_cli-0.3.0 → camoufox_cli-0.5.0/.claude/worktrees/hungry-mclaren-0aa1c0}/tests/test_cli.py +0 -0
  103. {camoufox_cli-0.3.0 → camoufox_cli-0.5.0/.claude/worktrees/hungry-mclaren-0aa1c0}/tests/test_commands.py +0 -0
  104. {camoufox_cli-0.3.0 → camoufox_cli-0.5.0/.claude/worktrees/hungry-mclaren-0aa1c0}/tests/test_e2e.py +0 -0
  105. {camoufox_cli-0.3.0 → camoufox_cli-0.5.0/.claude/worktrees/hungry-mclaren-0aa1c0}/tests/test_protocol.py +0 -0
  106. {camoufox_cli-0.3.0 → camoufox_cli-0.5.0/.claude/worktrees/hungry-mclaren-0aa1c0}/tests/test_proxy.py +0 -0
  107. {camoufox_cli-0.3.0 → camoufox_cli-0.5.0/.claude/worktrees/hungry-mclaren-0aa1c0}/tests/test_refs.py +0 -0
  108. {camoufox_cli-0.3.0 → camoufox_cli-0.5.0}/.claude-plugin/marketplace.json +0 -0
@@ -0,0 +1,20 @@
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
+ }
@@ -14,13 +14,13 @@ test-e2e:
14
14
  cd js && npx vitest run tests/e2e.test.ts
15
15
 
16
16
  build: clean
17
- python -m build
17
+ uv build
18
18
  cd js && npm run build
19
19
 
20
20
  publish: publish-pip publish-npm
21
21
 
22
22
  publish-pip: build
23
- twine upload dist/*
23
+ uvx twine upload dist/*
24
24
 
25
25
  publish-npm: build
26
26
  cd js && npm publish --access public
@@ -1,25 +1,3 @@
1
- Metadata-Version: 2.4
2
- Name: camoufox-cli
3
- Version: 0.3.0
4
- Summary: Anti-detect browser CLI & Skills for AI agents, powered by Camoufox
5
- Project-URL: Homepage, https://github.com/Bin-Huang/camoufox-cli
6
- Project-URL: Repository, https://github.com/Bin-Huang/camoufox-cli
7
- Project-URL: Issues, https://github.com/Bin-Huang/camoufox-cli/issues
8
- Author: Benn Huang
9
- License-Expression: MIT
10
- License-File: LICENSE
11
- Keywords: ai-agent,anti-detect,automation,browser,camoufox,headless
12
- Classifier: Development Status :: 3 - Alpha
13
- Classifier: Environment :: Console
14
- Classifier: Intended Audience :: Developers
15
- Classifier: Topic :: Internet :: WWW/HTTP :: Browsers
16
- Classifier: Topic :: Software Development :: Testing
17
- Requires-Python: >=3.10
18
- Requires-Dist: camoufox[geoip]
19
- Requires-Dist: pillow
20
- Requires-Dist: playwright
21
- Description-Content-Type: text/markdown
22
-
23
1
  # camoufox-cli
24
2
 
25
3
  Anti-detect browser CLI & Skills for AI agents, powered by [Camoufox](https://github.com/daijro/camoufox).
@@ -27,6 +5,7 @@ Anti-detect browser CLI & Skills for AI agents, powered by [Camoufox](https://gi
27
5
  ### Highlights
28
6
 
29
7
  - C++-level fingerprint spoofing via Camoufox (canvas, WebGL, audio, screen metrics, fonts)
8
+ - Persistent identities: stable fingerprint, OS, and locale across launches with `--persistent`
30
9
  - Accessibility-tree snapshots with `@ref` element targeting
31
10
  - Session isolation with cookie import/export
32
11
  - Shell commands, no code generation
@@ -173,11 +152,49 @@ camoufox-cli cookies export file.json # Export cookies
173
152
  --headed Show browser window (default: headless)
174
153
  --timeout <seconds> Daemon idle timeout (default: 1800)
175
154
  --json Output as JSON
176
- --persistent [path] Use persistent browser profile (default: ~/.camoufox-cli/profiles/<session>)
155
+ --persistent [path] Persistent identity (see below); default path: ~/.camoufox-cli/profiles/<session>
177
156
  --proxy <url> Proxy server (http:// or https://; auth: http://user:pass@host:port)
178
157
  --no-geoip Disable automatic GeoIP spoofing (auto-enabled with --proxy)
158
+ --locale <tag> Force browser locale (e.g. "en-US" or "en-US,zh-CN")
159
+ ```
160
+
161
+ ## Persistent Identity
162
+
163
+ Without `--persistent`, every launch gets a fresh random fingerprint. With `--persistent`, a `camoufox-cli.json` file is written next to the browser's user data. Fingerprint, OS, and canvas/font seeds are generated on the first launch and frozen afterwards; locale and proxy-derived timezone/geolocation are also saved but track whatever you pass on the command line. Every subsequent launch with the same path reloads the identity, so sites see the same device on every visit.
164
+
165
+ ```bash
166
+ # First launch: generates + stores identity
167
+ camoufox-cli --persistent ~/.camoufox-cli/profiles/alice open https://example.com
168
+
169
+ # Every subsequent launch: same fingerprint, same cookies, same device
170
+ camoufox-cli --persistent ~/.camoufox-cli/profiles/alice open https://example.com
171
+ ```
172
+
173
+ **Frozen** — generated on the first launch, permanent until the directory is deleted:
174
+
175
+ - Fingerprint (UA, canvas, WebGL, fonts, navigator properties)
176
+ - OS (defaults to host OS)
177
+ - Canvas/font noise seeds
178
+
179
+ **Persisted** — stored in the file, refreshed whenever you pass the flag:
180
+
181
+ - `--locale`: sets the stored locale; pass it again to change it, omit it to keep the stored value.
182
+ - Timezone / geolocation: re-derived from the current proxy's IP whenever `--proxy` is active (and `--no-geoip` isn't set), then written back. Without a proxy, the last stored values stay in effect.
183
+
184
+ **Per-launch** — never stored; pass on every launch for it to apply:
185
+
186
+ - `--proxy`: proxies rotate or expire; re-specify each launch to avoid silently running through a stale one.
187
+ - `--no-geoip`: follows the current `--proxy`.
188
+
189
+ **Multiple identities in parallel.** Each persistent path is a distinct identity. Combine with `--session` for concurrent daemons:
190
+
191
+ ```bash
192
+ camoufox-cli --session a --persistent ~/.camoufox-cli/profiles/alice open https://...
193
+ camoufox-cli --session b --persistent ~/.camoufox-cli/profiles/bob open https://...
179
194
  ```
180
195
 
196
+ **Resetting an identity.** Just delete the directory (`rm -rf ~/.camoufox-cli/profiles/alice`). The next launch generates a fresh one.
197
+
181
198
  ## Architecture
182
199
 
183
200
  ```
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "camoufox-cli",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Anti-detect headless browser automation CLI & Skills for AI agents, powered by Camoufox",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,11 +1,15 @@
1
1
  /** Browser manager: launches and manages Camoufox instance. */
2
2
 
3
3
  import { execFileSync } from "node:child_process";
4
+ import { mkdirSync } from "node:fs";
4
5
  import { Camoufox, launchOptions } from "camoufox-js";
5
6
  import { firefox, type Browser, type BrowserContext, type Page } from "playwright-core";
7
+ import { loadOrCreate, toLaunchOptions } from "./identity.js";
6
8
  import { parseProxySettings } from "./proxy.js";
7
9
  import { RefRegistry } from "./refs.js";
8
10
 
11
+ const MAX_HISTORY = 200;
12
+
9
13
  function ensureBrowserInstalled(): void {
10
14
  try {
11
15
  execFileSync("npx", ["camoufox-js", "path"], { stdio: "pipe" });
@@ -24,13 +28,15 @@ export class BrowserManager {
24
28
  private persistent: string | null;
25
29
  private proxy: string | null;
26
30
  private geoip: boolean;
31
+ private locale: string | null;
27
32
  private history: string[] = [];
28
33
  private historyIndex = -1;
29
34
 
30
- constructor(persistent: string | null = null, proxy: string | null = null, geoip: boolean = true) {
35
+ constructor(persistent: string | null = null, proxy: string | null = null, geoip: boolean = true, locale: string | null = null) {
31
36
  this.persistent = persistent;
32
37
  this.proxy = proxy;
33
38
  this.geoip = geoip;
39
+ this.locale = locale;
34
40
  }
35
41
 
36
42
  async launch(headless: boolean = true): Promise<void> {
@@ -50,11 +56,29 @@ export class BrowserManager {
50
56
  }
51
57
 
52
58
  if (this.persistent) {
59
+ // Persistent identity: freeze fingerprint/OS on first launch; reload
60
+ // it on subsequent launches. CLI-passed locale / proxy-derived geo
61
+ // overwrite the stored values so the file tracks current intent.
62
+ mkdirSync(this.persistent, { recursive: true });
63
+ const identity = await loadOrCreate(
64
+ this.persistent,
65
+ this.locale,
66
+ this.proxy,
67
+ this.geoip,
68
+ );
69
+ Object.assign(launchOpts, toLaunchOptions(identity));
53
70
  const opts = await launchOptions(launchOpts);
54
71
  this.context = await firefox.launchPersistentContext(this.persistent, opts);
55
72
  const pages = this.context.pages();
56
73
  this.page = pages[0] || await this.context.newPage();
57
74
  } else {
75
+ if (this.locale) {
76
+ // Non-persistent path: locale is a one-shot override, no identity file.
77
+ const locales = this.locale.split(",").map((s) => s.trim()).filter(Boolean);
78
+ if (locales.length > 0) {
79
+ launchOpts.locale = locales.length > 1 ? locales : locales[0];
80
+ }
81
+ }
58
82
  this.browser = await Camoufox(launchOpts) as Browser;
59
83
  this.page = await this.browser.newPage();
60
84
  this.context = this.page.context();
@@ -125,6 +149,9 @@ export class BrowserManager {
125
149
  pushHistory(url: string): void {
126
150
  this.history = this.history.slice(0, this.historyIndex + 1);
127
151
  this.history.push(url);
152
+ if (this.history.length > MAX_HISTORY) {
153
+ this.history = this.history.slice(-MAX_HISTORY);
154
+ }
128
155
  this.historyIndex = this.history.length - 1;
129
156
  }
130
157
 
@@ -30,7 +30,7 @@ function sendCommand(sockPath: string, command: Record<string, unknown>): Promis
30
30
  });
31
31
  }
32
32
 
33
- function spawnDaemon(session: string, headed: boolean, timeout: number, persistent: string | null, proxy: string | null = null, geoip: boolean = true): Promise<void> {
33
+ function spawnDaemon(session: string, headed: boolean, timeout: number, persistent: string | null, proxy: string | null = null, geoip: boolean = true, locale: string | null = null): Promise<void> {
34
34
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
35
35
  const daemonPath = path.join(__dirname, "daemon.js");
36
36
 
@@ -39,6 +39,7 @@ function spawnDaemon(session: string, headed: boolean, timeout: number, persiste
39
39
  if (persistent) args.push("--persistent", persistent);
40
40
  if (proxy) args.push("--proxy", proxy);
41
41
  if (!geoip) args.push("--no-geoip");
42
+ if (locale) args.push("--locale", locale);
42
43
 
43
44
  spawn("node", [daemonPath, ...args], {
44
45
  detached: true,
@@ -58,7 +59,7 @@ function spawnDaemon(session: string, headed: boolean, timeout: number, persiste
58
59
  });
59
60
  }
60
61
 
61
- async function ensureDaemon(session: string, headed: boolean, timeout: number, persistent: string | null, proxy: string | null = null, geoip: boolean = true): Promise<void> {
62
+ async function ensureDaemon(session: string, headed: boolean, timeout: number, persistent: string | null, proxy: string | null = null, geoip: boolean = true, locale: string | null = null): Promise<void> {
62
63
  const sockPath = getSocketPath(session);
63
64
  if (fs.existsSync(sockPath)) {
64
65
  // Verify daemon is alive
@@ -73,7 +74,7 @@ async function ensureDaemon(session: string, headed: boolean, timeout: number, p
73
74
  try { fs.unlinkSync(sockPath); } catch {}
74
75
  }
75
76
  }
76
- await spawnDaemon(session, headed, timeout, persistent, proxy, geoip);
77
+ await spawnDaemon(session, headed, timeout, persistent, proxy, geoip, locale);
77
78
  }
78
79
 
79
80
  export function listSessions(): string[] {
@@ -100,10 +101,11 @@ export interface Flags {
100
101
  persistent: string | null;
101
102
  proxy: string | null;
102
103
  geoip: boolean;
104
+ locale: string | null;
103
105
  }
104
106
 
105
107
  export function parseArgs(argv: string[]): { flags: Flags; command: Record<string, unknown> } {
106
- const flags: Flags = { session: "default", headed: false, timeout: 1800, json: false, persistent: null, proxy: null, geoip: true };
108
+ const flags: Flags = { session: "default", headed: false, timeout: 1800, json: false, persistent: null, proxy: null, geoip: true, locale: null };
107
109
  const rest: string[] = [];
108
110
 
109
111
  let i = 0;
@@ -137,6 +139,9 @@ export function parseArgs(argv: string[]): { flags: Flags; command: Record<strin
137
139
  case "--no-geoip":
138
140
  flags.geoip = false;
139
141
  break;
142
+ case "--locale":
143
+ flags.locale = argv[++i] ?? (process.stderr.write("Error: --locale requires a value\n"), process.exit(1), "");
144
+ break;
140
145
  default:
141
146
  rest.push(argv[i]);
142
147
  }
@@ -401,7 +406,7 @@ async function main() {
401
406
  }
402
407
 
403
408
  // Ensure daemon is running
404
- await ensureDaemon(flags.session, flags.headed, flags.timeout, flags.persistent, flags.proxy, flags.geoip);
409
+ await ensureDaemon(flags.session, flags.headed, flags.timeout, flags.persistent, flags.proxy, flags.geoip, flags.locale);
405
410
 
406
411
  const sockPath = getSocketPath(flags.session);
407
412
 
@@ -472,9 +477,10 @@ Flags:
472
477
  --headed Show browser window
473
478
  --timeout <secs> Daemon idle timeout (default: 1800)
474
479
  --json Output as JSON
475
- --persistent [path] Use persistent browser profile (default: ~/.camoufox-cli/profiles/<session>)
480
+ --persistent [path] Persistent identity — freeze fingerprint/OS/locale + store cookies/state (default: ~/.camoufox-cli/profiles/<session>)
476
481
  --proxy <url> Proxy server (e.g. http://host:port or https://host:443)
477
- --no-geoip Disable automatic GeoIP spoofing (auto-enabled with --proxy)`;
482
+ --no-geoip Disable automatic GeoIP spoofing (auto-enabled with --proxy)
483
+ --locale <tag> Force browser locale (e.g. "en-US" or "en-US,zh-CN")`;
478
484
 
479
485
  const isDirectRun = (() => {
480
486
  try {
@@ -16,8 +16,9 @@ const timeout = parseInt(getArg("--timeout", "1800"), 10);
16
16
  const persistent = args.includes("--persistent") ? getArg("--persistent", "") || null : null;
17
17
  const proxy = args.includes("--proxy") ? getArg("--proxy", "") || null : null;
18
18
  const geoip = !args.includes("--no-geoip");
19
+ const locale = args.includes("--locale") ? getArg("--locale", "") || null : null;
19
20
 
20
- const server = new DaemonServer({ session, headless, timeout, persistent, proxy, geoip });
21
+ const server = new DaemonServer({ session, headless, timeout, persistent, proxy, geoip, locale });
21
22
 
22
23
  process.stderr.write(`[camoufox-cli] Starting daemon session=${session} headless=${headless}\n`);
23
24
  server.start().catch((err) => {
@@ -0,0 +1,206 @@
1
+ /** Persistent identity: freeze fingerprint/OS into a persistent dir.
2
+ *
3
+ * When a user launches with `--persistent <dir>`, a `camoufox-cli.json` file
4
+ * is written on first launch capturing the generated fingerprint, OS, locale,
5
+ * and derived timezone/geolocation. Subsequent launches reload it so the browser
6
+ * reports the same device identity to every site.
7
+ *
8
+ * Fingerprint/OS/canvas+font seeds are frozen for the lifetime of the identity.
9
+ * User-controllable fields (locale; proxy-derived timezone/geolocation) are
10
+ * updated to match the command line whenever it's explicitly passed — so the
11
+ * stored identity always reflects the most recent intent.
12
+ */
13
+
14
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
15
+ import { join } from "node:path";
16
+ import type { Fingerprint } from "fingerprint-generator";
17
+ import { generateFingerprint } from "camoufox-js/dist/fingerprints.js";
18
+ import { publicIP, validIPv4, validIPv6 } from "camoufox-js/dist/ip.js";
19
+ import { getGeolocation } from "camoufox-js/dist/locale.js";
20
+ import { parseProxySettings } from "./proxy.js";
21
+
22
+ const IDENTITY_FILENAME = "camoufox-cli.json";
23
+ const IDENTITY_VERSION = 1;
24
+
25
+ export type HostOS = "windows" | "macos" | "linux";
26
+
27
+ export interface Identity {
28
+ version: number;
29
+ created_at: string;
30
+ os: HostOS;
31
+ locale: string | null;
32
+ fingerprint: Fingerprint;
33
+ config: Record<string, unknown>;
34
+ }
35
+
36
+ function hostOS(): HostOS {
37
+ if (process.platform === "win32") return "windows";
38
+ if (process.platform === "darwin") return "macos";
39
+ return "linux";
40
+ }
41
+
42
+ function identityPath(persistentDir: string): string {
43
+ return join(persistentDir, IDENTITY_FILENAME);
44
+ }
45
+
46
+ /**
47
+ * Return the identity for this persistent directory.
48
+ *
49
+ * On first launch, a fresh identity is generated and written:
50
+ * - fingerprint: fingerprint-generator, firefox, host OS
51
+ * - canvas/font seeds: random, stored so future launches reproduce them
52
+ * - timezone/geolocation: derived via GeoIP if proxy is set and geoip=true
53
+ * - locale: recorded if passed on this first launch, else null
54
+ *
55
+ * On subsequent launches, `<persistentDir>/camoufox-cli.json` is loaded.
56
+ * Fields the user explicitly passes on the command line overwrite the stored
57
+ * values (`--locale`; `--proxy` + geoip re-derives timezone/geolocation).
58
+ * Fingerprint, OS, and canvas/font seeds are never touched after first launch.
59
+ */
60
+ export async function loadOrCreate(
61
+ persistentDir: string,
62
+ locale: string | null,
63
+ proxy: string | null,
64
+ geoip: boolean,
65
+ ): Promise<Identity> {
66
+ const path = identityPath(persistentDir);
67
+ if (existsSync(path)) {
68
+ const identity = JSON.parse(readFileSync(path, "utf8")) as Identity;
69
+ const changed = await applyCliOverrides(identity, locale, proxy, geoip);
70
+ if (changed) writeFileSync(path, JSON.stringify(identity, null, 2));
71
+ return identity;
72
+ }
73
+
74
+ const os_ = hostOS();
75
+ const fp = generateFingerprint(undefined, { operatingSystems: [os_] });
76
+
77
+ const config: Record<string, unknown> = {
78
+ "canvas:aaOffset": Math.floor(Math.random() * 101) - 50,
79
+ "canvas:aaCapOffset": Math.random() < 0.5,
80
+ "fonts:spacing_seed": Math.floor(Math.random() * 0x1_0000_0000),
81
+ };
82
+
83
+ if (proxy && geoip) {
84
+ mergeGeo(config, await geolocateProxy(proxy));
85
+ }
86
+
87
+ const identity: Identity = {
88
+ version: IDENTITY_VERSION,
89
+ created_at: new Date().toISOString(),
90
+ os: os_,
91
+ locale,
92
+ fingerprint: fp,
93
+ config,
94
+ };
95
+
96
+ mkdirSync(persistentDir, { recursive: true });
97
+ writeFileSync(path, JSON.stringify(identity, null, 2));
98
+ return identity;
99
+ }
100
+
101
+ /** Mutate identity with CLI-passed values. Return true if anything changed. */
102
+ async function applyCliOverrides(
103
+ identity: Identity,
104
+ locale: string | null,
105
+ proxy: string | null,
106
+ geoip: boolean,
107
+ ): Promise<boolean> {
108
+ let changed = false;
109
+
110
+ if (locale !== null && identity.locale !== locale) {
111
+ identity.locale = locale;
112
+ changed = true;
113
+ }
114
+
115
+ if (proxy && geoip) {
116
+ if (!identity.config) identity.config = {};
117
+ const derived = await geolocateProxy(proxy);
118
+ if (mergeGeo(identity.config, derived)) changed = true;
119
+ }
120
+
121
+ return changed;
122
+ }
123
+
124
+ /** Merge proxy-derived geo into config. Return true if anything changed. */
125
+ function mergeGeo(config: Record<string, unknown>, derived: GeoInfo | null): boolean {
126
+ if (!derived) return false;
127
+ let changed = false;
128
+ if (derived.timezone && config["timezone"] !== derived.timezone) {
129
+ config["timezone"] = derived.timezone;
130
+ changed = true;
131
+ }
132
+ if (derived.latitude !== undefined && derived.longitude !== undefined) {
133
+ if (config["geolocation:latitude"] !== derived.latitude) {
134
+ config["geolocation:latitude"] = derived.latitude;
135
+ changed = true;
136
+ }
137
+ if (config["geolocation:longitude"] !== derived.longitude) {
138
+ config["geolocation:longitude"] = derived.longitude;
139
+ changed = true;
140
+ }
141
+ if (derived.accuracy !== undefined && config["geolocation:accuracy"] !== derived.accuracy) {
142
+ config["geolocation:accuracy"] = derived.accuracy;
143
+ changed = true;
144
+ }
145
+ }
146
+ return changed;
147
+ }
148
+
149
+ /**
150
+ * Translate identity into kwargs for Camoufox / launchOptions.
151
+ *
152
+ * Returns fingerprint/os/config (always) and locale (when set). Does NOT
153
+ * set persistent_context / user_data_dir — the caller handles those.
154
+ */
155
+ export function toLaunchOptions(identity: Identity): Record<string, unknown> {
156
+ const opts: Record<string, unknown> = {
157
+ fingerprint: identity.fingerprint,
158
+ os: identity.os,
159
+ config: { ...(identity.config || {}) },
160
+ };
161
+
162
+ if (identity.locale) {
163
+ const parts = identity.locale.split(",").map((s) => s.trim()).filter(Boolean);
164
+ if (parts.length > 0) {
165
+ opts.locale = parts.length > 1 ? parts : parts[0];
166
+ }
167
+ }
168
+
169
+ return opts;
170
+ }
171
+
172
+ interface GeoInfo {
173
+ timezone?: string;
174
+ latitude?: number;
175
+ longitude?: number;
176
+ accuracy?: number;
177
+ }
178
+
179
+ /** Return {timezone, latitude, longitude, accuracy?} from the proxy's public
180
+ * IP, or null if anything fails. */
181
+ async function geolocateProxy(proxyUrl: string): Promise<GeoInfo | null> {
182
+ try {
183
+ const ip = await publicIP(proxyUrlWithAuth(proxyUrl));
184
+ if (!validIPv4(ip) && !validIPv6(ip)) return null;
185
+ const geo = await getGeolocation(ip);
186
+ const out: GeoInfo = {
187
+ timezone: geo.timezone,
188
+ latitude: geo.latitude,
189
+ longitude: geo.longitude,
190
+ };
191
+ if (geo.accuracy !== undefined) out.accuracy = geo.accuracy;
192
+ return out;
193
+ } catch {
194
+ return null;
195
+ }
196
+ }
197
+
198
+ /** Rebuild proxy URL as scheme://user:pass@host:port for publicIP(). */
199
+ function proxyUrlWithAuth(proxyUrl: string): string {
200
+ const settings = parseProxySettings(proxyUrl).proxy;
201
+ if (!settings.username) return settings.server;
202
+ const url = new URL(settings.server);
203
+ url.username = encodeURIComponent(settings.username);
204
+ url.password = encodeURIComponent(settings.password ?? "");
205
+ return url.href;
206
+ }
@@ -17,13 +17,13 @@ export class DaemonServer {
17
17
  private lastActivity = Date.now();
18
18
  private watchdogTimer: ReturnType<typeof setInterval> | null = null;
19
19
 
20
- constructor(opts: { session?: string; headless?: boolean; timeout?: number; persistent?: string | null; proxy?: string | null; geoip?: boolean }) {
20
+ constructor(opts: { session?: string; headless?: boolean; timeout?: number; persistent?: string | null; proxy?: string | null; geoip?: boolean; locale?: string | null }) {
21
21
  this.session = opts.session ?? "default";
22
22
  this.headless = opts.headless ?? true;
23
23
  this.timeout = opts.timeout ?? 1800;
24
24
  this.socketPath = `/tmp/camoufox-cli-${this.session}.sock`;
25
25
  this.pidPath = `/tmp/camoufox-cli-${this.session}.pid`;
26
- this.manager = new BrowserManager(opts.persistent ?? null, opts.proxy ?? null, opts.geoip ?? true);
26
+ this.manager = new BrowserManager(opts.persistent ?? null, opts.proxy ?? null, opts.geoip ?? true, opts.locale ?? null);
27
27
  }
28
28
 
29
29
  async start(): Promise<void> {
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "camoufox-cli"
3
- version = "0.3.0"
3
+ version = "0.4.0"
4
4
  description = "Anti-detect browser CLI & Skills for AI agents, powered by Camoufox"
5
5
  requires-python = ">=3.10"
6
6
  license = "MIT"
@@ -163,6 +163,8 @@ camoufox-cli cookies import auth.json
163
163
  camoufox-cli reload
164
164
  ```
165
165
 
166
+ For long-lived accounts where the site also verifies device stability (not just the cookie), combine this with `--persistent` so the fingerprint stays fixed alongside the cookies — see the Persistent Identity section below.
167
+
166
168
  ### Multiple Tabs
167
169
 
168
170
  ```bash
@@ -313,9 +315,26 @@ camoufox-cli snapshot -i
313
315
  --headed Show browser window (default: headless)
314
316
  --timeout <seconds> Daemon idle timeout (default: 1800)
315
317
  --json Output as JSON instead of human-readable
316
- --persistent [path] Use persistent browser profile (default: ~/.camoufox-cli/profiles/<session>)
318
+ --persistent [path] Persistent identity — reuse the same fingerprint + cookies across launches
319
+ (default path: ~/.camoufox-cli/profiles/<session>)
317
320
  --proxy <url> Proxy server (http:// or https://; auth: http://user:pass@host:port)
318
321
  --no-geoip Disable automatic GeoIP spoofing (auto-enabled with --proxy)
322
+ --locale <tag> Force browser locale (e.g. "en-US" or "en-US,zh-CN")
323
+ ```
324
+
325
+ ## Persistent Identity
326
+
327
+ By default, every launch gets a fresh random fingerprint. Add `--persistent [path]` to reuse the same fingerprint + cookies across launches — fingerprint/OS/canvas+font seeds are frozen on first launch (delete the directory to reset); `--locale` and proxy-derived timezone/geolocation are stored but refreshed whenever you pass the flag; `--proxy` / `--no-geoip` are never stored, so pass them every launch.
328
+
329
+ **Use it when** the same device should see the same fingerprint across visits (account-bound tasks, parallel independent identities, or when `cookies import/export` alone isn't enough because the site also checks device stability). **Skip it** for one-off scraping or quick debugging.
330
+
331
+ ```bash
332
+ # Parallel identities, each with its own fingerprint + cookies
333
+ camoufox-cli --session a --persistent ~/.camoufox-cli/profiles/alice open https://app.example.com
334
+ camoufox-cli --session b --persistent ~/.camoufox-cli/profiles/bob open https://app.example.com
335
+
336
+ # Reset an identity: just remove the directory
337
+ rm -rf ~/.camoufox-cli/profiles/alice
319
338
  ```
320
339
 
321
340
  ## Documentation
@@ -120,6 +120,13 @@ camoufox-cli --session <name> ... # Isolated browser session
120
120
  camoufox-cli --headed ... # Show browser window (not headless)
121
121
  camoufox-cli --json ... # JSON output for parsing
122
122
  camoufox-cli --timeout <seconds> ... # Daemon idle timeout (default: 1800)
123
- camoufox-cli --persistent [path] ... # Use persistent browser profile (default: ~/.camoufox-cli/profiles/<session>)
123
+ camoufox-cli --persistent [path] ... # Persistent identity — reuse the same fingerprint + cookies
124
+ # across launches (default: ~/.camoufox-cli/profiles/<session>)
124
125
  camoufox-cli --proxy <url> ... # Proxy server (e.g. http://host:port or http://user:pass@host:port)
126
+ camoufox-cli --no-geoip ... # Disable automatic GeoIP spoofing (auto-enabled with --proxy)
127
+ camoufox-cli --locale <tag> ... # Force browser locale (e.g. "en-US" or "en-US,zh-CN")
125
128
  ```
129
+
130
+ ### `--persistent` in detail
131
+
132
+ Stores fingerprint, OS, canvas/font seeds, locale, and proxy-derived timezone/geolocation in `<path>/camoufox-cli.json` and reloads it on every launch with the same path. Fingerprint/OS/seeds are frozen — delete the directory to reset. `--locale` overwrites the stored locale when passed; `--proxy` + GeoIP re-derives timezone/geolocation each launch and writes back. `--proxy` and `--no-geoip` themselves are never stored. See the README for the full mental model.
@@ -15,6 +15,7 @@ def main():
15
15
  parser.add_argument("--persistent", default=None, help="Path for persistent browser profile")
16
16
  parser.add_argument("--proxy", default=None, help="Proxy server URL")
17
17
  parser.add_argument("--no-geoip", dest="geoip", action="store_false", default=True, help="Disable automatic GeoIP spoofing when using a proxy")
18
+ parser.add_argument("--locale", default=None, help="Force browser locale (e.g. 'en-US' or 'en-US,zh-CN')")
18
19
  args = parser.parse_args()
19
20
 
20
21
  headless = not args.headed
@@ -26,6 +27,7 @@ def main():
26
27
  persistent=args.persistent,
27
28
  proxy=args.proxy,
28
29
  geoip=args.geoip,
30
+ locale=args.locale,
29
31
  )
30
32
 
31
33
  print(f"[camoufox-cli] Starting daemon session={args.session} headless={headless}", file=sys.stderr)
@@ -3,14 +3,19 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import base64
6
+ import os
6
7
 
7
8
  from camoufox.sync_api import Camoufox
8
9
  from playwright.sync_api import BrowserContext, Page
9
10
 
11
+ from .identity import load_or_create, to_launch_kwargs
10
12
  from .proxy import parse_proxy_settings
11
13
  from .refs import RefRegistry
12
14
 
13
15
 
16
+ _MAX_HISTORY = 200
17
+
18
+
14
19
  def _ensure_browser_installed() -> None:
15
20
  """Check that the Camoufox browser binary is installed, raise if not."""
16
21
  try:
@@ -23,7 +28,7 @@ def _ensure_browser_installed() -> None:
23
28
 
24
29
 
25
30
  class BrowserManager:
26
- def __init__(self, persistent: str | None = None, proxy: str | None = None, geoip: bool = True):
31
+ def __init__(self, persistent: str | None = None, proxy: str | None = None, geoip: bool = True, locale: str | None = None):
27
32
  self._camoufox: Camoufox | None = None
28
33
  self._context: BrowserContext | None = None
29
34
  self._page: Page | None = None
@@ -32,6 +37,7 @@ class BrowserManager:
32
37
  self._persistent = persistent
33
38
  self._proxy = proxy
34
39
  self._geoip = geoip
40
+ self._locale = locale
35
41
  # Camoufox spoofs history API for anti-fingerprinting,
36
42
  # so we track navigation history ourselves.
37
43
  self._history: list[str] = []
@@ -51,9 +57,26 @@ class BrowserManager:
51
57
  kwargs["proxy"] = proxy_settings
52
58
  if self._geoip:
53
59
  kwargs["geoip"] = True
60
+
54
61
  if self._persistent:
62
+ # Persistent identity: freeze fingerprint/OS on first launch; reload
63
+ # it on subsequent launches. CLI-passed locale / proxy-derived geo
64
+ # overwrite the stored values so the file tracks current intent.
65
+ os.makedirs(self._persistent, exist_ok=True)
66
+ identity = load_or_create(
67
+ self._persistent,
68
+ locale=self._locale,
69
+ proxy=self._proxy,
70
+ geoip=self._geoip,
71
+ )
72
+ kwargs.update(to_launch_kwargs(identity))
55
73
  kwargs["persistent_context"] = True
56
74
  kwargs["user_data_dir"] = self._persistent
75
+ elif self._locale:
76
+ # Non-persistent path: locale is a one-shot override, no identity file.
77
+ locales = [s.strip() for s in self._locale.split(",") if s.strip()]
78
+ if locales:
79
+ kwargs["locale"] = locales if len(locales) > 1 else locales[0]
57
80
 
58
81
  self._camoufox = Camoufox(**kwargs)
59
82
  result = self._camoufox.__enter__()
@@ -127,6 +150,9 @@ class BrowserManager:
127
150
  # Truncate forward history when navigating to a new page
128
151
  self._history = self._history[:self._history_index + 1]
129
152
  self._history.append(url)
153
+ # Cap history to avoid unbounded growth in long-lived daemons.
154
+ if len(self._history) > _MAX_HISTORY:
155
+ self._history = self._history[-_MAX_HISTORY:]
130
156
  self._history_index = len(self._history) - 1
131
157
 
132
158
  def go_back(self) -> str | None: