Skip to content

Heal client: vision, advise, follow-ups, one selector refusal point, session origin guard - #25

Open
philmcneely wants to merge 32 commits into
mainfrom
feat/heal-phases-3-4
Open

philmcneely wants to merge 32 commits into
mainfrom
feat/heal-phases-3-4

Conversation

@philmcneely

Copy link
Copy Markdown
Owner

The Python heal client, updated for heal service phases 3 and 4.

What's in it

  • Vision: one masked viewport screenshot, sent only with HEAL_VISION=auto|on. Password-like fields, one-time-code and card fields, any input that was ever type=password, cross-origin or sandboxed frames, and HEAL_MASK selectors are all masked. If masking fails, no screenshot is sent.
  • Advise mode and post-PR follow-ups.
  • One selector-refusal point in build_locator. It refuses xpath=, internal:, >>, engine prefixes, text=, regexes, attribute operators on values, backslashes and comments, in every argument form.
  • An origin guard for the whole session: only BASE_URL plus HEAL_ALLOW_ORIGINS may load. Service workers are blocked and foreign WebSockets are closed.
  • by_language parity with the TypeScript helper. The difference that remains on pages without <html lang> is documented.

Tests
592 unit tests pass (--runxfail) against the matching heal-service branch, and lint is clean. Against an older installed service, the three advise and vision integration tests are skipped.

Merge after the heal service PR. Its strict request schema rejects the new scroll and Content-Type fields on older versions.

With HEAL_VISION off the page is never asked for a screenshot and the request
carries null. With auto or on LivePage takes a single viewport PNG with password
inputs (frames included) and the HEAL_MASK selectors blacked out, animations
frozen and the caret hidden, and sends it in the service's wire form with the
viewport scroll. A capture that cannot be masked sends nothing. Tests prove zero
image bytes leave with off and that the mask is painted in a real Chromium.
The CLI no longer claims no screenshot is sent: auto and on announce the masked
viewport screenshot, an unknown value says it runs with vision off.
…vice in advise mode

conftest no longer calls utils/ai_healing.py: with HEAL_ADVISE=on and HEAL_SERVICE_URL a test that fails for good is posted
to the heal service while its page is open (probe inventory, redacted aria snapshot, stack files, last green fingerprint).
The decision goes into the healing report folder, heal-summary.json and the observability report. No edit is made and no
model URL, name or prompt remains on the path. utils/ai_healing.py is kept and marked deprecated.
…t what became of them on the next run

scripts/heal.py --open-pr records {heal_id, file, from, to, test, pr_url} in .heal/heals.json. On the next run each recorded
heal is classified from gh and the file on disk (open, closed unmerged, merged unchanged, edited, reverted), with whether its
test failed again and runs and days since, and posted to the service's followup route. It skips quietly when gh or the
service is unavailable and never fails the run.
…n client against a fake service

The fixtures are a copy of the service's contract cases. The fake service answers from expected.json and the client must check
the recorded locator live, report the scripted result, write the recorded locator into the file only on healed (and leave it
alone otherwise) and end with the same decision and reason. A check that the fixtures hold no internal host or address
is included.
… a pick by mark

Real Chromium, the real client and the service app in process with stub models:
auto takes one screenshot, the service picks by mark, and with vision off no image
is sent. Skipped when the installed heal-service has no vision support.
The failure text sent to the service is cut to what its triage reads (no assertion values, locals, element HTML or code-frame
source lines). PR addresses must match in full and name github.com (or HEAL_GH_HOST), and one bad record never stops the rest;
a follow-up names the heal's repo and run. Service text in the advice report sits in code spans. The client caps rounds, reports
coarse counts, refuses attribute-operator and value selectors, accepts only a same-origin path as the route, keeps its origin
guard on from the first navigation with service workers blocked, cuts link queries, the chosen option and contenteditable text
from the aria snapshot, refuses a lone surrogate, and sends Content-Type: application/json on every POST.
Default masks now cover card and one-time-code autocomplete fields, current/new-password
fields, fields named pass, pwd, token, secret, ssn, card, cvv or otp (name or id), and every
cross-origin or sandboxed frame whole, in the page and in each frame. Any failure while
resolving them sends no screenshot. HEAL_MASK selectors are separated by newlines or ;; so a
comma inside a selector survives. The docs say the screenshot shows whatever the page shows.
…ble text in frames and shadow roots, align the language helper

