fix(humanize): apply humanize before the license guard on persistent contexts

A persistent context arrives with a page already open. The license guard
stores its wrappers on that page object, and an object attribute shadows the
class methods humanize patches, so guarding first pinned Playwright's own
methods and silently disabled humanize on that page. Python (sync and async)
and JS now humanize first, then guard. The .NET wrapper already composes in
this order; new tests lock it in.

- tests: Python call-order tests, JS real-browser guard-on-humanized-page
  test, .NET LicenseGuardCompositionTests
- remove internal support references from code comments and test docstrings
This commit is contained in:
CloakHQ committed 2026-10-11 04:10:13 +02:00
1 parent e439d89214
commit 60f35e0db4
14 files changed
+210 -39

No files matched your search

+21 -15
View File
@@ -726,6 +726,17 @@ def launch_persistent_context(
if proxy_extra_args:
_warn_on_request_client_use(context._impl_obj)
# Human-like behavioral patching. Applied BEFORE the license guard: the guard
# stores its wrapper on the page/context object itself (it needs this launch's
# denial path), and an object attribute shadows the class method humanize
# patches. Guarding first would therefore capture Playwright's own methods and
# silently disable humanize for every call on that page.
if humanize:
from .human import patch_context
from .human.config import resolve_config
cfg = resolve_config(human_preset, human_config)
patch_context(context, cfg)
# The persistent path hands back a context, so guard it (new_page deep — see
# launch()). A persistent context also arrives with a page already open, so
# the user navigates pages[0] directly and never calls new_page — guard those
@@ -736,13 +747,6 @@ def launch_persistent_context(
for _pg in context.pages:
_install_license_guard(_pg, denial_path)
# Human-like behavioral patching
if humanize:
from .human import patch_context
from .human.config import resolve_config
cfg = resolve_config(human_preset, human_config)
patch_context(context, cfg)
return context
@@ -893,6 +897,15 @@ async def launch_persistent_context_async(
if proxy_extra_args:
_warn_on_request_client_use(context._impl_obj)
# Human-like behavioral patching (async variant). Applied BEFORE the license
# guard, for the same reason as the sync variant: the guard stores its wrapper
# on the page/context object, which shadows the class method humanize patches.
if humanize:
from .human import patch_context_async
from .human.config import resolve_config
cfg = resolve_config(human_preset, human_config)
patch_context_async(context, cfg)
# The persistent path hands back a context, so guard it (new_page deep — see
# launch()). A persistent context also arrives with a page already open (see
# the sync variant): guard those existing pages so a post-handshake denial on
@@ -903,13 +916,6 @@ async def launch_persistent_context_async(
for _pg in context.pages:
_install_license_guard_async(_pg, denial_path)
# Human-like behavioral patching (async variant)
if humanize:
from .human import patch_context_async
from .human.config import resolve_config
cfg = resolve_config(human_preset, human_config)
patch_context_async(context, cfg)
return context
@@ -1637,7 +1643,7 @@ def _maybe_warn_windows_fonts(chrome_args: list[str]) -> None:
# Write straight to stderr (like the welcome banner and the JS/.NET
# 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).
# and this write is on the launch path.
try:
sys.stderr.write(
"[cloakbrowser] Incomplete Windows font set - installing the full "
+1 -1
View File
@@ -128,7 +128,7 @@ def _emit(text: str) -> None:
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).
binary-download path, that would abort the whole launch.
A banner is decoration - never let it propagate.
"""
try:
+1 -1
View File
@@ -156,7 +156,7 @@ public static class Download
// 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.
// path. Keep the three wrappers byte-parallel.
var sb = new System.Text.StringBuilder();
sb.Append('\n');
sb.Append(" CloakBrowser - stealth Chromium for automation\n");
@@ -60,7 +60,7 @@ 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
/// console a non-ASCII glyph can crash or mojibake the write, so
/// the banner must be pure ASCII. Mirrors the Python/JS welcome tests.
/// Serialized: mutates CLOAKBROWSER_CACHE_DIR and Console.Error.
/// </summary>
@@ -743,7 +743,7 @@ public class LicenseTests : IDisposable
{
var marker = Path.Combine(_tmp, $"latest_pro_version_{Config.GetPlatformTag()}");
File.WriteAllText(marker, "148.0.7778.215.2");
// Ticket 431 Fix 4: marker present but no Pro binary on disk -> null, NOT the
// Marker present but no Pro binary on disk -> null, NOT the
// free base. A valid Pro license must never fall back to the free binary.
Assert.Null(Config.GetEffectiveVersion(pro: true));
}
@@ -926,7 +926,7 @@ public class LicenseTests : IDisposable
[Fact]
public void CheckForProUpdate_already_latest_returns_null()
{
// Ticket 431 Fix 1: `update` on a Pro install already at latest is a no-op.
// `update` on a Pro install already at latest is a no-op.
File.WriteAllText(
Path.Combine(_tmp, $"latest_pro_version_{Config.GetPlatformTag()}"),
"148.0.7778.215.5");
@@ -0,0 +1,90 @@
using CloakBrowser;
using CloakBrowser.Human;
using CloakBrowser.Wrappers;
using Microsoft.Playwright;
using Xunit;
namespace CloakBrowser.Tests.Wrappers;
/// <summary>
/// The license guard and humanize must compose in this order: humanize wraps the raw
/// Playwright object first, the license-guard proxy wraps the humanized object. The
/// guard forwards to whatever it was handed, so guarding the raw object instead would
/// put Playwright's own methods back in front of the humanized ones for every call on
/// that handle — clicks would teleport and Playwright's action path would run instead
/// of the human engine (the Python and JS wrappers had exactly that bug).
/// </summary>
public class LicenseGuardCompositionTests
{
private static IPage MakeFakePage()
{
var (mouse, _) = Fake.Of<IMouse>();
var (keyboard, _) = Fake.Of<IKeyboard>();
var (page, pageRec) = Fake.Of<IPage>();
pageRec.On("Mouse", mouse);
pageRec.On("Keyboard", keyboard);
pageRec.On("ViewportSize", new PageViewportSizeResult { Width = 800, Height = 600 });
return page;
}
[Fact]
public void Guarded_context_wraps_the_humanized_context()
{
var (playwright, _) = Fake.Of<IPlaywright>();
var (ctx, _) = Fake.Of<IBrowserContext>();
var handle = new CloakContextHandle(
playwright, null, ctx, humanize: true, new HumanConfig(), "/tmp/denial-compose");
var guarded = Assert.IsAssignableFrom<IGuardedProxy>(handle.Context);
Assert.IsType<HumanizedBrowserContext>(guarded.GuardTarget);
Assert.Same(ctx, LicenseGuard.Unwrap(handle.Context));
}
[Fact]
public void Keyless_context_is_humanized_and_not_guarded()
{
var (playwright, _) = Fake.Of<IPlaywright>();
var (ctx, _) = Fake.Of<IBrowserContext>();
var handle = new CloakContextHandle(
playwright, null, ctx, humanize: true, new HumanConfig(), denialPath: null);
Assert.IsNotAssignableFrom<IGuardedProxy>(handle.Context);
Assert.IsType<HumanizedBrowserContext>(handle.Context);
}
[Fact]
public async Task Guarded_context_hands_back_a_guarded_humanized_page()
{
var (playwright, _) = Fake.Of<IPlaywright>();
var (ctx, ctxRec) = Fake.Of<IBrowserContext>();
ctxRec.On("NewPageAsync", Task.FromResult(MakeFakePage()));
var handle = new CloakContextHandle(
playwright, null, ctx, humanize: true, new HumanConfig(), "/tmp/denial-compose");
var page = await handle.NewPageAsync();
var guarded = Assert.IsAssignableFrom<IGuardedProxy>(page);
Assert.IsType<HumanizedPage>(guarded.GuardTarget);
}
[Fact]
public async Task Existing_pages_of_a_guarded_context_are_guarded_humanized_pages()
{
var (playwright, _) = Fake.Of<IPlaywright>();
var (ctx, ctxRec) = Fake.Of<IBrowserContext>();
var page = MakeFakePage();
ctxRec.On("Pages", new List<IPage> { page });
var handle = new CloakContextHandle(
playwright, null, ctx, humanize: true, new HumanConfig(), "/tmp/denial-compose");
var pages = handle.Context.Pages;
Assert.Single(pages);
var guarded = Assert.IsAssignableFrom<IGuardedProxy>(pages[0]);
Assert.IsType<HumanizedPage>(guarded.GuardTarget);
}
}
+1 -1
View File
@@ -368,7 +368,7 @@ export function showWelcome(tier = "keyless"): void {
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.
// Keep the three wrappers byte-parallel.
console.error();
console.error(" CloakBrowser - stealth Chromium for automation");
console.error(" https://github.com/CloakHQ/CloakBrowser");
+14 -11
View File
@@ -406,6 +406,20 @@ export async function launchPersistentContext(
if (proxyArgs.length) warnOnRequestClientUse(context);
// Human-like behavioral patching. Applied BEFORE the license guard: the guard
// stores its wrapper on the page/context object (it carries this launch's denial
// path), and an own property shadows the prototype method humanize patches. Guarding
// first would capture Playwright's own methods and silently disable humanize.
if (options.humanize) {
const { patchContext } = await import('./human/index.js');
const { resolveConfig } = await import('./human/config.js');
const cfg = resolveConfig(
options.humanPreset ?? 'default',
options.humanConfig,
);
patchContext(context, cfg);
}
// The persistent path hands back a context, so guard its newPage (see launch()).
if (denialPath) {
installLicenseGuard(context, denialPath);
@@ -418,17 +432,6 @@ export async function launchPersistentContext(
}
}
// Human-like behavioral patching
if (options.humanize) {
const { patchContext } = await import('./human/index.js');
const { resolveConfig } = await import('./human/config.js');
const cfg = resolveConfig(
options.humanPreset ?? 'default',
options.humanConfig,
);
patchContext(context, cfg);
}
return context;
}
+18
View File
@@ -166,6 +166,24 @@ describe.skipIf(!process.env.CLOAKBROWSER_BINARY_PATH)('humanize engine (real br
expect(await p.evaluate(() => (window as any).__dt)).toEqual([true, true]);
});
it('a license guard installed on a humanized page does not disable humanize', async () => {
// The guard stores its wrapper on the page object (it carries this launch's
// denial path), so the order it is applied in decides which methods it holds.
// Installed on an already-humanized page it must forward to the human engine.
const p: any = await open();
const { installLicenseGuard } = await import('../src/license.js');
installLicenseGuard(p, '/tmp/denial-compose-test');
await p.evaluate(() => {
(window as any).__m = 0;
document.addEventListener('mousemove', () => { (window as any).__m += 1; }, true);
});
await p.click('#btn');
// A humanized click travels in a curve; Playwright's own click teleports (1-2).
expect(await p.evaluate(() => (window as any).__m)).toBeGreaterThan(3);
});
it('the probe works: the page records events dispatched in its own world', async () => {
const p: any = await open();
const before = await p.evaluate(() => (window as any).__dispatched.length);
+1 -1
View File
@@ -340,7 +340,7 @@ describe("effective version", () => {
}
});
// Ticket 431 Fix 4: a valid Pro license must NEVER fall back to the free binary.
// A valid Pro license must NEVER fall back to the free binary.
it("returns null for Pro when nothing is cached (never the free base)", () => {
const orig = process.env.CLOAKBROWSER_CACHE_DIR;
process.env.CLOAKBROWSER_CACHE_DIR = `/tmp/cloakbrowser-test-${Date.now()}-pro`;
+1 -1
View File
@@ -5,7 +5,7 @@ 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
// console a non-ASCII glyph can crash or mojibake the write, so
// the banner must be pure ASCII. Mirrors the Python test_welcome_banner suite.
describe("welcome banner", () => {
let cacheDir: string;
+54
View File
@@ -311,3 +311,57 @@ async def test_persistent_context_async_seeds_widevine(_mock_seed, _mock_geoip,
await launch_persistent_context_async("/tmp/profile")
_mock_seed.assert_called_once_with("/tmp/profile", "/fake/chrome")
# ---------------------------------------------------------------------------
# Ordering: humanize must be applied BEFORE the license guard
#
# The guard stores its wrapper as an attribute on the page/context object (it
# carries this launch's denial path), and an object attribute shadows the class
# method humanize patches. Guarding first therefore captured Playwright's own
# methods, silently disabling humanize for every call on that page — clicks
# teleported.
# ---------------------------------------------------------------------------
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
@patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=(None, None, None))
def test_persistent_context_humanizes_before_the_license_guard(_mock_geoip, _mock_bin):
pw_cm, pw, context = _make_mock_pw_and_context()
context.pages = [MagicMock()]
order = []
with patch("playwright.sync_api.sync_playwright", return_value=pw_cm), \
patch("cloakbrowser.browser.mint_denial_file", return_value="/tmp/denial"), \
patch("cloakbrowser.browser.resolve_license_key", return_value="cb_test"), \
patch("cloakbrowser.browser._install_license_guard",
side_effect=lambda *a: order.append("guard")), \
patch("cloakbrowser.human.patch_context",
side_effect=lambda *a: order.append("humanize")):
from cloakbrowser.browser import launch_persistent_context
launch_persistent_context("/tmp/profile", humanize=True, license_key="cb_test")
# one guard call for the context plus one per already-open page
assert order[0] == "humanize" and set(order[1:]) == {"guard"}, order
@pytest.mark.asyncio
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
@patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=(None, None, None))
async def test_persistent_context_async_humanizes_before_the_license_guard(_mock_geoip, _mock_bin):
pw_cm, pw, context = _make_mock_async_pw_and_context()
context.pages = [MagicMock()]
order = []
with patch("playwright.async_api.async_playwright", return_value=pw_cm), \
patch("cloakbrowser.browser.mint_denial_file", return_value="/tmp/denial"), \
patch("cloakbrowser.browser.resolve_license_key", return_value="cb_test"), \
patch("cloakbrowser.browser._install_license_guard_async",
side_effect=lambda *a: order.append("guard")), \
patch("cloakbrowser.human.patch_context_async",
side_effect=lambda *a: order.append("humanize")):
from cloakbrowser.browser import launch_persistent_context_async
await launch_persistent_context_async("/tmp/profile", humanize=True, license_key="cb_test")
# one guard call for the context plus one per already-open page
assert order[0] == "humanize" and set(order[1:]) == {"guard"}, order
+3 -3
View File
@@ -623,7 +623,7 @@ def _make_pro_binary(version: str):
class TestUnpinnedProUpgrade:
"""Ticket 431: an unpinned Pro launch must track the server's latest stable,
"""An unpinned Pro launch must track the server's latest stable,
never roll down to a stale cached build, and never fall back to the free binary."""
OLD = "148.0.7778.215.3"
@@ -907,7 +907,7 @@ class TestUnpinnedProUpgrade:
class TestCheckForProUpdate:
"""`cloakbrowser update` for Pro installs (ticket 431 Fix 1)."""
"""`cloakbrowser update` for Pro installs."""
OLD = "148.0.7778.215.3"
NEW = "148.0.7778.215.5"
@@ -959,7 +959,7 @@ class TestCheckForProUpdate:
class TestEffectiveVersionProNoFreeFallback:
"""get_effective_version(pro=True) must return None — never the free base —
when no cached Pro binary matches the marker (ticket 431 Fix 4)."""
when no cached Pro binary matches the marker."""
def test_none_when_no_cached_pro_binary(self, tmp_path):
with patch.dict(os.environ, {"CLOAKBROWSER_CACHE_DIR": str(tmp_path)}):
+2 -2
View File
@@ -60,7 +60,7 @@ def test_free_reshows_after_interval(cache, capsys):
@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)."""
can always encode it. Regression for the '->' glyph."""
out = _banner(capsys, tier)
assert out != ""
non_ascii = [c for c in out if ord(c) > 0x7F]
@@ -70,7 +70,7 @@ def test_banner_is_ascii_only(cache, capsys, tier):
@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."""
the launch — the banner is cosmetic. Reproduces that 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