[Examples & benchmarks](https://cdpfleet.com/docs/cases) / #45 · 2026-10-14 · Example · Medium · for [testing](https://cdpfleet.com/cross-browser-testing), [ai agents](https://cdpfleet.com/ai-agents)

# Clipboard and file permissions on remote browsers: what's blocked and what works

The clipboard and file APIs on Chromium, Chrome, Firefox and WebKit: which calls are denied by default, which permission names each engine accepts, why \`setFiles\` can't answer \`showOpenFilePicker\`, and the workarounds.

[Chromium](https://cdpfleet.com/docs/browsers/chromium) [Google Chrome](https://cdpfleet.com/docs/browsers/chrome) [Firefox](https://cdpfleet.com/docs/browsers/firefox) [WebKit](https://cdpfleet.com/docs/browsers/webkit)

Run on production on 2026-10-14: ✓ Node.js ✓ Python ✓ Java ✓ C# ✓ Go

## The problem

Web apps copy and paste through `navigator.clipboard`, take files through `<input type="file">` or the newer File System Access picker (`showOpenFilePicker`), and keep data in the origin-private file system. On a remote browser nobody can click "Allow" on a permission prompt or pick a file in a native dialog — so which of these work out of the box, which need a permission granted, and which need a different approach entirely?

## What we used, and why

| What | Why |
| --- | --- |
| `context.route()` on a made-up `https://` origin | A secure context — the clipboard and file APIs require one — served by Playwright, with no network traffic. |
| `navigator.clipboard.writeText` / `readText` | The async clipboard API, each capped at 3 s so an unanswered prompt shows up as a timeout instead of a hang. |
| `context.grantPermissions(["clipboard-read", "clipboard-write"])` | Pre-answers the clipboard prompts — with Chromium's permission names. |
| `page.waitForEvent("filechooser")` + `setFiles()` | Playwright's way to answer a file dialog without a native window. |
| `navigator.storage.getDirectory()` | The origin-private file system: a sandboxed folder per site that needs no picker and no permission. |
| `context.addInitScript()` | Replaces `showOpenFilePicker` before the page loads, so the page's own code receives a file we provide. |

## How it works

1.  For each of four browsers, open the test page in a fresh context and round-trip a string through the clipboard with no permissions.
2.  Click the file input, answer the file chooser with `setFiles()`, and read back what the page received.
3.  Click the button that calls `showOpenFilePicker()` and wait for a `filechooser` event; write and read a file in the origin-private file system.
4.  In a new context, stub `showOpenFilePicker` with an init script and click the button again.
5.  In another context, grant `clipboard-read` and `clipboard-write` and repeat the clipboard round trip.

## The code

The same program in five languages (also [on GitHub](https://github.com/cdpfleet/cdpfleet/tree/main/cases/45-clipboard-and-files), with the raw output). Set these environment variables first:

-   `CDPFLEET_API_KEY` — your API key (dashboard → API keys)
-   `PROXY_URL` — your proxy, e.g. `http://user:pass@proxy.example.com:8000`

 

```node
// npm install playwright@1.60.0
// env: CDPFLEET_API_KEY, PROXY_URL
import { chromium, firefox, webkit } from 'playwright';

const KEY = process.env.CDPFLEET_API_KEY;
const TYPES = { chromium, firefox, webkit };
const SETUPS = [
  { label: 'Chromium, headless', engine: 'chromium', type: 'chromium', headless: true },
  { label: 'Chrome, headful', engine: 'chrome', type: 'chromium', headless: false },
  { label: 'Firefox, headful', engine: 'firefox', type: 'firefox', headless: false },
  { label: 'WebKit, headful', engine: 'webkit', type: 'webkit', headless: false },
];

// A made-up https origin served by Playwright's router: a secure context (the clipboard and
// file APIs need one) with no network traffic. The page has a file input and a button that
// opens the File System Access picker; both report what they received in data attributes.
const PAGE = 'https://app.example.test/';
const HTML = `<html><body>
<input type="file" id="f" onchange="document.body.dataset.input = this.files[0].name + ':' + this.files[0].size">
<button id="b" onclick="showOpenFilePicker().then(async ([h]) => { const f = await h.getFile(); document.body.dataset.picked = f.name + ':' + f.size; }).catch((e) => { document.body.dataset.picked = 'error: ' + e.name; })">open</button>
</body></html>`;
const serve = (context) => context.route(`${PAGE}**`, (route) => route.fulfill({ contentType: 'text/html', body: HTML }));

// Clipboard round trip inside the page; a permission prompt nobody answers would hang, so 3 s cap.
const CLIPBOARD = `(async () => {
  const cap = (p) => Promise.race([p, new Promise((_, rej) => setTimeout(() => rej(new Error('timed out (prompt?)')), 3000))]);
  const out = {};
  try { await cap(navigator.clipboard.writeText('copied-' + Date.now())); out.write = 'ok'; } catch (e) { out.write = e.name === 'Error' ? e.message : e.name; }
  try { const t = await cap(navigator.clipboard.readText()); out.read = t.startsWith('copied-') ? 'ok' : 'empty'; } catch (e) { out.read = e.name === 'Error' ? e.message : e.name; }
  return out;
})()`;

// The origin-private file system: a sandboxed folder per site, no picker, no permission.
const OPFS = `(async () => {
  try {
    const root = await navigator.storage.getDirectory();
    const fh = await root.getFileHandle('note.txt', { create: true });
    const w = await fh.createWritable(); await w.write('hello opfs'); await w.close();
    return (await (await fh.getFile()).text()) === 'hello opfs' ? 'ok' : 'mismatch';
  } catch (e) { return 'no (' + e.name + ')'; }
})()`;

// Stands in for the native picker: returns a handle to a file the script provides.
const PICKER_STUB = `window.showOpenFilePicker = async () => [{
  kind: 'file', name: 'report.csv', getFile: async () => new File(['id,total\\n1,42\\n'], 'report.csv', { type: 'text/csv' }),
}];`;

// 503 means the fleet is momentarily busy: wait Retry-After and try again.
async function launch(engine, body) {
  for (let attempt = 1; ; attempt++) {
    const res = await fetch(`https://starter.cdpfleet.com/${engine}/session`, {
      method: 'POST',
      headers: { 'x-api-key': KEY, 'content-type': 'application/json' },
      body: JSON.stringify(body),
    });
    if (res.ok) return res.json();
    if (res.status !== 503 || attempt === 5) throw new Error(`launch ${engine} ${res.status} ${await res.text()}`);
    await new Promise((r) => setTimeout(r, (Number(res.headers.get('retry-after')) || 5) * 1000));
  }
}