A hit is named only when it is an exact match of an entry of the inventory that was sent (a near miss names nothing). xpath=,
//, internal: engines, >> chains and regex locators are refused (the service writes none of them). The text of contenteditable
regions in iframes and open shadow roots is cut from the aria snapshot. The live page records the Content-Language it used, so
the by_language helper reaches the branch the client checked.
A frame is masked when the document it holds now is another origin (frame.url after redirects and
script navigation), when it is sandboxed without allow-same-origin, has no document, or its url
cannot be parsed. about:blank and srcdoc inherit the parent's origin. Tests: a redirecting src, a
frame navigated by script, inheriting frames, odd urls, and a page of unknown origin.
…, escapes and comments

waiting for, Timeout and strict mode lines keep only the locator expression (and the state words of a wait); when no printed
locator is left, the locator call of a code-frame line is kept without its arguments. The cut text triages as the whole did on
a corpus. locator(selector=...) is checked like a positional selector, and a selector with a backslash or a comment is refused.
… on both sides

The by_language helper cannot see Content-Language during the verify re-run, so the client no longer uses it to choose the
branch it checks, and does not send it to the service either.
…eplay, client hardening

Conflicts: the README keeps both the vision and the follow-up/allowed-origins paragraphs; build_request takes the
screenshot and the advise mode. The vision integration tests reach the in-process service at 127.0.0.1, since an open
service refuses any other Host name now; the vision tests reuse the shared project fixture by assignment (ruff F811).
…cks, WebSocket guard, one _origin, TS-parity masks

build_locator refuses every probing selector (the TypeScript list, plus [value] and [defaultValue]) so every expression run on the
live page is checked, a branch candidate's page-language branch and extra patches included, and a branch is checked like a plain
locator before the live check. Foreign WebSockets are closed through context.route_web_socket. _origin is one function returning None
for anything not http(s)/ws(s), so chrome-error and data frames are foreign. The screenshot clears contenteditable text, masks inputs
that were ever type=password (an init-script WeakSet, a data attribute only during the shot) and uses the TypeScript autocomplete
matching.
…; test helper and client agree without <html lang>
Copilot AI balanced review requested due to automatic review settings November 9, 2026 05:37

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has insufficient remaining quota. Try requesting a review at a lower effort level.

@philmcneely

Copy link
Copy Markdown
Owner Author

Fix pass: no changes needed. The only review on this PR is Copilot's quota error ("insufficient remaining quota"), which has no findings, and there are 0 inline review comments. No commits were pushed. CI at the time of this check: setup, lint and pr-opened passed; unit, smoke and semgrep were still running. To get a real review, re-request Copilot at a lower effort level once quota resets, or run /code-review.

🤖 Generated with Claude Code

@github-actions

github-actions Bot commented Nov 9, 2026

Copy link
Copy Markdown

✅ All smoke tests passed! View report

@github-actions

github-actions Bot commented Nov 9, 2026

Copy link
Copy Markdown

✅ All chromium login tests passed! View report

@github-actions

github-actions Bot commented Nov 9, 2026

Copy link
Copy Markdown

✅ All firefox login tests passed! View report

The _browser() helpers in test_heal_client_vision and test_heal_parity
started sync_playwright() and, when chromium.launch() raised (no browser
installed, as in the CI unit job), returned without stopping it. The
running driver left its event loop set on the main thread, so every later
asyncio.run() in the unit suite failed with "cannot be called from a
running event loop" (7 failures in CI run 42568369674).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016E8RmWaGbxMdGeZhQUgNit
@philmcneely

