fix(dotnet): unwrap page/frame args so NewCDPSessionAsync works through wrappers

context.NewCDPSessionAsync(page) threw NullReferenceException for any licensed
.NET user: Playwright down-casts the IPage/IFrame argument to its concrete
Page/Frame to read .Guid, which fails on the CloakBrowser proxy the wrapper
returns. The license-guard proxy (all licensed launches) and the humanize
decorator both produced a handle that fails that cast.

- guard proxy unwraps page/frame/handle arguments before forwarding, keeps
  page.Context guarded, and Wrap() is idempotent
- HumanizedBrowserContext.NewCDPSessionAsync unwraps its argument (covers the
  keyless+humanize path with no guard proxy)
- HumanizedPage.Context re-wraps so page.Context stays humanized
- public Humanize.Unwrap(page/frame) escape hatch
- regression tests build the guard proxy and assert the inner receives raw
This commit is contained in:
CloakHQ
2026-08-07 01:08:36 +02:00
parent c1dd58902a
commit 679122c2b6
5 changed files with 208 additions and 3 deletions
+68 -2
View File
@@ -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<IPage>(p, denialPath),
@@ -178,6 +179,32 @@ internal static class LicenseGuard
};
}
/// <summary>
/// Peel every CloakBrowser wrapper — the license-guard <see cref="DispatchProxy"/> first,
/// then the humanize decorator — off a page/frame/element handle to recover the raw
/// Playwright object. A few Playwright methods (notably
/// <see cref="IBrowserContext.NewCDPSessionAsync(IPage)"/>) 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.
/// </summary>
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>(T target, string denialPath) where T : class
{
var proxy = DispatchProxy.Create<T, LicenseGuardProxy<T>>();
@@ -195,11 +222,22 @@ internal static class LicenseGuard
/// getters + the EventEmitter surface). <c>get_Pages</c> is special-cased so a persistent
/// context's already-open pages are guarded too.
/// </summary>
internal class LicenseGuardProxy<T> : DispatchProxy where T : class
internal interface IGuardedProxy
{
/// <summary>The object this proxy forwards to (see <see cref="LicenseGuard.Unwrap"/>).</summary>
object GuardTarget { get; }
}
internal class LicenseGuardProxy<T> : 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<IBrowserContext, object> _contextGuardCache = new();
object IGuardedProxy.GuardTarget => _target;
internal void Init(T target, string denialPath)
{
_target = target;
@@ -216,6 +254,12 @@ internal class LicenseGuardProxy<T> : DispatchProxy where T : class
// Guard the pages a persistent context hands back (context.Pages[0]).
if (targetMethod.Name == "get_Pages" && value is IReadOnlyList<IPage> 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<T> : DispatchProxy where T : class
/// caller sees the genuine Playwright exception.</summary>
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<T> : DispatchProxy where T : class
}
}
/// <summary>
/// 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).
/// </summary>
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); }
+23 -1
View File
@@ -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()));
}
/// <summary>One humanized wrapper per raw context, so identity stays stable across accesses.</summary>
private static readonly System.Runtime.CompilerServices.ConditionalWeakTable<IBrowserContext, HumanizedBrowserContext> ContextCache = new();
/// <summary>
/// Wrap a raw Playwright <see cref="IBrowser"/> 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);
}
/// <summary>
/// Recover the raw Playwright <see cref="IPage"/> behind any CloakBrowser wrapper
/// (humanize decorator and/or license-guard proxy). Pass the result to Playwright APIs
/// that reject wrapped handles, e.g. <c>context.NewCDPSessionAsync(Humanize.Unwrap(page))</c>.
/// </summary>
public static IPage Unwrap(IPage page) => (IPage)LicenseGuard.Unwrap(page);
/// <summary>Recover the raw Playwright <see cref="IFrame"/> behind any CloakBrowser wrapper.</summary>
public static IFrame Unwrap(IFrame frame) => (IFrame)LicenseGuard.Unwrap(frame);
/// <summary>Recover the raw Playwright <see cref="IBrowserContext"/> behind any CloakBrowser wrapper.</summary>
public static IBrowserContext Unwrap(IBrowserContext context) => (IBrowserContext)LicenseGuard.Unwrap(context);
/// <summary>Recover the raw Playwright <see cref="IElementHandle"/> behind any CloakBrowser wrapper.</summary>
public static IElementHandle Unwrap(IElementHandle handle) => (IElementHandle)LicenseGuard.Unwrap(handle);
// -----------------------------------------------------------------------
// Internal re-wrap helpers (shared by the wrappers).
// -----------------------------------------------------------------------
@@ -38,4 +38,14 @@ public sealed partial class HumanizedBrowserContext : IBrowserContext
public async Task<IPage> RunAndWaitForPageAsync(System.Func<Task> 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<ICDPSession> NewCDPSessionAsync(IPage page) =>
_inner.NewCDPSessionAsync((IPage)LicenseGuard.Unwrap(page));
public Task<ICDPSession> NewCDPSessionAsync(IFrame frame) =>
_inner.NewCDPSessionAsync((IFrame)LicenseGuard.Unwrap(frame));
}
@@ -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
// -----------------------------------------------------------------------
@@ -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<ICDPSession>();
var (ctx, ctxRec) = Fake.Of<IBrowserContext>();
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<ICDPSession>();
var (ctx, ctxRec) = Fake.Of<IBrowserContext>();
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<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 });
var (rawCtx, _) = Fake.Of<IBrowserContext>();
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<HumanizedBrowserContext>(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<IBrowserContext>();
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<IBrowserContext>();
((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);
}
}