macOS native browser automation CLI via Safari + AppleScript.
Your browser, your session. Unlike headless Chromium tools, safari-browser controls the Safari you already use — localStorage, cookies, SSO sessions, 2FA tokens all persist. No login scripts, no cookie injection, no auth management.
| Headless tools (Playwright, etc.) | safari-browser | |
|---|---|---|
| Login state | Fresh browser every time | Permanently logged in |
| Bot detection | Frequently blocked by banks, social media | Real Safari — undetectable |
| 2FA / SSO | Must handle every time | Session already verified |
| User visibility | Hidden Chromium instance | Your Safari — watch and intervene anytime |
| macOS integration | None | Keychain, iCloud cookies, Safari Extensions |
Need login? ──── Yes → safari-browser
└── No → Need headless/CI? ──── Yes → Playwright / agent-browser
└── No → Either works
- Enterprise SaaS (Notion, Slack, JIRA) — SSO session persists
- Banking & finance — banks block headless browsers
- Social media (Facebook, Instagram, X) — bot detection immune
- AI + human collaboration — user watches AI work, takes over anytime
- 2FA / MFA sites — already authenticated in Safari
- Extract API tokens —
safari-browser js "localStorage.getItem('token')"
Run safari-browser mcp from an MCP client using the installed executable's
absolute path. The server provides all 79 public CLI leaf commands,
including help, setup and daemon controls. Names follow the command path:
wait becomes safari.wait, and tab focus becomes safari.tab.focus.
Hidden commands and the MCP transport are excluded. The catalog and input
schemas come from the running ArgumentParser metadata; no separate command
implementation or hand-maintained tool list is used.
For a client's stdio server configuration, substitute your actual home path:
{
"command": "/Users/YOUR_NAME/bin/safari-browser",
"args": ["mcp", "--timeout", "300"]
}Tool arguments have optional options, positionals and UTF-8 stdin fields;
required CLI arguments remain required in their generated schema. Values stay
strings because the metadata does not expose trustworthy Swift scalar types.
A flag's true adds that flag and false omits it. CLI defaults, value ranges,
mutually exclusive options and target checks still apply. For example:
{
"name": "safari.wait",
"arguments": {"positionals": {"milliseconds": "0"}}
}The server supports MCP 2026-07-28 per-request metadata, plus legacy
2025-06-18 and 2025-11-25 initialization. Modern requests include
params._meta["io.modelcontextprotocol/protocolVersion"] and
params._meta["io.modelcontextprotocol/clientCapabilities"]; server/discover
reports capabilities and versions. Legacy clients send initialize, then
notifications/initialized. tools/list returns at most 50 tools and an opaque
nextCursor; pass it as the next request's cursor. Only the tools capability
is advertised. MCP versioning.
By default, --worker-mode persistent lazily starts one supervisor and one
reusable CLI worker. Each call parses a fresh command and receives separate
stdin/stdout/stderr, trace and dialog-probe state. Healthy sequential calls reuse
the actual worker process. --worker-mode isolated explicitly selects a fresh
worker for every call, with its own lifetime supervisor. Idle persistent workers exit after 30 seconds;
--worker-idle-timeout accepts a finite value from 0.001 to 86400 seconds.
Within MCP, command subprocesses use POSIX spawn to inherit that worker’s process
group from creation. Ordinary CLI subprocesses keep the existing Foundation
launcher. This prevents nested exec and external commands from escaping MCP
cancellation; an explicitly started daemon detaches into its own group.
Workers use direct execution rather than implicit daemon routing; explicit daemon
tools retain their usual behavior. CLI output cannot enter the MCP protocol
stream. Results preserve separate stdout and stderr objects containing
encoding (utf-8 or base64) and data, plus exit_code, capture_complete
and failure. Text content presents diagnostics before stdout. A nonzero exit,
failed capture or transport limit produces isError: true. The output schema
describes this common capture envelope; command-specific JSON remains in stdout.
capture_complete describes capture, not command success. An isolated worker's
executable-identity rejection is a fully captured CLI error (exit 64, isError: true); a persistent pre-dispatch identity rejection also sets failure and
capture_complete: false. Both require restarting the server, never replaying
the failed request automatically.
There is one active tool call per server. Other calls receive a busy error
stating that they were not executed; ping, discovery and cancellation remain
available. The default timeout is 300 seconds (--timeout accepts 0.001–86400).
Pending replies are bounded to 64 frames and 16 MiB, including the frame being
written; reaching that queue limit closes the transport. Input processing and
EOF cleanup do not wait for the client to drain stdout. Limits are 2 MiB per
captured output stream, 4 MiB for stdin and 8 MiB per RPC frame. Oversized or incomplete command capture is a tool error, never a successful
silent truncation. Closing stdin means shutting down the transport: EOF cancels
active and idle workers and discards pending replies. Keep stdin open until you receive
the matching complete response. A missing or interrupted reply leaves the outcome
unknown, regardless of the server process’s exit status, and must not trigger an
automatic retry. Cancellation stops its process group and suppresses the active call’s response. Cancellation that arrives after
a call has completed and submitted its final response has no effect, including
when that response is still queued for delivery. Cancellation cannot undo earlier
effects or an explicitly started persistent daemon. Calls are never retried automatically.
After a crash, only a later distinct call can create a new worker, after cleanup
is confirmed. Unconfirmed cleanup prevents additional workers from starting.
Calls near the OS argument/environment size limit, or whose private encoding
exceeds the transport cap, select supervised one-shot execution before dispatch.
They preserve kernel admission and the same invocation deadline; this is not a
retry of a failure. Cancellation can race command startup during the bounded
grace period; it does not guarantee that an admitted command never starts.
The one-shot supervisor retains pending TERM from process creation and rejects
CLI startup when cancellation is observed before its final spawn check. The
check and kernel spawn are not atomic; cancellation arriving between them may
still allow brief execution. A CLI born after that TERM may only receive the
later KILL. Already-running CLI children receive TERM normally, preserving their
cleanup grace and the independent lease monitor. Pending checks remain on the
bootstrap's initial main thread because Darwin exposes thread-specific pending signals.
A persistent timeout may retain exit_code: null if no correlated completion
arrives. Isolated mode reports the actual signal status when its record is
available, otherwise null. Both outcomes remain incomplete errors and must not
be replayed automatically. The same one-shot owner is retained across calls: a cleanup
failure blocks both another one-shot and a new persistent pair. Userspace cleanup
returns within its configured budget while retaining an unconfirmed reservation;
lost ownership stops further signaling and requires restarting the server.
Restart the server after updating its executable: workers check the loaded Mach-O build UUID before running a command, including nested CLI calls. The host and a warm persistent worker also inspect the executable at the launch path before dispatch. A missing, malformed or changed image invalidates the runner until the server is restarted. This check is build consistency, not additional code-signing trust. MCP does not grant Accessibility, Automation, Screen Recording or Full Disk Access; the existing installation and permission requirements still apply. Tool metadata does not establish user authorization for a browser action.
make test-mcp checks both persistent and isolated modes, real stdio, every public
help route and representative CLI parity without operating Safari UI or querying personal databases. It does not
claim live GUI side-effect coverage for all tools.
git clone https://github.com/PsychQuant/safari-browser.git
cd safari-browser
make installBinary installs to ~/bin/safari-browser. Ensure ~/bin is in your $PATH.
Requirements: macOS 15+, Safari, Swift 6.0+
safari-browser --version # v2.6.0-31-gd454d7cThe string is git describe --tags --match 'v[0-9]*' --always --dirty taken when the binary was built, so it maps back to a release tag or a commit; a -dirty suffix means tracked files had uncommitted changes. It is stamped by make build (and so by make install and make install-signed). A binary built with a bare swift build, or from a source archive without .git, prints unknown. --version reports the binary you ran; it does not ask the persistent daemon, which keeps running the older binary until it is restarted.
sign-developer-id has been removed (#123). It signed the build directory without
installing; use DEVELOPER_ID=<cert-sha1> make install-signed for a signed install.
The runtime FDA guidance and the standalone guard now share SignatureAssessment
and read Security framework objects. Filename text cannot establish signing identity.
A durable grant survives rebuilds only while the signing identity and designated
requirement remain the same (#122).
The choice is not "dev vs release" — it is whether you will use history,
bookmarks, cloud-tabs, or downloads, the four commands that read
~/Library/Safari/ and therefore need Full Disk Access.
make install |
make install-signed |
|
|---|---|---|
| Signature | ad-hoc | Developer ID (needs DEVELOPER_ID, a certificate SHA-1) |
| Designated requirement | cdhash H"…" — the content hash |
identifier … and certificate leaf[subject.OU] = "…" |
| Full Disk Access grant | dies on the next rebuild, silently | survives rebuilds and version bumps |
| Everything else (Accessibility, Screen Recording, all other commands) | works | works |
Switching between the two re-grants everything, once. TCC keys every grant to the signature, so moving a binary from ad-hoc to Developer ID (or back) makes it a different subject: the Accessibility and Screen Recording grants you already gave do not carry over, and macOS may leave the stale entry visible in System Settings while the new subject has nothing. Remove the old entry and add it again after switching. This is a one-time cost — after that, the Developer ID side stops re-asking on every rebuild, which is the whole point.
DEVELOPER_ID=<cert-sha1> make install-signedTCC stores the designated requirement, not the path. An ad-hoc signature's requirement is the content hash, so rebuilding produces a binary the old grant no longer matches — not revoked, not an error, just no longer applying to anything. There is no warning at the moment it breaks.
Check which state you are in at any time:
make verify-install-signature
# or against any path: make .build/verify-install-signature && \
# .build/verify-install-signature <path>"Cannot hold a durable grant" has several causes and they need different fixes, so the answer is an exit code rather than a yes/no:
Codes 0–7 are statements about the binary. Anything else is a failure to
make one, and is deliberately kept outside that range:
| meaning | what to do | |
|---|---|---|
0 |
the requirement names an identity and the binary satisfies it | nothing |
1 |
the signature is ad-hoc, so the requirement is the content hash | make install-signed |
2 |
no code signature at all | make install-signed |
3 |
the signature does not validate, so TCC will not honour a grant on it. The message carries macOS's own description of the fault and only prescribes a reinstall for paths this project installs | reinstall (ours); investigate (anyone else's) |
4 |
valid seal, but the binary cannot satisfy the requirement it advertises, so a grant recorded against it never applies | make install-signed |
5 |
a requirement shape the tool does not recognise — it cannot tell | inspect it yourself |
6 |
--require-shape was given and the shape is a different, also durable one — install-signed's contract, not a fault in the binary |
sign with the identity that target expects |
7 |
--require-entitlement was given and the signature does not carry it |
re-sign with the entitlements plist |
64 |
usage error — bad flags. Nothing was inspected | fix the command |
70 |
the check could not run — unreadable file, API failure, no HOME. Not a verdict |
fix the environment |
That last split is the sixth round's doing. 2 used to mean both "this binary
is unsigned" and "the check could not run", 4 absorbed two contract failures
whose documented meaning is the opposite of what happened, and a flag missing
its value reached fatalError — which the shebang form surfaced as 5,
"unrecognised shape, inspect it yourself", about a binary that was never
opened. A caller cannot act on a code that means three things.
Direct execution of ./scripts/verify-install-signature.swift also uses the shared
build helper; a build failure returns 70 and is not an ad-hoc verdict. Use the
compiled guard for repeated checks rather than invoking the source with swift.
The guard validates every architecture in a universal binary for both its seal and requirement, so damage to a non-native slice is rejected too (#139).
The guard is compiled (make .build/verify-install-signature) from the shared
assessment and CLI wrapper by scripts/build-signature-guard.py, rather than
run as swift scripts/... for the same reason: the swift driver exits 1 when
it cannot compile the script, and 1 is the verdict "this binary is ad-hoc".
On a machine where swiftly leads PATH and .build was made by another
toolchain — this one, as it happens — that told the user their Developer ID
binary was ad-hoc. A compile failure is now a build failure, loudly, and never
reaches the exit-code contract.
5 is deliberate, and it is the part worth explaining. The tool answers from
the Security framework rather than from codesign's printed output: validity
is an OSStatus, and the designated requirement is a SecRequirement
object, handed straight back to the API to ask whether this binary satisfies
it. Text appears in one place only — deciding whether the requirement is one
of the shapes a standard codesign invocation emits. Anything outside that
set gets "I don't know".
Both halves of that are the lesson of five review rounds. The first four tried to judge arbitrary requirements by searching their text, and each round produced a requirement that walked past the previous rule — a keyword inside a quoted identifier, a negated clause, an extra conjunct. The requirement language has string literals, boolean operators and negation; grep sees none of them.
The fifth round broke the replacement without constructing anything exotic at
all. codesign --verify describes a bundle's unsigned subcomponent with the
words "not signed at all", so a properly Developer ID signed application came
back as unsigned. It describes bundle resource damage as "a sealed resource is
missing or invalid", which says nothing about the executable's own seal — two
applications that run fine were declared SIGKILL-bound, with a rm -f
prescription attached. And codesign -d -r- writes Executable=<path> into
the same stream the requirement is read from, so a filename containing a
newline injects a designated => line of the caller's choosing: two
byte-identical copies of /bin/ls, opposite verdicts.
None of those were failures of pattern-writing. They were failures of treating a human-readable diagnostic as a data interface. Hence Swift, and hence the narrowness: the tool no longer reads requirements it was not taught, and says so instead of guessing.
make test-install-atomic additionally exercises both actual install recipes in
temporary directories, including a running holder of the old inode and failed
signing/verification. It requires a local Developer ID, reports missing identity
as incomplete, and never replaces your installed CLI.
make test-all runs it with ALLOW_INCOMPLETE=1, because the suite needs
two signing identities in the keychain — a Developer ID and a
non-Developer-ID one — to exercise every case. With fewer it skips the cases
that need them, and names every one it could not run.
(How many that is is deliberately not written here. Round 8 found this sentence and the Makefile's each carrying a different wrong number, both introduced by the round that had just fixed two other stale restatements of a fact defined elsewhere. The suite counts them at runtime; a copy in prose cannot.)
make test-all # green anywhere; partial runs allowed, and announced
make test-install-signature-strict # refuses to pass on a partial run
make test-mutation-gate # does the suite DETECT the fixes it contains?make test-mutation-gate is the one to run when changing the guard or the
suite. It reverts each fix declared // @mutant(...) in the guard, one at a
time, and requires the suite to go red on a named assertion that was green
before. A fix the suite cannot detect the loss of is reported as a surviving
mutant, and a survivor fails the gate.
It exists because being green was not evidence. After nine review rounds the suite held 35 green assertions, and reverting the guard's fixes one at a time — measured, not argued — left it at 35/35 for eight of sixteen. Each round had added assertions for the instance it had just fixed; none had asked whether those assertions could see the fix removed.
A new fix to the guard should arrive with a declaration. The gate cannot enforce that — a fix can be one character, and no extraction tells a line that closes a review finding from any other line — so it is a review obligation, stated here, in the suite's header, and in the gate's.
It is deliberately not part of make test-all: it compiles the guard and runs
the whole suite once per mutant, and needs both identities.
Use the strict target when changing the guard. make test-all deliberately
does not gate on it: round 6 measured the non-strict suite going red on a
machine holding only a Developer ID — the exact configuration this section
tells you to have — and on a plain clone by anyone without an Apple Developer
account, for whom make install is the right target. A green-build gate that
fails the people the feature serves is worse than one that tells them what it
skipped.
(make verify-install-signature collapses every failure to make's own exit 2;
run .build/verify-install-signature <path> directly to tell them apart.)
Without a Developer ID certificate, make install is still the right choice —
the other three permissions are unaffected. You will simply need to re-grant
Full Disk Access after each rebuild if you use the four local-data commands.
# Navigate
safari-browser open "https://example.com"
# Discover interactive elements
safari-browser snapshot
# @e1 input[type="email"] #login-email placeholder="Email"
# @e2 input[type="password"] placeholder="Password"
# @e3 button "Sign In"
# Interact using @refs or CSS selectors
safari-browser fill @e1 "user@example.com"
safari-browser fill @e2 "secret"
safari-browser click @e3
# Wait for navigation
safari-browser wait --for-url "dashboard"
# Extract data
safari-browser get text "h1"
TOKEN=$(safari-browser js "localStorage.getItem('token')")safari-browser open <url> [--new-tab] [--new-window]
safari-browser back / forward / reload / closesafari-browser snapshot # scan interactive elements → @e1, @e2...
safari-browser snapshot -c # compact (exclude hidden)
safari-browser snapshot -d 3 # limit DOM depth
safari-browser snapshot -s "form" # scope to selector
safari-browser snapshot --json # JSON array output
safari-browser snapshot --page # full page state (accessibility tree + metadata)
safari-browser snapshot --page --json # full page state as JSON
safari-browser snapshot --page -s "main" # scoped page scanAll selector-accepting commands support @eN refs.
safari-browser click <sel> # click
safari-browser dblclick <sel> # double-click
safari-browser fill <sel> <text> # clear + fill (fires input/change events)
safari-browser type <sel> <text> # append text
safari-browser select <sel> <value> # dropdown
safari-browser hover <sel> # hover
safari-browser focus <sel> # focus
safari-browser check / uncheck <sel> # checkbox
safari-browser scroll <dir> [px] # up/down/left/right (default 500px)
safari-browser scroll <dir> [px] --selector <sel> # scroll a nested container
safari-browser scrollintoview <sel> # scroll into view
safari-browser drag <src> <dst> # drag and drop
safari-browser highlight <sel> # red outline (debug)--selector scrolls a nested scrollable container (message lists, feeds)
rather than the page — window.scrollBy does nothing to those, and the page
around them often has nothing to scroll at all. The command reports the two
outcomes that would otherwise be silent no-ops: an element with no overflow
fails with Element matches but is not scrollable (usually the selector
matched a wrapper rather than the child carrying the overflow, so the error
includes a one-liner that lists the page's real scroll containers), and a
container already at the end of its range prints a did not move note to
stderr — not an error, but a loop that cannot see it never terminates.
safari-browser press Enter
safari-browser press Tab / Escape
safari-browser press Control+a # modifier combos
safari-browser press Shift+Tabsafari-browser find text "Submit" click
safari-browser find role "button" click
safari-browser find label "Email" fill "user@example.com"
safari-browser find placeholder "Search" fill "query"safari-browser get url / title / source
safari-browser get text [selector] # full page or element
safari-browser get html <sel> # innerHTML
safari-browser get value <sel> # input value
safari-browser get attr <sel> <name> # attribute
safari-browser get count <sel> # element count
safari-browser get box <sel> # bounding box (JSON)safari-browser is visible <sel> # true/false
safari-browser is exists <sel>
safari-browser is enabled <sel>
safari-browser is checked <sel>
safari-browser is dialog # true/false; unknown exits 2
safari-browser is dialog --json # state, window_id, messages, reasonis dialog independently inspects the selected tab in Safari's main window using
Accessibility, so it does not wait behind a blocked JavaScript invocation. It
accepts no selector or target flags and does not activate Safari, change tabs,
or dismiss anything. Its AX observation has an 800 ms budget, separate from CLI
startup. true and false exit 0; unknown exits 2 with a reason on stderr.
JSON uses present, clear, or unknown and preserves the same exit behavior.
A clear result requires a complete observation of the same visible,
non-minimized window. Missing permissions, unavailable GUI, changed identity,
partial reads, or an empty observation with unconfirmed window visibility produce
unknown. A result is an observation, not a guarantee that a later click cannot
open a dialog or that every background tab is free of pending dialogs. Safari
need not be the foreground app. Any native blocking sheet/dialog in the
identified window counts as present; the result is not limited to JavaScript
alerts. Explicit
is dialog remains available when automatic entry probing is opted out.
safari-browser js "<code>" # execute JS, print result
safari-browser js --file script.js # from file
safari-browser js --large "<code>" # chunked read for large output (>1MB)
safari-browser js --output file "<code>" # write result to filejs is eval-free (#76): it works on strict-CSP pages (script-src without
'unsafe-eval' — facebook.com, claude.ai, most modern sites). A single
expression prints its value (js "1+1" → 2). A multi-statement script runs
as a function body — use return for a value:
safari-browser js "var a = 2; return a + 3;" # → 5Breaking change vs pre-#76: multi-statement scripts no longer yield the last
expression's value implicitly (old page-context eval() semantics); without
return the result is undefined. Code that itself calls eval()/new Function() still fails on strict-CSP pages — the error includes a hint.
Two edge notes (#76 verify round):
- Grammatically ambiguous inputs now parse as expressions:
js "{}"yields[object Object](eval treated it as an empty block →undefined), andjs "function f(){}"/js "class A {}"yield the source text instead of declaring anything.
The exec script js step and the CLI js command agree for the snippets
below — they move between the two unchanged (#80, measured). Beyond these cases
the daemon path of exec is not promised to match the CLI: the error channel,
results above 1MB and the CLI-only options (--file, --large, --output) are not
covered, and known result differences are tracked in #220:
| Snippet | CLI js |
exec js step |
|---|---|---|
1 + 1 |
2 |
2 |
document.title |
the title | the title |
{} |
[object Object] |
[object Object] |
var a = 2; a + 3 |
undefined |
undefined |
var a = 2; return a + 3 |
5 |
5 |
They arrive there by different routes — the CLI wraps your code so it can
carry results, errors and >1MB payloads across an AppleScript boundary that
only returns strings, while an exec step hands the code to Safari's
do JavaScript unwrapped — but Safari evaluates a script as a function body
too, so both end up needing return for a multi-statement value.
#80 was filed expecting exec to keep completion-value semantics
(var a = 2; a + 3 → 5). It does not, and the table above is the measured
behavior on both surfaces.
safari-browser screenshot [path] # window screenshot (default: screenshot.png)
safari-browser screenshot --full path # full page
safari-browser pdf --allow-hid [path] # export as PDF; refuses an existing destination
safari-browser pdf --allow-hid --overwrite page.pdf # explicitly allow replacement
safari-browser upload <sel> <file> # native file dialog (default, fast, large files OK)
safari-browser upload --js <sel> <file> # JS DataTransfer injection (no permissions, slow for large files)upload uses native file dialog by default when Accessibility permission is granted (fast, any file size). Without permission, it falls back to JS DataTransfer automatically. Use --js to force JS mode. pdf always requires --allow-hid (no JS alternative).
pdf --overwrite is a separate opt-in for replacing a destination and still requires --allow-hid. Without it, an existing effective path is rejected before GUI interaction; a path created later is preserved by an atomic no-replace operation. A path without an extension receives .pdf; an explicit extension (including .txt) is kept. A successful command prints the actual published path with terminal control characters escaped.
Safari exports to a unique private staging .pdf. The command waits for the original save panel to close, then copies and validates a coherent, readable PDF with at least one page before atomically publishing that independent snapshot. Old output, a Save acknowledgement, or a quiet interval cannot establish success. Native steps and file observation share a 60-second budget; this is not a hard real-time guarantee for a stalled filesystem syscall. No Save or uncertain publication is replayed. Any additional staging confirmation is refused, even with --overwrite; cancel the remaining save dialog before retrying. If the native process was interrupted, inspect the clipboard too.
With --overwrite, publication replaces the destination directory entry. A leaf symlink to a regular file or a missing target is replaced without writing through to its target; directories, links to directories, and special files are refused. Existing regular-file permissions are retained, and a new file receives the staging PDF's permissions. Temporary content stays in private directories and is cleaned up on normal success, failure, or cancellation.
PDF export opens the menu using English or Traditional Chinese labels; other menu languages fail explicitly without a Print fallback. Native upload and PDF export may confirm the initial Open/Save sheet for the path you supplied. Initial confirmation requires a unique enabled button named Open, Upload, Save, 打開, 開啟, 上傳, or 儲存, with frontmost and sheet checks. Missing, ambiguous, disabled, or unsupported-language buttons cause refusal; no Return fallback is sent. If the file chooser remains open after refusal, cancel it in Safari before retrying. The keyboard-control warning appears before GUI interaction. Captured confirmation diagnostics are returned afterwards as a terminal-escaped, bounded file dialog trace: on stderr, including on failure or timeout; they record attempted actions and do not prove the file operation succeeded.
Which commands synthesise keyboard/mouse events, which reach Safari another way, and the rule for when a keystroke path may be deleted: docs/operation-paths.md. Note --allow-hid on pdf gates the save-destination keystrokes, not the export itself — that part is already keystroke-free.
safari-browser tabs [--json] # list all tabs (front window)
safari-browser tabs --window 2 # list tabs of window 2
safari-browser tab <n> # switch to tab
safari-browser tab new # new tab
safari-browser tab new --window 2 # new tab in window 2A pending alert or confirm on a background tab may freeze its JavaScript while
remaining absent from Safari’s visible dialog tree. After a process timeout or
an empty get text result, fixed-tab targets get a bounded, read-only activity
check. If the resolved tab is still in the background, stderr explains that a
pending dialog may be hidden. This is a diagnostic possibility, not proof that
a dialog exists; empty text keeps its original success status and timeouts keep
their original error. Unknown/stale observations produce no background claim.
Recheck the target, run safari-browser tab focus with the same target flags,
then inspect safari-browser dialog list. These steps are not performed
automatically, and a timed-out action must not be replayed merely because the
target was in the background. Normal non-empty results add no activity query.
The diagnostic uses a direct osascript call with a 0.3-second timeout plus the
existing maximum one-second termination grace, without requiring AX permission.
dialog list waits up to 800 ms for a complete global inspection. Unresponsive windows, failed reads or traversal limits produce a nonzero incomplete-inspection error, never a successful “no dialog” answer. Entry and global probes share one worker; retry after an in-flight inspection finishes. Dismissal re-reads synchronously and refuses an incomplete or expired observation before pressing a named button.
Dialog messages include Safari’s read-only alert body as well as its source heading. Editable prompt fields are excluded; unreadable or incomplete AX details can still limit the message. Text output escapes control characters and marks truncation.
safari-browser dialog list # show the dialog's text and buttons
safari-browser dialog dismiss --button "取消" # press the button you namedFor an automated handoff, supply --expect-window-id and --expect-message
together when dismissing. The ID is Safari’s stable window ID, not the index
used by --window; the message is the exact raw text, not a truncated or
escaped display string. The command rejects an initial message mismatch and
checks the expected window on the fresh snapshot used to press the named
button. Its existing message/button fingerprint check still applies. Omit both
flags to keep the ordinary named-button workflow.
Commands that resolve a target document or native window check that window
for a blocking dialog before acting. This includes get, js, click,
fill, snapshot, close, pdf, tab focus, upload, save-image,
screenshot, and explicit tabs --window. A confirmed dialog is announced
on stderr; stdout keeps its normal format:
⚠ BLOCKING DIALOG in window id 2838: "Failed to add criteria, not all criteria has been entered." — buttons: "關閉". Run: safari-browser dialog list
Read-only AppleScript commands (get title, get url) still succeed — the
line is on stderr and stdout is untouched, so pipelines keep working. Anything
that has to run JavaScript in that tab (js, click, fill, …) fails at once
with a non-zero exit instead of waiting for the 30-second osascript timeout;
get text runs its AppleScript first and fails only if that came back empty.
documents and tabs annotate listing rows with a visible native-dialog
snapshot, as described below. Exec relays diagnostics in both subprocess and
daemon modes. A --tab deprecation notice or --first-match summary can precede the
dialog warning. 2>&1 | tail -1 still selects the last output line; use
set -o pipefail or inspect the upstream command status to preserve failures.
The native Accessibility walk uses a stable window ID, never AX array order.
It stops at the WebArea boundary, so a page's ARIA dialog does not count as a
native dialog that suspends JavaScript. Failed reads, missing identity,
truncated native UI branches, or a busy/timed-out worker produce unprobed
and a warning, not a claim that no dialog exists.
When the GUI session is locked or unavailable, dialog list, dialog dismiss,
and screenshots report that session problem before attempting AX operations.
Unlock the Mac or run from an active logged-in GUI session, then retry; a locked
session is not evidence that no dialog or Safari window exists. Entry probes
remain unprobed in that state (#144). A session reported as off-console or not
fully logged in is also unavailable, including a background user after fast user
switching. Remote shells must have an available GUI session; SSH-specific behavior
has not been verified.
Probes share a 200 ms total budget per logical command, following #126's
final budget decision. Each worker wait is limited to less than 100 ms; an
unfinished worker prevents new work from being queued. A daemon exec step is
one logical command, matching its subprocess counterpart. Window-ID resolution
uses the normal AppleScript transport and is separate from this AX budget.
Within a command, cached verdicts require the same key and a fresh monotonic
TTL; another window's dialog is never used to classify the target's failure. A
js subcommand invocation additionally permits at most one actual probe across target
resolution and all protocol steps, including timeout diagnostics. Cache expiry,
a changed window or forced refresh after that allowance is spent returns
unprobed; it never extends an old clear/blocked verdict. A timeout retains its
original error when no fresh evidence remains. Other commands retain their
existing refresh behavior. The daemon's in-process exec JS dispatcher currently
bypasses this subcommand wrapper and retains its original refresh policy.
Checks skipped because the single-probe allowance was spent do not add an
incomplete-inspection warning; debug mode labels them skipped: invocation limit.
The underlying state remains unprobed. Time-budget exhaustion and genuinely
incomplete probes retain their existing warning behavior.
SAFARI_BROWSER_DIALOG_PROBE_DEBUG=1 prints per-probe costs; the e2e harness
asserts their per-command sum stays within 200 ms.
SAFARI_BROWSER_NO_DIALOG_PROBE=1 (exactly 1) switches all of this off —
the warning, the fast-fail, and the "probe unavailable" notice — for scripts
that accept going back to the pre-#126 behaviour; SAFARI_BROWSER_DIALOG_PROBE_DEBUG=1
prints the probe's cost and verdict. A dialog that opens during an operation
cannot be predicted by the entry probe. Target-aware timeout paths recheck
only that window when both probe time and invocation allowance remain; otherwise the original error stays. Safari only renders a dialog in a window's
active tab: an alert pending in a background tab freezes that tab's JavaScript
without any dialog to find — tab focus the tab first (#131). Without the Accessibility grant the probe cannot run, and it
says so once rather than staying quiet — no permission means no information,
not "no dialog". (#126)
A JavaScript alert / confirm blocks js, get text and anything else that
runs script in that document — and it may be on a Space you are not looking at,
so dialog list is often the fastest way to find out why a command hung.
Dismissal deliberately makes you name the button, and there is no press-the-default shortcut: clicking an unread dialog can confirm an action you never saw. The dialog's full text is printed before the press so your log records what was dismissed. A title that matches nothing presses nothing and lists the real buttons instead — they are localized.
Dialog text escapes control characters and internal quotes. Each message and the
complete button list is limited to 256 Unicode scalars with explicit [truncated]
markers. Matching still uses original button titles; display escapes are not part
of a title. Inspect the dialog in Safari when the displayed text is incomplete.
If the page swaps the dialog out between list and dismiss, the press is
refused rather than aimed at whatever is there now, and the new dialog's text is
printed so you can decide against it. Same reason: a button you named on one
dialog is not consent for the button in that position on another.
The press uses the Accessibility action, not synthetic input, so the cursor never
moves and --allow-hid is neither needed nor accepted. It does need the
Accessibility grant (see below), and it does change state, which is why it is a
command you type rather than something that happens automatically.
Four read-only commands that query Safari's on-disk data rather than the running browser. They answer "what did I look at before" — the one question no other command in this tool can, since everything else only sees what is currently open. Safari does not need to be running.
safari-browser history --search "agent" --since 2026-01-01 # visited pages
safari-browser bookmarks --folder AI --search "swift" # bookmarks + Reading List
safari-browser cloud-tabs # tabs open on your other devices
safari-browser downloads --limit 20 # download historyAll four take --json. Explanatory text goes to stderr and data rows to stdout.
Text fields escape terminal controls, quotes, backslashes and embedded column
separators; JSON preserves the original values. Use JSON for lossless processing.
bookmarks --search matches title or URL case-insensitively and combines with
--folder as an intersection.
SQLite sources are opened read-only and backed up into memory using SQLite's
Backup API; plist bytes are read directly into
memory. The commands create no new disk copies of browser data, so interruption
cannot leave a new safari-data-* copy behind. SQLite participates in normal read
locking and WAL index maintenance, while source database content stays read-only.
The read transaction ends before parsing/querying the private snapshot. Busy
retries have a five-second budget; filesystem calls themselves remain OS-managed.
This replaces the old independent main/WAL/SHM copy strategy (#111–#113).
If macOS SQLite cannot open a checkpointed WAL database with absent sidecars,
a local APFS/HFS fallback holds an exclusive SQLite-compatible OFD lease before
reading the held descriptor as immutable. The lease requires a read/write file
descriptor solely for locking; no source content or sidecar is written. A nonempty
WAL, unsupported filesystem, or unavailable lease fails explicitly. Only the first
source open can report normal absence; missing auxiliary files cannot produce an
empty success. The lease protects against cooperating POSIX SQLite writers, not
arbitrary raw file modifications.
Missing files are normal absence; source EACCES/EPERM means permission denial;
other I/O errors are reported as I/O errors, with the original source path.
Malformed required fields fail when no examined record is usable. Partial failures
produce stderr counts and locations while retaining valid rows. Missing or
unrepresentable optional dates/counts appear as JSON null, not invented facts.
History stops stepping as soon as it has enough accepted matches. SQLite may still
need an internal scan/sort before yielding rows if no suitable index exists (#117).
history and downloads default to 50 rows (override with --limit)
because they expose a long-term record of behavior; bookmarks and
cloud-tabs have no limit because they show curated or currently-open state.
A source file that does not exist is not an error — cloud-tabs on a Mac that
never enabled iCloud tab syncing exits 0 with an empty result and a note on
stderr. These commands require Full Disk Access, which setup does not
handle; see below.
Publishers can treat a script-issued fetch() from the page as automated
traffic. Safari does not need one to show a PDF: WebKit's network cache already
holds the response body. pdf-cache reads that copy, so no request leaves the
machine and nothing in Safari is driven — it only reads files (and, when you
give a tab flag, asks Safari for that tab's URL).
safari-browser pdf-cache list # PDFs in the cache (URL shown without query)
safari-browser pdf-cache get paper.pdf --url example.org/a.pdf # the tab whose URL matches
safari-browser pdf-cache get paper.pdf --key 9F3A62C1 # a key (8+ hex chars) from `list`
safari-browser pdf-cache list --source webkit-pdfs # the WebKitPDFs-* temporaries
safari-browser pdf-cache get paper.pdf --source webkit-pdfs --file paper.pdfget acts only on an explicit selection — one of a tab flag, --key, or
--source webkit-pdfs --file — and never picks a PDF for you, not even when only
one is cached. A tab is matched by its exact URL (fragment removed); a URL
with no cached record stops and says so (and says how many PDFs in the cache it could
not read, because the one you mean may be among them); one cached in several
partitions stops and lists the candidates rather than guessing. The copy is written
0600 with no ACL entries, only after it opens as a PDF whose pages can all be found
in its page tree (content streams and images are not decoded), and is renamed into
place, so a truncated file never appears under your name. An existing destination is
kept unless --force. A destination inside Safari's own cache folder is refused,
with or without --force. The destination is resolved the way cp resolves it
(link/../x.pdf follows the link; a quoted ~ is a folder named ~).
Only the default profile's network cache is read: a named profile keeps its own
store, so pdf-cache get refuses --profile. That flag is the only way the command is
told about a profile: a tab chosen with --url or --window can still be a tab of a
named profile, whose URL is then looked up in the default profile's cache (#243).
URLs this command prints are shown without their query and fragment, because the
query of a signed URL can be a credential. (When a tab flag matches no tab, the
shared targeting error lists open tabs' URLs in full, as it does for every command;
see #227.) list defaults to 50 rows (--limit).
The record layout is WebKit's private format; only Version 17 is understood, and
anything else fails with what was seen instead of reporting an empty cache. Each
record must name itself (an embedded hash equal to its file name) and must describe
the body file beside it (its recorded body length equals the file's size; one that does
not means Safari was writing or replacing it, and it is left out and counted); a record
for a byte range — part of a resource — is never listed. A store Safari has never
written to (an empty Blobs folder and salt) is an empty cache.
Responses small enough to be stored inside the record itself (no -blob file)
are not covered. The WebKitPDFs-* folders were seen to appear after someone pressed
"Open with Preview" in Safari's PDF viewer (one observation), so that source is opt-in
and the command never presses it for you. Requires Full Disk Access, like the
local-data commands above.
safari-browser setup # show status, then request what is missing
safari-browser setup --check # status only, never raises a dialog (exit 1 if incomplete)
safari-browser setup --open-settings # also open the relevant System Settings panesThree macOS permissions gate part of the CLI:
| Permission | Needed for | Without it | Handled by setup? |
|---|---|---|---|
| Accessibility | window resolution for screenshot / pdf / upload --native, blocked-dialog detection |
those paths refuse; front-window resolution falls back to a heuristic and says so | yes |
| Screen Recording | the pixel capture inside screenshot |
screenshot refuses with guidance |
yes |
| Full Disk Access | reading ~/Library/Safari/ for history / bookmarks / cloud-tabs / downloads, and Safari's cache folders for pdf-cache |
those commands refuse with guidance specific to how this binary is signed | no — see below |
Full Disk Access is deliberately not part of setup (#109). It cannot be
requested programmatically the way Accessibility can — you add the binary by
hand in System Settings — and it is a far broader grant than the other two, so
requiring it up front would tax everyone who never runs the four local-data
commands. Those commands ask for it only when you actually run one.
Which subject should hold that grant depends on how the binary is signed, and
the four commands say which case you are in when they refuse. An ad-hoc build
(what make install produces) is identified by its code hash, so a grant given
to it stops applying after a rebuild; a Developer ID signed build (make install-signed, see Which install target?) is
identified by an identity and keeps the grant. Granting to the terminal app
instead always works but hands the terminal read access to every file on the
system.
setup is the only command that raises a permission dialog. Every other
command detects and refuses with guidance instead, because a background
automation run must never pop an alert — a dialog you asked for by typing
setup is not an interruption. Nothing else changed about that.
Three things regularly look like bugs and are not:
- A grant applies at process start, so the status printed immediately after granting still says NOT granted. Re-run to confirm.
- The grant attaches to whatever macOS holds responsible for the process, which
for a shell-launched CLI is often the terminal app rather than the binary.
setupprints the binary path so you can check both names in Settings. - macOS offers the Screen Recording prompt once per app, ever. If it never
appears, use
--open-settingsand add the binary with+.
Both install targets re-sign the copied binary, because TCC keys a grant to the
code signature — an unsigned copy is a different subject with no permissions.
Both also stage the new binary alongside the destination and land it with mv,
never writing the canonical path directly. Two reasons: cp writes in place and
reuses the inode, and macOS caches signature validation per inode, so
overwriting an inode still held open by a running process (the persistent daemon
holds one for up to its 600s idle timeout) makes the next launch die with
load code signature error 2 — SIGKILL, exit 137, no readable error at the
point of failure (#121). And writing the canonical path first would mean a
failed signing leaves a broken binary on your $PATH with the working one
already deleted.
When Safari has more than one window, every subcommand that reads from or drives a document accepts one of four mutually exclusive global flags:
safari-browser <cmd> --url <substring> # tab whose URL CONTAINS this text (see below)
safari-browser <cmd> --window <n> # current document of the Nth window (1-indexed)
safari-browser <cmd> --window <n> --tab-in-window <m> # window N's Mth tab — "wN.tM"
safari-browser <cmd> --tab <n> # document N (alias for --document)
safari-browser <cmd> --document <n> # document N in Safari's document collection
safari-browser <cmd> --profile <name> # restrict to windows of named Safari profile (#47)--url is a substring match against the URL — not the whole URL, and not
the page title. --url plaud matches https://app.plaud.ai/…; --url "My Notes" matches nothing even when that is exactly what the tab is called.
Stricter variants: --url-exact, --url-endswith, --url-regex.
A miss therefore reports No Safari document matches "…" — the flag ran and
found nothing. That is a different failure from an unsupported flag, which
reports Unknown option. Reading the first as the second sends you looking
for a missing feature instead of a better substring (#71); the error now says
which one happened and lists each tab's window N tab M coordinates so you
can retarget positionally (#72).
To target "window 1, tab 3" use --window 1 --tab-in-window 3. Run
safari-browser documents first — its wN.tM column is exactly these two
numbers.
Identity-anchored --url / --document resolution (#79): a --url or
--document match resolves to the tab's identity (tab T of window id W,
using Safari's stable window id), not its z-order position — so
multi-round-trip commands (js --large, a js whose result is over 131072 UTF-16 units, chunked reads) keep hitting the same tab
even if you click other Safari windows mid-command. Every round-trip
re-verifies the tab's URL (case-sensitively) in the same AppleScript; on a
miss (window closed, tab moved, or navigated to a non-matching URL) the
command re-resolves once within the same window and profile and
otherwise fails closed with targetTabChanged — never a silent wrong-tab
dispatch. Explicit --window N / --tab-in-window M stay positional
by design: those flags mean "whatever sits at this position right now".
Known boundaries: a same-tab navigation to a URL that still matches your pattern resets the page's JS context between round-trips — the guard cannot distinguish it (tracked in #82 with the navigation family); on strict same-URL duplicates within one window, a re-resolve after a tab shift may follow the duplicate (Safari tabs have no stable id — window identity is the strongest available anchor).
--profile (#47) is orthogonal — combine with any other lock flag to
disambiguate same-URL tabs across Safari profiles. Detection mechanism:
Safari prepends the active profile name to each window title with em-dash
separator (<profile> — <page-title>). AppleScript has no current profile
property (verified Safari 18), so this is the only reliable mechanism.
Currently honored by js, get url / title, screenshot, documents;
other commands accept the flag but the filter is a no-op (broader rollout
tracked).
Stderr warning for unhonored commands (#54): when --profile is passed
to a command that doesn't yet enforce the filter, safari-browser emits a
single stderr line before execution to prevent silent wrong-profile
dispatch:
$ safari-browser click --profile work "#delete"
warning: --profile 'work' is parsed but not yet enforced for 'click'. Tracked in #51.
→ Falling back: all profiles considered.
The warning goes to stderr only — stdout (e.g. get text body content) is
unaffected. To filter from a pipeline, redirect stderr: safari-browser click --profile work "#x" 2>/dev/null. Per #51 plumb-rollout, individual
commands graduate to honored over time; the warning helper exists only for
the transitional period.
Exec sub-step double-warning (#62, by design): a pathological invocation
that passes --profile to BOTH the exec parent and a sub-step with its own
--profile emits two warnings — one for the parent exec, one for the
child command — e.g.:
safari-browser exec --profile A --script '[{"cmd":"click","args":["x","--profile","B"]}]'
# warning: --profile 'A' is parsed but not yet enforced for 'exec'. ...
# warning: --profile 'B' is parsed but not yet enforced for 'click'. ...
This is intentional: each unhonored command that receives a --profile warns,
and an explicit sub-step --profile is a distinct override (it rides as its own
target per #60), so the dual warning is more informative, not a bug. A
sub-step with no --profile inherits the parent's profile silently (one warning
at the parent only).
Without any flag, commands default to document 1 — equivalent to
current tab of front window in single-window usage, so existing scripts
keep working unchanged. Read-only queries (get url, get title,
get source, js, etc.) use document-scoped access so they still return
even when the front window has a modal file dialog sheet open (the
classic #21 hang).
# Discover which documents are currently open
safari-browser documents # [1] https://… — title (per line)
safari-browser documents --json # machine-readable [{index, url, title}]
# Target by URL substring (most common)
safari-browser get url --url plaud # https://web.plaud.ai/
safari-browser click "button.upload" --url plaud
safari-browser js "document.title" --url plaud
# Target by window or document index
safari-browser get title --window 2
safari-browser fill "input#email" "user@example.com" --document 3documents --json and tabs --json include a blocking_dialog object on
every row:
{"state":"present","window_id":72,"messages":["Example alert"],"reason":null}window_id is the retained stable Safari window ID, not its current listing
index. All rows from a window share its observed state. present means that a
visible native dialog was found; clear requires a complete observation of
that window and has an empty messages array. unknown means the observation
could not establish its state, with a reason such as disabled, locked,
unavailable, denied, incomplete, missingID, or notobserved. A missing
window ID is JSON null; unknown rows have no reported messages. This snapshot
does not establish whether every tab is blocked or whether a background tab
has a pending dialog that Safari has not exposed yet.
Each nonempty listing uses one bounded observation without switching tabs or
dismissing dialogs. Text output appends [dialog: "…"] or [dialogs: N] only
for a confirmed visible dialog, with a legend on stderr. Clear and unknown
rows retain their original text; tabs keeps its first three TSV fields and
puts a confirmed-dialog annotation in a fourth field. Unknown observations
are explicitly reported on stderr, so an unmarked text row does not prove
that its window is clear. Use JSON for a per-window state and reason.
Setting SAFARI_BROWSER_NO_DIALOG_PROBE=1 skips the observation and its text
warnings, preserving the original text format. JSON still reports unknown
with reason disabled. The daemon's in-process exec documents step uses the
same JSON encoder and observation; its explicit per-request probe setting
takes precedence over the daemon process environment. Bare tabs --daemon
continues to render in the client after its AppleScript requests.
tabs, tab <n>, tab new, open --new-tab, and open --new-window
only accept --window because they are window-level UI operations;
supplying --url, --tab, or --document is rejected with a usage
error.
close, screenshot, pdf, and upload --native accept the full
targeting surface (--url, --window, --tab, --document) via
the native-path resolver introduced in #26 — the resolver maps every
targeting flag to a concrete (windowIndex, tabIndexInWindow) pair
before dispatching the keystroke / AX operation. Multi-match --url
patterns fail closed with ambiguousWindowMatch listing every
candidate, rather than silently picking first-match — deterministic
automation behavior.
URL matching is case-sensitive (AppleScript's native behavior).
Substring match — no regex — so --url plaud matches any URL containing
"plaud". If no document matches, you get a documentNotFound error
whose description lists every currently open document so you can fix
the pattern (URLs are listed shortened, since a query can be a credential: a
query or fragment, credentials, path parameters such as ;jsessionid=…,
data: / javascript: content and a long tail are replaced by …;
safari-browser documents prints them in full).
# Storage targeting (#23) — critical for per-origin tokens
safari-browser storage local get token --url plaud # Plaud's token
safari-browser storage local get token --url oauth # OAuth provider's token
# Wait targeting (#23)
safari-browser wait --for-url "/dashboard" --url plaud
# Snapshot targeting (#23)
safari-browser snapshot --url plaud
safari-browser snapshot --page --document 2
# Upload split path (#23 → #26) — both paths accept full targeting
safari-browser upload --js "input[type=file]" file.mp3 --url plaud
safari-browser upload --native "input[type=file]" file.mp3 --url plaud # #26
safari-browser upload --native "input[type=file]" file.mp3 --window 2
# Window-scoped operations (#23 → #26)
safari-browser close --url plaud # closes the plaud tab
safari-browser screenshot --url plaud out.png # captures plaud's window
safari-browser pdf --url docs --allow-hid out.pdf
safari-browser close --window 2 # closes window 2's current tab
safari-browser screenshot --window 2 out.png # captures window 2
safari-browser pdf --window 2 --allow-hid out.pdfpdf, upload --native, and close briefly raise the resolved
window to the front before their respective System Events keystroke
operations. Keystrokes inherently target the front window, so the raise
is part of the operation, not just identification. When the resolver
identifies a background tab within that window, these commands also
briefly switch to that tab (set current tab of window N to tab T)
before dispatching — documented as a passively interfering side effect
transitively authorized by --native / --allow-hid in the
non-interference spec.
screenshot deliberately does not tab-switch — it observes without
interfering. A --url that resolves to a background tab captures the
window's currently-visible content (which may differ from the targeted
tab). Users who need DOM-level content of a background tab should
switch tabs first, or use document-scoped commands (snapshot --url,
get text --url, get source --url) that read via JavaScript without
touching window focus.
screenshot (R7) uses the AXUIElement private SPI
(_AXUIElementGetWindow) to map AppleScript window indices to CG
window IDs without raising the window. Both screenshot --window N
and default screenshot (no flag) use AX when Accessibility is
granted — eliminating the silent wrong-window failure modes that
bedevil bounds- and title-based matching (see #23 verify R1-R6 for
the saga). Without Accessibility, screenshot (no flag) falls back
to the legacy CG name-match resolver; screenshot --window N throws
accessibilityNotGranted with grant instructions.
When --full is combined with AX-resolved targeting, window bounds
read/write also go through AX (kAXPositionAttribute /
kAXSizeAttribute) on the same element that was resolved for CG
capture — eliminating the R6 cross-API mismatch where resize could
hit a different window than the capture.
Ambiguity is fail-closed: if AX bounds matching can't uniquely
identify the requested window, the command throws noSafariWindow
instead of silently guessing. Previous rounds silently fell back to
"guess by AS-index = AX-index" — R7 removes that path entirely.
# Grant once via System Settings → Privacy & Security → Accessibility,
# then `screenshot --window N` works for all Safari windows including
# off-Space, unminimized, and arbitrarily-sized windows — no z-order
# disruption, no race conditions.
safari-browser screenshot --window 2 background.pngIf you don't want to grant Accessibility, use document-scoped commands
that don't need a CG window ID: snapshot --url <pattern>, get text --url <pattern>, get source --url <pattern>, etc. — these read DOM
content via JavaScript without crossing the window-ID boundary.
screenshot (no --window flag) still works without Accessibility —
it captures the current front Safari window via legacy CG name match.
safari-browser wait <ms> # wait milliseconds
safari-browser wait --for-url <pattern> # wait for URL match
safari-browser wait --js <expr> # wait for JS truthy
safari-browser wait --timeout <ms> # bounds when a poll after the first may start (default 30s); the command can end later
# Randomized pacing between steps (#182): one duration drawn from a Cauchy
# distribution doubly truncated to [--min, --max] ms. --median is the median
# of the truncated distribution. Defaults: 2000..60000 ms, median 3000, scale 800.
# Out-of-range draws are discarded, never clamped, so no delay piles up on a bound.
safari-browser wait --jitter cauchy
safari-browser wait --jitter cauchy --min 1500 --max 20000 --median 4000
# --max is the only cap (--timeout does not apply). --scale may be at most
# 100 × (--max − --min). --seed is for tests only: each wait is its own process,
# so the same seed gives the same delay every call — never use it to pace a script.
# Without --scale, the scale is 0.8 × the distance from --median to the nearer
# bound (800 for the defaults), so short intervals no longer inherit an unsuitable fixed scale.
# An explicit --scale that makes the median unreachable is an error that prints
# the achievable range. When the middle half of all delays spans less than
# 5% of the median or one nanosecond, a "nearly fixed" warning is printed.
# Numerical representability checks still apply. A --max over one hour
# (3600000 ms) requires --allow-long-wait: the heavy tail makes a long draw
# a matter of time.
# The underlying Cauchy location can lie outside the configured bounds; the
# solver checks the full attainable truncated-median range.
# Bounds are parsed as Double milliseconds. Sleep quantizes to the nearest
# interior integer nanosecond; at least two such durations must exist.
# Large millisecond offsets remain limited by Double precision.
# This clock quantization is separate from the continuous distribution, and
# OS scheduling can make actual elapsed time longer than the requested sleep.
# Seeds are reproducible within the same supported numerical environment.
# Target flags (--url, --window, ...) are accepted but ignored in this mode.
# Multi-window targeting (#23): wait polls the targeted document, not
# the front window, so you can wait for a Plaud redirect while some
# other window has focus.
safari-browser wait --for-url "/dashboard" --url plaud
safari-browser wait --js "window.loaded" --document 2Automatic spacing is off by default. Enable it in the calling environment
to add one bounded Cauchy delay after each ordinary command, including each
executed exec step:
export SAFARI_BROWSER_PACING=cauchy
safari-browser exec --script steps.json
# Disable for just this invocation, including any exec children.
SAFARI_BROWSER_PACING=off safari-browser documents
# Change the bounds and truncated median (milliseconds).
SAFARI_BROWSER_PACING_MIN_MS=1500 SAFARI_BROWSER_PACING_MAX_MS=20000 \
SAFARI_BROWSER_PACING_MEDIAN_MS=4000 safari-browser exec --script steps.json
# Disable for later invocations.
unset SAFARI_BROWSER_PACING| Environment variable | Default / meaning |
|---|---|
SAFARI_BROWSER_PACING |
Unset, empty, or off: disabled; cauchy: enabled. Other values are errors. |
SAFARI_BROWSER_PACING_MIN_MS |
2000 ms |
SAFARI_BROWSER_PACING_MAX_MS |
60000 ms; at most 3600000 ms (one hour) |
SAFARI_BROWSER_PACING_MEDIAN_MS |
3000 ms, strictly between the bounds |
SAFARI_BROWSER_PACING_SCALE_MS |
0.8 times the distance from the median to the nearer bound |
The parameters use the same distribution, representability checks, and
nearly-fixed warning as wait --jitter. There is no global seed or
allow-long-wait setting. When pacing is disabled, its numeric parameters are
ignored, so a single off override also disables inherited invalid settings.
Enabled settings and the draw are checked before the operation executes.
Successful commands and runtime failures both wait once; after a completed
wait, their output and exit status remain unchanged. Help, root and command-group usage, parser
errors, explicit wait, daemon management commands, and long-lived hosts do
not gain an extra wait. Hidden MCP wrappers pace their eligible inner command
once. An exec container does not add another wait after its batch, and
conditionally skipped steps do not wait.
Command completion and command-duration traces include the wait; MCP responses and buffered output can therefore arrive after it. Tab switching and new-tab actions are ordinary paced operations.
With pacing enabled, exec chooses its existing subprocess-per-step path
before sending any batch RPC. Each child still has ordinary daemon routing,
but this mode pays per-step process/connection overhead instead of using the
single exec.runScript optimization. off restores the original batch path.
No transmitted batch is retried to change its pacing mode.
MCP hosts inherit pacing from their launch environment; restart a host to
change it. The existing tool timeout includes all pacing waits and is not
extended automatically. Budget command execution plus the configured maximum
delay, and for an exec batch include every eligible step. For example,
SAFARI_BROWSER_PACING=cauchy safari-browser mcp --timeout=90 leaves room for
one default maximum delay plus command execution; longer batches need their
own budget. A timeout or cancellation during pacing can happen after effects
already occurred; it does not undo or replay the operation. Existing OS signal
handling is unchanged.
This is per-caller spacing, not a shared rate limiter for concurrent processes, and it does not guarantee avoidance of remote anti-bot measures.
safari-browser cookies get [name] # get cookies
safari-browser cookies get --json # as JSON object
safari-browser cookies set <n> <v> # set cookie
safari-browser cookies clear # clear all
safari-browser storage local get/set/remove/clear <key> [value]
safari-browser storage session get/set/remove/clear <key> [value]safari-browser set media dark # force dark mode
safari-browser set media light # force light modesafari-browser console --start # capture log/warn/error/info/debug
safari-browser console # read ([warn], [error] prefixed)
safari-browser console --clear
safari-browser errors --start # capture JS errors
safari-browser errors
safari-browser mouse move <x> <y> # mouse events
safari-browser mouse down / up / wheel <dy>Long-running daemon that reuses compiled AppleScript handles and can avoid repeated osascript launches. The benefit depends on the workload; use the performance benchmark below instead of assuming a fixed startup saving. Routing follows the existing flag, environment and live-socket detection rules.
safari-browser daemon start # fork detached, listen on Unix socket
safari-browser daemon status # pid, uptime, request count, last activity
safari-browser daemon logs # tail daemon log
safari-browser daemon stop # shut down + clean socket/pid
# Route a single command through the daemon
safari-browser documents --daemon
# Or set once, every future command auto-routes if socket is live
export SAFARI_BROWSER_DAEMON=1
# Namespace (two agents can run independent daemons)
SAFARI_BROWSER_NAME=alpha safari-browser daemon start
SAFARI_BROWSER_NAME=beta safari-browser daemon startPhase 1 command coverage (routed through daemon when enabled):
snapshot, click, fill, type, press, js, documents, get url, get title, wait, storage.
The daemon's --socket-dir (or inherited TMPDIR) must resolve to an existing
directory. Regular files, FIFOs, and symlinks to non-directories are rejected
before daemon launch or dispatch, even with --allow-unsafe-socket-dir; that
flag only relaxes the world-writable check. The existing stat lookup follows
symlinks, so a link to a directory uses the target's permissions and a dangling
link remains missing/not statable. These preflight checks do not lock the path
against replacement afterward.
NOT covered (fall through to stateless path even with daemon on):
screenshot, pdf, upload --native, upload --allow-hid.
Ordinary daemon requests share one 15-second deadline across connection, handshake,
transmission, and the complete response; exec has an explicit 60-second deadline.
Partial bytes do not restart the clock. A connection failure, incompatible handshake,
or unknown method before execution falls back with [daemon fallback: <reason>].
Once any request bytes have been sent, a timeout, lost response, or invalid reply
reports an unknown execution outcome and does not rerun the operation. The original
operation can still finish in Safari; inspect its result before retrying. Handler
errors also propagate without replay. Idle >10 minutes → daemon auto-exits.
The production daemon retains a five-second shutdown watchdog even if an AppleScript
is blocked. Embedded server instances perform teardown without terminating their
host process. make test and make test-unit require a completed XCTest suite
summary in addition to exit 0, so early process termination cannot report a pass.
The compiled AppleScript cache runs on the main thread, including compilation and
reuse, so repeated scripts that use delay complete normally (#130).
Set SAFARI_BROWSER_TRACE_TIMING=1 to append a bounded timing summary to stderr.
Normal stdout and command behavior are unchanged; timing is off by default.
SAFARI_BROWSER_TRACE_TIMING=1 safari-browser wait 0
python3 scripts/benchmark-performance.py --binary .build/debug/safari-browser --samples 20 --warmups 3 --timing both
# Explicitly include an owned localhost Safari fixture for read-only queries:
python3 scripts/benchmark-performance.py --binary .build/debug/safari-browser --samples 5 --warmups 2 --timing both --liveThe report separates fresh CLI processes, cold/warm daemon or MCP hosts, failures
and GUI skips. Daemon status measures transport/lifecycle, not AppleScript speed;
--live compares direct and warm-daemon page reads. No upload, PDF or Print is
benchmarked. See the measurement contract for the trace
schema, limits, cleanup rules and interpretation.
safari-browser exec runs a JSON array of step objects in one invocation with shared target resolution, variable capture ($name), and minimal conditional flow (if: with contains / equals / exists). Designed for agents and CI that need deterministic multi-step automation without bash plumbing.
# Heredoc style
safari-browser exec --url plaud <<'JSON'
[
{"cmd": "get url", "var": "u"},
{"cmd": "js", "args": ["1+1"], "if": "$u contains \"plaud\""},
{"cmd": "click", "args": ["button.upload"]}
]
JSON
# From file
safari-browser exec --script /tmp/login.json --url plaudBoth exec paths preserve diagnostics on stderr while stdout remains a single JSON
array. Daemon exec uses a request-local dialog gate and the internal compiled script
runner; it does not open a nested connection to itself. Every new request gets fresh
warning state, and diagnostics are returned on success and handler failure (#136).
Exec forwards the caller's SAFARI_BROWSER_NO_DIALOG_PROBE and
SAFARI_BROWSER_DIALOG_PROBE_DEBUG settings, each enabled only by exactly 1.
Unset values explicitly mean false even if the daemon started with either setting
on. Only these two booleans are sent, and each request is isolated. Older clients
that omit dialogProbe retain the daemon environment defaults; malformed options
are rejected before any step executes (#143). After upgrading the executable,
restart each existing daemon namespace with daemon stop followed by daemon start
before relying on these settings: a daemon still running the older executable
ignores the new options.
Output: single JSON array on stdout, one entry per executed/skipped step ({"step": N, "status": "ok"|"error"|"skipped", "value": ..., "var": "..."?}). Default cap of 1000 steps (override with --max-steps). With the daemon active, a script made only of js, documents, get url/title/text/source, click, fill, type, press and the storage subcommands (for the argument shapes exec accepts; no positional argument may start with -) runs in one request on the daemon (a --url target is resolved at the first step that needs it and reused after a check, #170); any other script runs one subprocess per step, so daemon opt-in still amortizes per-step cost. The two paths are not guaranteed to give the same results (#220). screenshot, pdf, upload fall through with unsupportedInExec. See openspec/specs/script-exec/spec.md.
--mark-tab wraps the target tab's title with an invisible zero-width marker so sibling safari-browser invocations can detect "I'm working here" via tab is-marked. Advisory, not a lock.
# Persist marker across invocations
safari-browser click button.upload --url plaud --mark-tab-persist
# Sibling process probes ownership (exit 0 = marked, 1 = unmarked, 2 = error)
safari-browser tab is-marked --url plaud && echo "tab is busy"
# Explicit cleanup if needed
safari-browser tab unmark --url plaudMarker is hardcoded zero-width characters (no caller-supplied content — security side-channel concern). Default OFF; opt-in only. Page-driven title changes during the operation surface as a stderr warning, no force-restore. v1 wires ClickCommand as the reference integration; broader command rollout is v2. See openspec/specs/tab-ownership-marker/spec.md.
| Feature | agent-browser | safari-browser |
|---|---|---|
| Login state | Fresh each time | Permanent |
| User visible | Hidden Chromium | User's Safari |
| Bot detection | Detectable | Real browser |
| 2FA / SSO | Handle each time | Already verified |
| macOS integration | None | Keychain, iCloud, Extensions |
| Engine | Playwright / Chromium | Safari + AppleScript |
| Headless | Yes | No |
| Cross-platform | Linux / Windows / macOS | macOS only |
| Network interception | Yes | No |
| Element discovery | CDP accessibility tree | JS DOM scan (snapshot) |
| Parallel sessions | Isolated instances | Shared Safari (multi-tab) |
Four breaking changes. Each has a simple opt-out for scripts that cannot migrate immediately.
| Old behavior (v2.4) | New behavior (v2.5) | Migration |
|---|---|---|
open <url> navigates the front window's current tab via JavaScript, regardless of whether that URL is already open elsewhere. |
open <url> focuses the existing tab if any tab's URL exactly matches; otherwise opens a new tab. Cross-window focus uses the spatial interaction gradient (open --focus-existing behavior). |
Need the navigate-front-tab semantics? Use safari-browser open --replace-tab <url>. |
js --url plaud "..." with two matching tabs silently picks the first. |
js --url plaud "..." with two matching tabs exits with ambiguousWindowMatch listing every candidate. |
Need silent first-match? Add --first-match (emits a stderr warning enumerating all matches and the chosen one). Or target a specific tab: --window M --tab-in-window N. |
--tab N aliases --document N (global document index). |
--tab N still works but emits a stderr deprecation warning. Will be removed in v3.0. |
Rename to --document N for identical semantics, or rewrite as --window M --tab-in-window N to address a specific tab within a window. |
safari-browser documents prints one line per Safari window (each window's front tab). |
Prints one line per tab across all windows. Format: [global] <current-marker> w<window>.t<tab> <url> — <title>. --json adds window / tab_in_window / is_current fields. |
Shell parsers counting document lines as "number of windows" must switch to grouping by the w{N} prefix or use --json. The --document N semantic is unchanged — it still targets the Nth tab in enumeration order. |
New flags:
--tab-in-window N— requires--window M. Targets the Nth tab within the Mth window. Escape hatch for duplicate-URL tabs.--first-match— accepts first--urlmatch when multiple tabs match (paired with stderr warning).--replace-tab— onopen, restores v2.4 navigate-front-tab behavior. Mutually exclusive with--new-tab/--new-window.
Design rationale lives in openspec/changes/archive/*-tab-targeting-v2/ after archive (or openspec/changes/tab-targeting-v2/ while in-flight). The new human-emulation principle is documented alongside non-interference in CLAUDE.md.
make test-background-dialog-harness runs Safari-free fixture checks.
make test-background-dialog is a separate live acceptance test: it checks the
GUI session, selected binary’s UUID and child-process-group behavior before
creating its own nonce fixture. It uses the guarded internal worker to keep
fixture subprocesses contained while exercising the original CLI commands.
Exit 77 means the live check was not performed, including on a locked desktop.
make test-dialog-listing extends that fixture with per-window JSON checks for
documents and tabs before the alert and after it becomes visible. It is
also a separate live test, excluded from make test-all.
make build # debug build
make install # release build + install to ~/bin
make test # run unit tests (24 tests, no Safari needed)
make test-e2e # run E2E tests (9 tests, requires Safari)
make clean # remove build artifactsMIT
make test-current-dialog-harness runs the pure fixture safety checks.
make test-current-dialog exercises an owned localhost confirm while its click
process is still waiting, checks the query latency and one handler execution,
then performs guarded recovery and owned-window cleanup. Exit 77 means no GUI
acceptance was performed. The query itself never activates or dismisses; fixture
setup and cleanup perform those explicit actions.