async function freshPage(browser, initScript) {
  const context = await browser.newContext();
  await serve(context);
  if (initScript) await context.addInitScript(initScript);
  const page = await context.newPage();
  await page.goto(PAGE);
  return { context, page };
}

async function one(s) {
  const { wsUrl } = await launch(s.engine, { proxy: process.env.PROXY_URL, headless: s.headless });
  const browser = await TYPES[s.type].connect(wsUrl, { headers: { 'x-api-key': KEY } });
  const out = { label: s.label };
  try {
    // 1. Clipboard with no permissions granted.
    let { context, page } = await freshPage(browser);
    const def = await page.evaluate(CLIPBOARD);
    out.clipboard_write_default = def.write;
    out.clipboard_read_default = def.read;

    // 2. Files in the same page: <input type=file>, the File System Access picker, the private file system.
    const chooser = page.waitForEvent('filechooser', { timeout: 3000 }).catch(() => null);
    await page.click('#f');
    const fc = await chooser;
    if (fc) await fc.setFiles({ name: 'report.csv', mimeType: 'text/csv', buffer: Buffer.from('id,total\n1,42\n') });
    out.input_set_files = fc ? await page.evaluate(() => document.body.dataset.input || 'nothing') : 'no filechooser event';
    out.show_open_file_picker = await page.evaluate(() => typeof window.showOpenFilePicker === 'function');
    if (out.show_open_file_picker) {
      const ev = page.waitForEvent('filechooser', { timeout: 3000 }).then(() => true, () => false);
      await page.click('#b');
      out.picker_filechooser_event = await ev;
    } else {
      out.picker_filechooser_event = false;
    }
    out.private_file_system = await page.evaluate(OPFS);
    await context.close();

    // 3. The picker stubbed with an init script: the page's own code receives our file.
    ({ context, page } = await freshPage(browser, PICKER_STUB));
    await page.click('#b');
    await page.waitForFunction(() => document.body.dataset.picked, null, { timeout: 5000 });
    out.picker_stubbed = await page.evaluate(() => document.body.dataset.picked);
    await context.close();

    // 4. Clipboard with Chromium's permission names granted. Some engines reject the names —
    // at grantPermissions or when the next page opens.
    context = await browser.newContext();
    await serve(context);
    try {
      await context.grantPermissions(['clipboard-read', 'clipboard-write'], { origin: PAGE });
      page = await context.newPage();
      await page.goto(PAGE);
      out.grant_clipboard = 'accepted';
      const granted = await page.evaluate(CLIPBOARD);
      out.clipboard_write_granted = granted.write;
      out.clipboard_read_granted = granted.read;
    } catch (err) {
      out.grant_clipboard = err.message.split('\n')[0].replace(/^.*?: /, '').slice(0, 80);
    }
  } finally {
    await browser.close();
  }
  return out;
}

const results = [];
for (const s of SETUPS) results.push(await one(s));
console.log(JSON.stringify({ results }, null, 2));
```

```python
# pip install playwright==1.60.0 requests
# env: CDPFLEET_API_KEY, PROXY_URL
import json
import os
import time

import requests
from playwright.sync_api import TimeoutError as PlaywrightTimeoutError, sync_playwright

KEY = os.environ["CDPFLEET_API_KEY"]
SETUPS = [
    {"label": "Chromium, headless", "engine": "chromium", "family": "chromium", "headless": True},
    {"label": "Chrome, headful", "engine": "chrome", "family": "chromium", "headless": False},
    {"label": "Firefox, headful", "engine": "firefox", "family": "firefox", "headless": False},
    {"label": "WebKit, headful", "engine": "webkit", "family": "webkit", "headless": False},
]

# A made-up https origin served by Playwright's router: a secure context (the clipboard and
# file APIs need one) with no network traffic. The page has a file input and a button that
# opens the File System Access picker; both report what they received in data attributes.
PAGE = "https://app.example.test/"
HTML = """<html><body>
<input type="file" id="f" onchange="document.body.dataset.input = this.files[0].name + ':' + this.files[0].size">
<button id="b" onclick="showOpenFilePicker().then(async ([h]) => { const f = await h.getFile(); document.body.dataset.picked = f.name + ':' + f.size; }).catch((e) => { document.body.dataset.picked = 'error: ' + e.name; })">open</button>
</body></html>"""

# Clipboard round trip inside the page; a permission prompt nobody answers would hang, so 3 s cap.
CLIPBOARD = """(async () => {
  const cap = (p) => Promise.race([p, new Promise((_, rej) => setTimeout(() => rej(new Error('timed out (prompt?)')), 3000))]);
  const out = {};
  try { await cap(navigator.clipboard.writeText('copied-' + Date.now())); out.write = 'ok'; } catch (e) { out.write = e.name === 'Error' ? e.message : e.name; }
  try { const t = await cap(navigator.clipboard.readText()); out.read = t.startsWith('copied-') ? 'ok' : 'empty'; } catch (e) { out.read = e.name === 'Error' ? e.message : e.name; }
  return out;
})()"""

# The origin-private file system: a sandboxed folder per site, no picker, no permission.
OPFS = """(async () => {
  try {
    const root = await navigator.storage.getDirectory();
    const fh = await root.getFileHandle('note.txt', { create: true });
    const w = await fh.createWritable(); await w.write('hello opfs'); await w.close();
    return (await (await fh.getFile()).text()) === 'hello opfs' ? 'ok' : 'mismatch';
  } catch (e) { return 'no (' + e.name + ')'; }
})()"""

