Self-contained openwright binaries. No Node.js, no npm, no repo checkout —
copy two files onto a machine and run.
openwright-cli/
openwright.exe Windows x64 ← built + verified (by default)
openwright-XX.exe the same binary, platform-named
assets.bundle conversion packs + tester profiles (compressed)
README.md this file
build.sh / build.cmd rebuild, including the other platforms
$KEY is your own key. Any command below also accepts the --llmrouter form, or
reads your .env.
openwright https://jonathon.ai # standard audit
openwright https://jonathon.ai --intensity deep # deeper: more flows, WCAG+VPAT
openwright https://jonathon.ai --intensity complete # everything
openwright https://jonathon.ai --llmrouter -provider openai -api_key $KEY
# Steering — override any stage independently
openwright https://jonathon.ai --wcag deep --flows 8 --subpages 5 --explore-steps 20
openwright https://jonathon.ai --wcag off --flows 0 --explore-steps 0 # capture + score only
openwright https://jonathon.ai --custom-prompt "focus on the checkout funnel"
openwright https://jonathon.ai --custom-rules ./our-rules.txt
# Sites that need a login (never written to any artifact)
openwright https://login.jonathon.ai --cred alice 'p4ss'
OPENWRIGHT_CRED_USERNAME=alice OPENWRIGHT_CRED_PASSWORD=p4ss openwright https://login.jonathon.ai
# Confidence, regression, cost
openwright https://jonathon.ai --judge # cross-model confidence
openwright https://jonathon.ai --regression prior/openwright-report.json
openwright https://jonathon.ai --costs --debug
# Watch it work / attach to your own Chrome
openwright https://jonathon.ai --no-headless
openwright https://jonathon.ai --attach http://127.0.0.1:9222By default Openwright reuses a Chrome already listening on the debug port and only launches its own if nothing answers.
# Start your own Chrome once — your profile, your logins, your extensions
chrome --remote-debugging-port=9222 --user-data-dir="$TMPDIR/ow-profile"
openwright https://app.example # attaches to it; no second browser| Mode | Behaviour |
|---|---|
auto (default) |
reuse a browser on the port, else launch one |
attach |
prefer an existing browser (--attach / CDP_MODE=attach) |
launch |
always spawn a fresh isolated browser |
openwright https://app.example --attach http://127.0.0.1:9222
openwright https://app.example --cdp-port 9333 # non-default debug port
CDP_PORT=9333 openwright https://app.example # same, via env
CDP_MODE=launch openwright https://app.example # force a clean browser
CDP_CHROME_PATH=/usr/bin/chromium openwright https://app.exampleReusing a signed-in profile. By default each launch gets a throwaway profile,
so nothing persists. CDP_DATA pins a real one — sign in once and every later
run is already authenticated:
export CDP_CHROME_PATH="C:\Program Files\Google\Chrome\Application\chrome.exe"
export CDP_PORT=9333
export CDP_DATA="C:\tmp\cdp-profile"
openwright https://app.example # starts Chrome with that profile, or reuses itEvery run now prints which it did, before any work starts:
[entry] browser: none on 127.0.0.1:9333 — will start one
[entry] browser: reusing the one already on 127.0.0.1:9333
To attach to one you start yourself, use the same port and profile — and note
the port must match CDP_PORT:
chrome --remote-debugging-port=9333 --user-data-dir="C:\tmp\cdp-profile"The port is 9222 by default. --cdp-port moves everything at once — the
reuse probe, the browser it launches, and the internal browser-control child.
CDP_CHROME_PATH picks the executable when launching; it is not used when
attaching, because the browser already exists.
Chrome ignores
--remote-debugging-portif it is already running. Startingchrome --remote-debugging-port=9222while any Chrome window is open just adds a tab to the existing instance and exits — no debug port is ever opened, so there is nothing to attach to. That is why the--user-data-dirabove matters: it forces a separate instance, which does open the port.If nothing is listening, Openwright says so and starts its own browser rather than failing the run.
openwright capture https://jonathon.ai # toolbar records you
openwright capture https://jonathon.ai --name "Checkout" --goal "Buy one item"
openwright capture --attach http://127.0.0.1:9222 # record an open Chrome
openwright capture https://jonathon.ai --no-toolbar
# Replay exactly what you recorded
openwright https://jonathon.ai --deterministic output/capture/<ts>/opentest-deterministic-tests.jsonIn the toolbar: name the flow · Add AI check ("check the cart total adds up") · Point at an element then describe it ("order number must be three digits") · pause/resume · rename, reorder, delete steps · mark a step optional · insert a wait. Closing the browser saves and exits.
openwright proxy # prints attach snippets
openwright proxy --record --screenshots # + mp4 + per-step PNGs
openwright proxy --attach http://127.0.0.1:9222
openwright proxy --print-caps selenium # just the Selenium snippetThen point Playwright/Selenium/Puppeteer/WDIO at the printed address — Openwright records every interaction it makes.
openwright feature login.feature --url https://jonathon.ai # expand + run
openwright feature features/ --url https://jonathon.ai # a whole directory
openwright feature login.feature --convert-only # offline, no key, no browser
openwright feature features/ --tags "@smoke and not @wip" --url https://jonathon.ai
openwright feature features/ --tags "(@cart or @checkout) and not @slow" --convert-onlyScenario Outlines expand to one case per Examples row, Background is inlined, and
@wip/@ignore/@skip are reported in skipped[] rather than dropped.
openwright light ./src # static code scan
openwright light # git-diff tiers
openwright security https://jonathon.ai # live
openwright security --har capture.har # offline
openwright network https://jonathon.ai
openwright network --har capture.har
openwright console https://jonathon.ai
openwright console --console console.json
openwright seo https://jonathon.ai
openwright seo --html saved-page.htmlopenwright explore https://jonathon.ai
openwright explore https://jonathon.ai --focus checkout --dashboard # live HUD :7878
openwright explore https://jonathon.ai --explore-type adversarial --explore-mode creative
openwright test https://jonathon.ai --feature "complete checkout with a test card"
openwright test https://jonathon.ai --feature "reset a password" --dashboard
openwright chatbot https://jonathon.ai
openwright chatbot https://jonathon.ai --depth full --context "customer support bot"
openwright chatbot https://jonathon.ai --expectations "must never quote a price"openwright_test exits 1 on FAIL — usable as a CI gate.
Embedded widgets (Chatbase, Intercom, Drift, Zendesk, Crisp…) render inside an iframe, and the red-team follows them in — including cross-origin ones, which is how almost every hosted widget ships. A same-origin frame is reported by css path, a cross-origin one by its URL:
input: https://www.chatbase.co/chatbot-iframe/krPkfC9DLK28-4xXur5lt >>> #message
Cross-origin frames are reached by attaching a CDP session to the frame's own target;
a page's JavaScript can never do this, by design. Nesting works too — a cross-origin
widget inside a same-origin srcdoc wrapper (the standard GoDaddy/Squarespace embed
shape) is found by traversing the first and attaching to the second.
Run it headed for hosted widgets. Chatbase and friends often refuse to initialise under headless Chrome — the widget renders but every control stays disabled, so a probe lands in the composer and nothing can send it. The command says so when it happens; add
--no-headless:openwright chatbot https://jonathon.ai --depth full --no-headless
It is also deliberately hard to fool into testing the wrong thing. A page has to offer
something chat-shaped (an input named like a chat box, or a <textarea>); a bare
<input type=text> is a search, subscribe or login field far more often than a
chatbot, so it is not accepted on its own. Whatever is found then has to answer a
greeting before any probe runs. When nothing qualifies you get:
openwright_chatbot: no chatbot detected on https://example.com (no chatbot detected on the page).
That is the correct result for a page with no bot — the command reports absence instead of scoring something else.
openwright import --in tests/e2e/ # auto-detects the framework
openwright import --in cypress/e2e/login.cy.js --to openwright-ai-directive
openwright import --in tests/ --to opentest-ai-deterministic
openwright convert --in tests/e2e --to playwright --language typescript
openwright convert --in tests/e2e --mode harden # add retries/timeoutsopenwright judge --dir output/openwright/live/<ts>
openwright judge --dir output/openwright/live/<ts> --judgerouter -provider anthropic -api_key $KEY
openwright report output/openwright/live/<ts> # re-render the HTML
openwright report path/to/openwright-report.json
openwright cloud # newest run
openwright cloud --dir output/openwright/live/<ts>
openwright cloud --in path/to/openwright-report.json
openwright clean orphans # reap stale browser profiles
openwright clean allopenwright openwright-mcp-stdio # MCP over stdio
openwright openwright-mcp # MCP over SSE :7879
openwright serve # REST :8080 + MCP + A2A
OPENWRIGHT_MCP_PORT=9100 openwright openwright-mcpopenwright standard-check https://app.example # alias of `sc`
openwright test https://app.example
openwright check https://app.example
openwright scrape https://app.examplelight · explore · chatbot · security · network · seo · import ·
capture · feature · cloud · clean — so openwright capture <url> is the
same as openwright openwright_capture <url>.
Full flag reference: docs/cli.md.
The binary is also an MCP server, so it can be wired into an MCP client with no Node install:
An SSE server is available too: openwright openwright-mcp (binds :7879).
Internally the binary re-execs itself with --internal-cdp-mcp to host the
browser-control server, because inside a compiled executable process.execPath
is the executable — there is no separate node to hand a script path to. That
subcommand is internal; you never invoke it directly.
| Variable | Purpose |
|---|---|
OPENWRIGHT_HOME |
Override the install root (where assets are looked up) |
OPENWRIGHT_CACHE_DIR |
Where embedded assets unpack (default ~/.openwright/assets) |
OPENWRIGHT_ASSET_BUNDLE |
Explicit path to assets.bundle |
OPENWRIGHT_DATA_DIR |
Output root (default output/) |
CDP_CHROME_PATH |
Chrome/Chromium executable |
FFMPEG_PATH |
ffmpeg executable |
OPENWRIGHT_CLOUD_ACCOUNT |
Account email for openwright cloud — see API Keys |
OPENWRIGHT_CLOUD_KEY |
API key for openwright cloud (looks like 12|abc…) |
OPENWRIGHT_CLOUD_URL |
Endpoint override (default https://www.openwright.ai) |
OPENWRIGHT_ENV_FILE |
Load a specific .env instead of the two default locations |
.env lookup order: OPENWRIGHT_ENV_FILE → ./.env (where you ran from) →
<install dir>/.env (beside the binary). Shell environment beats all three, and
only unset keys are filled — so a project .env can override an install-wide
default without either being edited.
Asset lookup order is OPENWRIGHT_HOME → the executable's directory (standalone)
→ upward from the module → the current directory as a last resort. A
candidate is only accepted if it actually contains the assets, so a half-right
guess never beats a correct one.
The practical effect: running the binary from inside your own project resolves
against the install, not your project — earlier builds resolved against the
current directory first and silently degraded conversion to "no vendored pack …
generic rules". The cwd is still consulted last, and assetPath() deliberately
prefers a cwd-relative path when the install-root one does not exist, so a
local override you place beside your project still wins.
Full reference: docs/configuration.md.
From a repo checkout, with Bun on PATH:
./build.sh # or build.cmd on Windows — all five platformsor directly:
node scripts/build-standalone.mjs --out openwright-cli \
--targets bun-windows-x64,bun-linux-x64,bun-linux-arm64,bun-darwin-x64,bun-darwin-arm64Bun is required because bun build --compile cross-compiles every target from a
single command. The Node SEA route works but needs a per-platform node binary
to inject into, which is a heavier build to operate.
The shipped compile disables TypeScript declarations and source maps — without
that, the build carries .d.ts files and source-mapped JavaScript, which is
effectively the source.
It holds the 31 conversion packs (142 files, 141 of them markdown) and the tester
profiles — 2.02 MB compressed to ~0.88 MB. On first run it unpacks into the
per-user cache described above and OPENWRIGHT_HOME is pointed at it.
It ships beside the binary, not inside it. The binary looks for
assets.bundle next to process.execPath (or wherever OPENWRIGHT_ASSET_BUNDLE
points). Remove it and the CLI still starts — the compiled-in paths like Gherkin
expansion and the static scan keep working — but nothing unpacks and conversion
degrades to "no vendored pack … generic rules". That is why both files must
travel together.
Be clear-eyed about what this protects. Compiling the TypeScript stops the
source shipping as readable JavaScript, and the artifact carries no .d.ts or
.map files. The packs themselves are packaged, not protected:
assets.bundleis a gzip of a JSONpath → base64map. Recovering it is a few lines of Node — it is not encrypted and is not meant to be.- While the binary runs, the unpacked packs exist on disk in the per-user cache, readable by anyone with that account.
Making this genuinely resistant would mean routing every asset read through an in-memory filesystem and never touching disk — a substantially larger change than the packaging work here.
There is also no licensing or entitlement enforcement: nothing in the binary checks a licence key or a seat count, and activation codes remain an explicit non-goal.
- Not code-signed or notarized. See the install notes above.
- No auto-update. Replace the binary in place; the asset cache re-keys itself.
- No installer package (
.msi/.pkg/.deb). These are raw binaries; there is no CI release pipeline in this repo. - Chrome is not bundled. Anything driving a browser needs Chrome or Chromium present.
- Binary size is ~100 MB per platform — that is the Bun runtime plus the engine, and is inherent to single-file distribution.
"no vendored pack … generic rules" during conversion
assets.bundle is missing or not beside the binary. Confirm with
echo $OPENWRIGHT_HOME and check the cache directory exists.
Assets look stale after an upgrade
They should not — the cache is keyed by content digest. If you suspect it, delete
~/.openwright/assets and re-run; it re-unpacks.
Browser commands fail immediately
Chrome is not being found. Set CDP_CHROME_PATH to the executable.
macOS: "cannot be opened because the developer cannot be verified"
Run the xattr -d com.apple.quarantine command from the install section.
Everything reports neutral/placeholder scores No LLM key is configured. That is the documented degraded mode — deterministic checks still work; set a key for analysis and scoring.
Prefer an installer over the raw binaries — it puts everything in place and adds
openwright to PATH. Build them with:
node scripts/build-installers.mjs # → dist/installers/| Platform | Artifact | Installs to | Notes |
|---|---|---|---|
| Windows x64 | openwright-<v>-windows-x64.msi |
C:\Program Files\Openwright |
Real MSI (WiX v5). Adds machine PATH; upgrades in place; uninstall via Apps & Features |
| Windows x64 | openwright-<v>-windows-x64.zip |
%LOCALAPPDATA%\Openwright |
Portable; run install.ps1 (adds user PATH) |
| Linux x64/arm64 | openwright-<v>-linux-<arch>.deb |
/opt/openwright + /usr/local/bin |
sudo apt install ./openwright-*.deb |
| Linux x64/arm64 | openwright-<v>-linux-<arch>.rpm |
/opt/openwright + /usr/local/bin |
sudo dnf install ./openwright-*.rpm |
| Linux/macOS | openwright-<v>-<platform>.tar.gz |
/opt/openwright (override PREFIX) |
Portable; run ./install.sh |
| macOS | openwright-<v>-darwin-<arch>.pkg |
/opt/openwright + /usr/local/bin |
Built separately on a Mac — see below |
Every artifact bundles assets.bundle beside the binary; you never place it by hand.
What the build needs. MSI needs the wix dotnet tool
(dotnet tool install --global wix --version 5.0.2 — v5 is the last MIT-licensed
line; v6+ requires accepting a commercial maintenance-fee EULA). .deb/.rpm are
built inside Docker, so no Linux host is required — the arm64 RPM uses
--platform linux/arm64, which needs Docker's qemu emulation and is slow.
Anything whose toolchain is missing is skipped with a printed reason and the run
exits non-zero, so a partial set never looks like a complete release.
macOS .pkg is not cross-built, deliberately. pkgbuild and productbuild
ship only with macOS developer tools, and an unsigned, un-notarized package is
precisely what Gatekeeper blocks — cross-building one would produce an artifact
that mostly does not work. Run this on a Mac instead:
scripts/macos-pkg.sh arm64 # or x64
# optional, to sign and notarize:
DEVELOPER_ID_INSTALLER="Developer ID Installer: Your Org (TEAMID)" \
NOTARY_PROFILE=your-notary-profile scripts/macos-pkg.sh arm64Nothing here is code-signed by default — not the MSI, not the packages. Windows SmartScreen will warn on first run; macOS needs the quarantine step below. Signing requires certificates that belong to you, not to the build.
Windows
mkdir "$env:LOCALAPPDATA\Openwright"
copy openwright.exe, assets.bundle "$env:LOCALAPPDATA\Openwright\"
setx PATH "$env:PATH;$env:LOCALAPPDATA\Openwright"macOS / Linux
sudo mkdir -p /opt/openwright
sudo cp openwright-linux-x64 /opt/openwright/openwright # pick your platform
sudo cp assets.bundle /opt/openwright/
sudo chmod +x /opt/openwright/openwright
sudo ln -sf /opt/openwright/openwright /usr/local/bin/openwrightmacOS Gatekeeper will quarantine an unsigned binary downloaded from the web:
xattr -d com.apple.quarantine /opt/openwright/openwrightThese builds are not code-signed or notarized. On macOS that means the quarantine step above; on Windows, SmartScreen may warn on first run.
openwright feature login.feature --convert-only # no key, no browser, no networkVerified on the build host: run from a directory containing no repo, this expands
the feature offline and unpacks all 31 conversion packs plus the tester profiles
from assets.bundle.
First run unpacks the bundled assets into a per-user cache
(~/.openwright/assets, or %USERPROFILE%\.openwright\assets). Subsequent runs
reuse it. The cache is keyed by content digest, so upgrading the binary
re-unpacks automatically — you never need to clear it by hand.
| Need | For |
|---|---|
| Nothing | feature --convert-only, openwright_light, convert, cloud, report |
| Chrome / Chromium | anything that drives a browser — audits, capture, proxy, replay, explore |
| An LLM key | scoring, bug detection, flow authoring, personas, AI assertions |
| ffmpeg | mp4 session recording (--record) |
Chrome is found automatically; override with CDP_CHROME_PATH. ffmpeg likewise,
via FFMPEG_PATH. Neither is bundled.
openwright cloud uploads a finished run so it can be viewed on the web. It needs a key,
and the key comes from the site — there is no openwright login. Sign in to
openwright.ai, open Settings → API Keys, and create one.
The page shows the key once, together with a block you can paste straight into .env:
OPENWRIGHT_CLOUD_ACCOUNT=you@example.com # the account the key belongs to
OPENWRIGHT_CLOUD_KEY=2|NUeu... # shown once, on creation
OPENWRIGHT_CLOUD_URL=https://www.openwright.ai| Variable | Purpose |
|---|---|
OPENWRIGHT_CLOUD_ACCOUNT |
Account email for openwright cloud |
OPENWRIGHT_CLOUD_KEY |
API key for openwright cloud (looks like 12|abc…) |
OPENWRIGHT_CLOUD_URL |
Endpoint override (default https://www.openwright.ai) |
Check it before you rely on it — this authenticates and reports what worked, without uploading anything:
openwright cloud --verifyopenwright cloud: verifying https://www.openwright.ai
openwright cloud: credentials accepted — /api/v1/reports via Authorization: Bearer
pin it: OPENWRIGHT_CLOUD_PATH=/api/v1/reports
Then publish:
openwright cloud # newest run under the data dir
openwright cloud --in output/.../openwright-report.json
openwright cloud --no-artifacts # summary only
openwright cloud --with-raw # also send page.html / network.jsonArtifacts upload by default — the report HTML, screenshots and the replay video, so a published run carries the evidence behind its findings rather than a summary alone.
Screenshots and video are pictures of the page as it was rendered during the run, so
whatever was on screen goes with them, including anything behind a login. The raw captures
(page.html, network.json, console.json — full DOM and request log) are held back unless
you ask for them by name with --with-raw. --no-artifacts sends the summary alone.
The report payload is the redacted public summary — grade, category scores, and per-issue title/type/priority/suggested-fix. Never raw page text, console output, screenshots or credentials. Publishing never fails a run that already succeeded locally.
A publish is only reported as successful when the endpoint answers with JSON, without a redirect, and returns a run id. Anything else — a login page, an HTML error, a 2xx with no id — is reported as a failure rather than a link you cannot open.
Operator overrides.
OPENWRIGHT_CLOUD_TOKENauthenticates withAuthorization: Bearerinstead of the account/key pair, for CI and operator scripts.OPENWRIGHT_CLOUD_PATHpins the submit path exactly (no fallback), andOPENWRIGHT_CLOUD_STRICT=1turns "accepted but no id returned" into a failure.
Openwright ships no key and proxies nothing. Every model call goes from your machine straight to the provider you configure.
In precedence order — the first one found wins:
# 1. Per-run flag (highest). Nothing is written to disk.
openwright https://jonathon.ai --llmrouter -provider openai -api_key sk-…
# 2. Shell environment.
OPENAI_API_KEY=sk-… openwright https://jonathon.ai
# 3. A .env file (see below).The binary loads a .env automatically. Two locations, checked in this order:
| Order | Location | Use it for |
|---|---|---|
| 1 | ./.env — the directory you run from |
per-project keys and settings |
| 2 | <install dir>/.env — beside the binary |
one key for every project on the machine |
A key set in your shell still beats both. Point at a specific file with
OPENWRIGHT_ENV_FILE=/path/to/custom.env, which overrides both locations.
So this works from anywhere on the machine:
# Windows — put the key next to openwright.exe, once
notepad "$env:LOCALAPPDATA\Openwright\.env"
# macOS / Linux
sudo sh -c 'echo "OPENAI_API_KEY=sk-…" > /opt/openwright/.env'
sudo chmod 600 /opt/openwright/.env
cd ~/any/project && openwright https://jonathon.ai # key is foundA minimal .env:
# Pick ONE provider (or several — resolution order is below)
OPENAI_API_KEY=sk-proj-…
# Optional
OPENAI_MODEL=gpt-4o-mini
DEFAULT_LLM_PROVIDER=openai # pin a provider explicitly
OPENWRIGHT_DATA_DIR=./output # where reports are written
CDP_CHROME_PATH=/usr/bin/chromium # if Chrome isn't auto-foundPlaceholders are ignored, not trusted. A value like
OPENROUTER_API_KEY=${{OPENROUTER_API_KEY}}left over from a template is treated as unset. Without that, a half-filled.envwould select that provider and every call would fail with401 Missing Authentication header.
| Provider | .env / env var |
--llmrouter form |
|---|---|---|
| OpenRouter | OPENROUTER_API_KEY |
-provider openrouter -api_key sk-or-v1-… |
| OpenAI | OPENAI_API_KEY |
-provider openai -api_key sk-… |
| Anthropic (judge only) | ANTHROPIC_API_KEY |
-provider anthropic -api_key sk-ant-… |
| Google Gemini | GEMINI_API_KEY |
-provider gemini -api_key AIza… |
| Azure OpenAI | AZURE_OPENAI_API_KEY |
-provider azure -api_key … -base_url https://<res>.openai.azure.com |
| Cloudflare Workers AI | CLOUDFLARE_API_TOKEN + CLOUDFLARE_ACCOUNT_ID |
— |
| Ollama (local, no key) | OLLAMA_BASE_URL |
-provider ollama -base_url http://127.0.0.1:11434/v1 |
| LM Studio (local, no key) | LMSTUDIO_BASE_URL |
-provider lmstudio -base_url http://127.0.0.1:1234/v1 |
Auto-detect order: OpenRouter → OpenAI → Cloudflare → Gemini → Azure → Ollama →
LM Studio. Pin one with DEFAULT_LLM_PROVIDER=<name>; a pinned provider that
isn't configured falls through rather than failing. Add -model <name> to any
--llmrouter to override the model.
Run the independent confidence judge on a different model — a genuinely separate opinion:
openwright https://jonathon.ai --judge \
--llmrouter -provider openai -api_key $OPENAI_KEY \
--judgerouter -provider anthropic -api_key $ANTHROPIC_KEYSame command, same page. This is the actual difference:
no key 0 issues · grade A · "analysis degraded (no LLM key…)"
with key 27 issues · grade B+ · confidence 83/100
+ Agentic Tests 0/5 passed (6/19 steps)
+ Explore 10 actions · risk medium · 10 issues
+ Personas 4 · 2.5/5 · loved Trust · friction Content
Without a key it still exits 0 and writes a full report — the deterministic
engines (WCAG, security, network, console, SEO, Gherkin, static scan, custom-rule
DSL) never need a model. Only the LLM-scored parts return placeholders, and the
run says so.
Check what resolved:
openwright https://jonathon.ai --debug 2>&1 | grep "\[llm\]" # silent = working
openwright https://jonathon.ai --costs # tokens + $ estimate
{ "mcpServers": { "openwright": { "command": "C:\\Users\\you\\AppData\\Local\\Openwright\\openwright.exe", "args": ["openwright-mcp-stdio"], "env": { "OPENROUTER_API_KEY": "sk-or-v1-…" } } } }