diff --git a/dotnet/src/CloakBrowser/Handles.cs b/dotnet/src/CloakBrowser/Handles.cs index e6260f9..3b1e9e4 100644 --- a/dotnet/src/CloakBrowser/Handles.cs +++ b/dotnet/src/CloakBrowser/Handles.cs @@ -169,6 +169,7 @@ internal static class LicenseGuard public static object Wrap(object target, string? denialPath) { if (denialPath == null) return target; + if (target is IGuardedProxy) return target; // already guarded — don't nest proxies return target switch { IPage p => WrapAs(p, denialPath), @@ -178,6 +179,32 @@ internal static class LicenseGuard }; } + /// + /// Peel every CloakBrowser wrapper — the license-guard first, + /// then the humanize decorator — off a page/frame/element handle to recover the raw + /// Playwright object. A few Playwright methods (notably + /// ) internally down-cast the + /// handle they are given to Playwright's concrete type, which throws on any wrapper; those + /// must receive the unwrapped handle. Non-wrapped objects are returned unchanged. + /// + public static object Unwrap(object handle) + { + var current = handle; + while (true) + { + switch (current) + { + case IGuardedProxy g: current = g.GuardTarget; break; + case HumanizedPage p: current = p.Original; break; + case HumanizedFrame f: current = f.Original; break; + case HumanizedElementHandle e: current = e.Original; break; + case HumanizedBrowserContext c: current = c.Original; break; + case HumanizedBrowser b: current = b.Original; break; + default: return current; + } + } + } + private static T WrapAs(T target, string denialPath) where T : class { var proxy = DispatchProxy.Create>(); @@ -195,11 +222,22 @@ internal static class LicenseGuard /// getters + the EventEmitter surface). get_Pages is special-cased so a persistent /// context's already-open pages are guarded too. /// -internal class LicenseGuardProxy : DispatchProxy where T : class +internal interface IGuardedProxy +{ + /// The object this proxy forwards to (see ). + object GuardTarget { get; } +} + +internal class LicenseGuardProxy : DispatchProxy, IGuardedProxy where T : class { private T _target = null!; private string _denialPath = null!; + // One guard wrapper per underlying context, so page.Context is a stable instance. + private readonly System.Runtime.CompilerServices.ConditionalWeakTable _contextGuardCache = new(); + + object IGuardedProxy.GuardTarget => _target; + internal void Init(T target, string denialPath) { _target = target; @@ -216,6 +254,12 @@ internal class LicenseGuardProxy : DispatchProxy where T : class // Guard the pages a persistent context hands back (context.Pages[0]). if (targetMethod.Name == "get_Pages" && value is IReadOnlyList pages) return pages.Select(p => (IPage)LicenseGuard.Wrap(p, _denialPath)).ToList(); + // Guard the context a page hands back (page.Context) so calls made through it — + // notably NewCDPSessionAsync, which unwraps its page/frame argument — stay guarded. + // Memoized per underlying context so repeated page.Context accesses return the + // same instance (reference identity parity with raw Playwright). + if (targetMethod.Name == "get_Context" && value is IBrowserContext ctx) + return _contextGuardCache.GetValue(ctx, c => LicenseGuard.Wrap((IBrowserContext)c, _denialPath)); return value; } @@ -250,7 +294,7 @@ internal class LicenseGuardProxy : DispatchProxy where T : class /// caller sees the genuine Playwright exception. private object? Forward(MethodInfo method, object?[]? args) { - try { return method.Invoke(_target, args); } + try { return method.Invoke(_target, UnwrapArgs(args)); } catch (TargetInvocationException tie) when (tie.InnerException != null) { ExceptionDispatchInfo.Capture(tie.InnerException).Throw(); @@ -258,6 +302,28 @@ internal class LicenseGuardProxy : DispatchProxy where T : class } } + /// + /// Unwrap any page/frame/element-handle arguments back to their raw Playwright objects + /// before the real method runs. Playwright methods that take a handle (e.g. + /// NewCDPSessionAsync) down-cast it to a concrete type and throw on a wrapper. Returns the + /// original array untouched when nothing needs unwrapping (the common case). + /// + private static object?[]? UnwrapArgs(object?[]? args) + { + if (args == null) return null; + object?[]? copy = null; + for (int i = 0; i < args.Length; i++) + { + if (args[i] is IPage or IFrame or IElementHandle) + { + var unwrapped = LicenseGuard.Unwrap(args[i]!); + if (!ReferenceEquals(unwrapped, args[i])) + (copy ??= (object?[])args.Clone())[i] = unwrapped; + } + } + return copy ?? args; + } + private async Task GuardTask(MethodInfo method, object?[]? args) { try { await ((Task)Forward(method, args)!).ConfigureAwait(false); } diff --git a/dotnet/src/CloakBrowser/Wrappers/Humanize.cs b/dotnet/src/CloakBrowser/Wrappers/Humanize.cs index 59fd74a..3ef83f2 100644 --- a/dotnet/src/CloakBrowser/Wrappers/Humanize.cs +++ b/dotnet/src/CloakBrowser/Wrappers/Humanize.cs @@ -40,9 +40,15 @@ public static class Humanize public static IBrowserContext Context(IBrowserContext context, HumanConfig? config = null) { if (context is HumanizedBrowserContext) return context; - return new HumanizedBrowserContext(context, config ?? new HumanConfig()); + // Memoize per raw context so page.Context returns a stable instance (Playwright's + // own context is a singleton); a fresh wrapper each access would break reference + // identity and dictionary-key use. + return ContextCache.GetValue(context, key => new HumanizedBrowserContext(key, config ?? new HumanConfig())); } + /// One humanized wrapper per raw context, so identity stays stable across accesses. + private static readonly System.Runtime.CompilerServices.ConditionalWeakTable ContextCache = new(); + /// /// Wrap a raw Playwright so every context/page it produces /// is humanized. @@ -54,6 +60,22 @@ public static class Humanize return new HumanizedBrowser(browser, config ?? new HumanConfig(), headless, headlessNoViewport); } + /// + /// Recover the raw Playwright behind any CloakBrowser wrapper + /// (humanize decorator and/or license-guard proxy). Pass the result to Playwright APIs + /// that reject wrapped handles, e.g. context.NewCDPSessionAsync(Humanize.Unwrap(page)). + /// + public static IPage Unwrap(IPage page) => (IPage)LicenseGuard.Unwrap(page); + + /// Recover the raw Playwright behind any CloakBrowser wrapper. + public static IFrame Unwrap(IFrame frame) => (IFrame)LicenseGuard.Unwrap(frame); + + /// Recover the raw Playwright behind any CloakBrowser wrapper. + public static IBrowserContext Unwrap(IBrowserContext context) => (IBrowserContext)LicenseGuard.Unwrap(context); + + /// Recover the raw Playwright behind any CloakBrowser wrapper. + public static IElementHandle Unwrap(IElementHandle handle) => (IElementHandle)LicenseGuard.Unwrap(handle); + // ----------------------------------------------------------------------- // Internal re-wrap helpers (shared by the wrappers). // ----------------------------------------------------------------------- diff --git a/dotnet/src/CloakBrowser/Wrappers/HumanizedBrowserContext.cs b/dotnet/src/CloakBrowser/Wrappers/HumanizedBrowserContext.cs index 08ba28c..7d6fca8 100644 --- a/dotnet/src/CloakBrowser/Wrappers/HumanizedBrowserContext.cs +++ b/dotnet/src/CloakBrowser/Wrappers/HumanizedBrowserContext.cs @@ -38,4 +38,14 @@ public sealed partial class HumanizedBrowserContext : IBrowserContext public async Task RunAndWaitForPageAsync(System.Func action, BrowserContextRunAndWaitForPageOptions? options = null) => await Humanize.WrapPageAsync(await _inner.RunAndWaitForPageAsync(action, options).ConfigureAwait(false), _cfg).ConfigureAwait(false); + + // NewCDPSessionAsync internally down-casts its IPage/IFrame argument to Playwright's + // concrete Page/Frame (reads .Guid) instead of using the interface, so a humanized (or + // guard-proxied) handle throws NullReferenceException. Unwrap the argument to the raw + // Playwright object before delegating. + public Task NewCDPSessionAsync(IPage page) => + _inner.NewCDPSessionAsync((IPage)LicenseGuard.Unwrap(page)); + + public Task NewCDPSessionAsync(IFrame frame) => + _inner.NewCDPSessionAsync((IFrame)LicenseGuard.Unwrap(frame)); } diff --git a/dotnet/src/CloakBrowser/Wrappers/HumanizedPage.cs b/dotnet/src/CloakBrowser/Wrappers/HumanizedPage.cs index 42bcbc5..6ec7ae3 100644 --- a/dotnet/src/CloakBrowser/Wrappers/HumanizedPage.cs +++ b/dotnet/src/CloakBrowser/Wrappers/HumanizedPage.cs @@ -55,6 +55,10 @@ public sealed partial class HumanizedPage : IPage public IMouse Mouse => _mouse; public IKeyboard Keyboard => _keyboard; + // Re-wrap the owning context so pages/CDP sessions obtained via page.Context stay + // humanized (the generator would otherwise forward the raw Playwright context). + public IBrowserContext Context => Humanize.Context(_inner.Context, _cfg); + // ----------------------------------------------------------------------- // Humanized selector actions // ----------------------------------------------------------------------- diff --git a/dotnet/tests/CloakBrowser.Tests/Wrappers/BrowserContextWrapperTests.cs b/dotnet/tests/CloakBrowser.Tests/Wrappers/BrowserContextWrapperTests.cs index aac4929..1dc19aa 100644 --- a/dotnet/tests/CloakBrowser.Tests/Wrappers/BrowserContextWrapperTests.cs +++ b/dotnet/tests/CloakBrowser.Tests/Wrappers/BrowserContextWrapperTests.cs @@ -1,3 +1,4 @@ +using CloakBrowser; using CloakBrowser.Human; using CloakBrowser.Wrappers; using Microsoft.Playwright; @@ -170,4 +171,106 @@ public class BrowserContextWrapperTests Assert.Same(browser, human.Original); Assert.Same(browser, human.Inner); } + + // ----------------------------------------------------------------------- + // NewCDPSessionAsync: the page/frame argument must reach Playwright unwrapped. + // Playwright down-casts it to its concrete Page/Frame (reads .Guid), which throws + // NullReferenceException on any wrapper. Regression for the .NET 0.5.4 report. + // ----------------------------------------------------------------------- + + [Fact] + public async Task Unwrap_peels_guard_proxy_and_humanize_off_a_page() + { + var raw = MakeFakePage(); + var humanized = await Humanize.PageAsync(raw, new HumanConfig()); + var denialPath = License.MintDenialFile()!; + var guarded = (IPage)LicenseGuard.Wrap(humanized, denialPath); + + Assert.NotSame(raw, guarded); + Assert.Same(raw, LicenseGuard.Unwrap(guarded)); // internal peeler + Assert.Same(raw, Humanize.Unwrap(guarded)); // public escape hatch + } + + [Fact] + public async Task Guarded_context_forwards_unwrapped_page_to_NewCDPSession() + { + var raw = MakeFakePage(); + var humanized = await Humanize.PageAsync(raw, new HumanConfig()); + var denialPath = License.MintDenialFile()!; + var guardedPage = (IPage)LicenseGuard.Wrap(humanized, denialPath); + + var (cdp, _) = Fake.Of(); + var (ctx, ctxRec) = Fake.Of(); + ctxRec.On("NewCDPSessionAsync", _ => Task.FromResult(cdp)); + var guardedCtx = (IBrowserContext)LicenseGuard.Wrap(ctx, denialPath); + + await guardedCtx.NewCDPSessionAsync(guardedPage); + + // The inner Playwright context must receive the RAW page, not a wrapper. + Assert.Same(raw, ctxRec.Last("NewCDPSessionAsync")!.Args[0]); + } + + [Fact] + public async Task Humanized_context_unwraps_page_for_NewCDPSession() + { + var raw = MakeFakePage(); + var humanized = await Humanize.PageAsync(raw, new HumanConfig()); + + var (cdp, _) = Fake.Of(); + var (ctx, ctxRec) = Fake.Of(); + ctxRec.On("NewCDPSessionAsync", _ => Task.FromResult(cdp)); + var humanCtx = Humanize.Context(ctx, new HumanConfig()); + + await humanCtx.NewCDPSessionAsync(humanized); + + Assert.Same(raw, ctxRec.Last("NewCDPSessionAsync")!.Args[0]); + } + + [Fact] + public async Task HumanizedPage_Context_stays_humanized() + { + var (mouse, _) = Fake.Of(); + var (keyboard, _) = Fake.Of(); + var (page, pageRec) = Fake.Of(); + pageRec.On("Mouse", mouse); + pageRec.On("Keyboard", keyboard); + pageRec.On("ViewportSize", new PageViewportSizeResult { Width = 800, Height = 600 }); + var (rawCtx, _) = Fake.Of(); + pageRec.On("Context", rawCtx); + + var humanized = await Humanize.PageAsync(page, new HumanConfig()); + + // page.Context must not leak the raw context (else page.Context.NewPageAsync() + // silently returns an un-humanized page). + Assert.IsType(humanized.Context); + + // ...and it must be the SAME instance across accesses (identity parity with + // Playwright's singleton context; a fresh wrapper each call breaks == / dict keys). + Assert.Same(humanized.Context, humanized.Context); + } + + [Fact] + public void Unwrap_recovers_raw_context_through_guard_and_humanize() + { + var (rawCtx, _) = Fake.Of(); + var humanized = Humanize.Context(rawCtx, new HumanConfig()); + var denialPath = License.MintDenialFile()!; + var guarded = (IBrowserContext)LicenseGuard.Wrap(humanized, denialPath); + + Assert.NotSame(rawCtx, guarded); + Assert.Same(rawCtx, Humanize.Unwrap(guarded)); // peels guard proxy + humanize decorator + } + + [Fact] + public async Task Guarded_page_Context_is_stable_across_accesses() + { + var raw = MakeFakePage(); + var (rawCtx, _) = Fake.Of(); + ((FakeProxy)(object)raw).On("Context", rawCtx); + var humanized = await Humanize.PageAsync(raw, new HumanConfig()); + var denialPath = License.MintDenialFile()!; + var guardedPage = (IPage)LicenseGuard.Wrap(humanized, denialPath); + + Assert.Same(guardedPage.Context, guardedPage.Context); + } }