# Stands in for the native picker: returns a handle to a file the script provides.
PICKER_STUB = """window.showOpenFilePicker = async () => [{
  kind: 'file', name: 'report.csv', getFile: async () => new File(['id,total\\n1,42\\n'], 'report.csv', { type: 'text/csv' }),
}];"""

CSV = {"name": "report.csv", "mimeType": "text/csv", "buffer": b"id,total\n1,42\n"}

def launch(engine, body):
    # 503 means the fleet is momentarily busy: wait Retry-After and try again.
    for attempt in range(1, 6):
        res = requests.post(f"https://starter.cdpfleet.com/{engine}/session", headers={"x-api-key": KEY}, json=body, timeout=60)
        if res.ok:
            return res.json()
        if res.status_code != 503 or attempt == 5:
            raise RuntimeError(f"launch {engine} {res.status_code} {res.text}")
        time.sleep(int(res.headers.get("retry-after", 5)))

def serve(context):
    context.route(f"{PAGE}**", lambda route: route.fulfill(content_type="text/html", body=HTML))

def fresh_page(browser, init_script=None):
    context = browser.new_context()
    serve(context)
    if init_script:
        context.add_init_script(init_script)
    page = context.new_page()
    page.goto(PAGE)
    return context, page

def chooser_after_click(page, selector):
    try:
        with page.expect_file_chooser(timeout=3000) as info:
            page.click(selector)
        return info.value
    except PlaywrightTimeoutError:
        return None

def one(p, s):
    session = launch(s["engine"], {"proxy": os.environ["PROXY_URL"], "headless": s["headless"]})
    browser = getattr(p, s["family"]).connect(session["wsUrl"], headers={"x-api-key": KEY})
    out = {"label": s["label"]}
    try:
        # 1. Clipboard with no permissions granted.
        context, page = fresh_page(browser)
        d = page.evaluate(CLIPBOARD)
        out["clipboard_write_default"], out["clipboard_read_default"] = d["write"], d["read"]

        # 2. Files in the same page: <input type=file>, the File System Access picker, the private file system.
        fc = chooser_after_click(page, "#f")
        if fc:
            fc.set_files(CSV)
        out["input_set_files"] = page.evaluate("() => document.body.dataset.input || 'nothing'") if fc else "no filechooser event"
        out["show_open_file_picker"] = page.evaluate("() => typeof window.showOpenFilePicker === 'function'")
        out["picker_filechooser_event"] = chooser_after_click(page, "#b") is not None if out["show_open_file_picker"] else False
        out["private_file_system"] = page.evaluate(OPFS)
        context.close()

        # 3. The picker stubbed with an init script: the page's own code receives our file.
        context, page = fresh_page(browser, PICKER_STUB)
        page.click("#b")
        page.wait_for_function("() => document.body.dataset.picked", timeout=5000)
        out["picker_stubbed"] = page.evaluate("() => document.body.dataset.picked")
        context.close()

        # 4. Clipboard with Chromium's permission names granted. Some engines reject the names —
        # at grant_permissions or when the next page opens.
        context = browser.new_context()
        serve(context)
        try:
            context.grant_permissions(["clipboard-read", "clipboard-write"], origin=PAGE)
            page = context.new_page()
            page.goto(PAGE)
            out["grant_clipboard"] = "accepted"
            g = page.evaluate(CLIPBOARD)
            out["clipboard_write_granted"], out["clipboard_read_granted"] = g["write"], g["read"]
        except Exception as err:
            msg = str(err).split("\n")[0]
            out["grant_clipboard"] = msg.split(": ", 1)[1][:80] if ": " in msg else msg[:80]
    finally:
        browser.close()
    return out

with sync_playwright() as p:
    results = [one(p, s) for s in SETUPS]
print(json.dumps({"results": results}, indent=2))
```

```java
// Maven: com.microsoft.playwright:playwright:1.60.0, com.google.code.gson:gson:2.11.0
// Run with PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1.
// env: CDPFLEET_API_KEY, PROXY_URL
import com.google.gson.*;
import com.microsoft.playwright.*;
import com.microsoft.playwright.options.FilePayload;
import java.net.URI;
import java.net.http.*;
import java.nio.charset.StandardCharsets;
import java.util.*;
import java.util.regex.*;

public class Main {
  static final String KEY = System.getenv("CDPFLEET_API_KEY");
  static final Gson GSON = new GsonBuilder().setPrettyPrinting().disableHtmlEscaping().create();

  record Setup(String label, String engine, String family, boolean headless) {}
  static final Setup[] SETUPS = {
    new Setup("Chromium, headless", "chromium", "chromium", true),
    new Setup("Chrome, headful", "chrome", "chromium", false),
    new Setup("Firefox, headful", "firefox", "firefox", false),
    new Setup("WebKit, headful", "webkit", "webkit", false),
  };

  // A made-up https origin served by Playwright's router: a secure context (the clipboard and
  // file APIs need one) with no network traffic. The page has a file input and a button that
  // opens the File System Access picker; both report what they received in data attributes.
  static final String PAGE = "https://app.example.test/";
  static final String HTML = """
      <html><body>
      <input type="file" id="f" onchange="document.body.dataset.input = this.files[0].name + ':' + this.files[0].size">
      <button id="b" onclick="showOpenFilePicker().then(async ([h]) => { const f = await h.getFile(); document.body.dataset.picked = f.name + ':' + f.size; }).catch((e) => { document.body.dataset.picked = 'error: ' + e.name; })">open</button>
      </body></html>
      """;

