@allansantos-dev/smart-tool 0.9.27 → 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 +46 -0
- package/README.md +1 -1
- package/bin/network.mjs +78 -0
- package/bin/smart-tool.mjs +33 -10
- package/browser_control.py +29 -0
- package/code_graph.py +1 -1
- package/endpoint_sync.py +92 -9
- package/install_runtime.py +16 -12
- package/node_locator.py +103 -0
- package/package.json +1 -1
- package/smart_tool_daemon.py +12 -2
- package/update_check.py +5 -2
- package/version.py +1 -1
- package/web_adapters/browser/fetch_camoufox.py +16 -1
- package/web_adapters/browser/requirements.txt +1 -0
- package/web_search_adapters.py +12 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,51 @@
|
|
|
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
|
+
|
|
32
|
+
## 0.9.28 - beta
|
|
33
|
+
|
|
34
|
+
- Node, npx and npm are looked up when they are needed, never remembered. A process keeps the PATH it was born with:
|
|
35
|
+
the tray started at logon never saw a Node installed, moved or removed afterwards, so the automatic update
|
|
36
|
+
(`npx --yes ... install`, run from the tray) stopped working once Node changed place, and everything the update then
|
|
37
|
+
started (installer, restarted tray) inherited the same stale PATH. New `node_locator.py`: the process PATH, then the
|
|
38
|
+
machine and user PATH stored in the registry, then the usual install folders (Program Files, nvm, Volta, fnm, Scoop,
|
|
39
|
+
Chocolatey); the Node inside Smart Tool (`web_adapters/node/.node-runtime`) is used only when the machine has none.
|
|
40
|
+
- The updater launches the install with that fresh PATH and the folder of the `npx` it found first in it.
|
|
41
|
+
- The web runtime installer takes the machine's Node from the PATH first (it preferred the one inside Smart Tool).
|
|
42
|
+
- Search adapters and the code analyzers resolve Node at each launch, so a Node changed while the daemon runs is
|
|
43
|
+
followed without a restart.
|
|
44
|
+
- The Playwright MCP registration writes the absolute Node path into the client's configuration; the daemon now
|
|
45
|
+
registers it again at start when that executable no longer exists (`browser_control.heal`).
|
|
46
|
+
- Tests: `tests/test_node_locator.py` (Node in an unusual folder, only in the registry PATH, only in a known folder,
|
|
47
|
+
nothing found, the updater without npx in the tray PATH, a registration whose Node is gone).
|
|
48
|
+
|
|
3
49
|
## 0.9.27 - beta
|
|
4
50
|
|
|
5
51
|
- No folder is read before it is classified, and a tool's hidden folder (`.crowd`, `.tokenguard`, `.vscode`, any name
|
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.
|
|
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
|
|
package/bin/network.mjs
ADDED
|
@@ -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
|
+
}
|
package/bin/smart-tool.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
|
39
|
-
if (
|
|
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
|
-
|
|
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.
|
|
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.
|
|
103
|
+
process.exitCode = installer.status ?? 1;
|
|
84
104
|
}
|
|
85
105
|
|
|
86
|
-
main().catch((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/browser_control.py
CHANGED
|
@@ -26,6 +26,7 @@ import tomllib
|
|
|
26
26
|
import atomic_io
|
|
27
27
|
import endpoint_sync
|
|
28
28
|
import install_runtime
|
|
29
|
+
import node_locator
|
|
29
30
|
import paths
|
|
30
31
|
|
|
31
32
|
SERVER = "playwright"
|
|
@@ -157,6 +158,34 @@ def registered():
|
|
|
157
158
|
return found
|
|
158
159
|
|
|
159
160
|
|
|
161
|
+
def registered_command(client):
|
|
162
|
+
"""Executable the client's `playwright` server is registered with, or None when it is not registered."""
|
|
163
|
+
try:
|
|
164
|
+
if client == "claude":
|
|
165
|
+
with open(endpoint_sync.claude_json(), encoding="utf-8") as stream:
|
|
166
|
+
entry = (json.load(stream).get("mcpServers") or {}).get(SERVER)
|
|
167
|
+
else:
|
|
168
|
+
with open(_codex_config(), encoding="utf-8") as stream:
|
|
169
|
+
entry = (tomllib.loads(stream.read()).get("mcp_servers") or {}).get(SERVER)
|
|
170
|
+
except (OSError, ValueError, AttributeError):
|
|
171
|
+
return None
|
|
172
|
+
command = entry.get("command") if isinstance(entry, dict) else None
|
|
173
|
+
return command if isinstance(command, str) else None
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def heal(log=None):
|
|
177
|
+
"""Registers again, through ensure, each registration of ours whose executable no longer exists: the Node path is
|
|
178
|
+
written into the client's configuration, which a moved or reinstalled Node leaves pointing nowhere. Returns
|
|
179
|
+
{client: ensure's outcome} for the clients refreshed."""
|
|
180
|
+
refreshed = {}
|
|
181
|
+
for client in ("claude", "codex"):
|
|
182
|
+
command = registered_command(client)
|
|
183
|
+
ours = status().get(client, {}).get("state") in ("registered", "undenied")
|
|
184
|
+
if ours and command and not node_locator.exists(command):
|
|
185
|
+
refreshed[client] = ensure(client, log)
|
|
186
|
+
return refreshed
|
|
187
|
+
|
|
188
|
+
|
|
160
189
|
def _codex_config():
|
|
161
190
|
"""Codex keeps config.toml in CODEX_HOME when set, else ~/.codex."""
|
|
162
191
|
return os.path.join(endpoint_sync.codex_dir(), "config.toml")
|
package/code_graph.py
CHANGED
|
@@ -242,7 +242,7 @@ def _run_analyzer(script, payload, failure):
|
|
|
242
242
|
needed more than the former fixed 512 MB). The run is killed after `ANALYZER_TIMEOUT_S` seconds (a context
|
|
243
243
|
variable: the edit hook lowers it so a slow file cannot hold its worker)."""
|
|
244
244
|
for heap in NODE_HEAP_MB:
|
|
245
|
-
process=subprocess.run([web_search_adapters.NODE_EXECUTABLE,f'--max-old-space-size={heap}',str(Path(__file__).with_name(script))],
|
|
245
|
+
process=subprocess.run([web_search_adapters.executable_path(web_search_adapters.NODE_EXECUTABLE),f'--max-old-space-size={heap}',str(Path(__file__).with_name(script))],
|
|
246
246
|
input=payload,capture_output=True,text=True,encoding='utf-8',timeout=ANALYZER_TIMEOUT_S.get(),
|
|
247
247
|
creationflags=getattr(subprocess,'CREATE_NO_WINDOW',0))
|
|
248
248
|
if process.returncode==0:
|
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 =
|
|
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
|
-
"""
|
|
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) !=
|
|
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
|
|
138
|
-
Raises RuntimeError with the command and the CLI's own message when it is missing, hangs or exits non-zero."""
|
|
139
|
-
executable =
|
|
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=
|
|
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 {
|
|
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}")
|
package/install_runtime.py
CHANGED
|
@@ -21,6 +21,8 @@ import venv
|
|
|
21
21
|
import zipfile
|
|
22
22
|
from pathlib import Path
|
|
23
23
|
|
|
24
|
+
import node_locator
|
|
25
|
+
|
|
24
26
|
|
|
25
27
|
ROOT = Path(__file__).resolve().parent
|
|
26
28
|
NODE_DIR = ROOT / "web_adapters" / "node"
|
|
@@ -32,7 +34,7 @@ JAVA_PARSER = NODE_DIR / 'node_modules' / 'java-parser' / 'src' / 'index.js'
|
|
|
32
34
|
ANGULAR_PARSER = NODE_DIR / 'node_modules' / '@angular' / 'compiler' / 'fesm2022' / 'compiler.mjs'
|
|
33
35
|
# Browser control for agents (browser_control.py): Microsoft's Playwright MCP over the Chromium installed below.
|
|
34
36
|
PLAYWRIGHT_MCP_CLI = NODE_DIR / "node_modules" / "@playwright" / "mcp" / "cli.js"
|
|
35
|
-
NODE_RUNTIME =
|
|
37
|
+
NODE_RUNTIME = Path(node_locator.LOCAL_RUNTIME)
|
|
36
38
|
# Moli renders the JavaScript pages web_fetch cannot read over HTTP; Chromium (Crawl4AI) stays as its fallback.
|
|
37
39
|
# Measured on 2026-10-10 over 21 such pages: 1.6 s and 90 MB peak (median) against 4.2 s and 581 MB for Chromium.
|
|
38
40
|
MOLI_VERSION = "1.1.15"
|
|
@@ -58,14 +60,16 @@ def _node_supported(node):
|
|
|
58
60
|
|
|
59
61
|
|
|
60
62
|
def _node_commands():
|
|
63
|
+
"""(node, npm) of the Node to use: the machine's, found through the PATH first, else the one inside Smart Tool;
|
|
64
|
+
(None, None) when neither is a supported version."""
|
|
65
|
+
node = node_locator.which("node")
|
|
66
|
+
npm = node_locator.which("npm.cmd") or node_locator.which("npm")
|
|
67
|
+
if node and npm and _node_supported(node):
|
|
68
|
+
return node, npm
|
|
61
69
|
local_node, local_npm = NODE_RUNTIME / "node.exe", NODE_RUNTIME / "npm.cmd"
|
|
62
|
-
if
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
return None, None
|
|
66
|
-
node = shutil.which("node")
|
|
67
|
-
npm = shutil.which("npm.cmd") or shutil.which("npm")
|
|
68
|
-
return (node, npm) if node and npm and _node_supported(node) else (None, None)
|
|
70
|
+
if local_node.is_file() and local_npm.is_file() and _node_supported(local_node):
|
|
71
|
+
return str(local_node), str(local_npm)
|
|
72
|
+
return None, None
|
|
69
73
|
|
|
70
74
|
|
|
71
75
|
def _download_verified(url, expected_sha256, path):
|
|
@@ -169,10 +173,10 @@ def _install_moli():
|
|
|
169
173
|
def _run(args, *, cwd=None, label="Web dependency", idle_s=600, stream=False, attempts=1):
|
|
170
174
|
"""Runs a step with UTF-8 output and closed stdin (a prompt fails at once instead of hanging); it is stopped only
|
|
171
175
|
after idle_s without any output, and stream shows the output live."""
|
|
172
|
-
|
|
176
|
+
node, _npm = _node_commands()
|
|
177
|
+
env = node_locator.environment(prefer=node)
|
|
173
178
|
env.update(PYTHONUTF8="1", PYTHONIOENCODING="utf-8")
|
|
174
|
-
|
|
175
|
-
env["PATH"] = str(NODE_RUNTIME) + os.pathsep + env.get("PATH", "")
|
|
179
|
+
env.setdefault("NODE_USE_SYSTEM_CA", "1")
|
|
176
180
|
flags = (subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.CREATE_NO_WINDOW) if os.name == "nt" else 0
|
|
177
181
|
for attempt in range(1, attempts + 1):
|
|
178
182
|
print(f"{label}: starting{f' (attempt {attempt} of {attempts})' if attempt > 1 else ''}...", flush=True)
|
|
@@ -253,7 +257,7 @@ def _check():
|
|
|
253
257
|
if status["browser_python"]:
|
|
254
258
|
try:
|
|
255
259
|
result = subprocess.run(
|
|
256
|
-
[str(BROWSER_PYTHON), "-c", "import camoufox, crawl4ai, playwright"],
|
|
260
|
+
[str(BROWSER_PYTHON), "-c", "import camoufox, crawl4ai, playwright, truststore"],
|
|
257
261
|
capture_output=True, check=False, timeout=30,
|
|
258
262
|
)
|
|
259
263
|
status["browser_packages"] = result.returncode == 0
|
package/node_locator.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Where Node.js (and npx/npm) is on this machine, looked up when it is needed and never remembered.
|
|
2
|
+
|
|
3
|
+
A process keeps the PATH it was born with: the tray started at logon never sees a Node installed, moved or removed
|
|
4
|
+
afterwards, and everything it launches (the updater, the installer, the daemon) inherits that stale PATH. The lookup is
|
|
5
|
+
therefore: the process PATH, then the PATH Windows stores for the machine and the user (what a new process would get),
|
|
6
|
+
then the usual install folders. The Node that Smart Tool downloads into web_adapters/node/.node-runtime is the last
|
|
7
|
+
resort of `node()`, used only when the machine has none.
|
|
8
|
+
"""
|
|
9
|
+
import os
|
|
10
|
+
import shutil
|
|
11
|
+
|
|
12
|
+
LOCAL_RUNTIME = os.path.join(os.path.dirname(os.path.abspath(__file__)), "web_adapters", "node", ".node-runtime")
|
|
13
|
+
_MACHINE_ENVIRONMENT = r"SYSTEM\CurrentControlSet\Control\Session Manager\Environment"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _registry_path():
|
|
17
|
+
"""Entries of the machine and user PATH as stored in the registry, variables expanded."""
|
|
18
|
+
if os.name != "nt":
|
|
19
|
+
return []
|
|
20
|
+
import winreg
|
|
21
|
+
entries = []
|
|
22
|
+
for hive, subkey in ((winreg.HKEY_LOCAL_MACHINE, _MACHINE_ENVIRONMENT), (winreg.HKEY_CURRENT_USER, "Environment")):
|
|
23
|
+
try:
|
|
24
|
+
with winreg.OpenKey(hive, subkey) as key:
|
|
25
|
+
entries += os.path.expandvars(winreg.QueryValueEx(key, "Path")[0]).split(os.pathsep)
|
|
26
|
+
except FileNotFoundError:
|
|
27
|
+
continue
|
|
28
|
+
return entries
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _unique(entries):
|
|
32
|
+
seen, kept = set(), []
|
|
33
|
+
for entry in entries:
|
|
34
|
+
entry = entry.strip().strip('"')
|
|
35
|
+
if entry and os.path.normcase(entry) not in seen:
|
|
36
|
+
seen.add(os.path.normcase(entry))
|
|
37
|
+
kept.append(entry)
|
|
38
|
+
return kept
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def search_path():
|
|
42
|
+
"""The process PATH followed by the registry PATH entries it does not have."""
|
|
43
|
+
return _unique(os.environ.get("PATH", "").split(os.pathsep) + _registry_path())
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def known_folders():
|
|
47
|
+
"""Where Node installers and version managers put node.exe, for a machine whose PATH does not list it."""
|
|
48
|
+
program_files = [os.environ.get(name) for name in ("ProgramFiles", "ProgramW6432", "ProgramFiles(x86)")]
|
|
49
|
+
local = os.environ.get("LOCALAPPDATA")
|
|
50
|
+
home = os.path.expanduser("~")
|
|
51
|
+
candidates = [os.path.join(base, "nodejs") for base in program_files if base]
|
|
52
|
+
candidates += [os.environ.get("NVM_SYMLINK"), os.environ.get("FNM_MULTISHELL_PATH"),
|
|
53
|
+
os.path.join(os.environ["VOLTA_HOME"], "bin") if os.environ.get("VOLTA_HOME") else None,
|
|
54
|
+
os.path.join(local, "Programs", "nodejs") if local else None,
|
|
55
|
+
os.path.join(home, "scoop", "apps", "nodejs", "current"),
|
|
56
|
+
os.path.join(home, "scoop", "apps", "nodejs-lts", "current"),
|
|
57
|
+
os.path.join(os.environ["ChocolateyInstall"], "bin") if os.environ.get("ChocolateyInstall") else None]
|
|
58
|
+
return _unique(path for path in candidates if path)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
_FOUND = {}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def which(name):
|
|
65
|
+
"""Full path of an executable (`node`, `npx.cmd`...): the PATH first, the known install folders only when the PATH
|
|
66
|
+
does not have it; None when neither does. A path found is remembered per PATH value and used again only while the
|
|
67
|
+
file is still there, so a lookup costs a stat, not a scan of every folder, and a moved Node is searched again."""
|
|
68
|
+
key = (name, os.environ.get("PATH", ""))
|
|
69
|
+
found = _FOUND.get(key)
|
|
70
|
+
if found and os.path.isfile(found):
|
|
71
|
+
return found
|
|
72
|
+
found = (shutil.which(name, path=os.pathsep.join(search_path()))
|
|
73
|
+
or shutil.which(name, path=os.pathsep.join(known_folders())))
|
|
74
|
+
if found:
|
|
75
|
+
_FOUND[key] = found
|
|
76
|
+
return found
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def local_node():
|
|
80
|
+
"""The Node downloaded into Smart Tool, or None."""
|
|
81
|
+
path = os.path.join(LOCAL_RUNTIME, "node.exe")
|
|
82
|
+
return path if os.path.isfile(path) else None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def node():
|
|
86
|
+
"""node.exe of the machine (PATH first), else the Node inside Smart Tool, else None."""
|
|
87
|
+
return which("node") or local_node()
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def environment(prefer=None):
|
|
91
|
+
"""A copy of the environment with the current PATH (see search_path), the folder of `prefer` (an executable)
|
|
92
|
+
first: what a process launched now should inherit."""
|
|
93
|
+
env = {key: value for key, value in os.environ.items() if key.upper() != "PATH"}
|
|
94
|
+
entries = search_path()
|
|
95
|
+
if prefer:
|
|
96
|
+
entries.insert(0, os.path.dirname(prefer))
|
|
97
|
+
env["PATH"] = os.pathsep.join(_unique(entries))
|
|
98
|
+
return env
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def exists(command):
|
|
102
|
+
"""Whether a registered executable (a full path, or a bare name resolved through the PATH) is still there."""
|
|
103
|
+
return os.path.isfile(command) or bool(which(command))
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@allansantos-dev/smart-tool",
|
|
3
|
-
"version": "0.9.
|
|
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/smart_tool_daemon.py
CHANGED
|
@@ -40,6 +40,7 @@ import capabilities
|
|
|
40
40
|
import client_hooks
|
|
41
41
|
import install_runtime
|
|
42
42
|
import integration_metrics
|
|
43
|
+
import node_locator
|
|
43
44
|
import config
|
|
44
45
|
import freshness
|
|
45
46
|
import model_client
|
|
@@ -2078,9 +2079,9 @@ def _run_search_script(executable, script_path, args, timeout_s):
|
|
|
2078
2079
|
if _IS_WINDOWS else {"start_new_session": True}
|
|
2079
2080
|
)
|
|
2080
2081
|
proc = subprocess.Popen(
|
|
2081
|
-
[executable, script_path, *args],
|
|
2082
|
+
[web_search_adapters.executable_path(executable), script_path, *args],
|
|
2082
2083
|
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, encoding="utf-8", errors="replace",
|
|
2083
|
-
env={**
|
|
2084
|
+
env={**node_locator.environment(), "SMART_TOOL_USER_AGENT": version.user_agent(config.load_config().get("contact"))},
|
|
2084
2085
|
**popen_kwargs,
|
|
2085
2086
|
)
|
|
2086
2087
|
try:
|
|
@@ -4183,6 +4184,14 @@ def _probe_capabilities():
|
|
|
4183
4184
|
_log(f"capabilities probe failed: {type(exc).__name__}: {exc}")
|
|
4184
4185
|
|
|
4185
4186
|
|
|
4187
|
+
def _heal_browser_control():
|
|
4188
|
+
"""Re-registers the Playwright MCP server when the Node it was registered with is gone (Node moved or reinstalled)."""
|
|
4189
|
+
try:
|
|
4190
|
+
browser_control.heal(_log)
|
|
4191
|
+
except Exception as exc:
|
|
4192
|
+
_log(f"browser control not refreshed: {type(exc).__name__}: {exc}")
|
|
4193
|
+
|
|
4194
|
+
|
|
4186
4195
|
def main():
|
|
4187
4196
|
global _PROJECT_MONITOR
|
|
4188
4197
|
# O bind vem antes de qualquer I/O de rede: enquanto a porta não abre, o
|
|
@@ -4206,6 +4215,7 @@ def main():
|
|
|
4206
4215
|
except (OSError, ValueError) as exc:
|
|
4207
4216
|
_log(f"clients not pointed to port {_BOUND_PORT}: {type(exc).__name__}: {exc}")
|
|
4208
4217
|
atexit.register(BROWSER_SERVICE.close)
|
|
4218
|
+
threading.Thread(target=_heal_browser_control, daemon=True).start()
|
|
4209
4219
|
try:
|
|
4210
4220
|
recovered = project_store.recover_interrupted()
|
|
4211
4221
|
project_store.import_known_legacy(indexer.INDEX_DIR)
|
package/update_check.py
CHANGED
|
@@ -13,6 +13,7 @@ import urllib.request
|
|
|
13
13
|
|
|
14
14
|
import atomic_io
|
|
15
15
|
import config
|
|
16
|
+
import node_locator
|
|
16
17
|
import paths
|
|
17
18
|
import version
|
|
18
19
|
|
|
@@ -136,7 +137,9 @@ def notices(now=None):
|
|
|
136
137
|
|
|
137
138
|
def run_update(latest, automatic):
|
|
138
139
|
"""Starts the install command in its own visible console, detached from the tray (the installer stops and restarts
|
|
139
|
-
the tray). The console closes on success and stays open with the error on failure.
|
|
140
|
+
the tray). The console closes on success and stays open with the error on failure. npx is looked up now, not in the
|
|
141
|
+
PATH the tray was born with: a Node installed or moved since the last logon still runs the update, and the folder
|
|
142
|
+
found is put first in the PATH the installer (and the tray it restarts) inherits."""
|
|
140
143
|
if automatic:
|
|
141
144
|
state = _state()
|
|
142
145
|
state.update(attempted=latest, at=time.time())
|
|
@@ -144,4 +147,4 @@ def run_update(latest, automatic):
|
|
|
144
147
|
flags = getattr(subprocess, "CREATE_NEW_CONSOLE", 0) | getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
|
|
145
148
|
command = f'title Smart Tool update to {latest} && {UPDATE_COMMAND} || pause'
|
|
146
149
|
subprocess.Popen(["cmd.exe", "/c", command], creationflags=flags, close_fds=True,
|
|
147
|
-
cwd=os.path.expanduser("~"))
|
|
150
|
+
cwd=os.path.expanduser("~"), env=node_locator.environment(prefer=node_locator.which("npx.cmd")))
|
package/version.py
CHANGED
|
@@ -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
|
package/web_search_adapters.py
CHANGED
|
@@ -31,11 +31,14 @@ Bing entra pelo tier yahoo.
|
|
|
31
31
|
import os
|
|
32
32
|
import sys
|
|
33
33
|
|
|
34
|
+
import node_locator
|
|
35
|
+
|
|
34
36
|
PACKAGE_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
35
37
|
NODE_ADAPTER_DIR = os.path.join(PACKAGE_DIR, "web_adapters", "node")
|
|
36
38
|
BROWSER_ADAPTER_DIR = os.path.join(PACKAGE_DIR, "web_adapters", "browser")
|
|
37
|
-
|
|
38
|
-
|
|
39
|
+
# Adapters name Node, not a path: `executable_path` finds it when the adapter is launched (node_locator), so a Node
|
|
40
|
+
# installed, moved or removed while the daemon runs is followed without a restart.
|
|
41
|
+
NODE_EXECUTABLE = "node"
|
|
39
42
|
CAMOUFOX_VENV_PYTHON = os.path.join(BROWSER_ADAPTER_DIR, ".venv", "Scripts", "python.exe")
|
|
40
43
|
BROWSER_SERVICE_SCRIPT = os.path.join(BROWSER_ADAPTER_DIR, "browser_service.py")
|
|
41
44
|
DDG_SEARCH_SCRIPT = os.path.join(NODE_ADAPTER_DIR, "ddg-search.mjs")
|
|
@@ -44,6 +47,13 @@ WIKIPEDIA_SEARCH_SCRIPT = os.path.join(NODE_ADAPTER_DIR, "wikipedia-search.mjs")
|
|
|
44
47
|
EXTERNAL_SEARCH_SCRIPT = os.path.join(PACKAGE_DIR, "web_search_external.py")
|
|
45
48
|
DDGS_SEARCH_SCRIPT = os.path.join(PACKAGE_DIR, "web_search_ddgs.py")
|
|
46
49
|
|
|
50
|
+
|
|
51
|
+
def executable_path(executable):
|
|
52
|
+
"""The program to launch for an adapter's `executable`: Node is looked up now (PATH first, then the Node inside
|
|
53
|
+
Smart Tool); any other executable is returned unchanged. A Node that is nowhere stays `node`, so the launch fails
|
|
54
|
+
with the system's own "not found"."""
|
|
55
|
+
return (node_locator.node() or NODE_EXECUTABLE) if executable == NODE_EXECUTABLE else executable
|
|
56
|
+
|
|
47
57
|
FETCH_TIMEOUT_S = 15
|
|
48
58
|
OPEN_WEBSEARCH_TIMEOUT_S = 30
|
|
49
59
|
WIKIPEDIA_TIMEOUT_S = 15
|