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 | 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 |
New decisions append here with the same format.