  // Clipboard round trip inside the page; a permission prompt nobody answers would hang, so 3 s cap.
  static final String CLIPBOARD = """
      (async () => {
        const cap = (p) => Promise.race([p, new Promise((_, rej) => setTimeout(() => rej(new Error('timed out (prompt?)')), 3000))]);
        const out = {};
        try { await cap(navigator.clipboard.writeText('copied-' + Date.now())); out.write = 'ok'; } catch (e) { out.write = e.name === 'Error' ? e.message : e.name; }
        try { const t = await cap(navigator.clipboard.readText()); out.read = t.startsWith('copied-') ? 'ok' : 'empty'; } catch (e) { out.read = e.name === 'Error' ? e.message : e.name; }
        return out;
      })()""";

  // The origin-private file system: a sandboxed folder per site, no picker, no permission.
  static final String OPFS = """
      (async () => {
        try {
          const root = await navigator.storage.getDirectory();
          const fh = await root.getFileHandle('note.txt', { create: true });
          const w = await fh.createWritable(); await w.write('hello opfs'); await w.close();
          return (await (await fh.getFile()).text()) === 'hello opfs' ? 'ok' : 'mismatch';
        } catch (e) { return 'no (' + e.name + ')'; }
      })()""";

  // Stands in for the native picker: returns a handle to a file the script provides.
  static final String PICKER_STUB = """
      window.showOpenFilePicker = async () => [{
        kind: 'file', name: 'report.csv', getFile: async () => new File(['id,total\\n1,42\\n'], 'report.csv', { type: 'text/csv' }),
      }];""";

  // 503 means the fleet is momentarily busy: wait Retry-After and try again.
  static JsonObject launch(String engine, JsonObject body) throws Exception {
    for (int attempt = 1; ; attempt++) {
      HttpResponse<String> res = HttpClient.newHttpClient().send(HttpRequest.newBuilder(URI.create("https://starter.cdpfleet.com/" + engine + "/session"))
          .header("x-api-key", KEY).header("content-type", "application/json")
          .POST(HttpRequest.BodyPublishers.ofString(body.toString())).build(), HttpResponse.BodyHandlers.ofString());
      if (res.statusCode() == 200) return JsonParser.parseString(res.body()).getAsJsonObject();
      if (res.statusCode() != 503 || attempt == 5) throw new RuntimeException("launch " + engine + " " + res.statusCode() + " " + res.body());
      Thread.sleep(Long.parseLong(res.headers().firstValue("retry-after").orElse("5")) * 1000);
    }
  }

  static void serve(BrowserContext context) {
    context.route(PAGE + "**", route -> route.fulfill(new Route.FulfillOptions().setContentType("text/html").setBody(HTML)));
  }

  static Page freshPage(Browser browser, String initScript) {
    BrowserContext context = browser.newContext();
    serve(context);
    if (initScript != null) context.addInitScript(initScript);
    Page page = context.newPage();
    page.navigate(PAGE);
    return page;
  }

  static FileChooser chooserAfterClick(Page page, String selector) {
    try {
      return page.waitForFileChooser(new Page.WaitForFileChooserOptions().setTimeout(3000), () -> page.click(selector));
    } catch (TimeoutError e) {
      return null;
    }
  }

  @SuppressWarnings("unchecked")
  static JsonObject one(Playwright pw, Setup s) throws Exception {
    JsonObject body = new JsonObject();
    body.addProperty("proxy", System.getenv("PROXY_URL"));
    body.addProperty("headless", s.headless());
    JsonObject session = launch(s.engine(), body);
    BrowserType type = switch (s.family()) { case "firefox" -> pw.firefox(); case "webkit" -> pw.webkit(); default -> pw.chromium(); };
    Browser browser = type.connect(session.get("wsUrl").getAsString(), new BrowserType.ConnectOptions().setHeaders(Map.of("x-api-key", KEY)));
    JsonObject out = new JsonObject();
    out.addProperty("label", s.label());
    try {
      // 1. Clipboard with no permissions granted.
      Page page = freshPage(browser, null);
      Map<String, Object> d = (Map<String, Object>) page.evaluate(CLIPBOARD);
      out.addProperty("clipboard_write_default", (String) d.get("write"));
      out.addProperty("clipboard_read_default", (String) d.get("read"));

      // 2. Files in the same page: <input type=file>, the File System Access picker, the private file system.
      FileChooser fc = chooserAfterClick(page, "#f");
      if (fc != null) fc.setFiles(new FilePayload("report.csv", "text/csv", "id,total\n1,42\n".getBytes(StandardCharsets.UTF_8)));
      out.addProperty("input_set_files", fc != null ? (String) page.evaluate("() => document.body.dataset.input || 'nothing'") : "no filechooser event");
      boolean hasPicker = (Boolean) page.evaluate("() => typeof window.showOpenFilePicker === 'function'");
      out.addProperty("show_open_file_picker", hasPicker);
      out.addProperty("picker_filechooser_event", hasPicker && chooserAfterClick(page, "#b") != null);
      out.addProperty("private_file_system", (String) page.evaluate(OPFS));
      page.context().close();

      // 3. The picker stubbed with an init script: the page's own code receives our file.
      page = freshPage(browser, PICKER_STUB);
      page.click("#b");
      page.waitForFunction("() => document.body.dataset.picked", null, new Page.WaitForFunctionOptions().setTimeout(5000));
      out.addProperty("picker_stubbed", (String) page.evaluate("() => document.body.dataset.picked"));
      page.context().close();

      // 4. Clipboard with Chromium's permission names granted. Some engines reject the names —
      // at grantPermissions or when the next page opens.
      BrowserContext context = browser.newContext();
      serve(context);
      try {
        context.grantPermissions(List.of("clipboard-read", "clipboard-write"), new BrowserContext.GrantPermissionsOptions().setOrigin(PAGE));
        page = context.newPage();
        page.navigate(PAGE);
        out.addProperty("grant_clipboard", "accepted");
        Map<String, Object> g = (Map<String, Object>) page.evaluate(CLIPBOARD);
        out.addProperty("clipboard_write_granted", (String) g.get("write"));
        out.addProperty("clipboard_read_granted", (String) g.get("read"));
      } catch (PlaywrightException err) {
        // Java wraps the message: Error { message='Unknown permission: …' … }; drop any "api.call: " prefix.
        Matcher m = Pattern.compile("message='([^'\\n]*)").matcher(err.getMessage());
        String msg = (m.find() ? m.group(1) : err.getMessage().split("\n")[0]).replaceFirst("^[A-Za-z]+\\.[A-Za-z]+: ", "");
        out.addProperty("grant_clipboard", msg.substring(0, Math.min(80, msg.length())));
      }
    } finally {
      browser.close();
    }
    return out;
  }

  public static void main(String[] args) throws Exception {
    JsonArray results = new JsonArray();
    try (Playwright pw = Playwright.create()) {
      for (Setup s : SETUPS) results.add(one(pw, s));
    }
    JsonObject result = new JsonObject();
    result.add("results", results);
    System.out.println(GSON.toJson(result));
  }
}
```

```csharp
// dotnet add package Microsoft.Playwright --version 1.60.0
// env: CDPFLEET_API_KEY, PROXY_URL
using System.Net.Http.Json;
using System.Text;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.RegularExpressions;
using Microsoft.Playwright;

var key = Environment.GetEnvironmentVariable("CDPFLEET_API_KEY")!;
var setups = new (string Label, string Engine, string Family, bool Headless)[]
{
    ("Chromium, headless", "chromium", "chromium", true),
    ("Chrome, headful", "chrome", "chromium", false),
    ("Firefox, headful", "firefox", "firefox", false),
    ("WebKit, headful", "webkit", "webkit", false),
};

// A made-up https origin served by Playwright's router: a secure context (the clipboard and
// file APIs need one) with no network traffic. The page has a file input and a button that
// opens the File System Access picker; both report what they received in data attributes.
const string PageUrl = "https://app.example.test/";
const string Html = """
<html><body>
<input type="file" id="f" onchange="document.body.dataset.input = this.files[0].name + ':' + this.files[0].size">
<button id="b" onclick="showOpenFilePicker().then(async ([h]) => { const f = await h.getFile(); document.body.dataset.picked = f.name + ':' + f.size; }).catch((e) => { document.body.dataset.picked = 'error: ' + e.name; })">open</button>
</body></html>
""";

// Clipboard round trip inside the page; a permission prompt nobody answers would hang, so 3 s cap.
const string Clipboard = """
(async () => {
  const cap = (p) => Promise.race([p, new Promise((_, rej) => setTimeout(() => rej(new Error('timed out (prompt?)')), 3000))]);
  const out = {};
  try { await cap(navigator.clipboard.writeText('copied-' + Date.now())); out.write = 'ok'; } catch (e) { out.write = e.name === 'Error' ? e.message : e.name; }
  try { const t = await cap(navigator.clipboard.readText()); out.read = t.startsWith('copied-') ? 'ok' : 'empty'; } catch (e) { out.read = e.name === 'Error' ? e.message : e.name; }
  return out;
})()
""";

// The origin-private file system: a sandboxed folder per site, no picker, no permission.
const string Opfs = """
(async () => {
  try {
    const root = await navigator.storage.getDirectory();
    const fh = await root.getFileHandle('note.txt', { create: true });
    const w = await fh.createWritable(); await w.write('hello opfs'); await w.close();
    return (await (await fh.getFile()).text()) === 'hello opfs' ? 'ok' : 'mismatch';
  } catch (e) { return 'no (' + e.name + ')'; }
})()
""";

// Stands in for the native picker: returns a handle to a file the script provides.
const string PickerStub = """
window.showOpenFilePicker = async () => [{
  kind: 'file', name: 'report.csv', getFile: async () => new File(['id,total\n1,42\n'], 'report.csv', { type: 'text/csv' }),
}];
""";

using var http = new HttpClient();
http.DefaultRequestHeaders.Add("x-api-key", key);
using var playwright = await Playwright.CreateAsync();

// 503 means the fleet is momentarily busy: wait Retry-After and try again.
async Task<JsonElement> Launch(string engine, JsonObject body)
{
    for (var attempt = 1; ; attempt++)
    {
        var res = await http.PostAsync($"https://starter.cdpfleet.com/{engine}/session",
            new StringContent(body.ToJsonString(), Encoding.UTF8, "application/json"));
        if (res.IsSuccessStatusCode) return await res.Content.ReadFromJsonAsync<JsonElement>();
        if ((int)res.StatusCode != 503 || attempt == 5) throw new Exception($"launch {engine} {(int)res.StatusCode} {await res.Content.ReadAsStringAsync()}");
        await Task.Delay((int)(res.Headers.RetryAfter?.Delta?.TotalMilliseconds ?? 5000));
    }
}

Task Serve(IBrowserContext context) => context.RouteAsync(PageUrl + "**", route => route.FulfillAsync(new() { ContentType = "text/html", Body = Html }));

async Task<IPage> FreshPage(IBrowser browser, string? initScript = null)
{
    var context = await browser.NewContextAsync();
    await Serve(context);
    if (initScript != null) await context.AddInitScriptAsync(initScript);
    var page = await context.NewPageAsync();
    await page.GotoAsync(PageUrl);
    return page;
}

async Task<IFileChooser?> ChooserAfterClick(IPage page, string selector)
{
    try { return await page.RunAndWaitForFileChooserAsync(() => page.ClickAsync(selector), new() { Timeout = 3000 }); }
    catch (TimeoutException) { return null; }
}

