Tool reference: secrets, credentials, extensions, stealth and captcha
Secrets registry (capability secrets)
Section titled “Secrets registry (capability secrets)”register_secret({ name, value, scope?, session? })
Section titled “register_secret({ name, value, scope?, session? })”Register a sensitive value the agent will use without ever seeing the real
string in any tool result. Gated behind the off-by-default secrets
capability, the same posture class as eval / network-body /
disableWebSecurity.
Shape:
name: agent-facing alias, must match/^[A-Z][A-Z0-9_]*$/(uppercase identifier, e.g.PASSWORD,OTP,SESSION_TOKEN). The<NAME>mask is the stable contract.value: the real secret. Stored per-session in memory only; never persisted, never logged. The registry never echoes it back, even on registration confirmation.scope?: optional origin, or a list of origins. Each entry is an origin (https://app.example.com,http://localhost:3000,app://com.acme.appfor a native app) or a bare host (app.example.com,app.example.com:8443) meaninghttps://on that host. A path, query, fragment, credentials or a*wildcard is refused at registration. When set, dispatch-side substitution refuses unless the origin of the frame that receives the value matches an entry exactly: same scheme, same host, same port. There are no subdomain rules:app.example.comdoes not matchlogin.app.example.com. The receiving frame is the one that owns thefill/presstarget element (so a field inside an iframe is checked against the iframe’s origin), or the focused frame for a targetlesspress. The value is then typed into that same element, and its frame’s URL is read again right before typing: if the frame moved to another origin in between, the action returnsok:falseand nothing is typed. A targetlesspressre-checks the focused frame the same way. Frames with no origin to name (about:blank,about:srcdoc,data:) never match. Egress masking is global regardless of scope.
Returns: { ok, registered, scope, origins, names, tokensEstimate }.
origins lists the normalised origins the scope matches (null when
unscoped). names echoes the live alias list (NOT values).
Example.
register_secret({ name: "PASSWORD", value: "s3cr3t-hunter2", scope: "app.example.com" })// → { "ok": true, "registered": "PASSWORD", "scope": "app.example.com",// "origins": ["https://app.example.com"], "names": ["PASSWORD"], "tokensEstimate": 52 }fill({ selector: "input[type=password]", value: "<PASSWORD>" })// every subsequent tool result shows <PASSWORD>, never the real valueDispatch-side pairing. Once registered, the agent calls:
fill({value: "<NAME>"}): runtime substitutes the real value AT Playwright dispatch; the action descriptor onActionResult.action.valuerecords the alias<NAME>, never the real value.press({key: "<NAME>"}): same substitution path for keypress flows (one-shot OTP into a focused field). Modifier+key shapes likeShift+Apass through unchanged; the<NAME>shape doesn’t collide.- Plain string values pass through unchanged. The substitution is
structural (
/^<[A-Z][A-Z0-9_]*>$/), not value-based, so a literal angle-bracketed text in the page stays a literal.
Egress-side masking. Every sink that could carry the real value is scanned on the way out:
| Sink | Status |
|---|---|
ActionResult.network.requests[].url (URLs in action-window tap) | masked |
ActionResult.network.mutations[].urlPattern + responseShape | masked |
ActionResult.network.wsFrames[].payload + url | masked |
network_read.requests[].url (session ring) | masked |
network_body.body (response body) | masked: JSON / text; base64 bodies only where the value’s own base64 appears (see below) |
ws_read.frames[].payload + .url | masked |
console_read.recent[].text + errors + pageErrors | masked |
snapshot() tree (a11y node names) | masked |
find() candidates (name, testId, selectorHint, context.rowText) | masked (deep-walk) |
text_search() matches (visible text) | masked (deep-walk) |
plan().evidence (selectorHint / role / name on the planned descriptor) | masked (deep-walk) |
inspect().styles (computed content / background-image: url(...)) | masked (deep-walk) |
point_probe() (textContent of element-under-point + ancestor text) | masked (deep-walk) |
verify_text / verify_value / verify_attribute, failure.actual on miss | masked (deep-walk); without this, a wrong-expected verify would echo the real value back |
verify_count / verify_visible / verify_predicate, failure.actual | masked (deep-walk) |
act_and_diff().diff (classDelta / styleDelta / attrDelta values) | masked (deep-walk); covers aria-* / data-* attribute values + inline-style values |
watch() regions / network / WS over the watch window | masked (NetworkTap takes the secrets registry; result deep-walked) |
extract, shadow_trees, cookies_get, idb_get, caches_get page data | masked (every tool result, see below) |
| any other tool’s text result | masked (every tool result, see below) |
screenshot() (image bytes) | partial, warning only, see below |
Every tool result. On top of the per-sink masking above, every
registered tool’s result goes through one more pass on the way out: each
text item has registered values replaced with <NAME>. A JSON result is
parsed and its string values, object keys and any number whose digits hold
a value are masked, then it is written back in the same layout, so a value
that JSON encoding changed is caught and the document stays valid. Such a
number is replaced as a whole by the string "<NAME>", so its type changes
from number to string; other numbers stay numbers. A result with no
registered value in it is returned untouched without being parsed, so
already-masked sinks cost one substring scan. Image items are not touched.
This pass covers every session’s registered values, and it runs only when
the secrets capability is on.
Masking guarantees. The egress layer composes with the existing URL sanitiser at the same boundary: URL sanitiser runs first (regex on URL structure: query/fragment/userinfo/token-paths), then the secrets layer (literal real-value substring scan). They don’t fight: the sanitiser may already have stripped a credentialled query, but the literal-value scan catches a real value that landed in a path / payload / header value.
Idempotent: re-masking a previously-masked string is a no-op (the
<NAME> mask never contains a registered value, by construction).
Longest-value-first: when two registered values overlap (one is a substring of another), the longer one is masked first, so a partial leak of the shorter alias is impossible.
Encoded forms: a value of 6 characters or more is also masked where it
appears percent-encoded (encodeURIComponent, and the form-body encoding
that writes a space as +) or as its own base64 or base64url encoding,
padded or not. A shorter value is masked only as typed, since its encodings
are short enough to turn up by chance.
Limitations (enumerated for the threat model):
screenshot()is a partial sink. PNG/JPEG bytes are not OCR’d server-side. Instead, the page’s text content is swept for any registered real-value, and when one is detected the result prepends a warning naming the affected aliases. Pixel-level redaction (region-blur of the bounding boxes that contain a matched value) is a typed seam for v0.2.x. For verified-clean evidence, prefersnapshot()/find()/text_search()(all fully masked) over a screenshot.- Base64 response bodies pass through
network_bodymostly unchanged. The body is masked only where the value’s own base64 appears at an encoding boundary. A value inside a larger encoded string is usually not found: base64 ofuser:password(Authorization: Basic …), a binary body, or a value at an offset that shifts its encoding. A prefix whose length is a multiple of 3 bytes (alice:) keeps the boundary, so that tail is masked. Decode + re-mask on the agent side if you fetch base64 bodies that may carry a secret. The common case (JSON / text) is fully masked. - Cap is 32 secrets per session. Bounded so the per-sink scan stays O(secrets × text-len) reasonable; realistic auth flows fit well under.
scopenarrows dispatch, not egress. Scoped secrets won’t be substituted into afillin a frame on another origin (refused with a clear error), but if a registered value reaches a sink for any reason, it’s masked regardless of scope.
Capability gate. Off by default. Add secrets to
BROWX_CAPABILITIES to enable. A one-time loud warning fires at server
boot (when the capability is on) and at the first register_secret call
(naming the egress sinks now engaged). Mirrors the
eval / network-body / disableWebSecurity posture documented in
Threat model.
Credentials hook (capability credentials)
Section titled “Credentials hook (capability credentials)”Pluggable hook into an operator-configured credentials / TOTP vault. Without
this, agents driving real auth flows block on 2FA, and the only escapes
(“bake the seed into the prompt”) defeat secrets-masking by leaking
the seed into transcripts. Gated behind the off-by-default credentials
capability, the same posture class as eval / network-body / secrets.
CRITICAL: provider is selected per-deployment, never bundled.
The browxai server NEVER auto-installs a CLI binary, NEVER auto-purchases a
vault, NEVER prompts the operator interactively. If the configured backend
is missing, every lookup returns a structured {ok:false, error, hint}
with the install instruction; the agent’s flow either retries with a
different account, calls await_human, or fails cleanly.
Provider matrix (selected via BROWX_CREDENTIALS_PROVIDER):
| Provider | TOTP | Credential | Dependency |
|---|---|---|---|
oathtool (default) | yes | no (TOTP-only) | system oathtool (macOS: brew install oath-toolkit; Debian/Ubuntu: apt install oathtool); seeds via env |
1password | yes | yes | 1Password CLI op on PATH; op signin performed out-of-band |
bitwarden | yes | yes | Bitwarden CLI bw on PATH; $BW_SESSION from bw unlock in server env |
lastpass | yes | yes | lpass CLI on PATH; lpass login performed out-of-band |
none | no | no | explicit no-op; useful for testing the surface without a real vault |
Configuration env:
BROWX_CREDENTIALS_PROVIDER=oathtoolBROWX_OATHTOOL_SEEDS="acme=JBSWY3DPEHPK3PXP,other=NBSWY3DPEHPK3PXP"# or one of:# BROWX_CREDENTIALS_PROVIDER=1password# BROWX_CREDENTIALS_PROVIDER=bitwarden# BROWX_CREDENTIALS_PROVIDER=lastpassOptional CLI-path overrides (when the binary lives outside PATH):
BROWX_OATHTOOL_BIN, BROWX_1PASSWORD_BIN, BROWX_BITWARDEN_BIN,
BROWX_LASTPASS_BIN.
Per-account origins for get_credential, in the same account=value,…
form as the seeds, with | between origins. get_credential needs the
account listed here; an unlisted account is refused before the vault is
asked:
BROWX_CREDENTIAL_ORIGINS="acme=https://app.acme.com|https://sso.acme.com,other=other.example"Each origin takes the register_secret scope forms. An account whose list
has an entry that does not parse is ignored with a startup warning, so it is
refused like an unlisted one. The variable is read at start: restart browxai
after changing it.
Account names. account must start with a letter or digit and use only
letters, digits, space and . _ @ + : / -, at most 128 characters. Anything
else is refused before the vault is asked; use the vault item’s id for a
name with other characters.
get_totp({ account })
Section titled “get_totp({ account })”Look up a one-time TOTP code. Returns {ok, code, provider} on success;
{ok:false, error, hint, provider} on failure (missing seed / CLI not on
PATH / CLI not logged in; actionable hint included).
account: provider-specific identifier. Foroathtool, a key fromBROWX_OATHTOOL_SEEDS. For1password/lastpass, an item name. Forbitwarden, an item id.
TOTP codes are NOT masked through the secrets registry: a TOTP is
single-use and short-lived, so masking buys little while complicating the
verify-step flow. The agent passes the code directly to
fill({value: code}) or compares against on-page text.
get_credential({ account, session? })
Section titled “get_credential({ account, session? })”Look up a {username, password} pair. Returns {ok, username, aliasName, origins, provider} on success, never the cleartext password. The password is
auto-registered into the per-session secrets registry under
<PASSWORD_<account>> (account sanitised to /^[A-Z][A-Z0-9_]*$/), scoped
to origins: the account’s BROWX_CREDENTIAL_ORIGINS list. fill then
materialises the password only into a frame on one of those origins. An
account with no BROWX_CREDENTIAL_ORIGINS entry is refused before the vault
is asked; the error names the variable and the account and gives an example
value. With acme-corp=https://app.acme.com set, the agent drives:
navigate({url:"https://app.acme.com/login"})get_credential({account:"acme-corp"}) → {username:"alice@…", aliasName:"PASSWORD_ACME_CORP", origins:["https://app.acme.com"]}fill({selector:"input[name=username]", value:"alice@…"})fill({selector:"input[name=password]", value:"<PASSWORD_ACME_CORP>"})Dispatch-side substitution materialises the real value at Playwright
dispatch; egress-side masking strips occurrences across every sink (see
the register_secret matrix above).
Pairing rule. get_credential ADDITIONALLY requires the secrets
capability to be enabled. Without it, the lookup refuses with a clear
error (returning a password in cleartext would leak it into the
transcript on first reference). Enable both:
BROWX_CAPABILITIES=read,navigation,action,human,credentials,secrets.
Per-provider notes:
oathtooldoes NOT supportget_credential(TOTP-only). Pair with a credential-bearing provider, ORawait_humanfor the username/password half andget_totpfor the TOTP half.1passwordreads theusername+passwordlabelled fields viaop item get --fields label=username,label=password --format json -- <account>.bitwardenreadslogin.username+login.passwordviabw get item -- <account>.lastpassreads vialpass show --username --password -- <account>.
Posture. Off by default; loud one-time warning at server boot when the
capability is on. Provider is per-deployment, never bundled, never
auto-installed. All shell invocations use fixed argv (no shell
interpolation, account name passed as a discrete argv element after --,
and an account name can never start with -). 5-second wall-clock timeout per call so a hung CLI
can’t block tool dispatch.
Extensions registry (capability extensions)
Section titled “Extensions registry (capability extensions)”Per-session unpacked-Chromium-extension management. Gated behind the
off-by-default extensions capability, the same posture class as eval /
network-body / secrets.
Trust posture. A loaded extension can read every page the session visits and make arbitrary network requests. The extension code is trust-equivalent to the agent’s own action surface: treat the extension’s filesystem path as in-scope trust, just like you would the agent’s tool calls. Mitigations: workspace-rooted paths (no escape), the capability is off by default with a loud boot warning, and extensions cannot be loaded on incognito or attached sessions.
Session-mode constraints.
- Headed + persistent sessions only. Chromium’s
--load-extensionflag is reliable only in headed mode;headless:truesessions refuse. The attached/BYOB session refuses because the human’s Chrome is not-owned (it already has its own extension set). The incognito session refuses because Chromium does not load unpacked extensions in incognito (the per-extension “allowed in incognito” flag is not togglable via the Playwright launch API). - install / reload / uninstall rebuild the underlying browser context.
Chromium does not support adding or removing extensions on a live
context, so the tools tear down the current
BrowserSession, relaunchopenManagedSessionwith the updated--load-extensionflag set, and splice the new pieces (page, console, network, ws, bridge, refs) onto the existingSessionEntry. Consequences: open refs invalidate, the page navigates to about:blank, console/network/ws buffers reset. Profile state on disk (cookies, localStorage, IndexedDB) survives; it lives in the profile dir. Treat install/reload/uninstall as “session-restart with new extension set”, not as hot reload.
extensions_install({ path, session? })
Section titled “extensions_install({ path, session? })”Load an unpacked extension (MV3 or MV2 directory containing
manifest.json) into the session’s managed-profile launch.
path: workspace-rooted directory. Traversal (..), absolute paths outside$BROWX_WORKSPACE, files (vs directories), and directories missingmanifest.jsonall reject with a structured error. Packed.crxarchives must be unpacked first.
Returns: {ok, session, installed: {id, name, version, path}, loaded: [{id, name, version, path, enabled}], note, tokensEstimate}. The id
is a stable hash of the resolved path; pass it back to
extensions_reload / extensions_trigger / extensions_uninstall.
Example.
extensions_install({ path: "extensions/my-helper" })// → { "ok": true, "installed": { "id": "ext-9f2c…", "name": "My Helper",// "version": "0.3.0", "path": "/…/.browxai/extensions/my-helper" },// "loaded": [{ "id": "ext-9f2c…", "enabled": true, … }],// "note": "context rebuilt — open refs invalidated, page is about:blank" }extensions_list({ session? })
Section titled “extensions_list({ session? })”Return the session’s currently-loaded extensions:
[{id, name, version, path, enabled}]. Empty list when none are loaded
(the default).
extensions_reload({ id, session? })
Section titled “extensions_reload({ id, session? })”Re-parse the manifest at the extension’s loaded path AND rebuild the browser context. Chromium re-injects content scripts and restarts the MV3 service worker on context start. Use after editing the extension’s source.
extensions_trigger({ id, command?, session? })
Section titled “extensions_trigger({ id, command?, session? })”Best-effort invocation surface.
- Without
command, navigates the session’s active page to the extension’schrome-extension://<runtime-id>/URL; the page renders the extension’sdefault_popup(when one is declared) and is driveable like any other page. - With
command, attempts to fire the named keyboard-command binding from the manifest’scommandsmap. Chromium does not expose extension keyboard-command dispatch via CDP / Playwright, so this branch returns a structuredok:falsewith a workaround hint. Use the popup branch (nocommand) or drive the extension’s underlying content-script API directly.
The id mapping caveat. browxai’s id is a hash of the unpacked
path. The Chrome runtime id (the <id> in
chrome-extension://<id>/… URLs) is derived from the extension’s
manifest.key field when present; otherwise it’s hash-derived but using
Chrome’s own algorithm, not ours. extensions_trigger discovers the
runtime id by inspecting the context’s service-worker / background-page
URLs (both start with chrome-extension://<runtime-id>/); when there’s
exactly one loaded extension and one detected runtime id we assume the
mapping. Otherwise the result returns the detected runtime-id set so
the caller can decide.
extensions_uninstall({ id, session? })
Section titled “extensions_uninstall({ id, session? })”Remove the extension from the session’s registry and rebuild the browser context without it.
Capability gate. Off by default. Add extensions to
BROWX_CAPABILITIES to enable. A one-time loud warning fires at server
boot (when the capability is on) describing the trust posture and the
rebuild semantics. Mirrors the eval / network-body / secrets
posture documented in Threat model.
Stealth fingerprint patches (capability stealth)
Section titled “Stealth fingerprint patches (capability stealth)”stealth is a behaviour gate: it registers no new MCP
tool. When the capability is on, every browser context created by the
server (managed / incognito / and on the rebuild path used by
extensions_*) loads a per-context init-script that overrides the
well-known Playwright fingerprint surface BEFORE any page script runs:
navigator.webdriver:falsenavigator.plugins: non-empty PluginArray-like (Chrome PDF Viewer)navigator.languages:["en-US", "en"]when the headless default emitted[]window.chrome: defined withruntime: {}when the UA tells
Patches use Object.defineProperty({configurable: true}), so legitimate
page code can still inspect or replace them. We’re spoofing detection,
not lying to legitimate code. The script is wrapped in an IIFE so no
helpers leak into page globals, and guarded by a sentinel
(window.__browx_stealth) so it is idempotent against re-application.
Capability gate. Off by default. Add stealth to
BROWX_CAPABILITIES to enable. A one-time loud warning fires at server
boot (when the capability is on) naming the legal/ToS exposure
explicitly: circumventing automation detection may violate a site’s
terms of service. browxai does NOT bundle a full anti-fingerprinting
library; only the four well-known patches above. Mirrors the eval /
network-body / secrets / extensions posture documented in
Threat model.
Captcha solver delegation (capability captcha)
Section titled “Captcha solver delegation (capability captcha)”solve_captcha({ type, selector?, siteKey?, imageBase64?, session? })
Section titled “solve_captcha({ type, selector?, siteKey?, imageBase64?, session? })”Delegate a captcha challenge to a configured external provider and
return the provider’s solution token / text. browxai is a delegation
seam: the tool POSTs the challenge to the provider’s HTTP API and polls
for the answer, and the solver runs entirely on the provider’s
infrastructure. Off by default (capability captcha).
What leaves the machine. On each call: BROWX_CAPTCHA_API_KEY (in
the form body of the submit and of every poll request, never in a URL), the URL
of the current page (pageurl), and the widget site-key, or the base64
image bytes for type:"image". They go to the provider’s API,
https://2captcha.com or https://api.capmonster.cloud unless
BROWX_CAPTCHA_API_BASE overrides it. Cookies, DOM, snapshots and
screenshots are not sent. With no provider configured nothing is sent.
Provider config (per-deployment, env-driven). browxai does NOT bundle a solver and does NOT auto-purchase credits. Operator chooses a provider, funds the account, and sets the env vars:
BROWX_CAPTCHA_PROVIDER(required):2captchaorcapmonster(case-insensitive).BROWX_CAPTCHA_API_KEY(required): the provider account API key.BROWX_CAPTCHA_API_BASE(optional): override the canonical base URL (useful for self-hosted CapMonster-compatible proxies / testing). It must be anhttpsURL, because the API key and the challenge go to it. Plainhttpis accepted only for127.0.0.1,::1andlocalhost; any other base makes the tool return an error that names the variable. A redirect on the submit or poll call ends the solve with an error namingBROWX_CAPTCHA_API_BASE, because the request carries the API key.BROWX_CAPTCHA_TIMEOUT_MS(optional, default120000): per-attempt deadline.BROWX_CAPTCHA_POLL_MS(optional, default5000): poll interval.
When the capability is on but no provider is configured, the tool
returns a structured {ok:false, error:"no captcha provider configured, …", hint:…} and sends nothing. Setting only one of
BROWX_CAPTCHA_PROVIDER / BROWX_CAPTCHA_API_KEY, or an unknown provider
name, returns an ok:false that names the missing piece.
Protocol target. v0.2.0 targets the 2Captcha-compatible REST API
(POST /in.php submit + POST /res.php poll). CapMonster Cloud
documents itself as drop-in compatible with this shape, so the same
code talks to either provider. Other providers (AntiCaptcha’s
/createTask + /getTaskResult, etc.) are extensible: add a branch
in src/page/solve-captcha.ts and append the provider name to
KNOWN_PROVIDERS.
Inputs.
type: one ofrecaptcha2,recaptcha3,hcaptcha,turnstile,image.selector(widget captchas): CSS selector for the widget element on the current page. When given, the server readsdata-sitekey(ordata-site-key/sitekey) to populatesiteKey.siteKey(widget captchas): explicit site-key (alternative toselector).imageBase64(imagetype): raw base64 image bytes (nodata:image/...;base64,prefix).
Returns. {ok, provider, solution, taskId, elapsedMs} on success;
{ok:false, provider, error, hint, providerCode?} on failure. The agent
is responsible for wiring the solution back into the page (different
sites call recaptcha callbacks differently, fill a hidden form field,
or invoke grecaptcha.getResponse). We do NOT auto-submit. The
solution string passes through the per-session secrets registry mask on
egress (same posture as other egress sinks).
Example.
solve_captcha({ type: "recaptcha2", selector: ".g-recaptcha" })// → { "ok": true, "provider": "2captcha", "solution": "03AGdBq2…",// "taskId": "7211…", "elapsedMs": 34000 }// then wire the token into the page yourself (site-specific).Capability gate. Off by default. Add captcha to
BROWX_CAPABILITIES to enable. A one-time loud warning fires at server
boot (when the capability is on) naming the legal/ToS exposure
explicitly: solving captchas may violate the target site’s terms of
service and, depending on jurisdiction, computer-misuse /
unauthorised-access law; the operator carries that exposure. Mirrors
the eval / network-body / secrets / extensions / stealth
posture documented in Threat model.
Device emulation: emulate_bluetooth / emulate_usb / emulate_hid / device_requests
Section titled “Device emulation: emulate_bluetooth / emulate_usb / emulate_hid / device_requests”Per-session synthetic-device catalogs for the three Web platform
device-picker APIs. The page-side init-script wrappers around
navigator.bluetooth.requestDevice / navigator.usb.requestDevice /
navigator.hid.requestDevice resolve with synthetic objects matching W3C
shapes, so an agent can drive a page that gates a flow behind a device
picker without owning the hardware.
Capability gate. Off by default. Add device-emulation to
BROWX_CAPABILITIES to enable. A one-time loud warning fires at server
boot. The capability is posture-broadening (the wrappers tell the
page it found physical devices that don’t exist), so it sits as its own
slot, separate from action. Same posture class as eval /
network-body / secrets / extensions / stealth / captcha. See
Threat model.
The wrappers install eagerly at session creation so a page calling
requestDevice on initial document parse never hangs. When the
capability is OFF, the wrappers still install (the page sees the
user-dismissed shape and no deadlocked promise), but the check
binding short-circuits to refused, and device_requests surfaces the
attempt with handledAs:"refused" so an operator without the capability
can still see that the page asked.
emulate_bluetooth({devices?, session?}): stage a Bluetooth
catalog. {devices:[…]} installs; omit or pass {devices:[]} to clear
(next requestDevice rejects with NotFoundError, the user-dismissed
shape). The synthetic BluetoothDevice carries {id, name, uuids, gatt, addEventListener, watchAdvertisements, forget}. gatt.connect()
resolves with a stub server whose getPrimaryService /
getPrimaryServices reject; v1 covers picker-clear flows, not full
GATT exchange. Returns {ok, session, api:"bluetooth", catalog:{devices}, warnings?, tokensEstimate}.
emulate_usb({devices?, session?}): stage a USB catalog. The
synthetic USBDevice carries {vendorId, productId, productName, manufacturerName, serialNumber, deviceClass, deviceSubclass, deviceProtocol, usbVersionMajor/Minor/Subminor, deviceVersionMajor/Minor/Subminor, configuration, configurations} plus
the full method surface (open, close, selectConfiguration,
claimInterface, releaseInterface, selectAlternateInterface,
controlTransferIn/Out, clearHalt, transferIn/Out,
isochronousTransferIn/Out, reset, forget). All resolve;
transfer endpoints resolve with zero-byte payloads (no synthetic data
flow).
emulate_hid({devices?, session?}): stage a HID catalog. The HID
API is multi-result by construction: requestDevice resolves with an
Array<HIDDevice>; an EMPTY catalog resolves with [] (the HID
user-dismissed shape), NOT a rejection. The synthetic HIDDevice
carries {opened, vendorId, productId, productName, collections, oninputreport} plus open / close / forget / sendReport /
sendFeatureReport / receiveFeatureReport (resolves with an empty
DataView). oninputreport is never fired; no synthetic input
stream.
device_requests({since?, session?}): read-side companion.
Returns {ok, session, supportedApis:["bluetooth","usb","hid"], requests:[{api, handledAs, returned, filters?, ts}], tokensEstimate}.
handledAs:
"resolved": catalog non-empty; picker resolved with synthetic device (Bluetooth/USB) or list (HID)."rejected": Bluetooth/USB + catalog empty; picker rejected withNotFoundError(user-dismissed shape)."empty": HID + catalog empty; picker resolved with[](the HID user-dismissed shape)."refused": capability was OFF at call time; the wrapper short-circuited but the buffer recorded the attempt.
since slices the buffer to ts >= since; omit to return everything
(buffer is capped at 200 records).
Example.
emulate_bluetooth({ devices: [{ name: "HR Monitor", id: "hr-1", services: ["heart_rate"] }] })// …drive the page's "pair device" button…device_requests({})// → { "ok": true, "supportedApis": ["bluetooth", "usb", "hid"],// "requests": [{ "api": "bluetooth", "handledAs": "resolved",// "returned": "HR Monitor", "filters": […], "ts": 1765540264420 }] }Synthetic device fields (W3C compatibility). The devices[] entries
accept the W3C-relevant union of fields across the three APIs, and each
wrapper picks the ones its spec exposes:
| Field | Bluetooth | USB | HID | Default |
|---|---|---|---|---|
name | device.name | device.productName | device.productName | "browxai-virtual" |
id | device.id | n/a | n/a | "browxai-<api>-<index>" |
vendorId | n/a | device.vendorId | device.vendorId | 0x0000 |
productId | n/a | device.productId | device.productId | 0x0000 |
manufacturerName | n/a | device.manufacturerName | n/a | "browxai virtual" |
serialNumber | n/a | device.serialNumber | n/a | "BROWX-VIRTUAL" |
deviceClass | n/a | device.deviceClass | n/a | 0xFF |
deviceSubclass | n/a | device.deviceSubclass | n/a | 0x00 |
deviceProtocol | n/a | device.deviceProtocol | n/a | 0x00 |
services | device.uuids | n/a | n/a | [] |
collections | n/a | n/a | device.collections | [] |
Missing fields default to deterministic placeholders so the page sees a complete shape regardless of how sparsely the catalog was populated. The fields the wrapper doesn’t surface for an API are still accepted on the agent side (a single catalog entry can carry every field, useful for a multi-API page that probes the same device via different APIs).
Deferred follow-ups (v2+). GATT service emulation for Bluetooth
(synthetic characteristics + read/write/notify so a page can exchange
data over the synthetic device); transferIn / transferOut synthetic
data streams for WebUSB; oninputreport synthetic input streams for
WebHID; getDevices() cross-permission-grant persistence so an
already-paired device survives a navigation.