Skip to content

About

No description, website, or topics provided.

Resources

Stars

1 star

Watchers

0 watching

Forks

Repository files navigation

safari-browser

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.

Why safari-browser?

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

When to use

Need login? ──── Yes → safari-browser
              └── No → Need headless/CI? ──── Yes → Playwright / agent-browser
                                           └── No → Either works

Use cases

  • 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')"

MCP stdio

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.

Install

git clone https://github.com/PsychQuant/safari-browser.git
cd safari-browser
make install

Binary installs to ~/bin/safari-browser. Ensure ~/bin is in your $PATH.

Requirements: macOS 15+, Safari, Swift 6.0+

Which build is this? (#278)

safari-browser --version    # v2.6.0-31-gd454d7c

The 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.

Which install target? (#119)

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-signed

TCC 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.

Running the signature suite

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.

Quick Start

# 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')")

Commands (46)

Navigation

safari-browser open <url> [--new-tab] [--new-window]
safari-browser back / forward / reload / close

Element Discovery (Snapshot + @ref)

safari-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 scan

All selector-accepting commands support @eN refs.

Element Interaction

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.

Keyboard

safari-browser press Enter
safari-browser press Tab / Escape
safari-browser press Control+a         # modifier combos
safari-browser press Shift+Tab

Find Elements

safari-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"

Page & Element Info

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)

State Checks

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, reason

is 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.

JavaScript

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 file

js 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;"   # → 5

Breaking 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), and js "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.

Screenshot, PDF & Upload

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.

Tab Management

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 2

Blocking Dialogs

A 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 named

For 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.

Local Safari Data (#109)

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 history

All 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.

Cached PDFs (#210)

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.pdf

get 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.

Permissions

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 panes

Three 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. setup prints 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-settings and 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.

Multi-window Targeting (#17 #18 #21 #23 #79)

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 3

documents --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.pdf

pdf, 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.png

If 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.

Wait

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 2

Automatic command pacing

Automatic 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.

Storage

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]

Settings

safari-browser set media dark          # force dark mode
safari-browser set media light         # force light mode

Debug

safari-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>

Daemon (opt-in, Phase 1)

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 start

Phase 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).

Performance timing and benchmarks

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 --live

The 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.

Exec scripts (multi-step automation)

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 plaud

Both 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.

Tab ownership marker (opt-in)

--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 plaud

Marker 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.

Comparison with agent-browser

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)

Migrating from v2.4 to v2.5 (tab-targeting-v2)

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 --url match when multiple tabs match (paired with stderr warning).
  • --replace-tab — on open, 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.

Development

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 artifacts

License

MIT

Current native dialog acceptance

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.

About

No description, website, or topics provided.

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages