Cases / #45 · 2026-10-14 · Medium
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 Google Chrome Firefox 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
- For each of four browsers, open the test page in a fresh context and round-trip a string through the clipboard with no permissions.
- Click the file input, answer the file chooser with
setFiles(), and read back what the page received. - Click the button that calls
showOpenFilePicker()and wait for afilechooserevent; write and read a file in the origin-private file system. - In a new context, stub
showOpenFilePickerwith an init script and click the button again. - In another context, grant
clipboard-readandclipboard-writeand repeat the clipboard round trip.
The code
The same program in five languages (also on GitHub, 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:[email protected]:8000
// npm install [email protected]
// 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));
# 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))
// 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));
}
}
// 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 get github.com/playwright-community/[email protected]
// 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: thefilechooserevent fired on all four andsetFiles()delivered the file (name and size as the page saw them).setFiles()can't answershowOpenFilePicker(): Chromium and Chrome have the File System Access picker, but clicking it raised nofilechooserevent — Playwright has nothing to hand the file to. Stub the function withaddInitScriptinstead: 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.storagethere.