Browse docs
Credential manager
BetterWright includes an encrypted password vault by default. It lives outside the browser profile, exposes only account metadata to agent code, matches login items to the current site, and resolves the password only while the worker is filling a detected form.
The agent flow
On a login page, inspect matching accounts and let BetterWright find the fields:
const form = await credentials.inspect();
const accounts = await credentials.list({ text: "work", category: "login" });
if (accounts.length !== 1) {
return { form, accounts, needsAccountSelection: true };
}
await credentials.fill({ id: accounts[0].id, submit: true });
inspect() reports field roles and ambiguity, never values. list() returns
metadata such as id, username, label, category, URL policy, and
timestamps. It never returns a password or other secret.
fill() detects the visible username, current-password, and submit controls
from autocomplete tokens, input types, names, labels, ARIA text, and form
proximity, including forms in child frames and open shadow roots. A
password-only second step is valid. If multiple plausible forms remain, the
call fails instead of guessing; use targets from a fresh snapshot:
await credentials.fill({
id,
usernameSelector: "aria-ref=e2",
passwordSelector: "aria-ref=e3",
submitSelector: "aria-ref=e4",
});
Targets may be CSS or current aria-ref=eN locators. Existing selector-based
integrations continue to work. submit: true detects the form's submit action;
without it, filling does not submit.
The same operation is available as:
bw.fillCredential({ id, submit: true })in the JavaScript SDK.browser_loginin MCP and Pi.- The
logintool in BetterWright's built-in agent.
All return only metadata: which field roles were filled, whether the form was submitted, the selected record id/username, and timing/page information.
Signup and rotation
Generated passwords use a two-phase flow so a rejected signup never becomes a saved login:
const pending = await credentials.generateAndFill({
username: "[email protected]",
matchMode: "exact-origin",
submit: true,
});
// Verify the page's real success state first.
if ((await page.getByRole("heading").textContent()) === "Account created") {
await credentials.commitGenerated({ pendingId: pending.pendingId });
} else {
await credentials.discardGenerated({ pendingId: pending.pendingId });
}
The generated value is written atomically as a provisional entry inside the
encrypted vault before it is typed into detected new-password and
confirmation fields. It is excluded from list, match, and fill until
commitGenerated() promotes it, so a rejected signup never becomes an active
login. The normal finalization window is 60 seconds. After that window or a
process restart, the exact returned pendingId and matching site can still
recover or discard the provisional entry; BetterWright never guesses among
concurrent signups and never silently deletes the only copy of an accepted
password.
If generation reaches the vault but the page detaches, times out, or the worker
exits before filling can report success, the failed result includes a
secret-free pendingCredential recovery object. Do not blindly generate
another password. Inspect the site's visible outcome, then commit or discard
that pending id. The provisional entry itself survives a new host process. If
the whole host exited before it could return the id, revisit the matching site
and call credentials.listPending() (or host-side
bw.listPendingCredentials()) to recover secret-free ids and timestamps. Never
auto-pick when several attempts are present. A live host also retains the exact
frame origin across worker restarts and redirects.
One browser execution may create at most one generated credential. This keeps a failure envelope unambiguous: it can always identify the one provisional entry that needs recovery. Finalize it, then start another generation in a later execution.
New generated logins use base-domain matching by default. Pass matchMode as
host, exact-origin, or never when the account needs a narrower policy.
Failed or abandoned attempts should always call discardGenerated() so they do
not consume the bounded provisional-entry capacity.
For a password change, pass the existing record id to
generateAndFill({id, ...}). A successful commit updates that record in place
rather than creating a duplicate. When the change form has a current-password
field, BetterWright fills it from that same record before generating matching
new-password and confirmation values. Rotation preserves the record's existing
URL scope; update its matchMode separately before rotating if the scope must
change.
If detection reports an ambiguous change-password form, pin each role from a fresh snapshot. BetterWright resolves and validates all handles before reading either secret:
await credentials.generateAndFill({
id,
currentPasswordSelector: "aria-ref=e2",
passwordSelector: "aria-ref=e3",
confirmPasswordSelector: "aria-ref=e4",
});
Trusted SDK hosts can call generateAndFillCredential(), verify success, then
commitGeneratedCredential() or discardGeneratedCredential() with the
returned pending id. listPendingCredentials() recovers provisional metadata
for the current site after a complete process restart.
Save and manage records
Use save only from a trusted host-authored channel, for a user-supplied secret
the application has confirmed the site accepted. Never let a model author or
inspect this payload; agent-facing code should remain metadata-only:
async function rememberAcceptedCredential({ username, password }) {
const payload = JSON.stringify({
username,
password,
label: "work",
matchMode: "base-domain",
});
return bw.run(`return credentials.save(${payload});`);
}
Metadata-only agent code may update or remove an existing record:
await credentials.update({ id, label: "primary work account" });
await credentials.remove({ id });
Login is the default category. Other supported categories include
credit-card, identity, api-credential, secure-note, and ssh-key; they
store category-specific fields. Model-visible responses stay metadata-only.
Browser capture
Accepted logins are captured automatically at the browser level, so the vault
stays populated without any agent code calling save:
- The model types a login or signup. Any accepted credential submission on
a page the model just drove is saved silently (an upsert with
base-domainmatching and the host as label). This covers signups done by typing instead ofgenerateAndFill; whengenerateAndFill+commitGeneratedalready saved, the capture is a no-op rewrite. - The user logs in by hand in a headed window. After the site accepts the
login, an in-page banner asks "Save password?" with Save, Not now,
and Never for this site. Choosing an existing username's account offers
"Update saved password?" instead. "Never" is remembered per origin in
$BETTERWRIGHT_HOME/browser/save-prompt.json(owner-only). In headless sessions there is nobody to ask, so user-driven captures are dropped.
Capture is implemented by a worker-injected sensor running in a dedicated CDP
isolated world per frame (src/vault-sensor.ts + src/vault-capture.ts),
not an extension and not page-visible JavaScript. Only trusted initiating click
or Enter-key events (event.isTrusted) trigger a capture; a submit event alone
is deliberately insufficient because Chromium can mark a page-script-initiated
submit as trusted. A capture is only kept when the login appears
accepted: the frame navigates somewhere new, or the app unmounts the password
field within a few seconds of submitting. A rejected attempt (the form
re-renders with an error, or the password is cleared for a retry) is never
saved — the same rule the two-phase generated-credential commit enforces.
Captured passwords enter the worker redaction set the moment they arrive, and
the banner never displays the password; it stays in worker memory until the
save or dismissal. Saves reuse the ordinary vault save path, so matching,
upsert, limits, and audit entries are identical to credentials.save.
Capture is on by default whenever a vault is active. Disable it with
credentialCapture: false (it is forced off when vault is false/null).
Login forms inside cross-site iframes (OOPIFs) are not captured; SSO flows
that redirect or open a popup are covered.
Getting a password back (betterwright vault)
Everything above is agent-facing: site-scoped, metadata-only, and deliberately incapable of returning a secret. That is the right shape for model-authored code, and the wrong shape for you — a password the agent generated during a signup, or captured from a login you typed, would otherwise be unreachable.
betterwright vault is the human door. It talks to the vault's owner-only API
rather than the worker RPC, so nothing here widens what agent code can do:
betterwright vault list # every record, metadata only
betterwright vault list --query github # filter by site, username, or label
betterwright vault show <id> # one record; password stays hidden
betterwright vault copy <id> # password → clipboard
betterwright vault show <id> --reveal # print it to the terminal
betterwright vault rm <id> # delete one record
betterwright vault audit # recent activity, metadata only
betterwright vault path # where the encrypted files are
Ids come from list; any unambiguous prefix works (vault copy cred_4c8).
Three rules govern the one operation that produces plaintext:
- Nothing prints a secret unless you ask.
listandshoware metadata-only;--revealis the explicit request. --revealwrites only to a terminal. Redirect it, pipe it, or run it where something captures stdout and it refuses, pointing atvault copyinstead.--force(orBETTERWRIGHT_VAULT_ALLOW_NON_INTERACTIVE=1) overrides that when you genuinely mean to redirect. This guards against passwords landing in files and CI logs by accident; it is not a defense against someone who already has your shell — see SECURITY.md.- Every reveal is audited.
owner-revealentries carry the timestamp, record id, and site — never the secret — and show up invault audit.
vault copy is the recommended path: the password goes to the clipboard
through the platform's own tool (pbcopy, clip, wl-copy/xclip/xsel)
and never enters terminal scrollback or shell history.
Uncommitted signup passwords — a generateAndFill that never reached
commitGenerated — are listed separately and can be revealed by their pending
id, which is how you recover an account whose signup succeeded but whose commit
did not.
The same operations are on the vault object for trusted JavaScript hosts:
ownerList(), ownerReveal(id), ownerRemove(id), and ownerAudit(). They
are intentionally absent from handleRequest, the surface the worker and
therefore model code addresses; a custom adapter does not need to implement
them, and betterwright vault always talks to the built-in local vault.
Site matching
Each login stores the URL where it was accepted and a match mode:
base-domain(default): registrable-domain matching backed by the Public Suffix List, including private suffixes. Related service subdomains can share a login, while separate tenants such asa.github.ioandb.github.iodo not.host: the hostname must match; ports and paths may differ.exact-origin: scheme, hostname, and effective port must match.never: keep the item out of matching/list/fill; an explicit id can still update or remove it from its exact saved origin.
An HTTPS-saved item is never offered on HTTP. Local *.localhost development
sites receive deterministic tenant-aware matching; IP addresses and a bare
localhost stay host-scoped. list() and fill() apply these rules before any
secret is resolved.
Storage and auditing
The default store is under $BETTERWRIGHT_HOME/vault/:
vault/
├── vault.key
├── vault.enc
├── audit.jsonl
└── vault.lock/
└── owner.json
The complete record table is authenticated and encrypted with AES-256-GCM and a
fresh random nonce on every atomic rewrite. Directories are owner-only where
the platform supports permissions; the key, ciphertext, lock directory, and
metadata-only audit log use owner-only modes. Active records and provisional
generated secrets share the same authenticated atomic snapshot. Writes are
serialized across clients. Lock ownership combines a heartbeat with an
immutable OS process identity, so an old PID can be distinguished from a live
owner after PID reuse without stealing an active lock. The audit log keeps
timestamp, action, matched site, and opaque record id, never usernames or
secrets. Owner-only operations append there too (owner-reveal,
owner-remove), so a read that exposed plaintext leaves the same trail a write
does. A mutation is persisted before its audit append; if that append fails,
the successful result contains the bounded, secret-free
auditWarning.code === "AUDIT_WRITE_FAILED". Treat the mutation as committed
and repair audit storage instead of retrying it blindly.
Handled secrets remain in the worker's redaction set for as long as its pages can still expose them. If that bounded set fills, BetterWright returns a static failure and restarts the worker instead of evicting old plaintext while an old page is alive.
The default key file protects against plaintext logs, support bundles, casual file inspection, and copying only the ciphertext. It is not a defense against malware or another process already able to read files as the same OS user. Use an external password manager or secret service when that is in scope for your threat model.
Custom or disabled vaults
Pass a custom adapter to keep credentials in 1Password, Bitwarden, a cloud secret service, or another host-controlled store:
new BetterWright({
vault: {
async handleRequest(action, payload, origin) {
// list | list-pending | save | update | remove | fill | generate | commit | discard
},
},
});
Every request includes the canonical current HTTP(S) origin. A custom adapter
must enforce its own URL and access policy and must return secret only for the
internal fill/generate response. BetterWright removes it from public results
and tracks it for worker-side output redaction. An adapter may also implement
the optional redact(value) hook as a second host-side pass, but it must replace
every active secret rather than returning its input unchanged. Adapters that
retain their own redaction material may implement resetRedactionSecrets();
BetterWright calls it only after the owning worker and all its pages close.
For save and update, passwords, notes, and every nested string value under
fields are registered with the worker redaction net before the adapter runs.
Credential promises started by sandbox code are joined to that browser
execution even if the snippet forgets to await them, so recovery state cannot
bleed into the next run.
For generate, BetterWright supplies an opaque pendingId before storage. A
custom adapter must persist and echo that id, and retries with the same id and
identical request must return the same provisional secret. list-pending must
return only current-site metadata under pendingCredentials; it must never
include a secret. These rules make generation recoverable across ambiguous I/O
failures and complete host restarts.
Set vault: false (or null) to disable credential management for a browser.
An unlocked extension can still autofill in a headed persistent profile.
Boundary
Vault APIs never return stored or generated secrets to the model. The
owner-only methods that do (ownerReveal, and betterwright vault show --reveal / copy on top of them) are not routed by handleRequest, so
the browser worker cannot reach them however a snippet is written; they are for
a trusted host acting for the person who owns the files. Snapshots,
control inspection, console output, serialized results, and direct password
field read-back are scrubbed as a final net. Like every browser password
manager, the filled value necessarily exists in the live page DOM and is
available to that site's JavaScript. Do not treat arbitrary model-authored code
plus an unlocked vault as isolation from a malicious page; site matching,
trusted worker fill, provisional-entry isolation, and output redaction limit
the exposure rather than changing the web platform.