Copy link
Copy Markdown
Owner Author

Fix pass (2a41059).

The smoke comment needed no change. The unit check was failing, though: 7 tests failed with RuntimeError: asyncio.run() cannot be called from a running event loop (run 42568369674).

Cause: _browser() in tests/unit/test_heal_client_vision.py and tests/unit/test_heal_parity.py called sync_playwright().start(). When chromium.launch() then raised, it returned (None, None) without stopping the driver. The CI unit job installs no browsers, so launch always raised there. The leftover driver kept its event loop set on the main thread, and every later asyncio.run() in the suite failed (fingerprints, hardening4 and locale tests).

Fix: stop the driver in the except branch. Test-only change, +6 lines.

Evidence (local, Python 3.12, same pinned pytest 9.1.1 / pytest-asyncio 1.4.0 / playwright 1.63.0):

  • Reproduced CI before the fix by hiding the browsers (PLAYWRIGHT_BROWSERS_PATH=/nonexistent): 7 failed, 570 passed, 15 skipped. Same 7 failures and same 15 skips as CI.
  • After the fix, no browsers: 572 passed, 15 skipped, 5 xpassed. 0 failed.
  • After the fix, with browsers: 582 passed, 3 skipped, 7 xpassed. 0 failed. The xpasses come from local stability-quarantine history written by my repro runs (gitignored, not present in CI).
  • ruff check . (0.14.14): all checks passed.

@github-actions

github-actions Bot commented Nov 9, 2026

Copy link
Copy Markdown

✅ All smoke tests passed! View report

@github-actions

github-actions Bot commented Nov 9, 2026

Copy link
Copy Markdown

✅ All chromium login tests passed! View report

@github-actions

github-actions Bot commented Nov 9, 2026

Copy link
Copy Markdown

✅ All firefox login tests passed! View report

@philmcneely

Copy link
Copy Markdown
Owner Author

CI after 2a41059: unit, lint, smoke, full and semgrep all pass. The first full attempt failed while downloading Allure (GitHub returned 504 Gateway Time-out on the allure-2.27.0.tgz release asset, then allure: command not found), not in the tests. The re-run passed.

…t, top_at_center, copy_only, copy_cut, for_attr) and inventory_copy_more; vendor the current probe
…_wrap) and pass copy_wrap through the allowlist
…entory_copy_withheld and inventory_copy_withheld_data; vendor the current probe
@github-actions

Copy link
Copy Markdown

✅ All smoke tests passed! View report

@philmcneely

Copy link
Copy Markdown
Owner Author

Fix pass: no changes needed. The comment that triggered this pass is the github-actions smoke report (run 42744309809), and it shows a pass. It contains no findings. The only review is Copilot's quota error, and there are no inline comments. lint, unit, setup and smoke all pass. full and semgrep were still pending when I checked. No commits were pushed.

@github-actions

Copy link
Copy Markdown

✅ All chromium login tests passed! View report

@philmcneely

Copy link
Copy Markdown
Owner Author

Fix pass: no changes needed. The comment that triggered this pass is the github-actions chromium login report for run 42744309809, and it shows a pass with no findings. There are 0 inline review comments, and the only review is Copilot's quota error. Checks: lint, unit, setup, smoke and semgrep pass; full is still running. No commits were pushed.

@github-actions

Copy link
Copy Markdown

✅ All firefox login tests passed! View report

@philmcneely

Copy link
Copy Markdown
Owner Author

Fix pass: no changes needed. This pass was triggered by the github-actions firefox login report for run 42744309809, which reports a pass and contains no findings. All 6 checks pass (lint, unit, setup, smoke, full, semgrep). There are 0 inline review comments, and the only review is Copilot's quota-error notice. No commits were pushed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants