fix(wrapper): ASCII-safe welcome banner for legacy Windows consoles

The banner wrote '->' and em dashes straight to stderr. On a cp1252/strict
console (UK/EU-locale Windows) that raised UnicodeEncodeError and aborted the
binary-download path, blocking license apply and profile launch in the Manager.

Swap the banner glyphs to ASCII and route the writes through a swallow-all
helper so a cosmetic banner can never abort a launch. Same treatment in the JS
and .NET wrappers plus the Windows-font warning. Regression tests added across
all three (ASCII-only + cp1252-no-crash).
This commit is contained in:
CloakHQ committed 2026-08-28 21:00:28 +02:00
1 parent d6bad5de26
commit bb6f2f00e4
9 files changed
+167 -39

No files matched your search

+12 -7
View File
@@ -1586,13 +1586,18 @@ def _maybe_warn_windows_fonts(chrome_args: list[str]) -> None:
if present is None or present:
return # full set present, or can't determine — don't warn
# Write straight to stderr (like the welcome banner and the JS/.NET
# wrappers) so an app's logging config can't silence it.
sys.stderr.write(
"[cloakbrowser] Incomplete Windows font set — installing the full "
"set is strongly advised for best results when spoofing Windows on "
"Linux. https://github.com/CloakHQ/cloakbrowser#font-setup-on-linux "
"(silence: CLOAKBROWSER_SUPPRESS_FONT_WARNING=1)\n"
)
# wrappers) so an app's logging config can't silence it. ASCII-only and
# swallow-everything: on a legacy Windows console stderr is cp1252/strict
# and this write is on the launch path (ticket 2354).
try:
sys.stderr.write(
"[cloakbrowser] Incomplete Windows font set - installing the full "
"set is strongly advised for best results when spoofing Windows on "
"Linux. https://github.com/CloakHQ/cloakbrowser#font-setup-on-linux "
"(silence: CLOAKBROWSER_SUPPRESS_FONT_WARNING=1)\n"
)
except Exception:
pass
try:
marker.parent.mkdir(parents=True, exist_ok=True)
marker.write_text("")
+35 -17
View File
@@ -113,6 +113,20 @@ def _warn_preview_fallback() -> None:
)
def _emit(text: str) -> None:
"""Write a cosmetic line to stderr, swallowing any error.
On a legacy Windows console sys.stderr is cp1252/strict; an unencodable
glyph raises UnicodeEncodeError. Because the welcome banner runs on the
binary-download path, that would abort the whole launch (ticket 2354).
A banner is decoration - never let it propagate.
"""
try:
sys.stderr.write(text)
except Exception:
pass
def _show_welcome(tier: str = "keyless") -> None:
"""Show welcome message on launch. A marker file gates the cadence: a paid
(Pro) key shows once ever; free (keyless or a free GitHub key) re-shows every
@@ -126,36 +140,40 @@ def _show_welcome(tier: str = "keyless") -> None:
marker = get_cache_dir() / ".welcome_shown"
if not _welcome_due(marker, pro=(tier == "pro")):
return
sys.stderr.write("\n")
sys.stderr.write(" CloakBrowser — stealth Chromium for automation\n")
sys.stderr.write(" https://github.com/CloakHQ/CloakBrowser\n")
sys.stderr.write("\n")
# ASCII-only banner + a swallow-everything writer: on a legacy Windows
# console sys.stderr is cp1252/strict, so a non-ASCII glyph (or any IO
# error) here would raise and abort the whole binary-download path. A
# cosmetic banner must never be able to kill a launch.
_emit("\n")
_emit(" CloakBrowser - stealth Chromium for automation\n")
_emit(" https://github.com/CloakHQ/CloakBrowser\n")
_emit("\n")
if tier == "pro":
sys.stderr.write(
f" CloakBrowser Pro active (v{PRO_MAJOR}) — latest binary, newest patches.\n"
_emit(
f" CloakBrowser Pro active (v{PRO_MAJOR}) - latest binary, newest patches.\n"
)
sys.stderr.write(" Pro support → support@cloakbrowser.dev\n")
_emit(" Pro support -> support@cloakbrowser.dev\n")
elif tier == "free":
sys.stderr.write(
_emit(
f" CloakBrowser free (v{PRO_MAJOR}): the latest binary, 1 concurrent session.\n"
)
sys.stderr.write(
" For more than one concurrent session → https://cloakbrowser.dev\n"
_emit(
" For more than one concurrent session -> https://cloakbrowser.dev\n"
)
else:
free_major = CHROMIUM_VERSION.split(".")[0]
sys.stderr.write(
_emit(
f" Running the free binary (v{free_major}). "
f"The latest binary (v{PRO_MAJOR}) is free too, with 1 concurrent session.\n"
)
sys.stderr.write(
_emit(
" Get your key: run cloakbrowser login or visit https://cloakbrowser.dev/free\n"
)
sys.stderr.write(
" For more than one concurrent session → https://cloakbrowser.dev\n"
_emit(
" For more than one concurrent session -> https://cloakbrowser.dev\n"
)
sys.stderr.write(" Star us if CloakBrowser helps your project!\n")
sys.stderr.write("\n")
_emit(" Star us if CloakBrowser helps your project!\n")
_emit("\n")
try:
marker.parent.mkdir(parents=True, exist_ok=True)
marker.write_text(str(int(time.time())))
@@ -1222,7 +1240,7 @@ def _check_wrapper_update() -> None:
latest = resp.json()["info"]["version"]
if _version_newer(latest, _wrapper_version):
logger.warning(
"Update available: cloakbrowser %s → %s. "
"Update available: cloakbrowser %s -> %s. "
"Run: pip install --upgrade cloakbrowser",
_wrapper_version,
latest,
+1 -1
View File
@@ -568,7 +568,7 @@ public static class CloakLauncher
var present = WindowsFontsPresent();
if (present != false) return; // true (full set) or null (undeterminable)
CloakLog.Warning(
"[cloakbrowser] Incomplete Windows font set — installing the full " +
"[cloakbrowser] Incomplete Windows font set - installing the full " +
"set is strongly advised for best results when spoofing Windows on " +
"Linux. https://github.com/CloakHQ/cloakbrowser#font-setup-on-linux " +
"(silence: CLOAKBROWSER_SUPPRESS_FONT_WARNING=1)");
+9 -6
View File
@@ -134,25 +134,28 @@ public static class Download
/// "free" — a free GitHub key (latest binary, one concurrent session)
/// "keyless" — no key, running the older free binary (invite the free login)
/// </summary>
private static void ShowWelcome(string tier = "keyless")
internal static void ShowWelcome(string tier = "keyless")
{
var marker = Path.Combine(Config.GetCacheDir(), ".welcome_shown");
if (!WelcomeDue(marker, tier == "pro")) return;
// ASCII-only banner: on a legacy Windows console a non-ASCII glyph can
// crash or mojibake the write, and this runs on the binary-download
// path (ticket 2354). Keep the three wrappers byte-parallel.
var sb = new System.Text.StringBuilder();
sb.Append('\n');
sb.Append(" CloakBrowser — stealth Chromium for automation\n");
sb.Append(" CloakBrowser - stealth Chromium for automation\n");
sb.Append(" https://github.com/CloakHQ/CloakBrowser\n");
sb.Append('\n');
if (tier == "pro")
{
sb.Append($" CloakBrowser Pro active (v{ProMajor}) — latest binary, newest patches.\n");
sb.Append(" Pro support → support@cloakbrowser.dev\n");
sb.Append($" CloakBrowser Pro active (v{ProMajor}) - latest binary, newest patches.\n");
sb.Append(" Pro support -> support@cloakbrowser.dev\n");
}
else if (tier == "free")
{
sb.Append($" CloakBrowser free (v{ProMajor}): the latest binary, 1 concurrent session.\n");
sb.Append(" For more than one concurrent session → https://cloakbrowser.dev\n");
sb.Append(" For more than one concurrent session -> https://cloakbrowser.dev\n");
}
else
{
@@ -160,7 +163,7 @@ public static class Download
sb.Append($" Running the free binary (v{freeMajor}). " +
$"The latest binary (v{ProMajor}) is free too, with 1 concurrent session.\n");
sb.Append(" Get your key: run cloakbrowser login or visit https://cloakbrowser.dev/free\n");
sb.Append(" For more than one concurrent session → https://cloakbrowser.dev\n");
sb.Append(" For more than one concurrent session -> https://cloakbrowser.dev\n");
}
sb.Append(" Star us if CloakBrowser helps your project!\n");
sb.Append('\n');
@@ -58,6 +58,45 @@ public class WelcomeCadenceTests
}
}
/// <summary>
/// The welcome banner runs on the binary-download path. On a legacy Windows
/// console a non-ASCII glyph can crash or mojibake the write (ticket 2354), so
/// the banner must be pure ASCII. Mirrors the Python/JS welcome tests.
/// Serialized: mutates CLOAKBROWSER_CACHE_DIR and Console.Error.
/// </summary>
[Collection("env-serial")]
public class WelcomeBannerAsciiTests
{
[Theory]
[InlineData("keyless")]
[InlineData("free")]
[InlineData("pro")]
public void Banner_is_ascii_only(string tier)
{
var prev = Environment.GetEnvironmentVariable("CLOAKBROWSER_CACHE_DIR");
var tmp = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName());
Directory.CreateDirectory(tmp);
var origErr = Console.Error;
var buf = new StringWriter();
try
{
Environment.SetEnvironmentVariable("CLOAKBROWSER_CACHE_DIR", tmp);
Console.SetError(buf);
Download.ShowWelcome(tier); // marker absent -> shows
var outText = buf.ToString();
Assert.NotEqual("", outText);
foreach (var c in outText)
Assert.True(c <= 0x7F, $"non-ASCII U+{(int)c:X4} in {tier} banner");
}
finally
{
Console.SetError(origErr);
Environment.SetEnvironmentVariable("CLOAKBROWSER_CACHE_DIR", prev);
try { Directory.Delete(tmp, recursive: true); } catch { /* best-effort */ }
}
}
}
/// <summary>
/// Linux Windows-font mismatch warning. Platform detection and fc-list aren't
/// mockable without a DI refactor, so these cover the deterministic paths: the
+11 -7
View File
@@ -355,24 +355,28 @@ function warnPreviewFallback(): void {
);
}
function showWelcome(tier = "keyless"): void {
// Exported for tests only; not re-exported from the package index.
export function showWelcome(tier = "keyless"): void {
const marker = path.join(getCacheDir(), ".welcome_shown");
if (!welcomeDue(marker, tier === "pro")) return;
// ASCII-only banner: on a legacy Windows console a non-ASCII glyph can
// crash or mojibake the write, and this runs on the binary-download path
// (ticket 2354). Keep the three wrappers byte-parallel.
console.error();
console.error(" CloakBrowser — stealth Chromium for automation");
console.error(" CloakBrowser - stealth Chromium for automation");
console.error(" https://github.com/CloakHQ/CloakBrowser");
console.error();
if (tier === "pro") {
console.error(
` CloakBrowser Pro active (v${PRO_MAJOR}) — latest binary, newest patches.`,
` CloakBrowser Pro active (v${PRO_MAJOR}) - latest binary, newest patches.`,
);
console.error(" Pro support → support@cloakbrowser.dev");
console.error(" Pro support -> support@cloakbrowser.dev");
} else if (tier === "free") {
console.error(
` CloakBrowser free (v${PRO_MAJOR}): the latest binary, 1 concurrent session.`,
);
console.error(
" For more than one concurrent session → https://cloakbrowser.dev",
" For more than one concurrent session -> https://cloakbrowser.dev",
);
} else {
const freeMajor = CHROMIUM_VERSION.split(".")[0];
@@ -384,7 +388,7 @@ function showWelcome(tier = "keyless"): void {
" Get your key: run cloakbrowser login or visit https://cloakbrowser.dev/free",
);
console.error(
" For more than one concurrent session → https://cloakbrowser.dev",
" For more than one concurrent session -> https://cloakbrowser.dev",
);
}
console.error(" Star us if CloakBrowser helps your project!");
@@ -1218,7 +1222,7 @@ export async function checkWrapperUpdate(): Promise<void> {
const data = (await resp.json()) as { version: string };
if (data.version && versionNewer(data.version, WRAPPER_VERSION)) {
console.warn(
`[cloakbrowser] Update available: ${WRAPPER_VERSION} → ${data.version}. ` +
`[cloakbrowser] Update available: ${WRAPPER_VERSION} -> ${data.version}. ` +
`Run: npm install cloakbrowser@latest`
);
}
+1 -1
View File
@@ -221,7 +221,7 @@ async function resolveExitIp(proxyUrl: string | null | undefined, timeoutMs?: nu
try {
({ SocksProxyAgent } = await import("socks-proxy-agent"));
} catch {
console.warn("[cloakbrowser] socks-proxy-agent not installed — cannot resolve exit IP through SOCKS5 proxy. Install it: npm install socks-proxy-agent");
console.warn("[cloakbrowser] socks-proxy-agent not installed - cannot resolve exit IP through SOCKS5 proxy. Install it: npm install socks-proxy-agent");
return null;
}
const { default: https } = await import("node:https");
+38
View File
@@ -0,0 +1,38 @@
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { showWelcome } from "../src/download.js";
// The welcome banner runs on the binary-download path. On a legacy Windows
// console a non-ASCII glyph can crash or mojibake the write (ticket 2354), so
// the banner must be pure ASCII. Mirrors the Python test_welcome_banner suite.
describe("welcome banner", () => {
let cacheDir: string;
let prevCache: string | undefined;
let errSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
cacheDir = fs.mkdtempSync(path.join(os.tmpdir(), "cb-welcome-"));
prevCache = process.env.CLOAKBROWSER_CACHE_DIR;
process.env.CLOAKBROWSER_CACHE_DIR = cacheDir;
errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
});
afterEach(() => {
errSpy.mockRestore();
if (prevCache === undefined) delete process.env.CLOAKBROWSER_CACHE_DIR;
else process.env.CLOAKBROWSER_CACHE_DIR = prevCache;
fs.rmSync(cacheDir, { recursive: true, force: true });
});
for (const tier of ["keyless", "free", "pro"]) {
it(`${tier} banner is ASCII-only`, () => {
showWelcome(tier);
const out = errSpy.mock.calls.map((c) => String(c[0] ?? "")).join("\n");
expect(out).not.toBe("");
// eslint-disable-next-line no-control-regex
expect(out).toMatch(/^[\x00-\x7F]*$/);
});
}
});
+21
View File
@@ -1,4 +1,6 @@
"""Tier-aware launch banner: keyless (v146) / free key / paid, + cadence."""
import io
import sys
import time
from pathlib import Path
@@ -55,6 +57,25 @@ def test_free_reshows_after_interval(cache, capsys):
assert _banner(capsys, "free") != "" # interval passed -> shows again
@pytest.mark.parametrize("tier", ["keyless", "free", "pro"])
def test_banner_is_ascii_only(cache, capsys, tier):
"""Banner must be pure ASCII so a legacy Windows console (cp1252/strict)
can always encode it. Regression for ticket 2354 (the '->' glyph)."""
out = _banner(capsys, tier)
assert out != ""
non_ascii = [c for c in out if ord(c) > 0x7F]
assert non_ascii == [], f"non-ASCII in {tier} banner: {non_ascii!r}"
@pytest.mark.parametrize("tier", ["keyless", "free", "pro"])
def test_banner_never_raises_on_cp1252_stderr(cache, monkeypatch, tier):
"""Even if a future glyph slips in, a cp1252/strict stderr must not abort
the launch — the banner is cosmetic. Reproduces ticket 2354's crash path."""
strict = io.TextIOWrapper(io.BytesIO(), encoding="cp1252", errors="strict")
monkeypatch.setattr(sys, "stderr", strict)
download._show_welcome(tier) # pre-fix: UnicodeEncodeError; post-fix: returns
def test_ensure_pro_binary_maps_plan_to_tier(monkeypatch):
"""A free-plan key shows the free banner; any other plan shows Pro."""
seen = []