async Task<JsonObject> One(string label, string engine, string family, bool headless)
{
    var session = await Launch(engine, new JsonObject { ["proxy"] = Environment.GetEnvironmentVariable("PROXY_URL"), ["headless"] = headless });
    var type = family switch { "firefox" => playwright.Firefox, "webkit" => playwright.Webkit, _ => playwright.Chromium };
    var browser = await type.ConnectAsync(session.GetProperty("wsUrl").GetString()!, new() { Headers = new Dictionary<string, string> { ["x-api-key"] = key } });
    var outp = new JsonObject { ["label"] = label };
    try
    {
        // 1. Clipboard with no permissions granted.
        var page = await FreshPage(browser);
        var d = await page.EvaluateAsync<JsonElement>(Clipboard);
        outp["clipboard_write_default"] = d.GetProperty("write").GetString();
        outp["clipboard_read_default"] = d.GetProperty("read").GetString();

        // 2. Files in the same page: <input type=file>, the File System Access picker, the private file system.
        var fc = await ChooserAfterClick(page, "#f");
        if (fc != null) await fc.SetFilesAsync(new FilePayload { Name = "report.csv", MimeType = "text/csv", Buffer = Encoding.UTF8.GetBytes("id,total\n1,42\n") });
        outp["input_set_files"] = fc != null ? await page.EvaluateAsync<string>("() => document.body.dataset.input || 'nothing'") : "no filechooser event";
        var hasPicker = await page.EvaluateAsync<bool>("() => typeof window.showOpenFilePicker === 'function'");
        outp["show_open_file_picker"] = hasPicker;
        outp["picker_filechooser_event"] = hasPicker && await ChooserAfterClick(page, "#b") != null;
        outp["private_file_system"] = await page.EvaluateAsync<string>(Opfs);
        await page.Context.CloseAsync();

        // 3. The picker stubbed with an init script: the page's own code receives our file.
        page = await FreshPage(browser, PickerStub);
        await page.ClickAsync("#b");
        await page.WaitForFunctionAsync("() => document.body.dataset.picked", null, new() { Timeout = 5000 });
        outp["picker_stubbed"] = await page.EvaluateAsync<string>("() => document.body.dataset.picked");
        await page.Context.CloseAsync();

        // 4. Clipboard with Chromium's permission names granted. Some engines reject the names —
        // at GrantPermissions or when the next page opens.
        var context = await browser.NewContextAsync();
        await Serve(context);
        try
        {
            await context.GrantPermissionsAsync(new[] { "clipboard-read", "clipboard-write" }, new() { Origin = PageUrl });
            page = await context.NewPageAsync();
            await page.GotoAsync(PageUrl);
            outp["grant_clipboard"] = "accepted";
            var g = await page.EvaluateAsync<JsonElement>(Clipboard);
            outp["clipboard_write_granted"] = g.GetProperty("write").GetString();
            outp["clipboard_read_granted"] = g.GetProperty("read").GetString();
        }
        catch (PlaywrightException err)
        {
            var msg = Regex.Replace(err.Message.Split('\n')[0], @"^[A-Za-z]+\.[A-Za-z]+: ", "");
            outp["grant_clipboard"] = msg[..Math.Min(80, msg.Length)];
        }
    }
    finally
    {
        await browser.CloseAsync();
    }
    return outp;
}

var results = new JsonArray();
foreach (var s in setups) results.Add(await One(s.Label, s.Engine, s.Family, s.Headless));
Console.WriteLine(new JsonObject { ["results"] = results }.ToJsonString(new JsonSerializerOptions { WriteIndented = true, Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping }));
```

```go
// go get github.com/playwright-community/playwright-go@v0.6000.0
// Driver: build playwright-core 1.60.0 from npm and set PLAYWRIGHT_DRIVER_PATH (see /docs/quickstart).
// env: CDPFLEET_API_KEY, PROXY_URL
package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"io"
	"log"
	"net/http"
	"os"
	"regexp"
	"strconv"
	"strings"
	"time"

	"github.com/playwright-community/playwright-go"
)

var key = os.Getenv("CDPFLEET_API_KEY")

type setup struct {
	label, engine, family string
	headless              bool
}

var setups = []setup{
	{"Chromium, headless", "chromium", "chromium", true},
	{"Chrome, headful", "chrome", "chromium", false},
	{"Firefox, headful", "firefox", "firefox", false},
	{"WebKit, headful", "webkit", "webkit", false},
}

// A made-up https origin served by Playwright's router: a secure context (the clipboard and
// file APIs need one) with no network traffic. The page has a file input and a button that
// opens the File System Access picker; both report what they received in data attributes.
const pageURL = "https://app.example.test/"
const html = `<html><body>
<input type="file" id="f" onchange="document.body.dataset.input = this.files[0].name + ':' + this.files[0].size">
<button id="b" onclick="showOpenFilePicker().then(async ([h]) => { const f = await h.getFile(); document.body.dataset.picked = f.name + ':' + f.size; }).catch((e) => { document.body.dataset.picked = 'error: ' + e.name; })">open</button>
</body></html>`

// Clipboard round trip inside the page; a permission prompt nobody answers would hang, so 3 s cap.
const clipboard = `(async () => {
  const cap = (p) => Promise.race([p, new Promise((_, rej) => setTimeout(() => rej(new Error('timed out (prompt?)')), 3000))]);
  const out = {};
  try { await cap(navigator.clipboard.writeText('copied-' + Date.now())); out.write = 'ok'; } catch (e) { out.write = e.name === 'Error' ? e.message : e.name; }
  try { const t = await cap(navigator.clipboard.readText()); out.read = t.startsWith('copied-') ? 'ok' : 'empty'; } catch (e) { out.read = e.name === 'Error' ? e.message : e.name; }
  return out;
})()`

// The origin-private file system: a sandboxed folder per site, no picker, no permission.
const opfs = `(async () => {
  try {
    const root = await navigator.storage.getDirectory();
    const fh = await root.getFileHandle('note.txt', { create: true });
    const w = await fh.createWritable(); await w.write('hello opfs'); await w.close();
    return (await (await fh.getFile()).text()) === 'hello opfs' ? 'ok' : 'mismatch';
  } catch (e) { return 'no (' + e.name + ')'; }
})()`

// Stands in for the native picker: returns a handle to a file the script provides.
const pickerStub = `window.showOpenFilePicker = async () => [{
  kind: 'file', name: 'report.csv', getFile: async () => new File(['id,total\n1,42\n'], 'report.csv', { type: 'text/csv' }),
}];`

