@allansantos-dev/smart-tool 0.9.28 → 0.9.29

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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,34 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.9.29 - beta
4
+
5
+ - Registering the MCP server in Claude Code or Codex is idempotent. `claude mcp add` refuses a name that exists
6
+ ("MCP server smart-tool already exists in user"), so a second registration used to fail. Now the same URL runs
7
+ nothing and succeeds, another URL (the port changed) is removed with `claude mcp remove smart-tool --scope user`
8
+ (`codex mcp remove smart-tool`) and added again, and no entry only adds. The result is read back from the client's
9
+ configuration, as before.
10
+ - The client CLI is resolved before it runs and never left hanging:
11
+ - Only `.exe`, `.cmd` and `.bat` are candidates, never a `.ps1` or the extensionless script npm leaves beside its
12
+ shims.
13
+ - A `.exe` that does not start with `MZ`, or an npm shim whose `claude-code` package holds such a file (Claude Code
14
+ installed by npm 11 with its postinstall blocked: a ~500-byte text `claude.exe`, which makes Windows open a modal
15
+ "not compatible" box and hang the command), fails at once with the reason and, for Claude Code,
16
+ `npm install -g @anthropic-ai/claude-code@latest --include=optional --allow-scripts=@anthropic-ai/claude-code`.
17
+ - The limit is 120 s (it was 60) and `SMART_TOOL_CLI_TIMEOUT_S` sets it.
18
+ - The CLI is searched in the PATH as Windows stores it now (machine and user, from the registry), not the one the daemon
19
+ started with, and then in `%USERPROFILE%\.local\bin`, `%LOCALAPPDATA%\Programs\OpenAI\Codex\bin` and `%APPDATA%\npm`.
20
+ - Networks that inspect TLS with a corporate root CA:
21
+ - `npx ... install` shows the real error code (`SELF_SIGNED_CERT_IN_CHAIN`, kept by Node in `error.cause`, not
22
+ "fetch failed") and, on Node 22.15+/23.8+/24, runs itself again once with `NODE_USE_SYSTEM_CA=1`; otherwise it says
23
+ to set `NODE_EXTRA_CA_CERTS`. uv gets one retry with `UV_NATIVE_TLS=1` on a certificate error. The installer steps
24
+ (npm, Playwright) run with `NODE_USE_SYSTEM_CA=1`, and the Camoufox download uses the Windows store through
25
+ `truststore`. `NODE_EXTRA_CA_CERTS`, `SSL_CERT_FILE`, `REQUESTS_CA_BUNDLE`, `CURL_CA_BUNDLE`, `UV_NATIVE_TLS` and
26
+ `NODE_USE_SYSTEM_CA` are never overwritten.
27
+ - The bootstrap ends by setting `process.exitCode` instead of calling `process.exit()` while a fetch is closing (Node
28
+ 24 on Windows aborts with `Assertion failed: !(handle->flags & UV_HANDLE_CLOSING)`).
29
+ - Tests: `tests/test_cli_registration.py`, `tests/test_bootstrap_network.py`, and cases in `test_fetch_camoufox.py` and
30
+ `test_install_runtime.py`.
31
+
3
32
  ## 0.9.28 - beta
4
33
 
5
34
  - Node, npx and npm are looked up when they are needed, never remembered. A process keeps the PATH it was born with:
package/README.md CHANGED
@@ -15,7 +15,7 @@ registered as the `playwright` server where this machine lets a browser be drive
15
15
  An optional hook routes the agent's native Grep/Glob/Read/Bash/WebSearch/WebFetch calls to these tools when they
16
16
  are cheaper, or just tells the agent what Smart Tool would do.
17
17
 
18
- **Status:** 0.9.28 beta. Windows only. What changed in each version: [CHANGELOG.md](CHANGELOG.md).
18
+ **Status:** 0.9.29 beta. Windows only. What changed in each version: [CHANGELOG.md](CHANGELOG.md).
19
19
 
