Ostler
GitHub

Docs Help

Ostler changelog

What changed in Ostler, including the Unreleased section that is on the main branch but not in a tagged release.

On this page

Entries under Unreleased are on the main branch and are not in a tagged release yet. This includes sealed memory, local embeddings and per-tenant tokens; see Memory and privacy.

Unreleased#

Added#

  • Less data through the model (ADR-0019): where/select filters on extract and links; search_tools (names, descriptions or full schemas for any tool); {{fact:KEY}} values so a saved fact is filled without the agent seeing it; and run, a sandboxed Rhai script that calls Ostler's own tools (no files or network, 60-call cap, all gates still apply). The default tool list grows by one tool (16 core, 32 in all).
  • ostler start: one command that finds the AI apps on the computer (Claude Desktop and Code, Cursor, VS Code, Windsurf, Codex), connects Ostler to each (same safe edit as ostler install: backup, other servers kept, idempotent), checks Chrome and prints example prompts.
  • Claude Desktop extension bundle (.mcpb, macOS) built by the release workflow via scripts/package-mcpb.sh: download, double-click, done.
  • docs/GETTING-STARTED.md for non-technical users and docs/ACCESSIBILITY-PLAN.md (who is blocked today, what this change fixes, what is left).
  • MCP prompts: five ready-made tasks (summarize a page, research with checked sources, fill a form and review before sending, check my page, compare pages) served over prompts/list and prompts/get, so clients can show them in a menu.
  • ostler login <url>: sign in once in a visible Chrome window; the session is kept in ~/.ostler/profile for --profile-dir.
  • Cursor and VS Code install links in the getting-started guide.
  • ostler start --cautious registers Ostler with --require-confirm-submit; start prints a practice prompt; the guide explains the tool messages in plain English; docs/DISTRIBUTION-CHECKLIST.md.
  • Python package ostler-mcp (pip install ostler-mcp, uvx --from ostler-mcp ostler serve): a maturin wheel carrying the same Rust binary, built and smoke-tested by the release and CI workflows, uploaded to PyPI when PYPI_TOKEN is set. The PyPI name ostler is taken by an unrelated project.
  • VS Code extension (editors/vscode, also for Cursor and Windsurf via Open VSX): registers Ostler as an MCP server, uses ostler on PATH or downloads and checksum-verifies the release once, commands for setup check and sign-in. Built, tested and attached to the release by the workflow; published when VSCE_PAT / OVSX_PAT are set.
  • Website: new Get started without programming page; the install page, agents page, landing page, FAQ and llms.txt now cover pip/uvx, the VS Code extension, Open VSX, Homebrew, npm and crates.io with links to each listing. Structured-data sameAs stays GitHub-only until the packages are live.