// Field order matches the other languages' output.
type result struct {
	Label                  string `json:"label"`
	ClipboardWriteDefault  string `json:"clipboard_write_default"`
	ClipboardReadDefault   string `json:"clipboard_read_default"`
	InputSetFiles          string `json:"input_set_files"`
	ShowOpenFilePicker     bool   `json:"show_open_file_picker"`
	PickerFilechooserEvent bool   `json:"picker_filechooser_event"`
	PrivateFileSystem      string `json:"private_file_system"`
	PickerStubbed          string `json:"picker_stubbed"`
	GrantClipboard         string `json:"grant_clipboard"`
	ClipboardWriteGranted  string `json:"clipboard_write_granted,omitempty"`
	ClipboardReadGranted   string `json:"clipboard_read_granted,omitempty"`
}

// 503 means the fleet is momentarily busy: wait Retry-After and try again.
func launch(engine string, opts map[string]any) (map[string]any, error) {
	body, _ := json.Marshal(opts)
	for attempt := 1; ; attempt++ {
		req, _ := http.NewRequest("POST", "https://starter.cdpfleet.com/"+engine+"/session", bytes.NewReader(body))
		req.Header.Set("x-api-key", key)
		req.Header.Set("content-type", "application/json")
		res, err := http.DefaultClient.Do(req)
		if err != nil {
			return nil, err
		}
		b, _ := io.ReadAll(res.Body)
		res.Body.Close()
		if res.StatusCode == 200 {
			var session map[string]any
			return session, json.Unmarshal(b, &session)
		}
		if res.StatusCode != 503 || attempt == 5 {
			return nil, fmt.Errorf("launch %s %d %s", engine, res.StatusCode, strings.TrimSpace(string(b)))
		}
		wait, err := strconv.Atoi(res.Header.Get("retry-after"))
		if err != nil {
			wait = 5
		}
		time.Sleep(time.Duration(wait) * time.Second)
	}
}

func serve(context playwright.BrowserContext) error {
	return context.Route(pageURL+"**", func(route playwright.Route) {
		route.Fulfill(playwright.RouteFulfillOptions{ContentType: playwright.String("text/html"), Body: html})
	})
}

func freshPage(browser playwright.Browser, initScript string) (playwright.Page, error) {
	context, err := browser.NewContext()
	if err != nil {
		return nil, err
	}
	if err := serve(context); err != nil {
		return nil, err
	}
	if initScript != "" {
		if err := context.AddInitScript(playwright.Script{Content: playwright.String(initScript)}); err != nil {
			return nil, err
		}
	}
	page, err := context.NewPage()
	if err != nil {
		return nil, err
	}
	_, err = page.Goto(pageURL)
	return page, err
}

func chooserAfterClick(page playwright.Page, selector string) playwright.FileChooser {
	fc, err := page.ExpectFileChooser(func() error { return page.Click(selector) }, playwright.PageExpectFileChooserOptions{Timeout: playwright.Float(3000)})
	if err != nil {
		return nil
	}
	return fc
}

func clip(page playwright.Page) (string, string, error) {
	v, err := page.Evaluate(clipboard)
	if err != nil {
		return "", "", err
	}
	m := v.(map[string]any)
	return m["write"].(string), m["read"].(string), nil
}

func evalString(page playwright.Page, expr string) (string, error) {
	v, err := page.Evaluate(expr)
	if err != nil {
		return "", err
	}
	return fmt.Sprint(v), nil
}

var apiPrefix = regexp.MustCompile(`^[A-Za-z]+\.[A-Za-z]+: `)

func one(pw *playwright.Playwright, s setup) (result, error) {
	out := result{Label: s.label}
	session, err := launch(s.engine, map[string]any{"proxy": os.Getenv("PROXY_URL"), "headless": s.headless})
	if err != nil {
		return out, err
	}
	browserType := pw.Chromium
	switch s.family {
	case "firefox":
		browserType = pw.Firefox
	case "webkit":
		browserType = pw.WebKit
	}
	browser, err := browserType.Connect(session["wsUrl"].(string), playwright.BrowserTypeConnectOptions{Headers: map[string]string{"x-api-key": key}})
	if err != nil {
		return out, err
	}
	defer browser.Close()

	// 1. Clipboard with no permissions granted.
	page, err := freshPage(browser, "")
	if err != nil {
		return out, err
	}
	if out.ClipboardWriteDefault, out.ClipboardReadDefault, err = clip(page); err != nil {
		return out, err
	}

	// 2. Files in the same page: <input type=file>, the File System Access picker, the private file system.
	out.InputSetFiles = "no filechooser event"
	if fc := chooserAfterClick(page, "#f"); fc != nil {
		if err := fc.SetFiles([]playwright.InputFile{{Name: "report.csv", MimeType: "text/csv", Buffer: []byte("id,total\n1,42\n")}}); err != nil {
			return out, err
		}
		if out.InputSetFiles, err = evalString(page, "() => document.body.dataset.input || 'nothing'"); err != nil {
			return out, err
		}
	}
	has, err := page.Evaluate("() => typeof window.showOpenFilePicker === 'function'")
	if err != nil {
		return out, err
	}
	out.ShowOpenFilePicker = has == true
	out.PickerFilechooserEvent = out.ShowOpenFilePicker && chooserAfterClick(page, "#b") != nil
	if out.PrivateFileSystem, err = evalString(page, opfs); err != nil {
		return out, err
	}
	page.Context().Close()

	// 3. The picker stubbed with an init script: the page's own code receives our file.
	if page, err = freshPage(browser, pickerStub); err != nil {
		return out, err
	}
	if err := page.Click("#b"); err != nil {
		return out, err
	}
	if _, err := page.WaitForFunction("() => document.body.dataset.picked", nil, playwright.PageWaitForFunctionOptions{Timeout: playwright.Float(5000)}); err != nil {
		return out, err
	}
	if out.PickerStubbed, err = evalString(page, "() => document.body.dataset.picked"); err != nil {
		return out, err
	}
	page.Context().Close()

	// 4. Clipboard with Chromium's permission names granted. Some engines reject the names —
	// at GrantPermissions or when the next page opens.
	context, err := browser.NewContext()
	if err != nil {
		return out, err
	}
	if err := serve(context); err != nil {
		return out, err
	}
	grant := func() error {
		if err := context.GrantPermissions([]string{"clipboard-read", "clipboard-write"}, playwright.BrowserContextGrantPermissionsOptions{Origin: playwright.String(pageURL)}); err != nil {
			return err
		}
		p, err := context.NewPage()
		if err != nil {
			return err
		}
		if _, err := p.Goto(pageURL); err != nil {
			return err
		}
		out.GrantClipboard = "accepted"
		out.ClipboardWriteGranted, out.ClipboardReadGranted, err = clip(p)
		return err
	}
	if err := grant(); err != nil {
		msg := apiPrefix.ReplaceAllString(strings.Split(err.Error(), "\n")[0], "")
		if len(msg) > 80 {
			msg = msg[:80]
		}
		out.GrantClipboard = msg
	}
	return out, nil
}