20
20
  ## How it works
21
21
 
@@ -0,0 +1,78 @@
1
+ // Network failures of the bootstrap on machines whose firewall inspects TLS (a corporate root CA in the Windows store,
2
+ // unknown to Node's bundled certificates). Nothing here overwrites a TLS setting the user already made.
3
+
4
+ export class Failure extends Error {}
5
+
6
+ const CERTIFICATE_CODES = new Set([
7
+ "SELF_SIGNED_CERT_IN_CHAIN",
8
+ "DEPTH_ZERO_SELF_SIGNED_CERT",
9
+ "UNABLE_TO_GET_ISSUER_CERT",
10
+ "UNABLE_TO_GET_ISSUER_CERT_LOCALLY",
11
+ "UNABLE_TO_VERIFY_LEAF_SIGNATURE",
12
+ "CERT_UNTRUSTED",
13
+ "CERT_HAS_EXPIRED",
14
+ ]);
15
+ const CERTIFICATE_TEXT = /certificate|UnknownIssuer|invalid peer|self[- ]signed/i;
16
+
17
+ /** The real reason of a failed fetch: Node reports "fetch failed" and keeps the code in `error.cause`. */
18
+ export function errorCode(error) {
19
+ return error?.cause?.code ?? error?.code ?? "unknown";
20
+ }
21
+
22
+ export function isCertificateError(error) {
23
+ return CERTIFICATE_CODES.has(errorCode(error));
24
+ }
25
+
26
+ /** Whether this Node can read the system certificate store through NODE_USE_SYSTEM_CA (22.15+, 23.8+). */
27
+ export function supportsSystemCa(version = process.versions.node) {
28
+ const [major, minor] = version.split(".").map(Number);
29
+ return major >= 24 || (major === 23 && minor >= 8) || (major === 22 && minor >= 15);
30
+ }
31
+
32
+ /** The environment of a second run that trusts the system certificate store, or null when the user already chose
33
+ * (NODE_USE_SYSTEM_CA set to anything), this is already that second run, or this Node cannot do it. */
34
+ export function systemCaEnvironment(env = process.env, version = process.versions.node) {
35
+ if (env.NODE_USE_SYSTEM_CA !== undefined || env.SMART_TOOL_SYSTEM_CA_RETRY || !supportsSystemCa(version)) return null;
36
+ return { ...env, NODE_USE_SYSTEM_CA: "1", SMART_TOOL_SYSTEM_CA_RETRY: "1" };
37
+ }
38
+
39
+ /** The environment for retrying uv with the Windows certificate store, or null when UV_NATIVE_TLS is already set. */
40
+ export function nativeTlsEnvironment(env = process.env) {
41
+ return env.UV_NATIVE_TLS !== undefined ? null : { ...env, UV_NATIVE_TLS: "1" };
42
+ }
43
+
44
+ export function isCertificateText(text) {
45
+ return CERTIFICATE_TEXT.test(text);
46
+ }
47
+
48
+ export function certificateAdvice(env = process.env) {
49
+ const configured = ["NODE_EXTRA_CA_CERTS", "SSL_CERT_FILE", "REQUESTS_CA_BUNDLE", "UV_NATIVE_TLS"].filter((name) => env[name]);
50
+ return "The network seems to inspect TLS with a certificate Node does not trust. Point NODE_EXTRA_CA_CERTS at the " +
51
+ "corporate root certificate (a .pem file), or use Node 22.15+ with NODE_USE_SYSTEM_CA=1 to trust the Windows " +
52
+ "certificate store." + (configured.length ? ` Already set here and left untouched: ${configured.join(", ")}.` : "");
53
+ }
54
+
55
+ export function describeFetchError(url, error, env = process.env) {
56
+ const code = errorCode(error);
57
+ const reason = error?.cause?.message ? `${error.message}: ${error.cause.message}` : String(error?.message ?? error);
58
+ return `download of ${url} failed (${code}): ${reason}` + (isCertificateError(error) ? `\n${certificateAdvice(env)}` : "");
59
+ }
60
+
61
+ /** Downloads url as a Buffer. A certificate failure first runs `rerun(environment)` once, a second run of the whole
62
+ * bootstrap that trusts the system store, and returns { rerun: result } when it did; otherwise it throws Failure with
63
+ * the error code and what to configure. */
64
+ export async function download(url, { fetchFn = fetch, env = process.env, version = process.versions.node, rerun } = {}) {
65
+ let response;
66
+ try {
67
+ response = await fetchFn(url);
68
+ } catch (error) {
69
+ const retry = isCertificateError(error) && rerun ? systemCaEnvironment(env, version) : null;
70
+ if (retry) {
71
+ console.log(`TLS inspection detected (${errorCode(error)}); running again with the Windows certificate store...`);
72
+ return { rerun: rerun(retry) };
73
+ }
74
+ throw new Failure(describeFetchError(url, error, env));
75
+ }
76
+ if (!response.ok) throw new Failure(`download of ${url} failed: HTTP ${response.status}`);
77
+ return { body: Buffer.from(await response.arrayBuffer()) };
78
+ }
@@ -5,6 +5,7 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync
5
5
  import { homedir, tmpdir } from "node:os";