Changed#

  • Framework docs, a research demo, and a fix for the first release. New page LangChain, LlamaIndex and AutoGen: each snippet was run against Ostler 0.2.0 over stdio (tools loaded, read then cite returned VERIFIED and NOT VERIFIED), with no model in the loop. It records the one rule that matters: keep one Ostler session open, because LangChain's default get_tools() and LlamaIndex's BasicMCPClient start a new Ostler process per call, so cite answers NOT READ; AutoGen needs mcp<2. New assets/demo-research.gif (real replies and page screenshots: read, a VERIFIED and a NOT VERIFIED cite, an evidence report with a paywalled gap), now in the README and on the homepage beside the existing browse-and-forms GIF. The release workflow built the Intel macOS binary on macos-13, which GitHub has retired; it now uses macos-15-intel, so a tag will not wait for a runner that no longer exists. Adds glama.json for claiming the Glama listing. docs/RELEASING.md lists all eleven publishable crates, not eight.
  • Website and README describe what Ostler is now, and are easier to find. One definition ("an open-source MCP browser for AI coding agents that checks its own work: research that cites only pages it read, forms filled in one call with every value read back") is used by the landing page, README, docs, llms.txt, npm and crates.io metadata. The landing page gains a research section with real cite/evidence output, a Firecrawl row and a research column in the comparison, research limits, and FAQ entries (what Ostler is, adding it to Claude Code, research with citations, Firecrawl). The site's tools reference was stale (21 tools, all as default); it now lists all 30 tools, the five profiles and the research tools. The docs generator now writes sitemap.xml, llms.txt and llms-full.txt (llmstxt.org) and schema.org JSON-LD (TechArticle, BreadcrumbList; the landing page has SoftwareApplication, SoftwareSourceCode and FAQPage). The Pages deploy smoke-tests these files and pings IndexNow. Also CITATION.cff and a server.json for the official MCP registry (to publish once npm is live; see docs/RELEASING.md).
  • Site follows the SEO and generative-engine audit. New pages: research with checked citations (real tool output, a rejected quote, an evidence report, what is not checked) and Ostler vs Playwright MCP (five scripted fixture tasks, with what the comparison does not show). Every page has a task-specific title, description and H1; documentation JSON-LD reuses the homepage #app, #creator and #website identifiers, and the homepage graph adds a Person creator and a WebPage. The unpublished npm package is no longer linked as the same entity, and the version is labelled development source. Memory wording no longer says "structure only, never the values": secret detection is called heuristic, and a new "where your data goes" table says tool output reaches the MCP client's model provider. The homepage separates three kinds of evidence (local fixtures, scripted competitor comparison, live practice pages), labels each metric with its baseline and adds a data table beside each chart. The benchmark document now says its run revision was not recorded. llms-full.txt has absolute links and no :unreleased: or repo: markers. site/tools/check_site.py fails the Pages deploy on invalid or contradictory JSON-LD, FAQ markup that differs from the visible FAQ, duplicate titles, a wrong tool count or release status, and non-absolute LLM links; the IndexNow ping now announces only changed pages. site/tools/host-root/robots.txt is the host-root policy to copy into the user-site repository.
  • Research tools: read, cite, evidence, search, open_many, extract, links. The research libraries are now MCP tools, built around one contract: an answer can only contain what was read and verified. read returns the best passages for a query (BM25 plus MMR, so a page costs a few hundred tokens, not the whole page) and remembers the page with its hash and fetch time. cite accepts a quote only if its words occur in a page that was read in this session (markup on either side is ignored, so **1887** and [Marta Ilves](/m) still verify); otherwise the call fails with NOT READ or NOT VERIFIED and the closest text on the page. A verified quote comes back with a #:~:text= link a person can use to check it. If a page is read again and its text changed, earlier citations are re-checked and reported stale. evidence lists verified citations, stale ones and the gaps: pages that were paywalled, behind a bot wall, disallowed by robots.txt, an HTTP error or empty are recorded, never silently dropped. Text on a page that reads like an instruction to an AI is flagged in the header and on any citation that overlaps it, and a verification wall or error page is never stored as a source. open_many reads up to 10 URLs in a scratch tab (your tab is untouched) with robots.txt (RFC 9309), per-host spacing and a time budget; search uses SearXNG or Brave (OSTLER_SEARXNG_URL, or OSTLER_SEARCH_PROVIDER=brave + OSTLER_BRAVE_API_KEY); extract returns tables and lists with a PARTIAL: 5 of 13 verdict when the page says more exists. read is a core tool; the others are revealed after the first read or search in --tools auto, or listed with --tools research. The starting tool list grows from 4.1 KB to about 4.9 KB. ostler-extract gains plain_text (markdown to words) and its injection heuristics now catch notes addressed to "AI assistants" and "ignore the task you were given". Limits: pages are read one at a time (no parallel tabs yet), no PDF text layer, no persistence of sources across sessions (the source memory kind is not wired in), no override for robots.txt.
  • Research and workflow foundations (libraries only, not yet MCP tools). New crates ostler-extract, ostler-workflow and ostler-research (see README, "Beyond forms"). ostler-memory gains a source kind with sealed sparse (BM25) vectors and hybrid Qdrant search. ostler-browser gains read, table/list/pagination extraction and a diagnostics collector. ostler-fixtures gains five domain packs (34 tasks) with a records endpoint; ostler-bench packs scores false success. ADRs 0012-0018, docs/BUILD-PLAN.md, and an eval harness under scripts/eval/. Test threads now get a 16 MiB stack (.cargo/config.toml) because the ostler-bench pack tests overflowed the 2 MiB default when run in parallel. No change to the MCP tool surface.
  • Reliability pass. (1) Tool failures carry a typed code and retry rule (STALE_REF, ACTION_UNCERTAIN, BLOCKED, NOT_ACTIONABLE, NAVIGATION_FAILED), raised from typed BrowserError variants. (2) Every tool call has a 120 s deadline (OSTLER_TOOL_TIMEOUT_SECS, 0 disables; wait_for exempt). (3) form_submit refuses an identical repeat within 30 s of a submit that may have gone through unless again=true. (4) A2A: body limit, header/body/request timeouts, concurrent-task limit with 429 and Retry-After, /healthz, /readyz, request ids, graceful drain on SIGTERM (--max-concurrent-tasks, --drain-secs, --max-body-bytes). (5) Memory: episodes expire after 30 days and are capped at 500 per tenant, forget cascades to playbooks and episodes, the embedding model is checked on open, reads and upserts retry with jittered backoff. (6) Fix: IPv4-compatible IPv6 hosts such as [::127.0.0.1] bypassed the private-network and metadata block. (7) Supply chain: cargo-deny and cargo-audit workflow, Dependabot, rustdoc warnings denied in CI; property tests and fuzz targets for the URL policy and form matching.
  • Product and system-design documentation. New: docs/SYSTEM-DESIGN.md (context and data-flow diagrams, trust boundaries, explicit constraints and non-goals, capacity assumptions, trade-offs), docs/THREAT-MODEL.md (STRIDE per boundary with residual risk), docs/FAILURE-MODES.md (FMEA with status), docs/SLOS.md (proposed SLIs/SLOs and how each is measured), docs/COMPATIBILITY.md (semver, deprecation, MSRV, tested platform matrix), docs/OPERATIONS.md (runbook, upgrade, rollback, key rotation) and ADR-0011 (reliability principles). Documentation only; no behaviour change.
  • Real-site evaluation (ostler-bench real) and the two bugs it found. Fills seven live public form-practice pages and reads 45 fields back out of the DOM with independent JavaScript; every field is ok, flagged (the report said it could not) or a false claim (the report said nothing), and the run exits non-zero on any false claim. First run: 42 of 44 fields, 2 false claims. Both traced to real bugs, now fixed: (1) with two forms matching the keys equally (ultimateqa.com), the later form was filled silently; a tie now goes to the first form and the fill report adds WARNING: N other form(s) … matched these keys equally well (…); this filled form eN. Pass form=<ref> (new rival_forms field on FillReport). (2) navigate and navigating clicks waited up to 30 s (20 s for clicks) for the load event, which includes every ad and tracker (pages took 31.7 s and 38.7 s to open with the form usable at once); once the new document is interactive, load now gets 5 more seconds and the agent is told the page was cut short. Now 44 of 45 fields correct and 1 flagged (a deliberate unmatched key), 0 false claims; those two pages open in 7.5 s and 14.0 s. Small practice-page corpus, no model in the loop, not a success rate for the open web: see BENCHMARKS §7. Fixtures twin_forms.html and slow_load.html guard both.
  • Fix: replacing the memory key twice in one second failed. ostler memory key import --force named its backup memory.key.old-<unix seconds> and refused to overwrite, so a second forced import in the same second (a recovery attempt right after a wrong one) errored out. Backups now get a -1, -2, … suffix and every replaced key is kept. This also made the a_replaced_key_is_refused… test fail intermittently on CI.
  • Memory without Qdrant: --memory local. With no Qdrant credentials, memory can now persist across sessions in one file, memory.local, next to the config (ostler init --local, answering yes to the interactive prompt, or ostler config set memory local). The whole file is AES-256-GCM encrypted with the memory key (--seal off stores plain JSON), written atomically (temp file, fsync, rename) and shared safely between processes with a file lock, so ostler serve and ostler memory can run together without losing writes. Search is an exact cosine scan, which is instant at agent-memory sizes (tens of points per user). Embeddings never leave the machine: local word hashing by default (not semantic), --embeddings ollama for semantic search. A wrong key, an edited file, or a changed embedding size is refused with the fix instead of being read as empty. Zero-config behaviour is unchanged: with no flags serve stays in-process and writes nothing.
  • New core tools hover (menus, tooltips) and scroll (to an element, or up/down/top/bottom with a position report).
  • Tool listing defaults to --tools auto: 14 core tools at start, forms and memory tools added (with tools/list_changed) once a page shows form fields. All tools stay callable; core|forms|all still pin a fixed set.
  • Realistic-forms suite (ostler-bench suite) and the bugs it found. 30 local fixtures modelled on how real sites build checkout, job-application, tax/government, address, phone, signup, consent, date-picker, rich-text, shadow-DOM, iframe, reveal-on-select, async-validation, CSRF and chip forms, each with a ground-truth final state read from the fixture server (not from the tool). Seven are meant to be refused (CAPTCHA, wrong format, server rejection, disabled control, unknown field, terms that stay disabled, username taken). First run: 17 passed, 6 refused correctly, 3 failed honestly and 1 wrong-fill; after the fixes below 30/30 (a regression floor on fixtures written alongside the fixes, not a claim about the open web). Fixed: (1) an array sent to a chip/tag combobox was typed as text and reported Verified with nothing chosen; each value is now picked from the suggestion list, a value that is not offered fails with how many were picked, and the field is APPLIED (cannot verify) because the selection is not readable; (2) inputs inside web-component shadow roots nested in a <form> were split into separate "page" forms with no submit button and now belong to that form; (3) "5" matched the day options 05, 15 and 25 and was refused as ambiguous, it now picks 05 (zero-padded numbers match by value); (4) an address suggestion list that shows street, city, state ZIP can be picked by the street part when exactly one suggestion starts with it before a comma (the box is still read back). Failure reports: a value the page rejects after it was entered is now REJECTED e3 "Username" = "admin" PAGE SAYS: "..." instead of OK; a key naming a control that exists but is collapsed or not yet revealed says it is hidden and what to do, instead of "no field matches"; form_fill, fill_field and form_submit end with Screenshot: call screenshot with ref=eN ... for the failing fields (refs only, never page text; not added for A2A, which has no screenshot tool). Run it with ostler-bench suite; see Benchmarks section 5. Not covered: cross-origin iframes, real third-party widgets (Google Places, reCAPTCHA, Stripe Elements), and anything behind a login.
  • New docs section: When not to use Ostler (bot walls and CAPTCHA, no stealth or proxies, one local browser, no OTP identities); see Troubleshooting and Forms.
  • Sealing-key hardening (parked items from the last cycle). (1) A first start never creates a key blind. With no key yet, serve, a2a, doctor and the memory commands create one only after Qdrant was reached and holds no sealed memory for the tenant; with Qdrant unreachable they refuse with the reason and the way out (start Qdrant, ostler memory key import, or --allow-new-key / OSTLER_ALLOW_NEW_KEY=1 for a genuine first-time setup). Sealed memory already there and no key: refused, flag or not, with the import and wipe commands. Previously the key was created before the mismatch check could run, so a later import of your original key reported a mismatch. (2) Linux Secret Service, deterministic. secret-tool store always replaces, so two simultaneous first starts could each keep their own key; creation now happens under an exclusive lock file (memory.key.lock, stale after 60 s) with check, store and read-back inside it, so exactly one key wins. With no Secret Service (no D-Bus session, locked, not installed) every keychain call fails with the reason and the fallback (ostler config set key_store file); Ostler never falls back by itself, and with key_store = keychain while a memory.key file exists it refuses rather than creating a second key in the keychain. The tool is driven through an injectable runner, so the Linux behaviour is tested on any host with a scripted fake (still not against a real keyring). (3) v1 sealed blobs are no longer read. They lacked authentication of the clear fields; memory list, serve and friends now refuse with ostler memory seal --tenant T, which opens them once, with the right key, and saves them as v2 (no transition flag: the upgrade is one command and a flag would only keep the weaker format readable). (4) Keys that norm() changed are reconciled. ostler memory seal also finds facts and playbooks whose lookup key was derived with an older normalisation (emoji-only keys, decomposed accents), saves them under today's key and removes the stray old copy, never overwriting a newer fact; the same condition is detected on every start and reported with the same fix. Plaintext copies from before sealing were already carried over by the sealing step. (5) Page-script isolation (investigated, not implemented): see SECURITY.md and ADR-0004 for exactly why the runtime cannot move to an isolated world without a redesign, and what a hostile page can still do.
  • Memory hardening from fuzzing and review. Sealed format v2 authenticates the clear fields (tenant, wins, losses, verified_ts) and the lookup hashes are re-checked against what was sealed, so a malicious server can no longer move a point to another tenant, inflate its win count, or swap two points' lookup hashes so that a lookup for "email" returns the "home address" fact; v1 points were still readable at this stage (see the sealing-key hardening entry above: no longer). Concurrent writers no longer lose counter updates or let learn overwrite a fact you saved; two tenants can no longer collide on a point id through a separator character; memory seal no longer overwrites a newer fact with a stale plaintext one; unopenable points no longer crowd out readable ones; prune, probe_key and the legacy count read every point instead of the first 10,000; a value that JSON cannot round-trip exactly is refused instead of stored one digit off; the circuit breaker lets exactly one trial through after the cooldown; error text reaching the agent is capped, single-line and free of credentials (also for a local embedder URL); card numbers are caught through any separators and inside nested values; keychain tools time out after 20 s instead of hanging startup. 42 unit tests, 25 sealing fuzz tests, 10 redaction fuzz tests and 10 real-Qdrant hardening tests cover this. Known limit: counters are exact only within one process. (The emoji/decomposed-accent orphaning, v1 blobs, key creation with Qdrant unreachable and the Linux first-start race listed here earlier are fixed; see the sealing-key hardening entry above.)
  • Memory is sealed before it reaches Qdrant (on by default for Qdrant). Fact values, descriptions, playbooks and episodes are encrypted on your machine (AES-256-GCM, point id bound as associated data). Site names, field labels, profile keys and fingerprints are replaced by keyed hashes (HMAC-SHA-256), and point ids are keyed too, so a Qdrant operator cannot test guesses ("passport number") against what it stores. The key lives in OSTLER_MEMORY_KEY or an owner-only memory.key next to the config file, created atomically on the first start (once Qdrant has been checked for existing sealed memory); back it up, there is no recovery. --seal off opts out. With the default --embeddings cloud, the text of field labels, host and headings is still sent to Qdrant Cloud Inference in the clear to be embedded, so sealing then protects what is stored, not what the cloud embedder sees: use --embeddings ollama for that. Also visible to the server: vectors, tenant name, win/loss counters, timing, point counts, and that two points share a site. Memory saved before this is invisible until ostler memory seal, which copies your saved facts into sealed form, checks they read back, and only then deletes the old plaintext points (safe to interrupt and rerun; old learned skills and episodes are dropped and relearned).
  • First-run friction removed. (1) ostler install --client <claude-code|claude-desktop|cursor|vscode|windsurf|codex> registers the server in that client's config file, with --list, --dry-run and --uninstall: it parses the file first and refuses malformed JSON/TOML, backs it up (<file>.ostler-backup-<time>), keeps every other entry (Codex's TOML is edited as text so comments survive), is idempotent, prints the before/after, and says "unsupported" for an OS/client pair with no known path (Claude Desktop on Linux) instead of guessing. OSTLER_INSTALL_HOME redirects the home directory for tests. (2) Zero config: with no Qdrant URL and no flags, serve and a2a now use in-process (ephemeral) memory instead of none (--memory off still means off; the memory subcommands still inspect persistent memory only), and serve prints a short block of what is on and off. Qdrant, sealing and learning stay opt-in via ostler init. (3) ostler doctor prints a fix: command for every failing check, reports config-dir, key-file mode and which clients are registered, and doctor --fix applies only safe repairs (create the config dir, chmod 600 an open key file); it never creates keys, installs software or uses the network. (4) serve --cdp-url http://127.0.0.1:9222 (and doctor --cdp-url) attaches to a Chrome you started yourself: loopback only (checked again on the URL Chrome advertises), validated at start-up, Ostler uses and closes only its own tab and never kills that browser, and it never opens a debugging port itself. Limits: --cdp-url is refused in A2A mode and with --profile-dir; --allow-domain then relies on request interception without Chrome's DNS pinning; Chrome needs a dedicated --user-data-dir to accept the debug port; VS Code files with comments are refused rather than rewritten; JSON clients' files are re-serialised (key order kept, formatting normalised, backup made); serde_json's preserve_order is now on, so tool-output JSON keeps insertion order.
  • Popup and target hardening (second review). A page can no longer flood the browser with popups: at most 8 are adopted at once (held popups over the cap are closed before they can run, with one note), closed tabs are reaped, and the original page keeps answering (150 window.open calls used to produce 151 tabs and ~12 s of lag). A worker, service worker or iframe whose interception cannot be set up now stays paused and is closed instead of being resumed uninstrumented. The CDP event channel is 16x larger and a receiver that falls behind is told so: the popup watcher re-syncs against Target.getTargets and closes any page it does not know, and page/frame loops report the loss as a warning on the next action. A confirmed form_submit whose form targets a new window (target=_blank) now goes through, while an unconfirmed one is still blocked (the popup inherits the opener's armed state for its first request only). Limits: a dropped Target.attachedToTarget for an iframe or worker, and a dropped Fetch.requestPaused, cannot be recovered (the request or frame stays paused: safe but stuck); more than 8 legitimate simultaneous popups are closed; a hostile page that opens a window during a confirmed submit can let that popup's first request through. A real Chrome buffer overflow was not reproduced.
  • Second review round on the fill engine. (1) The faster settle (below) could report Verified before a slow server-side check finished and the page then showed an error ("Username already taken", a rejected value in the fifth of five inputs that each fire the same validation request). Background classification no longer ages a request into the background by duration; recurrence only counts when no input happened in between (any input, change, click or keydown resets it); timers up to 2.5 s are waited for; the per-field wait ceiling is 3 s. Cost: a form whose every field triggers a ~1.5 s server check now takes that long per field, which is the honest price of reporting the late error. (2) A checkbox with its label before it no longer takes the label that belongs to the next control (Email [x] Phone [x] ticked Email for "Phone"): the layout convention of the whole group decides, and an undecidable layout falls back to the field name instead of guessing. (3) A successful search no longer reads NOT ACCEPTED because a result snippet contains "invalid certificate error": plain-text error words count only when the page stayed on the same path and the text sits next to the form, not in a list or article. (4) Option labels like "Email" under a "Contact preference" radio group no longer make a real "Email" field ambiguous, and "billing address" / "shipping address" resolve through their fieldsets. (5) A read-only dd/mm/yyyy datepicker no longer fails after the date rewrite; years outside 1900-2100 are refused and two-digit years expanded (a negative year used to be able to panic); an assumed mm/dd/yyyy is disclosed in the warning; a reordered radio/option group is re-resolved by label at fill time instead of by stale index; last_form no longer survives a navigation. (6) Page-controlled validation text could contain a newline and forge a trusted Ready: hint line in tool output; every page-derived note, warning and reason is now flattened to one bounded line. Not fixed: a form with four or more identical validation requests from one action is treated as chatter afterwards; a timer created after a fill and shorter than 2.5 s makes that fill wait to the 3 s cap and report unsettled.
  • Memory hardening from fuzzing and review. Sealed format v2 authenticates the clear fields (tenant, wins, losses, verified_ts) and the lookup hashes are re-checked against what was sealed, so a malicious server can no longer move a point to another tenant, inflate its win count, or swap two points' lookup hashes so that a lookup for "email" returns the "home address" fact; v1 points still open. Concurrent writers no longer lose counter updates or let learn overwrite a fact you saved; two tenants can no longer collide on a point id through a separator character; memory seal no longer overwrites a newer fact with a stale plaintext one; unopenable points no longer crowd out readable ones; prune, probe_key and the legacy count read every point instead of the first 10,000; a value that JSON cannot round-trip exactly is refused instead of stored one digit off; the circuit breaker lets exactly one trial through after the cooldown; error text reaching the agent is capped, single-line and free of credentials (also for a local embedder URL); card numbers are caught through any separators and inside nested values; keychain tools time out after 20 s instead of hanging startup. 42 unit tests, 25 sealing fuzz tests, 10 redaction fuzz tests and 10 real-Qdrant hardening tests cover this. Known limits: counters are exact only within one process; keys made only of emoji/symbols or written with decomposed accents resolve to different points than before this release (an old plaintext copy would be orphaned); v1 blobs are still accepted until a point is re-saved; a first start while Qdrant is unreachable creates a new key before the mismatch check can run, so a later import of your original key will then report a mismatch (back up the key, and start with Qdrant reachable); on Linux two simultaneous first starts can race on the keychain entry.
  • Real-site trial fixes (httpbin, Selenium web-form, the-internet, formy, demoqa). A checkbox is now named by its own label, the text after the control, not the previous sibling's: on the-internet's checkboxes page {"checkbox 1": true} used to act on the second box and report success. Radio and checkbox options are addressable by their own label ({"College": true}, {"Highest level of education": "College"}); two fields with the same label are refused as ambiguous instead of the first winning. A react-select style combobox that showed the chosen value in a sibling element was reported FAILED although the page took it; the displayed value is read now (a pick that really did not take still fails). A verified value that breaks the control's own constraints (time outside 11:00..21:00, bad email, pattern) now carries a WARNING: instead of a bare "verified". Filled N/M counts requested keys, a disabled control says it is disabled, and unlabelled controls are named by what they are. A text date field with a date-format placeholder shows its format in form_inspect and an ISO date is rewritten to it. A page that reloads to the same form with an error is NOT ACCEPTED (the server did receive it) rather than NOT SUBMITTED. Not fixed: slow fills on busy pages (settle waits), and "Nothing changed" when a redirect lands after the click returns. None of this was re-run on the live sites after the fix. Fixtures: checks, choices, constraints, datefmt, lockout, login_flash, react_select, react_select_broken, unlabelled.
  • --require-confirm-submit and --allow-domain host:port now hold for popups and cross-site iframes. Popups are held at creation (Target.setAutoAttach with waitForDebuggerOnStart) and interception is on before they run; an iframe or worker that cannot be secured stays paused; a popup that cannot be secured is closed. Three canary tests failed before and pass now; controls prove the canary is reachable with the gate off. WebSockets are still not covered, and worker sessions have no test.
  • A lost or replaced sealing key can no longer silently shadow your memory. serve, a2a and the memory commands check that the current key opens the sealed points Qdrant already holds for the tenant, and refuse to start with the exact recovery and wipe commands if it does not (doctor reports it as a failure). New: ostler memory key export --yes-print and ostler memory key import [--force] (the old key is kept as memory.key.old-<time>), and optional OS key storage with ostler config set key_store keychain (macOS Keychain via security, Linux Secret Service via secret-tool; the key never appears on a command line; the Linux path was tested only against a fake tool). serve, a2a, memory list and doctor warn when unsealed memory remains (ostler memory seal). config set rejects bad values (seal banana) with the allowed list, and memory options work after the subcommand (memory list --tenant a).
  • Review fixes: wrong form and false success. A bare form_submit used to click the first form on the page (a header newsletter box) even after form_fill had filled the order form, and said "Submitted". It now submits the form that was filled, and with several forms and none filled it refuses and lists them. A submit answered with an HTTP 4xx/5xx and no validation messages is SERVER ERROR (A2A: failed), not "Submitted". A submit that is cancelled or times out closes the --require-confirm-submit gate again instead of leaving it open. --block-private-networks now checks every redirect hop and sub-request, not only the address you typed. SIGTERM/SIGINT close Chrome and remove its temporary profile (they used to leak about 8 MB each). Qdrant and Ollama clients no longer follow redirects (which re-sent the API key) or use ambient proxies, and a slow local embedder trips the same circuit breaker as Qdrant. Error-episode redaction also masks (555) 123-4567, dates, non-ASCII digits, user@localhost and scheme-less URL queries. Fixtures: two_forms, server_error.
  • Memory is sealed before it reaches Qdrant (on by default for Qdrant). Fact values, descriptions, playbooks and episodes are encrypted on your machine (AES-256-GCM, point id bound as associated data). Site names, field labels, profile keys and fingerprints are replaced by keyed hashes (HMAC-SHA-256), and point ids are keyed too, so a Qdrant operator cannot test guesses ("passport number") against what it stores. The key lives in OSTLER_MEMORY_KEY or an owner-only memory.key next to the config file, created atomically on first use; back it up, there is no recovery. --seal off opts out. With the default --embeddings cloud, the text of field labels, host and headings is still sent to Qdrant Cloud Inference in the clear to be embedded, so sealing then protects what is stored, not what the cloud embedder sees: use --embeddings ollama for that. Also visible to the server: vectors, tenant name, win/loss counters, timing, point counts, and that two points share a site. Memory saved before this is invisible until ostler memory seal, which copies your saved facts into sealed form, checks they read back, and only then deletes the old plaintext points (safe to interrupt and rerun; old learned skills and episodes are dropped and relearned).
  • Local embeddings: --embeddings ollama (--embedding-url, default http://127.0.0.1:11434; set --embedding-model and --embedding-dim to match). Text is embedded on your machine and only vectors reach Qdrant. Remote plain-http embedders are refused.
  • Plain http:// to a non-loopback Qdrant is refused (the API key and facts would cross the network unencrypted). --allow-insecure-qdrant overrides for links already protected by a VPN or tunnel.
  • Per-tenant access tokens, enforced by Qdrant. --isolation collection gives each tenant its own collections; ostler memory token mints a JWT that opens only those (read-write or --read-only, expiring), so a leaked client token exposes one tenant, not the cluster. Needs service.jwt_rbac: true. Qdrant 1.20 rejects the old per-point payload claim as deprecated, so isolation is per collection. Tested on a real Qdrant 1.20: another tenant's collections answer 403; expired, forged and read-only tokens are refused.
  • Episodes are redacted before they are embedded or stored: emails, runs of 4+ digits (phones, PIN codes, card numbers) and URL query strings are masked, in addition to the values the agent typed.
  • Learned skills and error episodes expire: serve forgets those not verified for 180 days on start (--retain-days, 0 keeps them). Saved facts are never expired.
  • Submit results are judged on evidence, not just on the URL. A form that posts in the background and says "Thanks! Your message has been sent" in place used to read Nothing changed (and an A2A task failed, inviting a duplicate submit); it is now Submitted when new confirmation text appeared after the click. A thank-you page that still has an optional input (newsletter box, search) is Submitted, not Advanced, unless something on it is required, it has a step counter or a "next" button. A form that stays put and says nothing is still Nothing changed. Fixtures: xhr_form, order/order_done.
  • Errors are attached to their own field. A message that sat in a wrapper shared by several fields was shown on every one of them; it now belongs to the field it follows, before the next different field. Bootstrap 3 .help-block is help text, not an error, unless the group is .has-error. Fixture: shared_errors.
  • Pages without a <form>: only real action buttons are submit candidates. The header's "Search" button, anything inside header/nav/role=search, and type-less buttons whose label says nothing about submitting ("Show tips") no longer count. Fixture: formless.
  • Qdrant can no longer stall a fill. Requests time out after 4 s (was 10), and after two failures in a row (transport error or 5xx) lookups are skipped for 30 s, then one trial request goes out. While skipped, tool results start with Note: memory is unavailable (...); continuing without it. instead of silently returning nothing. 4xx answers do not trip it. Startup still exits if Qdrant is unreachable when you asked for it.
  • Cloud metadata addresses are always blocked. Navigating to a link-local literal (169.254.0.0/16, fe80::/10, fd00:ec2::254) or metadata.google.internal is refused even without --block-private-networks, unless allowlisted. Loopback and LAN stay open for serve because coding agents browse local dev servers. Navigations and literal addresses only; see SECURITY.md for what this does not cover.
  • doctor explains launch failures (sandbox/root, profile in use, missing libraries, otherwise how to see Chrome's own error) and treats a Qdrant timeout like an unreachable server.
  • Docs. ARCHITECTURE.md no longer claims one page, no tabs, a WebSocket transport or 11 fixtures; clippy and rustfmt are clean again.
  • Fill keys match hyphenated labels. email now finds "E-mail address" (a key equal to a run of the label's words written together); two equally plausible fields are still refused as ambiguous.
  • Round-5 fixes. A date typed into a bootstrap-datepicker box was verified and then submitted empty: the widget learns the text only from a keyup and rewrote the box when it closed. Fills now send one harmless keyup (found by reproducing on selenium.dev's web-form; fixture picker). A heading's ref now reads that heading's whole section (snapshot ref=eN), wait_for accepts ms for a plain pause, and the docs note that Bing may ignore pagination for headless sessions.
  • Round-4 live trial fixes. form_submit re-reads the fields it verified just before clicking and reports any the page cleared or rewrote in between (CHANGED BEFORE SUBMIT), because a date picker had been reported "verified" and then submitted empty. Deltas use the same size budget as their baseline (a long page's tail no longer looks "removed"), observe: none still records a baseline, and a 404 says what to do next.
  • Round-3 live trial fixes. Clicks that only change the hash or query of the same page (anchors, Bing "Page 2") now return a delta instead of the whole page; back at the start of a tab's history (including tabs new) says so instead of landing on about:blank; deltas ignore separator-only text (|) churn; empty image-only duplicates of a link are listed once; a result-less DuckDuckGo page is reported as a block with the "use another source" advice; unnamed verification pages are named by host.
  • Round-2 live trial fixes. back with no earlier page says so (popup tabs start empty) instead of dumping the page; pages whose wrapper has a click handler (single-page apps such as rtings.com) are no longer collapsed into one clickable; deltas after navigate have a baseline instead of reprinting the page; footers collapse only with 5+ links and mention hidden fields (no more 0 links omitted on cards); 429 reads "rate limited", binary files are not called PDFs, a stale ref no longer says "page script error", and the relevance warning is skipped on challenge pages.
  • Search-engine UX pass (first end-user trial). Search-result links now show the real destination instead of a truncated tracking redirect (Bing /ck/a, Google /url, DuckDuckGo /l/, Yahoo /RU=). Bot-block pages that have no widget (Brave "Captcha", Startpage "Access Temporarily Suspended", "Access denied", /sorry/) now produce the CHALLENGE: hand-off, which also tells the agent to use another source. Navigation errors explain themselves (DNS, certificate, HTTP error status, download/abort, refused/reset/timeout). Interactive snapshots say that page text is omitted.
  • Search-engine bot walls are detected. DuckDuckGo's "bots use DuckDuckGo too" page (a form with only a Submit button) was returned as an ordinary page, so an agent would try to click through it. It now produces the same CHALLENGE: hand-off as other walls. Found by driving real MCP sessions against Google, DuckDuckGo and Bing. Fixture: captcha_search_wall.
  • Clearer error when a --profile-dir is already in use. Chrome allows one process per profile, so a second ostler serve --profile-dir X exited with "browser exited before DevTools was ready". The message now names the profile and says to use one profile per server.
  • Upload confinement. File fields are refused unless --upload-dir DIR allows the directory. Paths are resolved first, so .. and symlinks cannot escape, and the browser receives the resolved path. Breaking: uploads used to accept any local path.
  • No DevTools port. Chrome now talks to Ostler over --remote-debugging-pipe instead of an unauthenticated localhost WebSocket port that any local user could drive. Verified: no listening socket in the browser's process tree (one appears with OSTLER_CDP_TRANSPORT=ws, kept for debugging). Chrome also exits on its own when Ostler dies. Large messages work (a 121 MB screenshot message in 3.6 s).
  • Port-aware allowlist and private-network blocking. --allow-domain example.com:8443 (or https://example.com:8443, [::1]:8080) pins a port; an entry without a port still allows every port. --block-private-networks refuses loopback, RFC 1918, link-local/cloud metadata, CGNAT, ULA, mapped IPv6, .local/.internal names and names that resolve to them when no allowlist is set. On by default for ostler a2a (--allow-private-networks opts out), off for serve.
  • confirm() and prompt() are dismissed instead of auto-accepted (--accept-dialogs restores the old behaviour). The agent is told which dialogs appeared and what was done. Behaviour change.
  • form_fill no longer prints page labels on its trusted verdict line. Found by a new test that puts attacker text in a title, body, label, alert, validation message and hidden element and checks every tool output: labels now sit below the verdict inside the wrapper, while our own trailing Ready: hints stay outside it.
  • Allowlist now covers every request type, WebSockets and popups. A 20-minute soak with a canary server on a non-allowlisted origin found that --allow-domain let <img>, stylesheets, WebSockets and a window.open popup reach it (the interception listed only six resource types, and a popup is attached after its first request has gone out). Interception now holds every request, and Chrome starts with --host-resolver-rules so every other host is unresolvable browser-wide. After the fix the canary receives nothing across 12 exfiltration channels, by hostname and by IP literal.
  • Hidden text is not shown to the agent. Text with computed opacity 0, a font size under 2px, far off-screen, or the same colour as a solid background is omitted from snapshots and labels, and the snapshot says how many runs were left out. Controls are never dropped (a native checkbox at opacity 0 under a custom one still works), and screen-reader-only text is kept. Fixture: concealed.html.
  • Titles, URLs, dialog text and the look legend are inside the untrusted wrapper. They were bounded and defanged but sat on trusted lines, so a page title could carry an instruction.
  • screenshot no longer stalls 30 s when a popup took the front. Chrome produces no frame for a tab that is not in front, so a full-page capture of a tall page waited out the timeout. The tab is brought to front first (also fixes look).
  • Form inspection scales better on large forms. Error-cue detection scanned the whole grandparent for every field; it now looks a bounded distance ahead. 1,000 flat fields: inspect 513 to 257 ms; 5,000: 13.6 s to 6.8 s. Fill on very large forms (thousands of fields) is still slow because every key re-reads the form.
  • Verification walls are detected and handed to a person (ADR-0009). reCAPTCHA / hCaptcha / Turnstile widgets and "verify you are human" interstitials now produce a CHALLENGE: line on page-affecting tool results, a new wait_for_human tool (needs --headed), and an input-required A2A task. Ostler does not solve or bypass them. Invisible badges and ordinary pages that mention verification are not flagged. Fixtures: captcha_widget, captcha_interstitial, captcha_badge_only.
  • Actions are never replayed after a navigation. eval retries across navigations, which re-ran the whole script on whichever page came next (a script that posts once ran 8 times in a test). Runtime calls that change the page (jsClick, apply, setNative, comboPick, pickDate, finish) now use eval_once and report that the page changed; a click that replaces its own page counts as a successful click. Closes the last open P0 item from the external review.
  • Learning loop, ranked in Qdrant (ADR-0008). Playbooks now keep wins and losses per form and per field mapping and are patched by every fill outcome. Mappings that fail more than they win are dropped. After a redesign, mappings transfer from a similar form on the same site (via: similar_form). Candidates are ranked inside Qdrant by a score-boosting formula over similarity, smoothed win rate and recency. New: ostler memory skills, ostler memory prune --days N, form_inspect hints for transferable knowledge, new payload indexes (host, labels_norm, verified_ts, wins, losses) created on existing collections too, and tests/learning_eval.rs (1 to 8 correct of 10 fillable fields on redesigned forms, 0 wrong, on both the in-memory store and real Qdrant 1.20).
  • Verification fixes (external review, 2026-10-31). datetime-local fields were always graded Exact; they are now normalized and compared like dates and times, so a page that rewrites or clears the value fails. Combobox verification no longer treats a substring as equivalent ("United States" vs "United States Minor Outlying Islands" is a mismatch), and the in-page picker now only picks an exact (normalized) option label, listing the available options otherwise. form_fill reports unsettled (a count) when the page was still busy after a settle timed out. New fixtures datetime.html and combobox_diverge.html with e2e tests.
  • Snapshot quality. Long text is split into several lines with an explicit … [N more chars cut] marker instead of silent cuts at 300 chars; refs keep their numbers past a truncated view; footers collapse to one line in interactive and delta views (snapshot ref=eN expands); delta now applies across same-path URLs and ignores ref renumbering; unknown-ref errors say what to do.
  • Searching the web. navigate warns when a Google/Bing/DuckDuckGo/Brave/Yahoo/Ecosia/Startpage results page shows too few of the query's words (the engine may be degrading results). New "Searching the web" docs: what to expect per engine, --headed + --profile-dir, wait_for_human, and search APIs for discovery. Docs and doctor now say to reconnect the MCP client after rebuilding.
  • Navigation and click honesty. navigate reports the HTTP status (Navigated (HTTP 404)) and puts it, with a plain-language reason, in navigation errors; PDFs/non-HTML documents, downloads and redirects to a sign-in page are labelled; click now ends with a Result: line (navigated, popup opened as tab tN, page updated, or no visible change).
  • look tool: where to click. A viewport screenshot with every clickable element boxed and labelled by its ref (green submits, blue inputs, purple links, red has an error), plus a legend of what each does. Boxes come from DOM geometry; elements under a modal or sticky bar are left out and counted. Overlay is removed afterwards.
  • Visual error cues. Fields marked invalid only by colour (red border/outline/glow, red message text with no ARIA or class) and floating toasts/banners are now reported as field and page errors. Leftover errors on fields just filled are tagged as possibly stale. Fixes from the design review (status table there), plus three features.
  • Renamed Waymarker to Ostler (crates ostler-*, binary ostler, env vars OSTLER_*, config ~/.config/ostler/config.toml). Move an existing ~/.config/waymarker directory to ~/.config/ostler and rename WAYMARKER_* variables.
  • Tool output is terse by default. Verified fields are named once instead of one line each, form names no longer include the form's whole text, and fingerprints, timings and repeated page titles are gone. About 30% less text on a four-call form flow (1.5 KB to 1.0 KB). --verbose (or verbose = true in the config) restores the detail.
  • Tabs and shared context. New tabs tool (list, new, switch, close). All tabs share one browser context, so cookies carry across; popups open as tabs under the same policy; new context tool is a notepad every tab and later call can read, kept across browser restarts.
  • A2A. ostler a2a serves Agent2Agent v1.0 (JSON-RPC, SSE streaming): Agent Card, SendMessage, SendStreamingMessage, GetTask, ListTasks, CancelTask, SubscribeToTask. Tasks move to input-required for missing values, rejected input and submit confirmation. Per-conversation browsers; loopback by default; bearer token to listen elsewhere; Origin/Host checks. Verified with the official A2A Python SDK. See docs/A2A.md.
  • Wrong-fill prevention: keys resolve against every visible field once, so an ambiguous key cannot win by elimination; forms re-mounted by a framework are followed; keys can no longer be silently dropped, and FillReport::ok() counts unmatched keys.
  • Memory: learned values never overwrite facts you saved; playbooks persist only exact (playbook/autocomplete) matches; semantic matches need a margin over the runner-up and never apply to labels about other people; fixed the playbook key being cut at the first space.
  • Safety: the allowlist is enforced at the network layer; --require-confirm-submit blocks all non-GET requests outside a confirmed submit; the untrusted-content wrapper cannot be closed by page text; secrets are masked in snapshots and notes and scrubbed from stored episodes.
  • Reliability: a crashed Chrome is relaunched and the agent is told; closed connections fail immediately; larger WebSocket frames; panic = "abort" removed.
  • MSRV is now Rust 1.89. (An earlier entry said 1.82; that was never true for the locked dependencies, which need edition 2024. CI now checks it.)
  • New: ostler setup <claude|codex|cursor|vscode|json> [--apply] [-- serve flags] prints client config with the binary's absolute path; --apply runs claude mcp add. doctor now points to it.
  • New: ostler init (interactive or flag-driven; saves Qdrant URL/key, tenant, defaults to ~/.config/ostler/config.toml with 0600 permissions and tests the connection) and ostler config show|path|set|unset. Precedence: flag, environment variable, config file, default. Qdrant Cloud URLs get :6333 added. Key is masked in config show.
  • Docs: README rewritten (quickstart, real tool output, measured-vs-modelled labelling, known gaps), docs/TROUBLESHOOTING.md, and the design review.

0.2.0#

Found by writing harder fixtures first; each fix has a regression test.

  • Fixed: custom ARIA radio groups (role=radio inside role=radiogroup) were reported with no options and, on re-read, as a multi-select. They are now a single radio field with options from the widgets' labels.
  • New: calendar date pickers on read-only inputs. When a text field is read-only and the value is a date, the engine opens the popup, navigates months and clicks the day (pickDate). Fails with a reason, not silently, when no popup or navigation is found.
  • Tests: 4 new e2e tests (custom ARIA widgets and contenteditable, conditional fields revealed mid-fill, hidden-branch validation message surfaced, calendar picker) and a hostile-input test that sends malformed arguments to every MCP tool. 73 tests total.
  • Efficiency: release profile with thin LTO, one codegen unit, stripped symbols. ostler binary 9.3 MiB to 6.0 MiB (same machine, same commit apart from the profile). Task latency was not re-measured and is not claimed to change.
  • Fixtures: aria, conditional, datepicker (14 total).

Known limits of the new pieces: the date picker is heuristic (month header text plus prev/next controls plus day cells) and has only been tried on one fixture; year-only or dropdown-based pickers are not handled.

0.1.0#

First public MVP. See the README.

↑↓ to moveEnter to openEsc to close