// run returns errors instead of exiting, so deferred Close and Stop calls still run.
func run() error {
	pw, err := playwright.Run(&playwright.RunOptions{SkipInstallBrowsers: true})
	if err != nil {
		return err
	}
	defer pw.Stop()
	results := []result{}
	for _, s := range setups {
		r, err := one(pw, s)
		if err != nil {
			return fmt.Errorf("%s: %w", s.label, err)
		}
		results = append(results, r)
	}
	var buf bytes.Buffer
	enc := json.NewEncoder(&buf)
	enc.SetEscapeHTML(false)
	enc.SetIndent("", "  ")
	if err := enc.Encode(map[string]any{"results": results}); err != nil {
		return err
	}
	fmt.Print(buf.String())
	return nil
}

func main() {
	if err := run(); err != nil {
		log.Fatal(err)
	}
}
```

## What we got

| Setup | Clipboard write / read (default) | grantPermissions | Write / read (granted) | <input type=file> | Picker → filechooser | Picker stubbed | Private file system |
| --- | --- | --- | --- | --- | --- | --- | --- |
| Chromium, headless | NotAllowedError / NotAllowedError | accepted | ok / ok | report.csv:14 | no event | report.csv:14 | ok |
| Chrome, headful | ok / timed out (prompt?) | accepted | ok / ok | report.csv:14 | no event | report.csv:14 | ok |
| Firefox, headful | ok / ok | Unknown permission: clipboard-read | — | report.csv:14 | no picker API | report.csv:14 | ok |
| WebKit, headful | ok / NotAllowedError | Unknown permission: clipboard-write | — | report.csv:14 | no picker API | report.csv:14 | no (TypeError) |

From the Node.js run on 2026-10-14. IP addresses are replaced with placeholders (203.0.113.x); equal addresses stay equal. The other languages produced the same findings.

Raw output (Node.js)

```
{
  "results": [
    {
      "label": "Chromium, headless",
      "clipboard_write_default": "NotAllowedError",
      "clipboard_read_default": "NotAllowedError",
      "input_set_files": "report.csv:14",
      "show_open_file_picker": true,
      "picker_filechooser_event": false,
      "private_file_system": "ok",
      "picker_stubbed": "report.csv:14",
      "grant_clipboard": "accepted",
      "clipboard_write_granted": "ok",
      "clipboard_read_granted": "ok"
    },
    {
      "label": "Chrome, headful",
      "clipboard_write_default": "ok",
      "clipboard_read_default": "timed out (prompt?)",
      "input_set_files": "report.csv:14",
      "show_open_file_picker": true,
      "picker_filechooser_event": false,
      "private_file_system": "ok",
      "picker_stubbed": "report.csv:14",
      "grant_clipboard": "accepted",
      "clipboard_write_granted": "ok",
      "clipboard_read_granted": "ok"
    },
    {
      "label": "Firefox, headful",
      "clipboard_write_default": "ok",
      "clipboard_read_default": "ok",
      "input_set_files": "report.csv:14",
      "show_open_file_picker": false,
      "picker_filechooser_event": false,
      "private_file_system": "ok",
      "picker_stubbed": "report.csv:14",
      "grant_clipboard": "Unknown permission: clipboard-read"
    },
    {
      "label": "WebKit, headful",
      "clipboard_write_default": "ok",
      "clipboard_read_default": "NotAllowedError",
      "input_set_files": "report.csv:14",
      "show_open_file_picker": false,
      "picker_filechooser_event": false,
      "private_file_system": "no (TypeError)",
      "picker_stubbed": "report.csv:14",
      "grant_clipboard": "Unknown permission: clipboard-write"
    }
  ]
}
```

## Takeaways

-   **Clipboard defaults differ by engine:** headless Chromium denies both writing and reading (`NotAllowedError`); headful Chrome lets a page write but reading waits on a prompt nobody answers; Firefox allowed both; WebKit allowed writing and denied reading.
-   **Grant permissions with Chromium's names on Chromium only:** `grantPermissions(["clipboard-read", "clipboard-write"])` made both work on Chromium and Chrome. Firefox and WebKit reject those names (`Unknown permission`), and Firefox only says so when the next page opens — guard the call per engine.
-   **`<input type="file">` works everywhere:** the `filechooser` event fired on all four and `setFiles()` delivered the file (name and size as the page saw them).
-   **`setFiles()` can't answer `showOpenFilePicker()`:** Chromium and Chrome have the File System Access picker, but clicking it raised no `filechooser` event — Playwright has nothing to hand the file to. Stub the function with `addInitScript` instead: the page's own code then got our file on every engine.
-   **The origin-private file system** wrote and read back on Chromium, Chrome and Firefox with no permission; WebKit has no `navigator.storage` there.

---
Source: https://cdpfleet.com/docs/cases/clipboard-and-files · Markdown: https://cdpfleet.com/docs/cases/clipboard-and-files.md · All docs for AI tools: https://cdpfleet.com/llms.txt