6
6
  import { dirname, join } from "node:path";
7
7
  import { fileURLToPath } from "node:url";
8
+ import { Failure, certificateAdvice, download, isCertificateText, nativeTlsEnvironment } from "./network.mjs";
8
9
 
9
10
  const PACKAGE_DIR = join(dirname(fileURLToPath(import.meta.url)), "..");
10
11
  const RUNTIME_DIR = join(homedir(), ".smart-tool", "runtime");
@@ -20,8 +21,7 @@ const USAGE = `Usage: npx @allansantos-dev/smart-tool install [--target DIR] [--
20
21
  Installs or updates Smart Tool for this Windows user. Run it again with @latest to update.`;
21
22
 
22
23
  function fail(message) {
23
- console.error(`smart-tool: ${message}`);
24
- process.exit(1);
24
+ throw new Failure(message);
25
25
  }
26
26
 
27
27
  function run(command, args, options = {}) {
@@ -31,13 +31,18 @@ function run(command, args, options = {}) {
31
31
  return (result.stdout || "").trim();
32
32
  }
33
33
 
34
+ function runAgain(env) {
35
+ const again = spawnSync(process.execPath, [fileURLToPath(import.meta.url), ...process.argv.slice(2)], { stdio: "inherit", env });
36
+ if (again.error) fail(`could not run again: ${again.error.message}`);
37
+ return again.status ?? 1;
38
+ }
39
+
34
40
  async function ensureUv() {
35
41
  if (existsSync(UV_EXE)) return;
36
42
  const url = `https://github.com/astral-sh/uv/releases/download/${UV_VERSION}/${UV_ASSET}`;
37
43
  console.log(`Downloading uv ${UV_VERSION} (Python manager)...`);
38
- const response = await fetch(url);
39
- if (!response.ok) fail(`download of ${url} failed: HTTP ${response.status}`);
40
- const archive = Buffer.from(await response.arrayBuffer());
44
+ const { body: archive, rerun } = await download(url, { rerun: runAgain });
45
+ if (rerun !== undefined) return { rerun };
41
46
  const digest = createHash("sha256").update(archive).digest("hex");
42
47
  if (digest !== UV_SHA256) fail(`${UV_ASSET} checksum mismatch (expected ${UV_SHA256}, got ${digest}).`);
43
48
  const scratch = mkdtempSync(join(tmpdir(), "smart-tool-uv-"));
@@ -55,7 +60,17 @@ async function ensureUv() {
55
60
  function ensurePython() {
56
61
  const env = { ...process.env, UV_PYTHON_INSTALL_DIR: PYTHON_DIR };
57
62
  console.log(`Preparing Python ${PYTHON_VERSION} in ${PYTHON_DIR}...`);
58
- run(UV_EXE, ["python", "install", PYTHON_VERSION, "--no-bin", "--no-registry"], { env });
63
+ const install = ["python", "install", PYTHON_VERSION, "--no-bin", "--no-registry"];
64
+ try {
65
+ run(UV_EXE, install, { env });
66
+ } catch (error) {
67
+ const native = nativeTlsEnvironment(env);
68
+ if (!(error instanceof Failure) || !isCertificateText(error.message)) throw error;
69
+ if (!native) throw new Failure(`${error.message}\n${certificateAdvice(env)}`);
70
+ console.log("TLS inspection detected by uv; running it again with the Windows certificate store...");
71
+ env.UV_NATIVE_TLS = native.UV_NATIVE_TLS;
72
+ run(UV_EXE, install, { env });
73
+ }
59
74
  const python = run(UV_EXE, ["python", "find", "--managed-python", "--no-python-downloads", PYTHON_VERSION], { env });
60
75
  if (!python.toLowerCase().startsWith(PYTHON_DIR.toLowerCase())) fail(`uv returned a Python outside ${PYTHON_DIR}: ${python}`);
61
76
  return python;
@@ -69,10 +84,15 @@ async function main() {
69
84
  }
70
85
  if (command !== "install") {
71
86
  console.log(USAGE);
72
- process.exit(command === undefined || command === "--help" || command === "-h" ? 0 : 1);
87
+ process.exitCode = command === undefined || command === "--help" || command === "-h" ? 0 : 1;
88
+ return;
73
89
  }
74
90
  if (process.platform !== "win32" || process.arch !== "x64") fail(`Smart Tool runs on Windows x64 only (this is ${process.platform}-${process.arch}).`);
75
- await ensureUv();
91
+ const again = await ensureUv();
92
+ if (again) {
93
+ process.exitCode = again.rerun;
94
+ return;
95
+ }
76
96
  const python = ensurePython();
77
97
  const installer = spawnSync(python, [join(PACKAGE_DIR, "install.py"), "--skip-tests", ...rest], {
78
98
  stdio: "inherit",
@@ -80,7 +100,10 @@ async function main() {
80
100
  env: { ...process.env, PYTHONUTF8: "1", PYTHONIOENCODING: "utf-8" },
81
101
  });
82
102
  if (installer.error) fail(`the installer did not start: ${installer.error.message}`);
83
- process.exit(installer.status ?? 1);
103
+ process.exitCode = installer.status ?? 1;
84
104
  }
85
105
 
86
- main().catch((error) => fail(error.stack || String(error)));
106
+ main().catch((error) => {
107
+ console.error(error instanceof Failure ? `smart-tool: ${error.message}` : `smart-tool: ${error.stack || error}`);
108
+ process.exitCode = 1;
109
+ });
package/endpoint_sync.py CHANGED
@@ -8,11 +8,12 @@ Cada arquivo é relido logo antes da gravação atômica: o Claude Code reescrev
8
8
  import json
9
9
  import os
10
10
  import re
11
- import shutil
12
11
  import subprocess
13
12
  import time
14
13
  import urllib.parse
15
14
 
15
+ import node_locator
16
+
16
17
  SERVER = "smart-tool"
17
18
  HOOK_PATH = "/hooks/pretool"
18
19
  POST_HOOK_PATH = "/hooks/posttool"
@@ -110,7 +111,11 @@ def registrations():
110
111
  return found
111
112
 
112
113
 
113
- REGISTER_TIMEOUT_S = 60
114
+ REGISTER_TIMEOUT_S = 120
115
+ TIMEOUT_ENV = "SMART_TOOL_CLI_TIMEOUT_S"
116
+ CLI_EXTENSIONS = (".exe", ".cmd", ".bat")
117
+ NPM_CLAUDE_FIX = "npm install -g @anthropic-ai/claude-code@latest --include=optional --allow-scripts=@anthropic-ai/claude-code"
118
+ _NPM_CLAUDE_BINARY = os.path.join("node_modules", "@anthropic-ai", "claude-code", "bin", "claude.exe")
114
119
  _CMD_METACHARS = re.compile(r'[&|<>^%!"\r\n]')
115
120
 
116
121
 
@@ -124,21 +129,99 @@ def register_command(client, base):
124
129
  raise ValueError(f"Client without MCP registration: {client!r}")
125
130
 
126
131
 
132
+ def remove_command(client):
133
+ """Official CLI command that removes the smart-tool MCP server a client holds."""
134
+ if client == "claude":
135
+ return ["claude", "mcp", "remove", SERVER, "--scope", "user"]
136
+ if client == "codex":
137
+ return ["codex", "mcp", "remove", SERVER]
138
+ raise ValueError(f"Client without MCP registration: {client!r}")
139
+
140
+
127
141
  def register(client, base):
128
- """Runs the client's own CLI (it owns its config file; ~/.claude.json is rewritten by Claude Code all the time)."""
142
+ """Makes the client's smart-tool MCP entry point to base, through the client's own CLI (it owns its config file;
143
+ ~/.claude.json is rewritten by Claude Code all the time). Idempotent: the same URL runs nothing, another URL (the
144
+ port changed) is removed first because `mcp add` refuses an existing name, no entry only adds. Returns what was run."""
145
+ url = f"{base}/mcp"
146
+ current = registrations().get(client)
147
+ if current == url:
148
+ return f"nothing to run: {client} already points to {url}"
129
149
  command = register_command(client, base)
150
+ if current:
151
+ run_cli(remove_command(client))
130
152
  run_cli(command)
131
- if registrations().get(client) != f"{base}/mcp":
153
+ if registrations().get(client) != url:
132
154
  raise RuntimeError(f"'{' '.join(command)}' finished, but the registration did not appear in the client's configuration.")
133
155
  return " ".join(command)
134
156
 
135
157
 
158
+ def cli_folders():
159
+ """Where the native installers of the clients put their CLI, for a PATH that does not list them."""
160
+ home, local, roaming = os.path.expanduser("~"), os.environ.get("LOCALAPPDATA"), os.environ.get("APPDATA")
161
+ folders = [os.path.join(home, ".local", "bin")]
162
+ if local:
163
+ folders.append(os.path.join(local, "Programs", "OpenAI", "Codex", "bin"))
164
+ if roaming:
165
+ folders.append(os.path.join(roaming, "npm"))
166
+ return folders
167
+
168
+
169
+ def resolve_cli(name):
170
+ """Full path of the client CLI `name` as a .exe, .cmd or .bat (never a .ps1 or the extensionless script npm leaves
171
+ beside its shims, which Windows cannot run), looked up in the PATH as Windows stores it now and then in cli_folders;
172
+ None when absent."""
173
+ for folder in node_locator.search_path() + cli_folders():
174
+ for extension in CLI_EXTENSIONS:
175
+ path = os.path.join(folder, name + extension)
176
+ if os.path.isfile(path):
177
+ return path
178
+ return None
179
+
180
+
181
+ def _is_native(path):
182
+ with open(path, "rb") as stream:
183
+ return stream.read(2) == b"MZ"
184
+
185
+
186
+ def check_runnable(executable):
187
+ """Raises RuntimeError, without running anything, when executable is a .exe that is not a Windows program or an npm
188
+ shim whose package holds such a file: Windows answers those with a modal "not compatible" box and the command hangs.
189
+ That is how Claude Code installed by npm 11 ends up when its postinstall is blocked (a ~500-byte text claude.exe)."""
190
+ if executable.lower().endswith(".exe"):
191
+ broken = executable if not _is_native(executable) else None
192
+ else:
193
+ package_binary = os.path.join(os.path.dirname(executable), _NPM_CLAUDE_BINARY)
194
+ broken = package_binary if os.path.isfile(package_binary) and not _is_native(package_binary) else None
195
+ if not broken:
196
+ return
197
+ if os.path.join("@anthropic-ai", "claude-code") in broken:
198
+ raise RuntimeError(f"Claude Code was installed by npm without its native binary ({broken} is a text file). "
199
+ f"Run: {NPM_CLAUDE_FIX}")
200
+ raise RuntimeError(f"{broken} is not a Windows executable (it does not start with MZ); reinstall that CLI.")
201
+
202
+
203
+ def cli_timeout_s():
204
+ """Seconds a client CLI may run: SMART_TOOL_CLI_TIMEOUT_S when set (a positive number), else REGISTER_TIMEOUT_S."""
205
+ raw = os.environ.get(TIMEOUT_ENV)
206
+ if raw is None:
207
+ return REGISTER_TIMEOUT_S
208
+ try:
209
+ seconds = float(raw)
210
+ except ValueError:
211
+ seconds = 0
212
+ if seconds <= 0:
213
+ raise RuntimeError(f"{TIMEOUT_ENV} must be a positive number of seconds, not {raw!r}.")
214
+ return seconds
215
+
216
+
136
217
  def run_cli(command):
137
- """Runs a client CLI command (claude, codex) found on PATH, killing its whole shim tree after REGISTER_TIMEOUT_S.
138
- Raises RuntimeError with the command and the CLI's own message when it is missing, hangs or exits non-zero."""
139
- executable = shutil.which(command[0])
218
+ """Runs a client CLI command (claude, codex) found by resolve_cli, killing its whole shim tree after cli_timeout_s.
219
+ Raises RuntimeError with the command and the CLI's own message when it is missing, cannot run, hangs or exits non-zero."""
220
+ executable = resolve_cli(command[0])
140
221
  if not executable:
141
222
  raise RuntimeError(f"CLI '{command[0]}' not found in Smart Tool's PATH. Register it manually: {' '.join(command)}")
223
+ check_runnable(executable)
224
+ timeout = cli_timeout_s()
142
225
  unsafe = [arg for arg in command[1:] if _CMD_METACHARS.search(arg)]
143
226
  if executable.lower().endswith((".cmd", ".bat")) and unsafe:
144
227
  # cmd.exe parses a .cmd shim's arguments again: & | < > ^ % ! would run as commands or expand as variables.
@@ -152,7 +235,7 @@ def run_cli(command):
152
235
  except OSError as exc:
153
236
  raise RuntimeError(f"'{' '.join(command)}' did not start: {exc}") from None
154
237
  try:
155
- stdout, stderr = proc.communicate(timeout=REGISTER_TIMEOUT_S)
238
+ stdout, stderr = proc.communicate(timeout=timeout)
156
239
  except subprocess.TimeoutExpired:
157
240
  # npm shims are cmd.exe -> node: killing only cmd.exe leaves node holding the pipes forever.
158
241
  if os.name == "nt":
@@ -160,7 +243,7 @@ def run_cli(command):
160
243
  else:
161
244
  proc.kill()
162
245
  proc.communicate(timeout=10)
163
- raise RuntimeError(f"'{' '.join(command)}' did not finish in {REGISTER_TIMEOUT_S}s.") from None
246
+ raise RuntimeError(f"'{' '.join(command)}' did not finish in {timeout:g}s.") from None
164
247
  if proc.returncode != 0:
165
248
  detail = (stderr or stdout).strip()[-400:]
166
249
  raise RuntimeError(f"'{' '.join(command)}' failed (code {proc.returncode}): {detail}")
@@ -176,6 +176,7 @@ def _run(args, *, cwd=None, label="Web dependency", idle_s=600, stream=False, at
176
176
  node, _npm = _node_commands()
177
177
  env = node_locator.environment(prefer=node)
178
178
  env.update(PYTHONUTF8="1", PYTHONIOENCODING="utf-8")
179
+ env.setdefault("NODE_USE_SYSTEM_CA", "1")
179
180
  flags = (subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.CREATE_NO_WINDOW) if os.name == "nt" else 0
180
181
  for attempt in range(1, attempts + 1):
181
182
  print(f"{label}: starting{f' (attempt {attempt} of {attempts})' if attempt > 1 else ''}...", flush=True)
@@ -256,7 +257,7 @@ def _check():
256
257
  if status["browser_python"]:
257
258
  try:
258
259
  result = subprocess.run(
259
- [str(BROWSER_PYTHON), "-c", "import camoufox, crawl4ai, playwright"],
260
+ [str(BROWSER_PYTHON), "-c", "import camoufox, crawl4ai, playwright, truststore"],
260
261
  capture_output=True, check=False, timeout=30,
261
262
  )
262
263
  status["browser_packages"] = result.returncode == 0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@allansantos-dev/smart-tool",
3
- "version": "0.9.28",
3
+ "version": "0.9.29",
4
4
  "description": "Local MCP server that gives coding agents (Claude Code, Codex) cheaper, sharper tools than their built-in search. Windows.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Allan Santos",
package/version.py CHANGED
@@ -1,7 +1,7 @@
1
1
  """Single source of the Smart Tool version and of the User-Agent sent to public APIs."""
2
2
  import re
3
3
 
4
- VERSION = "0.9.28"
4
+ VERSION = "0.9.29"
5
5
  _CONTACT_RE = re.compile(r"^(?:[^\s@()<>;]+@[^\s@()<>;]+\.[^\s@()<>;]+|https?://[^\s()<>;]+)$")
6
6
 
7
7
 
@@ -2,7 +2,10 @@
2
2
  caught here and turned into a failing exit, and the browser executable must exist afterwards. The 1.3 GB download
3
3
  goes through ranged_download (Range requests that resume after a dropped or stalled connection; camoufox still checks
4
4
  the sha256), its other requests get a timeout (requests passes None, which also overrides socket.setdefaulttimeout),
5
- and progress is printed as plain lines (the rich bar stays invisible in a pipe)."""
5
+ and progress is printed as plain lines (the rich bar stays invisible in a pipe). requests trusts only certifi's bundle, so on
6
+ a network that inspects TLS with a corporate root CA its release lookups fail; the Windows certificate store is used
7
+ instead (truststore), unless the user already set a CA bundle variable."""
8
+ import os
6
9
  import sys
7
10
  from io import BytesIO
8
11
  from pathlib import Path
@@ -14,6 +17,7 @@ from camoufox import pkgman
14
17
  import ranged_download
15
18
 
16
19
  TIMEOUT_S = (30, 60)
20
+ CA_VARIABLES = ("REQUESTS_CA_BUNDLE", "SSL_CERT_FILE", "CURL_CA_BUNDLE")
17
21
  REPORT_EVERY_MB = 50
18
22
  _update = camoufox_cli.CamoufoxUpdate.update
19
23
  _get = requests.get
@@ -59,7 +63,18 @@ def installed_executable():
59
63
  return executable
60
64
 
61
65
 
66
+ def trust_system_certificates():
67
+ """Makes ssl (and so requests) verify against the Windows certificate store; False and untouched when the user set
68
+ REQUESTS_CA_BUNDLE, SSL_CERT_FILE or CURL_CA_BUNDLE."""
69
+ if any(os.environ.get(name) for name in CA_VARIABLES):
70
+ return False
71
+ import truststore
72
+ truststore.inject_into_ssl()
73
+ return True
74
+
75
+
62
76
  def main():
77
+ trust_system_certificates()
63
78
  requests.get = _get_with_timeout
64
79
  pkgman.webdl = _ranged_webdl
65
80
  camoufox_cli.CamoufoxUpdate.update = _recording_update
@@ -1,2 +1,3 @@
1
1
  camoufox==0.5.6
2
+ truststore==0.10.5
2
3
  crawl4ai==0.9.3