Companion to ROADMAP.md. This document records the keep/change decisions for the codebase as it stands in August 2026, with reasons, so the next months of work don't re-litigate them. Format per decision: Decision → Status → Rationale → Consequences / revisit trigger.
Decision: the core stays Swift — the shared HeadlessProtocol library,
the CLI, the MCP server, and both hosts. No Rust/Go rewrite.
Status: decided 2026-08-04 (owner delegated the recommendation; this is it).
Rationale:
- The investment is already amortized: ~7.5k lines of working, tested Swift spanning both platforms, with zero third-party dependencies and a QA evidence trail proving behavior. A rewrite resets all of that for a benefit that is mostly hypothetical.
- The macOS host is irreducibly Swift (Cocoa/WebKit). A Rust/Go core would add a language boundary (FFI or IPC between the Swift app and the new core) rather than remove one.
- Swift on Linux is genuinely fine here and proven in this repo: static
stdlib builds in Docker (
Dockerfile.linux), stripped binaries, no runtime to install. - The real pain attributed to "Swift" is actually duplication (two hand- written hosts) and stringly-typed errors — fixable in place (decision §3), far cheaper than a rewrite.
- Contributor-pool concerns are mitigated by the agent-first reality: this repo is built to be worked on by coding agents, and the rule files + contract docs matter more than language familiarity.
Costs accepted:
- Windows: Swift-on-Windows exists (the Browser Company ships it) but the toolchain is rougher than Rust/Go. Accepted because Windows is a stretch goal (roadmap Phase W), and Phase 2's engine split confines the port to transport + process-spawn + artifact backends.
- Binary distribution stays per-platform build scripts rather than
cargo/goreleaserconveniences. Phase 3 does this work once.
Revisit trigger: if Windows-native is ever promoted to must-have and a
spike shows Swift-on-Windows cannot pass the Linux E2E scenario within ~2
weeks of effort, revisit with a concrete proposal: keep the WKWebView app in
Swift, move HeadlessProtocol + Chromium host to Rust, talk over the existing
JSON protocol (which is language-neutral by design and makes this migration
tractable later). Do not drift into a rewrite without hitting this trigger.
Decision: keep the pnpm monorepo (apps/headless Swift package,
apps/web Next.js, empty packages/). Node is only the workflow driver and
the web app; the product has no runtime Node dependency — keep it that way.
Addition: when benchmark/docs generation lands (roadmap Phase 5),
generated machine-readable outputs (benchmark JSON, command tables) live in
packages/ or apps/headless/build/ with explicit provenance, so the web app
imports data instead of transcribing it.
Status: implemented 2026-08-10.
Decision: extract everything currently duplicated between
apps/headless/main.swift (macOS, ~340-line dispatch) and
apps/headless/LinuxHost/main.swift (~280-line dispatch) into a shared
HostCore in HeadlessProtocol (or a sibling target):
- one command dispatcher, one flow replay loop, one screenshot-series loop, one trace ring buffer, one report bundler, one error→code mapping;
- a small
BrowserEngineprotocol implemented twice:WebKitEngine(wrapsAgentBridge) andChromiumEngine(wrapsBrowserProcess), laterChromiumEngineon Windows; - typed errors (
HostErrorenum carrying the protocol error code) replacing themessage.contains("ELEMENT_NOT_FOUND")string matching on both hosts and inAgentBridge.swift:416-418; - single definitions for the constants currently written 2–4×: blocked/caution
extension sets (Swift and the JS copy get a cross-check test), screenshot
bounds, artifact charset, local-address list, inspect/console/storage/scroll
enums, numeric bounds (CLI and validator currently disagree — e.g. scroll
amount
>0vs>=0.1).
Rationale: every new verb is currently written twice and the compiler only
checks enum exhaustiveness, not behavioral equivalence; silent divergence has
already happened (report page shape, capture-info shape, tour timeout, JPEG
quality path, PDF raster-vs-vector). This refactor is the precondition for
Windows and for keeping principle "one contract" true.
Non-goal: merging the engines' capabilities. Divergent capability stays
explicit (UNSUPPORTED_CAPABILITY); the point is that the common path is
single-sourced and the divergent one is declared, generated into
capabilities, and asserted by tests.
Capability-matrix consequence (implemented 2026-08-10): each engine owns
one exhaustive profile used by both headless capabilities and the active
host's additive ping.capabilities field. Profiles partition every protocol
command into supported and unsupported sets and declare behavioral differences
that cannot be made identical without weakening an engine. This compatible
response addition does not bump protocol 0.5.
Decision: keep newline-delimited JSON over the private Unix socket,
version "0.5", strict decoding, per-command parameter allow-lists, 1 MiB
frames. The protocol is deliberately language- and transport-neutral — that
neutrality is what keeps both the Windows port and the (rejected-for-now)
Rust option cheap.
Amendments planned (backlog §A5, §G3):
- Response-side bounding:
qa reportandartifact.listcan exceed the 1 MiB frame today and surface a misleadingINVALID_REQUEST. Add pagination (--limit/--cursor) or server-side truncation withtruncated: true, consistent with the pruning philosophy. This is a compatible addition. - Response
idmust echo the requestidand clients should verify it (today failure paths returnid:"unknown"and the client never checks).
Rejected: gRPC/protobuf, TCP+TLS, HTTP. They add dependencies, listeners, or both, against principle 6 (local-first security).
Decision: keep /tmp/headless-<uid> 0700/0600 + getpeereid /
SO_PEERCRED as the Unix mechanism. For Windows (Phase W), define a
ControlTransport seam in Phase 2 so a named-pipe + SID-ACL backend can slot
in without touching HostCore.
Known hardening items stay on the backlog (§A): accept-loop error spin,
hard-coded SO_PEERCRED = 17, HEADLESS_SOCKET asymmetry, no peer-UID test.
Decision (unchanged from P0): remote control remains deferred until it has authentication, authorization, and transport security. SSH + stdio MCP remains the only remote story. A hosted service is out of scope for this roadmap (owner decision 2026-08-04: package-manager distribution, no cloud offering).
Decision: Windows is a stretch goal (owner decision 2026-08-04: "later / best-effort", not required for done). When attempted:
- Engine: the Chromium engine only. The WKWebView host is never ported.
- Port surface (known and bounded):
Transport.swift(named pipes + SID peer check),Artifacts.swift(ACLs instead of POSIX modes;O_EXCLequivalent viaCREATE_NEW), the spawn half ofBrowserProcess.swift(CreateProcess+ inheritable HANDLEs for--remote-debugging-pipe— Chromium on Windows takes handles viaSTARTUPINFO, not fd 3/4), the I/O half ofCDP.swift(overlapped I/O instead ofpoll), signal handling (console control handler),ChromiumRuntime.swift(registry + Program Files + Edge discovery,;PATH splitting), ffmpeg.exediscovery, and re-authoring the shell scripts (the E2E suite is POSIX sh). - Portable already:
Protocol.swift,CLI.swift,AgentRuntime.swift(the JS is engine-agnostic),Diagnostics.swift,Flows.swift,CaptureFormats.swift,ScreenshotSeries.swift,Recording.swift(modulo discovery),MCP/main.swift. - Interim answer (Phase 3): published Docker image + WSL2 documented as the supported Windows path.
Update — 2026-08-22, Swift-for-Windows spike failed. Per PLAN.md step 1,
we attempted to build the shared core with Swift 6.3.3 for
x86_64-unknown-windows-msvc on a real windows-latest runner
(workflow: .github/workflows/windows-spike.yml, branch
spike/windows-core; run logs preserved there). Findings:
- Cross-compilation from Linux is not possible; Swift SDK bundles target Linux and WebAssembly only.
- The winget toolchain is broken out of the box: runtime DLLs are split
across two install trees (
Toolchains\6.3.3+Asserts\usrandRuntimes\6.3.3\usr).swift.exeexitsSTATUS_DLL_NOT_FOUNDuntil the trees are merged by hand. - After repair, no Swift code compiles: even
swiftc hello.swiftfails with "unable to load standard library for target x86_64-unknown-windows-msvc", both via SPM and directswiftc. Suspected cause is the+Assertstoolchain packaged against a non-asserts runtime, or missing stdlib modules in the package.
Consequence: the "portable already" claim above does not hold on today's toolchain, so a Windows engine adapter written in Swift is not viable. WSL2/Docker remains the only supported Windows path. A native Windows host requires either a materially better Swift-for-Windows toolchain or a scoped Rust port of the shared core; revisit only when native Windows becomes an actual product requirement, and record a new decision entry first.
Decision: keep the WKWebView host as macOS's default engine. It is the differentiated experience (a real visible browser window a human can watch the agent drive, passkey story, clipboard) and it exercises the "same contract, two engines" discipline that keeps the protocol honest.
Acknowledged limits (stay documented, not "fixed"): diagnostics are best-effort (no full network event stream), no network emulation/mocking, raster PDF. If agent demand ever requires full-fidelity diagnostics on macOS, the answer is offering the Chromium engine on macOS as an additional runtime behind the same CLI (the Linux host already builds on macOS-adjacent Foundation APIs) — not hacking WKWebView. That would be a new decision entry.
Resolved (backlog §B8, decision §18): the page-world QA diagnostics bridge
is documented as detectable and forgeable; the host fixes its provenance,
bounds it per document, and marks its evidence untrusted. Agent actions and
inspection remain in WKContentWorld.
Decision: click/fill/press keep one portable command contract. On
Linux, the isolated agent world resolves the semantic target, applies the
existing link-safety policy, focuses it, and returns a bounded visible point;
the host then acts through CDP Input.dispatchMouseEvent,
Input.dispatchKeyEvent, and Input.insertText. Page handlers consequently
receive trusted browser input. Fill values travel directly in the validated
CDP command and are never returned or added to flows.
WKWebView retains the synthetic isolated-world implementation because it has
no equivalent safe host input API. capabilities declares trusted-cdp for
Chromium and synthetic-dom for WebKit. This is an engine fidelity difference,
not a second set of verbs.
Rationale: synthetic events fail on real-world widgets (rich editors, canvas apps, key-repeat handlers); Chromium can do better cheaply; the contract machinery from Phase 2 makes the divergence declarable.
Decision: keep BrowserRecording's design (host-captured PNG frames piped
to an allow-listed ffmpeg; browser-frames-only recording scope). Revisit the
mpeg4 (Part 2) codec choice in Phase 3: it exists to avoid x264
licensing, but produces large, poorly-compatible files. Evaluate defaulting
MP4 to H.264 where a system encoder is available (VideoToolbox on macOS) or
making WebM/VP9 the recommended default in docs, keeping mpeg4 as fallback.
Add palettegen to the GIF path (quality, cheap).
Decision: keep the single shared agentRuntimeJavaScript string as the
one implementation of page-side semantics for every engine (it is what makes
"same contract" real). Fix the delivery mechanics (backlog §B7):
- Linux re-creates the isolated world and re-sends ~30 KB of JS on every
evaluate — 3 CDP round trips per command, polled at 20 Hz by
wait(BrowserProcess.swift:777-834). Cache the world/context per navigation and usePage.addScriptToEvaluateOnNewDocument. - macOS re-sends the same source per call through
callAsyncJavaScript; install once per navigation viaWKUserScriptin the agent content world. - Extract the JS to a
.jsresource compiled into the binary (SwiftPM resources) so tooling/tests stop regex-extracting it from a Swift string literal (Tests/agent-runtime.test.mjs's/#"""…"""#/coupling).
Status: implemented 2026-08-10. The source is now the compiled
Resources/AgentRuntime.js; WebKit installs it in HeadlessAgent at document
start, while Chromium installs it with
Page.addScriptToEvaluateOnNewDocument, caches the isolated context for the
document, and invalidates or retries it across navigation races. Distribution
scripts install the generated resource bundle alongside each host executable.
Decision: sessions are windows (macOS) / tabs (Linux) sharing one
profile — cookies and storage are shared across sessions on both engines. This
matches the "persistent logged-in browser" product idea, so keep it as the
default, but document it loudly (it reads like an isolation boundary and
is not). If per-session isolation is wanted later, the Chromium engine gets
Target.createBrowserContext behind a session create --isolated flag; the
WKWebView engine would declare UNSUPPORTED_CAPABILITY or use non-persistent
WKWebsiteDataStore. New decision entry required when scheduled.
Status: implemented 2026-08-12.
Decision: the git tag becomes the single version source: injected at build
time (already works via HEADLESS_VERSION), reported by a new headless --version/version command and in ping, matched by package.json, MCP
serverInfo (today it reports protocol version "0.5" as the server version),
and the website. Protocol version stays independent (wire compatibility ≠
product version). CHANGELOG generated per tag.
Status: implemented 2026-08-27.
Decision: keep apps/web on Next.js/Tailwind — no framework change. The
architectural change is content provenance: benchmark numbers, command
tables, and docs prose must be imported from repo artifacts (benchmark JSON
emitted by benchmark.sh, command reference generated from CLI.swift's
parser/help, shared markdown) instead of hand-copied into TSX/TS in three
places. Also: delete dead visual code (side-rays.tsx/ogl, unused assets),
reconsider shipping two WebGL bundles for decoration, add deploy pipeline +
CI, metadata/sitemap/404. Details: backlog §F.
The website now validates and derives its benchmark presentation from the generated benchmark JSON. Its rendered and copyable documentation share the README and generated command reference as build-time sources, with a web lint gate that fails on missing or malformed provenance instead of preserving a stale hand-written fallback.
Status: implemented 2026-08-10.
Decision: keep the three-layer shape (protocol unit suite, jsdom runtime
suite, per-platform E2E), and add the missing keystone: a cross-engine
conformance runner — one scenario file executed against both engines
asserting identical JSON shapes (or declared capability errors), replacing
today’s hand-mirrored macos-e2e.sh/linux-e2e.sh assertions that have
already drifted. The hand-rolled no-XCTest runner is fine (it keeps Linux
docker runs trivial); don't churn it to a framework.
The portable Tests/conformance.sh scenario is invoked by both platform E2E
suites. It asserts the same response fields for shared behavior and consults
the generated engine profile only for declared differences, so adding an
engine or changing a shared response requires updating one executable contract.
Decision (owner, 2026-08-04): package managers, no hosted service.
Concretely: Homebrew tap (notarized), Linux curl installer + GHCR-published
Docker image, npm binary-wrapper for JS-stack reach, winget only with Phase W.
Checksums on everything; keep release CI's script-reuse design (the workflow
calls the same build.sh/test.sh a developer runs — preserve that
property when adding PR CI).
Decision: global --json and --session options are recognized only
before the first -- sentinel. The sentinel is removed before command
parsing. fill accepts its text as exactly one shell argument rather than
joining multiple arguments with inserted spaces.
Status: decided 2026-08-10 while resolving backlog §A6.
Rationale: typed values are data and must reach the browser byte-for-byte as represented by the Swift string. Searching the whole argv for global flags could silently remove literal text, while joining tokens normalized tabs and repeated spaces. Standard shell quoting plus an end-of-options sentinel makes the boundary explicit and testable.
Consequences: callers quote multi-word fill text and place -- before a
value containing a literal --json or --session. This changes only CLI
parsing; the wire protocol and protocol version remain unchanged.
Decision: keep stop and session close callable through the single
argv-based MCP tool. Describe the tool as state-mutating and explicitly set
the MCP annotations readOnlyHint: false, destructiveHint: true,
idempotentHint: false, and openWorldHint: true.
Status: decided 2026-08-10 while resolving backlog §C2.
Rationale: the MCP adapter deliberately mirrors the remote CLI surface. Special-casing two valid remote commands in the adapter would create policy drift and prevent an MCP operator from recovering a wedged host or cleaning up a session. The same tool already navigates, clicks, fills, and changes browser state, so describing it as universally "safe" was inaccurate. Because MCP annotations apply to the whole tool rather than individual argv variants, the tool must advertise the risk of its most destructive valid invocation.
Consequences: trusted MCP clients can require confirmation for the tool,
and callers can still invoke the complete browser-command surface. Annotations
are risk metadata, not authorization; the private socket, peer-UID check,
protocol validation, and host-enforced safety rules remain the security
boundary. Local-only commands such as start remain rejected by the adapter.
This changes MCP discovery metadata only and does not bump the wire protocol.
Decision: keep the macOS console/fetch/XHR observer in the page content
world, while keeping every agent action and inspection helper in the named
HeadlessAgent isolated world. Treat all diagnostic output on both engines as
untrusted page evidence. The macOS host assigns the fixed source
webkit-page-bridge, ignores a page's claimed source, accepts at most 500
bridge messages per document, and reports rejected messages as truncation.
Status: decided 2026-08-10 while resolving backlog §B8.
Rationale: WKWebView exposes neither page console messages nor a complete subresource network stream to an isolated content world. Patching the page's console, fetch, and XHR APIs is therefore best-effort observation, not a security boundary: the page can detect, replace, invoke, or spam that bridge. Removing it would discard useful QA evidence; presenting its messages without trust metadata would let a hostile page counterfeit host facts. Fixed native provenance, a native acceptance cap, and pervasive untrusted markers preserve the evidence without overstating its authority.
Consequences: protocol 0.5 adds untrustedContent: true to diagnostic
reports, events, derived issues, console listings, and network listings/details.
Consumers must not interpret diagnostic text or URLs as instructions. Native
navigation and download events use the same conservative marker because they
can contain page-selected URLs. A hostile-page macOS E2E test locks source
override, the 500-event bound, and truncation reporting.
Decision: windows created by CLI-launched macOS agent hosts are visible but
ordered behind the user's current application. headless start --foreground
and --background override presentation for a newly launched host. Users can
change the persistent default with the validated headless config set startup-presentation foreground|background command and inspect it with
headless config get startup-presentation. Presentation choices never reorder
a host that is already running. Direct GUI launches retain normal foreground
behavior.
Status: implemented 2026-08-12.
Rationale: persistent agent automation should not interrupt the user's keyboard and visual focus merely because a host or session starts. Keeping the window visible preserves observability, screenshots, recording, and WebKit rendering without making background automation disruptive. Making foreground activation explicit still supports interactive demonstrations and debugging.
Consequences: automatic startup and headless start use the configured
presentation on macOS, falling back to background. Session windows created by
that host follow the same policy. Launch flags take precedence over the saved
setting. Linux behavior and the wire protocol are unchanged; presentation
configuration fails there with UNSUPPORTED_CAPABILITY. macOS E2E coverage
asserts the real frontmost process for configured background and foreground
startup, session creation, running-host no-op behavior, and launch overrides.
Decision: direct macOS releases are universal Developer ID Application
builds with hardened runtime, secure timestamps, notarization, and stapling.
They omit com.apple.developer.web-browser.public-key-credential by default.
The build accepts that restricted entitlement only when an Apple-approved
provisioning profile and entitlement file are both supplied explicitly for
com.headless.app.
Status: implemented 2026-08-12 for Phase 3 distribution.
Rationale: Apple's Developer ID capability set does not generally include the restricted web-browser passkey entitlement. Claiming it without matching provisioning approval can make macOS terminate the app and can fail notarization. Shipping a signed app that launches reliably is stronger than advertising a passkey path the distribution identity cannot support. The host already detects its own entitlement and hides WebAuthn when absent so sites can offer password, phone, or other fallback sign-in.
Consequences: normal Homebrew and ZIP installs do not expose WKWebView passkeys. Apple approval can enable them later without changing the protocol: the release operator supplies both provisioning inputs and the existing runtime check detects the granted entitlement. Tagged builds fail rather than falling back to ad-hoc signing or skipping notarization. Pull-request dry runs still exercise the universal package path with an ad-hoc signature and no Apple credentials.
Status: decided 2026-08-22 (owner approved after the Windows spike).
Decision: the Swift-for-Windows spike (§6 update) failed, so a native Windows host needs the shared core in a language whose toolchain actually works on Windows. We port the shared core to Rust, incrementally and without disturbing the shipping Swift product:
- Scope:
Sources/HeadlessProtocol/semantics — protocol types, validation, navigation boundaries, artifact-name rules, bounds. Later increments: transport, CLI parser, then a Chromium engine host that ports the Linux CDP logic (BrowserProcess.swift,CDP.swift) so macOS, Linux, and Windows all run the same Chromium engine. - Not in scope: the agent runtime JS (stays as-is), the WKWebView macOS host (stays Swift), the protocol version or wire format (unchanged; both implementations speak protocol 0.5).
- Verification: the Rust crate carries its own tests mirroring the Swift protocol suite's security-critical cases (unsafe URL rejection, credential embedding, frame caps, strict field validation), and CI builds it natively on Linux and Windows from day one.
- Dependencies:
serde/serde_jsononly. The zero-third-party rule was a Swift-host decision; for Rust these two are the ecosystem baseline and are pinned.
Progress, 2026-08-27: #140 adds the platform-neutral connection/listener
seam, bounded newline framing, response correlation, and the secure Unix
backend. The Unix implementation preserves the private 0700 runtime,
0600 socket, effective-UID peer authorization, stale-socket safety, and
live-endpoint protection. This does not implement or claim Windows transport;
the named-pipe, ACL, and SID-authentication backend remains a separate step.
The Swift product remains the reference implementation until the Rust core passes an equivalent conformance suite; only then can it start replacing hosts. Nothing in this decision changes the hard rules: no arbitrary-JS verb, no TCP listener, fail closed, bounded everything.
Decision: headless start --allow PATTERN installs a process-wide host
origin allowlist for agent navigation. Repeatable --allow flags and
comma-separated values in one flag are both accepted. Omitting --allow
keeps today's behavior: any otherwise-legal HTTP(S) URL. Presentation flags
stay macOS-only.
The matcher is a small NavigationAllowlist type in HeadlessProtocol, used
as an extra conjunct in agentMayNavigate. It cannot add file:,
javascript:, credentials, or blocked extensions; normalizedWebURL is
unchanged. When the list is set, visit, top-frame navigation, and in-page
clicks to a non-matching host fail with UNSAFE_NAVIGATION. status / ping
report navigationAllowlist (empty array means unrestricted). Changing the
list on an already-running host is rejected; matching list (set equality,
order-independent) or start without --allow against a running host
remains a no-op success. Every successful start ping is revalidated against
the requested list, including the post-spawn ready loop.
Status: implemented 2026-09-10.
Rationale: a prompt-injected or confused agent can otherwise leave the app
under test and open an arbitrary site. Scheme/credential/extension checks are
not an origin policy. The allowlist is a host-enforced boundary, not a prompt
rule, so it must live in the same function already consulted by CLI visit,
WKWebView decidePolicyFor, Linux Fetch Document pause / extra-target close,
and the isolated click guard. Linux does not treat
Page.frameRequestedNavigation recovery as the boundary: that event can run
after an off-list request or popup has already started.
Consequences: CLI start --allow sets HEADLESS_NAVIGATION_ALLOWLIST on
the spawned host. The injected agent runtime receives a JSON-encoded copy as
defense in depth and preflights <a href> plus submit controls (formaction,
then form.action, then the document URL). Page JS cannot widen the
host-trusted policy, and onclick that assigns location or calls
window.open is not a JS-visible target, so Linux fails those Document
requests at Fetch.requestPaused and closes extra page targets that
auto-attach off the list. Subresource requests (XHR, images, scripts) are
not filtered; the allowlist is a navigation policy, not a network firewall.
macOS continues to cancel in decidePolicyFor and ignore disallowed
createWebViewWith URLs. Protocol version stays 0.5 (additive ping field).
Patterns are hosts with optional :port and optional leading *., capped at
32, case-insensitive, fail closed on * alone, non-ASCII, paths, schemes,
and credentials.
Decision: upload is a protocol command that names an existing basename
in the private artifact store and asks the engine to attach that on-disk file.
No CLI, protocol, or MCP command ingests an arbitrary local path. File bytes
never appear on the Unix socket, in protocol parameters, MCP, logs, flows,
snapshots, diagnostics, or errors. Downloads remain denied. There is no TCP
fixture server, no home-directory path on upload, and no arbitrary-JS verb.
upload targets a file input with the same grammar as click.
Artifact pathname integrity relies on the private per-user store. A malicious
same-UID process can inspect or replace files there, which is the documented
same-user limitation in SECURITY.md; operators must isolate untrusted agents
under a separate OS account when that boundary matters.
Linux Chromium attaches via DOM.setFileInputFiles using an isolated-world
objectId. Attachment success is completion: bounded {ref, role, name}
metadata is captured before attach, and a successful CDP response is not
followed by a second node lookup. macOS WKWebView returns
UNSUPPORTED_CAPABILITY until a native attach path exists that does not
evaluate page JavaScript or shuttle file bytes through JS. Capabilities
declare fileUpload accordingly; inspect advertises upload only when that
flag is true.
Status: partially implemented by #169 and revised 2026-09-12 after security review. The operator-file staging criterion in #168 remains open and must not be closed by the attach-only implementation.
Rationale: resume/import/image QA needs file inputs, and the existing store
has the confinement properties required for engine attachment. Any local-path
ingest command available to an agent, including a nominally local CLI or a
scriptable TTY confirmation, would let it copy arbitrary readable host files
into an uploadable location. That violates SECURITY.md. Putting bytes on the
wire would also exceed the frame boundary and leak contents into logs. WebKit
has no equivalent of setFileInputFiles without a JS hole.
Consequences: agent-facing surfaces cannot import operator files. Upload
remains useful for allowed artifacts already created in the store, and test
harnesses may seed their isolated store directly. Test seeding is not a user
workflow. #168 must remain open or be split so a trusted human staging surface
is designed, implemented, and tested separately. WebKit clients must skip
upload or fail closed. Replay of upload requires the same artifact basename
still in the store.
Revisit trigger: operator-file import requires a trusted native picker or broker that proves explicit user approval on both supported platforms. WebKit support separately requires a documented native attach API that does not execute page JS or pass file bytes through the JS bridge.
Numbered 24 because 22 and 23 are claimed by in-review PRs #170 (origin allowlist) and #169 (artifact upload).
Decision: Headless will grow a host-owned credential broker and durable normal-profile login state without paying for Apple Developer Program membership. Implement the local/community macOS tier and the Linux secret backend now. Keep the stored record format compatible with a later Developer ID build. Do not ship silent credential use on unsigned builds (#166 is out of this product).
The broker is the only component allowed to read password values from macOS
Keychain or Linux Secret Service / KWallet. The agent-facing protocol speaks
origin-bound aliases and short-lived challenge IDs, never password values.
Direct fill remains available for test passwords, with the existing
disclosure that Headless cannot erase a secret from a model-provider
transcript after the user typed it to the agent. Once the vault exists, the
default is aliases only.
Saved-credential use always requires current user presence: Touch ID or the macOS account password on macOS; an unlocked approved Linux secret-service prompt on Linux. Peer UID on the control socket is not authorization to release a secret. If the OS vault is missing, locked, or a plaintext Chromium fallback, fail closed.
Status: decided 2026-09-10 (owner). Implementation starts at #155, then #156 and #157.
Rationale: agents today put passwords through fill, so secrets show up
in tool transcripts. A vault is useful. It does not require notarization.
Keychain Services and Local Authentication work on ad-hoc local builds.
Developer ID buys Gatekeeper-friendly distribution and a stable signer
across updates (#45), not
the ability to store a password. Skipping the $99 is fine for this feature.
Skipping per-use confirmation on an unsigned binary is not: after that
toggle, Headless cannot prove a human chose the alias, and there is no
Apple-verified identity to hang a weaker policy on.
Durable cookies are the higher-value login gap. WKWebView already persists; Linux Chromium currently keeps the profile under the runtime directory and drops it across reboot. Fixing that is most of "stay logged into staging" without a password manager.
Guarantees
- Password values never appear in snapshots, command output, MCP, logs, flows, recordings, diagnostics, errors, environment variables, or process arguments.
- Page JavaScript cannot enumerate aliases or query the vault.
- Records bind to a canonical exact HTTPS origin (localhost http is the documented development exception) plus an account identity and a user-chosen alias.
- Normal-profile aliases, cookies, and storage are invisible to private contexts (#35). Private contexts may hold only in-memory credentials enrolled in that context, and those die with the last session using it.
- Cookie import (backlog G9) stays rejected.
- Passkeys stay as decision 20: omitted unless Apple grants the restricted entitlement. The vault does not unblock WebAuthn.
Residual risks we will state, not paper over
- Same-user malware, root, and a replaced unsigned binary can still reach Keychain items the way any local process of that user can, subject to macOS prompts.
- Rebuilds and signing-identity changes may re-prompt or look like a different app. Document that. Do not invent a self-signed cert as public trust.
- Prompt injection can still name an alias. Per-use user presence is what makes that fail closed on this tier.
- Login cookies are as stealable as in any persistent browser. Treat them as session secrets in the threat model, separate from vault passwords.
macOS unsigned tier
- Ad-hoc or source builds. Gatekeeper may require a manual open.
- Broker owns its Keychain items. No Keychain access groups.
- Every saved-credential use requires Touch ID or the macOS password.
- Capabilities report a local/unnotarized credential tier. Do not describe this as notarized or enterprise-ready.
- A later Developer ID build may add session-level or trusted-origin approvals only after a new decision. Notarization alone does not widen permissions.
Linux
- Secret Service or KWallet only. No Apple dependency. No Chromium basic plaintext store. Locked or missing backends return a specific error.
Durable normal profile (#155)
- One per-user profile. Sessions keep sharing it (architecture §11).
- Cookies and site storage survive host and machine restart until the user logs out, clears, or uses a private context.
- Linux: private XDG data directory, mode
0700, atomic setup, single-owner lock, migration from the current runtime profile only when that move is safe, explicit corruption recovery. Not the ephemeral runtime dir. - macOS: keep the persistent WKWebView data store, but document and test the contract instead of leaving it implicit.
- No unrestricted
--profile-path. No cookie-import command.
Out of this decision
- #166 unsigned silent fill.
- #158 Settings window. Interactive CLI plus a native save sheet are enough for the first cut.
- #153 full settings registry. Needed later if "aliases only vs allow direct fill" becomes a persisted user-only setting. It does not block #155.
- Paying for Developer ID in order to start this work.
Consequences: #154 is the decision. Implementation PRs must keep
passwords off the socket, fail closed without a vault, and advertise the
unsigned tier honestly. Protocol additions (credentials *, auth login,
AUTH_REQUIRED) are compatible additions; do not bump 0.5 unless a breaking
change appears. Tagged macOS releases still fail closed without Developer ID
secrets; that remains #45 and is not a vault prerequisite.
Revisit trigger: a Developer ID identity is actually used for public macOS distribution, and we want weaker-than-per-use confirmation. Write a new decision. Do not turn #166 on just because notarization started working.
Decision: application preferences use one typed registry that declares
each key's type, default, platform scope, effect timing, access class, storage
identity, and validation. The local CLI provides config list, describe,
get, set, and reset; these commands never enter the browser protocol or
MCP surface. Agent callers cannot discover user-only keys, cannot mutate
agent-readable keys, and can mutate only agent-writable keys. The macOS
Settings window (Command-,) is the trusted native surface: it is built from the
same registry and writes through the same backends as headless config. Linux
has no Settings GUI. User-only string preferences render as secure text and
redact their defaults from Accessibility; credential values and approvals
remain outside the settings store. Ordinary CLI or PTY presence is not proof
of a human.
macOS stores preferences in the existing com.headless.app UserDefaults
domain. The initial startup-presentation definition deliberately retains its
existing physical AgentStartupPresentation key, so adopting the registry
does not copy, lose, or resurrect a prior value. Linux uses a bounded,
versioned XDG config file under a 0700 Headless directory, with a 0600
lock and data file, descriptor-relative no-follow operations, strict decoding,
locking, atomic replacement, and file plus directory synchronization.
Status: implemented 2026-09-12 by #153. The macOS Settings window landed in #158.
Rationale: settings need one discoverable contract before more preferences arrive, but moving host security boundaries into a writable preference would turn policy into an opt-out. Access classification is enforced below parsing, not merely documented. User-only authorization remains in a trusted native or broker path, and credential secrets and approvals remain outside this store.
Consequences: safety invariants, diagnostic gates, sandbox behavior, allowed navigation schemes, downloads, arbitrary JavaScript, remote control, and credential authorization are not settings. Corrupt or insecure persisted state fails closed. Adding a setting requires a registry entry and tests; a wire-protocol version bump is unnecessary because config remains local-only.
Decision: session create NAME --isolated creates a session whose cookies,
storage, cache, permissions, and authentication state are separate from the
durable normal profile and every other isolated session. Each isolated session
owns exactly one engine context. Closing that session destroys the context;
there is no caller-selected profile path, context reuse, import, or persistence.
macOS uses a fresh non-persistent WKWebsiteDataStore. Linux creates a
Chromium browser context through the existing private DevTools pipe and
disposes it after closing its only target. profile clear closes all sessions,
including isolated sessions, then clears only the durable normal profile and
recreates the normal default session.
Private sessions cannot enumerate or retrieve normal-vault aliases, credentials, or approvals. Interactive enrollment writes only to an in-memory credential store owned by the isolated session. The store is exact-origin bound and bounded, and erases every secret when the session closes, the host stops, the profile is cleared, or crash recovery replaces the process. It does not use Keychain, Secret Service, or the normal nonsecret index.
Status: implemented 2026-09-12 as the isolation slice of #35.
Rationale: a named session currently means another view into one durable profile, not a privacy boundary. Engine-native ephemeral contexts provide a clear, testable boundary without adding profile-path escape hatches or a second host. One session per context keeps ownership and cleanup deterministic.
Consequences: session creation gains one optional compatible parameter. Capabilities report the isolation contract. Normal sessions continue sharing the durable profile. Hover, drag, select, scoped evaluation, and response-body inspection are not part of this decision.
Decision: auth login --interactive obtains the username and password only
through a host-owned native secure dialog on macOS or the foreground
/dev/tty on Linux. It never accepts password arguments or protocol fields.
The host fills once, verifies the resulting authentication state, and only
then presents a separate save decision whose default is No. An approved save
passes one bounded binary credential frame directly to the trusted broker over
stdin; the browser control socket, MCP, JSON, environment, and process
arguments continue to carry no secret value.
Interactive login may create a challenge from the current confirmed,
same-origin POST form. Existing challenge IDs remain session-, document-,
origin-, expiry-, and replay-bound. Additional verification, passkeys, failed
credentials, and unknown verification outcomes do not offer persistence. Raw
fill remains compatible and never implies save consent.
Status: implemented 2026-09-12 by #157.
Consequences: the candidate secret exists only in host memory for the login and immediate save decision and is cleared on every return path. Broker storage still applies exact-origin and case-insensitive alias uniqueness. The Linux terminal path enables interactive login but does not weaken the separate rule that durable saved-credential retrieval needs trusted per-use presence.
Decision: SDK generation starts from a deterministic machine-readable
contract emitted by headless schema. The Swift descriptors define every wire
command's parameter names, primitive types, required fields, byte and numeric
bounds, enum values, array limits, API scope, and transport timeout policy.
Those descriptors execute before
command-specific semantic validation. sdk/protocol-schema.json is a golden
artifact checked against the executable in tests, not a second hand-maintained
validator.
SDKs use only the existing newline-framed JSON protocol over the private same-user Unix socket. They do not add TCP, remote control, arbitrary JavaScript, a Chromium debug port, unrestricted profile paths, or direct credential values. Authentication APIs carry aliases and challenge IDs only. Page-derived results stay identified as untrusted, sensitive diagnostics keep both gates, and unsupported engine behavior is capability-negotiated and fails explicitly.
Wire compatibility is exact by headlessProtocolVersion and independent of
the product and SDK package versions. Compatible additions may retain the wire
version; removals, changed meanings, weaker validation, or incompatible
envelopes require a new wire version. The schema format has a separate integer
version. Clients reject unknown schema versions and mismatched response IDs or
wire versions before decoding command results.
Cancellation before send writes no request. Cancellation or timeout after send
closes the client connection but cannot claim the browser operation was rolled
back; clients report an unknown outcome and refresh state. SDK-owned hosts use
headless start --supervised, which refuses to attach to an existing host and
creates a private owner pipe after launching the host. Ownership is granted only
when the startup response PID matches that child. Closing launcher input, or the
launcher exiting, closes the private pipe, stops the owned host, and lets the
launcher reap it. SDKs never stop a shared host they discovered.
Status: accepted for #163.
Consequences: TypeScript and Python clients generate or validate their public command types from the golden schema and pin its digest. Schema, SDK, shared fixtures, CLI-parser, protocol-validator, and MCP parity are tested together. SDK package versions are independent of the wire version. A package that also distributes the Headless product tracks product tags so its launcher can resolve a matching release; standalone SDK packages may version independently while declaring their supported wire version. Deprecation, support-window, provenance, and security-reporting rules live with the schema so client packages cannot silently invent a different policy.
Decision: a detached host no longer sends stdout and stderr to
/dev/null. The CLI starts a minimal writer process and connects the host to
it through an inherited pipe. The writer emits bounded JSON lines to
/tmp/headless-<uid>/host.log, keeps one host.log.1 generation, and caps
each file at 1 MiB. A pipe is required instead of launch-time rotation because
only a live consumer can enforce a disk bound across a long host lifetime.
The runtime directory remains the default trust boundary: it is an
owner-checked 0700 directory, while the active log, archive, and lock are
owner-owned regular files with one link and mode 0600. Opens use
O_NOFOLLOW; rotation is serialized with a private file lock. Unsafe files,
symlinks, hard links, and relative overrides fail closed. The existing
HEADLESS_HOST_LOG absolute-path override remains for operators and tests,
but receives the same final-file checks, redaction, rotation, and bounds.
The writer records only native host stdout and stderr. It does not receive protocol requests, page snapshots, fill values, credentials, cookies, or storage. Every line is byte-bounded and common secret assignments and URL userinfo are redacted before persistence. Startup exit and timeout responses may include only the selected path and an 8 KiB tail that has already passed through that writer. Logging adds no protocol command, listener, or browser capability.
Status: implemented for #193.
Consequences: normal failures are diagnosable without a special environment variable, disk use is bounded to two generations, and logging survives the launching CLI process without keeping that CLI resident. The writer exits on pipe EOF when the host exits. Log content remains diagnostic data, not trusted evidence, and must not be exposed through the agent-facing protocol.
Decision: headless doctor is an offline local command with a versioned,
bounded JSON report. Checks have stable identifiers, one of healthy,
warning, unsupported, or failed, a severity, a short controlled detail,
and an optional actionable suggestion. The report includes product, protocol,
and platform versions. Output stays below the protocol frame budget even
though doctor does not use the host protocol.
Doctor inspects the running CLI, browser runtime, FFmpeg, private runtime and
socket, artifact storage, typed settings, bounded host log, and applicable
Linux sandbox constraints. It may send the existing non-disruptive ping to
an owned private socket, but it never launches a browser, contacts the network,
creates or repairs storage, changes configuration, removes stale sockets, or
reads arbitrary file contents. Settings parsing is bounded to the existing
settings-file limit. Reports use controlled messages and never include
environment values, page data, credentials, cookies, storage values, URLs, or
file contents.
Missing optional tools and storage that has not been initialized are warnings. Unsafe storage, corrupt settings, stale or unsafe sockets, missing required Linux Chromium, an unresolved CLI, and running the Linux host as root are failures. The command exits nonzero only when at least one check failed.
Status: implemented for #194.
Consequences: users and agents get one deterministic installation report without changing the machine they are diagnosing. Repair remains an explicit operator action, and startup keeps its existing validation and failure behavior rather than trusting doctor's earlier result.
Decision: session.list preserves its ordered sessions name array and
adds one fixed-shape detail per session. Details contain the name, isolation,
monotonic age in milliseconds, lifecycle status, nullable current HTTP(S) URL
and title, independent truncation flags, and untrustedContent: true. The
mixed-trust result is not wrapped as wholly untrusted: the ordered sessions
compatibility array stays directly accessible, while each detail record marks
its page-derived URL and title as untrusted. URLs are
limited to 8,192 UTF-8 bytes after userinfo removal; titles are limited to
1,000 UTF-8 bytes. Non-web URLs are omitted.
The core snapshots session references and creation times under its state lock,
then releases that lock before asking either engine for metadata. WebKit reads
WKWebView URL, title, and loading state on the main thread. Chromium uses the
read-only Page.getNavigationHistory CDP command plus host-owned navigation
state. Neither adapter activates a window, enables agent control, evaluates
page JavaScript, takes a semantic snapshot, or changes navigation. A session
that closes or fails while being queried remains in that response with status
unavailable; it does not fail metadata for other sessions and disappears
from the next snapshot.
Status: implemented for #196.
Consequences: agents can choose an existing session without inspecting or
focusing every page. Page-controlled titles and URLs remain explicitly
untrusted and bounded. No cookies, storage, credential aliases, authentication
state, native window identifiers, process identifiers, or filesystem paths are
exposed. The fields are additive at protocol version 0.5; the generated SDK
schema defines SessionDetail so clients do not need untyped casts.
Decision: artifact.list, console.list, and network.list share one
pagination implementation. Cursors are random UUID tokens backed by state in
the owning host process. Tokens encode no offsets, paths, filters, session
names, page data, or secrets. Records bind a token to its owning store,
command, filter set, collection fingerprint, direction, and next position.
They expire after five minutes, the registry retains at most 512 records, and
all records disappear on host restart.
Limits are integers from 1 through 250. Artifact pages are ordered newest
first, then by name when creation times match. Diagnostic output keeps its
existing behavior: the newest batch is returned first and entries within each
batch remain chronological; subsequent pages walk older batches. Every page
reserves at most 768 KiB for encoded list values so response metadata remains
inside the 1 MiB frame. Pages report returned, truthful total or
available, nullable nextCursor, truncated, and mutation: "none".
Existing fields and default limits remain.
Collection fingerprints are computed from canonical bounded list values and
kept only in private process memory. Mutation between pages fails with
PAGINATION_CURSOR_STALE; expired, unknown or malformed, and wrong-command or
wrong-filter tokens fail with distinct typed cursor errors. A cursor presented
to a different session or artifact store is unknown and fails closed. Recovery
for every cursor error is to restart without --cursor; data is never resumed
against a changed snapshot.
Status: implemented for #195.
Consequences: stable bounded collections can be traversed completely without increasing the 1 MiB frame limit. Callers that omit pagination options retain the prior first-page behavior. Cursor replay is safe until expiry, but cursors are intentionally not durable across host restarts and do not promise snapshot retention after source mutation.
Decision: the deterministic Docker benchmark in this repository remains a
required regression gate. Heavy multi-run agent evaluation belongs in a
separate LockInTime/headless-benchmark-lab repository once that repository is
provisioned. The product repository may retain reviewed, immutable result
snapshots under docs/qa/evidence/, but it does not own the lab runner, model
credentials, provider setup, or live-site tasks.
The external lab reports three tracks separately. Browser-tool comparisons hold the runner, model, prompt, task, limits, and environment constant while changing only Headless versus a pinned Playwright MCP baseline. Headless compatibility runs the same Headless tasks through Codex, Claude Code, and OpenCode. Product comparisons may use each product's strongest supported path, but cannot attribute the result to the browser tool alone or share a leaderboard with the paired track.
The primary suite uses deterministic local fixtures for navigation, extraction, search, tables, pagination, forms, dynamic updates, redirects, tabs, persistent authentication, isolated sessions, stale references, delays, races, recovery, prompt injection, unsafe navigation, secret handling, confirmation boundaries, and unintended side effects. Live-site tasks require explicit permission, controlled credentials and rate limits, and reversible or read-only behavior. The lab never stores production credentials and never deploys the Headless website.
Every published run pins runner, model, reasoning settings, browser tool, browser, prompt, validator, limits, and environment. It preserves immutable sanitized event streams and a versioned machine-readable result. Publication removes contributor-specific paths, credentials, and unrelated local file contents without changing event order, tool calls, metrics, or scoring data. Unsanitized logs remain restricted lab artifacts. Mechanical validators decide success where possible. Security results stay separate from performance. Default and tuned configurations, clean and persistent profiles, unsupported capabilities, provider usage, price snapshots, resource measurements, failures, and uncertainty are reported rather than normalized away.
Status: proposed for #161. A non-Claude, three-trial local pilot is preserved as reviewed evidence, but it does not satisfy the broader task taxonomy, startup parity, resource measurement, or sample-size requirements.
Consequences: removing the in-repository benchmark requires equivalent replacement coverage and a separate decision. Evidence snapshots in this repository must identify their method, versions, sample count, limitations, sanitization, and published-run checksums. A pilot cannot close #161 or support a general product claim.
Decision: wait --network-idle adds one optional predicate to the portable
wait command. Every requested URL, text, settled, and network-idle condition
must hold at the same time. A bare wait preserves its existing settled-page
default, while wait --network-idle alone does not silently add another
predicate.
Chromium tracks Network.requestWillBeSent identifiers until
Network.loadingFinished or Network.loadingFailed. Idle means no qualifying
request is active for 500 ms measured from command start or the latest
qualifying event. Redirect reuse does not double-count an identifier.
WebSocket and EventSource connections are excluded because they are expected
to remain open; ordinary fetch/XHR long polling remains qualifying and may
time out. Tracking is bounded to 4,096 identifiers with a 512-byte identifier
limit, and overflow fails closed by preventing an idle result.
WKWebView has no complete trusted network event stream. It returns
UNSUPPORTED_CAPABILITY immediately rather than consulting the detectable,
forgeable page diagnostics bridge. The engine capability matrix declares this
difference. Wait output remains the existing bounded page state and never adds
request URLs, identifiers, headers, bodies, or timing records.
Status: implemented for #204.
Consequences: agents can replace blind sleeps after asynchronous Chromium work with a deterministic bounded predicate. Sites with qualifying long-lived requests must choose another semantic condition. macOS callers can negotiate the limitation before issuing the command and still receive a typed failure if they do not.
Decision: add one portable select command for native single-selection
HTML <select> controls. It reuses the click/fill target grammar and requires
exactly one option matcher: a whitespace-normalized exact label or an exact
value. Missing, ambiguous, disabled, multi-select, custom ARIA, and stale-target
cases fail before mutation. A changed selection dispatches input followed by
change; selecting the active option is idempotent. Responses include bounded
target metadata and the option index, never the option label or value.
Both engines run the same fixed operation inside the isolated agent world.
Chromium does not claim trusted CDP dispatch for native popup selection because
keyboard navigation differs across platforms and disabled option groups. A
separate selectDispatch: synthetic-dom capability preserves the accuracy of
the existing click/fill/key inputDispatch claim. This does not expose caller
JavaScript or expand the arbitrary-evaluation boundary.
Custom comboboxes, listboxes, multi-select, hover, drag, and arbitrary evaluation remain out of scope. Select commands are not replayable flow steps.
Status: implemented for #206.
Consequences: agents get deterministic dropdown selection without brittle key sequences, while unsupported widgets remain explicit instead of receiving partial behavior. Option matchers are treated as sensitive protocol parameters for logging, even though callers still control their own CLI process arguments.
Decision: screenshot --by-region @rN is a third screenshot-series mode
alongside viewport and section series. It accepts only an opaque region
reference issued by the current page runtime. It never accepts selectors,
element references, roles, or names.
The isolated runtime freezes the region's document geometry and divides its vertical extent into at most 80 bounded image slices. Each slice is no taller than the viewport or the shared screenshot pixel limit permits, and the final region extent is included when truncation is not required. Before each capture, the runtime resolves the same region reference again, requires it to remain connected and visible, verifies that its geometry has not changed, and returns only the exact slice rectangle. Regions that are horizontally outside the viewport, exceed the portable 4096 CSS-pixel width bound, mutate during capture, or produce invalid geometry fail closed.
A region reference means the referenced element's rendered border box, not the
broader logical scope used by inspect --within. Nested scroll-container
content outside that border box is not expanded. If more than 80 slices are
required, Headless captures the first 79 and final slice and reports both
truncated: true and the complete totalPoints; the missing middle is not
represented as complete coverage.
HostCore reserves the complete artifact set before capture, discards every reservation on failure, and requires restoration of the original scroll position after both success and failure while the original document identity remains active. WebKit captures the validated viewport rectangle; Chromium captures the corresponding document rectangle through CDP. Both use the same plan, artifact, truncation, and response contract. Region capture does not add selectors, arbitrary JavaScript, page content, or coordinates to logs or flows. Screenshot-series metadata is marked untrusted because section labels originate in the page.
Status: implemented for #211.
Consequences: an agent can progress from outline inspection to visual evidence for one semantic region without broad full-page capture. Very wide, moving, sticky, or dynamically resizing regions require a fresh inspection or a narrower region instead of yielding partial or misleading evidence.
Decision: protocol 0.5 gains one compatible portable hover command with
the same fresh element-ref or exact role/name target grammar as click. A ref
resolves only against the latest inspection. A later inspect or a document
refresh expires it, and a detached node fails closed. After scrolling the
target into view, both engines reject a hidden target and a target whose
viewport center is covered by another element, including a dropdown or other
overlay, before any pointer event. The public response contains only the
bounded target ref, role, and name. It never returns the internal hit-test
coordinates.
Chromium performs one trusted Input.dispatchMouseEvent mouse move after that
check. WebKit tracks the previous synthetic target and emits fixed
pointer/mouse leave, enter, and move transitions. The capability matrix
reports hoverDispatch as trusted-cdp or synthetic-dom. The command never
focuses or clicks directly, accepts selectors or coordinates, or exposes
caller JavaScript. Page-controlled hover handlers can still navigate or cause
other page side effects, subject to the existing navigation and download
policies.
Hover is not a flow step. A flow replays commands later. Hover only describes
where the pointer is now, and WebKit's synthetic sequence does not activate
CSS :hover, so a replay would not reproduce the revealed surface. Screenshot
series are saved images from one capture. They are not replayed commands, so
they are not flow steps either. Hover follows that rule. A hover that reveals
a login form still returns AUTH_REQUIRED through the existing challenge
path, and the hover itself is not stored for replay. Inspection does not
advertise hover because markup cannot prove that hovering has meaningful
behavior.
Generic drag remains deferred to #208. That contract must separate pointer and HTML drag behavior, deny caller payloads and file paths, bound timing and auto-scroll, and define cross-frame behavior before code is accepted. Arbitrary evaluation and response-body inspection remain denied.
Status: implemented for #35.
Consequences: agents can reveal tooltips and hover-only controls without
falling back to coordinates. CSS :hover fidelity is guaranteed only by the
trusted Chromium path; WebKit callers can negotiate its synthetic limitation.
The additive command retains protocol 0.5; schema format 1 remains
unchanged.
| # | Decision | Status | Date |
|---|---|---|---|
| 1 | Keep Swift core; Rust only via revisit trigger | Decided | 2026-08-04 |
| 3 | Extract HostCore + BrowserEngine, typed errors | Implemented | 2026-08-10 |
| 5 | Remote stays SSH-only; no cloud offering | Decided (owner) | 2026-08-04 |
| 6 | Windows = stretch via Chromium engine; WSL2/Docker interim | Decided (owner); spike failed 2026-08-22, native deferred | 2026-08-04 |
| 8 | Real CDP input on Linux as capability upgrade | Implemented | 2026-08-13 |
| 12 | Version unification on git tag | Implemented | 2026-08-04 |
| 14 | Run one conformance scenario against every engine | Implemented | 2026-08-10 |
| 15 | Package-manager distribution set | Decided (owner) | 2026-08-04 |
| 16 | Preserve CLI value boundaries with -- and shell quoting |
Decided | 2026-08-10 |
| 17 | Keep full MCP surface; annotate its maximum risk | Decided | 2026-08-10 |
| 18 | Treat WebKit page diagnostics as bounded untrusted evidence | Decided | 2026-08-10 |
| 19 | Keep macOS agent startup behind the current app | Implemented | 2026-08-12 |
| 20 | Omit passkeys unless Apple provisions Developer ID release | Implemented | 2026-08-12 |
| 21 | Rust port of shared core, protocol layer first | In progress | 2026-08-22 |
| 22 | Optional host origin allowlist on headless start |
Implemented | 2026-09-10 |
| 23 | Upload attaches existing store basenames only; downloads denied | Partially implemented; trusted staging remains #168 | 2026-09-10 |
| 24 | Credential broker on the unsigned local tier | Decided | 2026-09-10 |
| 25 | Typed local settings registry; security policy stays fixed | Implemented | 2026-09-12 |
| 26 | Isolated sessions own one ephemeral browser context | Implemented | 2026-09-12 |
| 27 | Interactive authentication keeps consent in trusted host | Implemented | 2026-09-12 |
| 28 | SDKs derive from one Swift-owned protocol contract | Decided | 2026-09-12 |
| 29 | Detached hosts use a private bounded log writer | Implemented | 2026-09-19 |
| 30 | Doctor is a read-only local readiness report | Implemented | 2026-09-19 |
| 31 | Session metadata is a bounded read-only host snapshot | Implemented | 2026-09-19 |
| 32 | List pagination uses bounded server-side opaque cursors | Implemented | 2026-09-19 |
| 33 | Keep heavy agent benchmarks outside the product repository | Proposed | 2026-09-19 |
| 34 | CDP-backed bounded network-idle wait | Implemented | 2026-09-20 |
| 35 | Fixed native single-select operation | Implemented | 2026-09-20 |
| 36 | Bounded exact-crop region screenshot series | Implemented | 2026-09-22 |
| 37 | Semantic bounded hover with declared dispatch fidelity | Implemented | 2026-09-22 |
New decisions append here with the same format.