Skip to content

fix(browser): preserve modal controls and expose bounded SPA readiness - #21

Merged
sheehanmunim merged 6 commits into
munimtechnologies:mainfrom
ElliotDrel:fix/browser-state-contract
Oct 8, 2026
Merged

sheehanmunim merged 6 commits into
munimtechnologies:mainfrom
ElliotDrel:fix/browser-state-contract

Conversation

@ElliotDrel

@ElliotDrel ElliotDrel commented Oct 7, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Closes #18.
Closes #19.

This is one combined PR because the defects overlap in the browser-state path and the identical Swift/Rust schemas. The implementation keeps the existing 250-control budget and tool count. It adds no runtime dependencies and no arbitrary-script tool.

Root causes and fixes

  • browser_snapshot silently drops foreground modal controls after 250 background elements #18: SNAPSHOT_JS collected page controls in DOM order and silently sliced the first 250, even when those controls were hidden or covered by a modal. Scope to the topmost visible modal first, exclude hidden/inert subtrees, then paginate the selected scope. Return scope, total, truncation and next offset explicitly. Global indices remain actionable across pages of an unchanged UI. Native modeless dialogs and explicit aria-modal=false do not scope the page. Implicit ARIA dialogs require viewport-centre coverage and paint-order evidence, so corner chat widgets and bottom cookie banners retain page controls. Password values remain redacted.
  • return_state treats tab loading status as SPA readiness and returns before delayed dialogs render #19: a fixed 350 ms pause plus tabs.status cannot establish same-document application readiness. Add a bounded explicit wait_for_selector condition to state-returning browser actions and read-only browser_snapshot. Return readiness separately from action success (met, timeout, error, not_requested). A timeout does not roll back or replay a click. Re-observe with browser_snapshot, not another non-idempotent action.
  • Share one page-serialized visibility predicate between snapshots and readiness. Explicit selector waits do not depend on unrelated resource loading. Recheck site policy before each observation and the final snapshot; validate wait types/bounds before side effects.
  • Mirror argument forwarding and status/pagination rendering in both native backends. Consolidate browser regressions into one dependency-free CDP runner, run it in CI, and document the contracts and limitations.
  • Repair a smoke-test teardown race exposed by log inspection: isolated POSIX process groups ensure Chromium renderers/native hosts stop before their test profile is deleted. The final run has no post-success cleanup exception.

Regression evidence

The same tests were exercised against untouched upstream deb4c7a5243d2024b7a627a305e0eecc6bab8c7c, independently:

  • --suite snapshot: fails. The visible dialog's Domain/Next/Verify controls are missing; the snapshot returns 250 Background controls instead.
  • --suite readiness: fails. One click returns after approximately 363 ms; Next=false, dialogRendered=false, then snapshot returned before the delayed dialog rendered.
  • Fixed source: both suites pass. The 1100 ms delayed dialog is present in the returned modal-scoped snapshot, with exactly one click and one dialog. Timeout plus read-only recovery does not replay the action.
node --test chrome-extension/background.test.mjs
node scripts/snapshot-cleanup.test.mjs
node scripts/build-extension.test.mjs
node scripts/check-tool-parity.mjs
node scripts/tool-defs.mjs --check
node scripts/snapshot-dom.test.mjs --chrome /path/to/chrome
node scripts/e2e-browser.mjs --server /path/to/munim-computer-use --chrome /path/to/chromium
cargo test --locked --manifest-path windows-linux/Cargo.toml
cargo check --locked --manifest-path windows-linux/Cargo.toml

Verified locally:

  • Extension contract/security checks: 49 passed.
  • Extension build checks: 3 passed.
  • Dependency-free real-Chrome/Chromium DOM regressions: 18 scenario groups passed on Windows and isolated Linux, including native/stacked/nested/background-tab modals, pagination/indexed clicks, hidden/inert ancestry, passwords, delayed UI, read-only recovery and selector injection protection.
  • Native Rust: 96 tests passed on Windows; 98 on Linux. cargo check --locked passed on both; Linux native build passed.
  • Full native MCP bridge + extension + Chromium integration: 18 passed on isolated Linux, including modal scoping/pagination and delayed-dialog recovery.
  • 32 tools match across Swift/Rust; definition round-trip, JS syntax and Git whitespace checks pass.

Limits / compatibility

An explicit selector checks a visible match in the top-level document, not arbitrary application completion, clickability, occlusion, frames or shadow roots. Use a selector specific to the newly expected UI. Timeout defaults to 8000 ms, maximum 10000 ms; the budget excludes browser/API latency and approval prompts. Without a selector, state-returning actions preserve the existing short pause/bounded navigation wait and report not_requested rather than pretending that the app is ready.

These options need both the updated native server and extension. macOS cannot be built locally on the Windows host; the actual release build, bridge and real-Chrome integration were verified in fork CI below. Upstream approval/execution is a separate gate.

Opus 5.5 audit disposition

The independent subscription-authenticated reviewer used claude-opus-5-5, reviewed f2a466b, and posted three findings directly on this PR. All three were independently reproduced as RED tests before fixes in signed commit 1646abc1dfef109a2d15b83995517a1556f863df:

  • R1: modeless ARIA widgets suppress page controls: viewport-centre evidence gates implicit dialog scoping; page actions remain indexed and clickable beside cookie/chat widgets, including missing hit-test stacks.
  • R2: self-transparent native inputs disappear: native form hit targets remain indexed, clickable and ready, while transparent ancestors and containers remain hidden. An unrestricted ancestors-only opacity change fails the new transparent-dialog guard, so the exception is deliberately limited.
  • R3: Chrome profile fallback writes into the checkout: the runner uses TMPDIR || os.tmpdir(). Tests cover an absent override and an explicit override; an actual native Windows run with TMPDIR unset also passed without checkout artifacts.

Current CI verification

Current head: 98606830b5a4e81e436bc5d7fad7be5efeb1077f. This adds only the test-cleanup follow-up from fork-main commit 655fc19132449429da55fd90d04bddc6696ff0f3: the DOM runner owns a private POSIX process group, signals surviving descendants even after the launcher exits, and runs four deterministic Linux/macOS teardown regressions in CI. No production/browser-state behavior changed in this follow-up.

Current-head local checks passed: all four cleanup regressions, 49 extension checks, three extension-build checks, 32-tool parity, definition round-trip on an LF checkout, runner JS syntax, and Git whitespace checks. The Windows checkout inherited CRLF conversion; the unchanged definition emitter was separately exercised on exact tracked LF bytes. No installed browser/profile or server was used for these local checks.

Current-head CI is being exercised in the verification-only fork PR against the actual upstream base deb4c7a5243d2024b7a627a305e0eecc6bab8c7c:
ElliotDrel#3

Fork integration is already verified independently: merging this head into fork main 655fc19132449429da55fd90d04bddc6696ff0f3 is conflict-free and reproduces its exact tree 552e466b46c42ec44105e6aed26d47c74c64ca73. The EOF fix ec3dac7936eb97f4074ba6476ba16b2fc97f3bad and integration merge bf0469ec044b587a6793aae77c295eafd2ccb982 remain ancestors of fork main, but neither is imported into this PR. Fork main was not changed.

All four fork-main CI jobs passed at 655fc19132449429da55fd90d04bddc6696ff0f3, including Windows/Linux Rust, the Windows backend EOF/restart regression, extension/parity/cleanup gates, Linux real-Chrome DOM/native integration, and the macOS release build/120-peer bridge/real-Chrome native integration:
https://github.com/ElliotDrel/munim-computer-use/actions/runs/37655803451

Historical verification at previous PR head 1646abc1dfef109a2d15b83995517a1556f863df passed all four jobs, including 18 real-Chrome native Swift integration checks:
https://github.com/ElliotDrel/munim-computer-use/actions/runs/37643965060

That previous-head synthetic merge tree equaled 157e0e00d5c2cd760628f82b9206a95d2e0b545b. Its first macOS attempt failed the existing credential-cancellation harness check with no live DevTools target answered; one unchanged rerun passed all 18 checks. This is historical evidence, not a claim that the new head has completed CI.

Current upstream CI still requires maintainer approval (action_required) at the new head:
https://github.com/munimtechnologies/munim-computer-use/actions/runs/37657800741

The account has read-only upstream repository permissions and cannot approve that workflow. No approval policy was bypassed or weakened. The PR is conflict-free; upstream CI approval/execution remains the final gate. No installed server, native host, Chrome user profile, MCP configuration or site policy was changed.

Latest-head CI closeout

Verified head 98606830b5a4e81e436bc5d7fad7be5efeb1077f: fork CI passed all four jobs, including macOS release build, multi-peer bridge and real Chrome browser integration, plus Windows/Linux and extension checks. The first macOS attempt failed in the credential-cancellation harness with no live DevTools target answered; one unchanged-head rerun passed. This demonstrates a successful rerun, not a repair of that intermittent harness race. Fork verification is separate from upstream maintainer approval and merge.

…rols

Keep hidden and inert background subtrees out of dialog snapshots without dropping reachable offscreen controls. Preserve global actionable indices across bounded pages and expose scope, totals, and continuation in both native backends.

Add real Chromium regression coverage, malformed-option tests, Rust formatter coverage, native e2e checks, and CI/documentation updates. Fixes munimtechnologies#18.
Wait for an expected visible CSS selector on state-returning actions or read-only snapshots instead of treating a loaded tab as application readiness. Preserve action success separately from readiness met, timeout, error, or not_requested. Forward the contract and render status consistently in Rust and Swift, with site-policy checks on every observation.

Add a real Chromium regression proven to fail on deb4c7a, delayed-dialog MCP integration checks, contract tests, and safe retry documentation. Verified Windows and Linux Rust suites, 16 full browser integration checks, extension/build tests, and tool schema parity.
@ElliotDrel

Copy link
Copy Markdown
Contributor Author

Note

🤖 gpt-6.1-sol responding on behalf of Elliot

The exact PR head f2a466b3d5c1cb01241d251332116a737b2669bb passed all existing CI jobs in the fork: https://github.com/ElliotDrel/munim-computer-use/actions/runs/37636660077

This includes the macOS release Swift build, bridge check with 120 connected peers, and 18 real-Chrome browser integration checks against the Swift server. Windows/Linux Rust and extension/parity checks also passed. Both original regressions were independently proven failing on untouched upstream source and passing after the fix; the PR body has commands, evidence and limits.

The upstream workflow is awaiting maintainer approval: https://github.com/munimtechnologies/munim-computer-use/actions/runs/37636339482 . This account cannot grant that approval. Please approve the upstream CI run; no CI security policy has been changed or bypassed.

@ElliotDrel ElliotDrel left a comment •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Note

🤖 Opus 5.5 responding on behalf of Elliot

Independent review of f2a466b3d5c1cb01241d251332116a737b2669bb (base deb4c7a)

Coverage: read the PR body, #18/#19 (no comments on either), and every changed file: background.js, background.test.mjs, main.swift, browser.rs, tools.rs, e2e-browser.mjs, snapshot-dom.test.mjs, ci.yml, README.md. I also read the surrounding CLICK_JS, checkTab, credential fill and settle/withState code.

Run locally (Windows 11, branded Chrome): node --test chrome-extension/background.test.mjs passed 49/49. check-tool-parity.mjs reported 32 tools OK. tool-defs.mjs --check round-tripped OK. A reviewer probe used CDP against headless Chrome and evaluated the PR's own SNAPSHOT_JS and visibleSelectorInPage (evidence is inline). I did not run snapshot-dom.test.mjs: without TMPDIR it writes into the checkout (R3), and the sandbox would not let me set the variable. I did not run Swift/Rust builds, cargo tests or e2e. For those I relied on the source and the fork CI run cited in the PR body.

Claim verdicts

  1. Partly holds. Scope is chosen before the 250-control budget. Native top layer, painted stacking, the nested dialog, hidden/inert ancestry and the background-tab fallback all behave correctly in my probe. However, an ARIA role=dialog without aria-modal is treated as modal. A non-covering banner or widget therefore hides every page control (R1).
  2. Holds. Global indices are tagged across the whole selected scope. nextOffset/truncated/offset > total are correct, stale tags are cleared on every snapshot, and password values stay redacted. Paging a changed UI silently remaps indices, but this is documented as "unchanged UI".
  3. Holds, with one regression in the shared rule. The selector wait polls independently of tab.status and returns met/timeout/error without replaying the action. Site policy is rechecked on each poll. JSON.stringify closes the selector-injection path. In my probe, a link click that navigated mid-poll queued behind the commit and then returned met; it did not error. The shared visibility predicate adds an element-level opacity:0 exclusion, which drops common real controls (R2).
  4. Holds. Rust normalise and Swift withReturnState forward limit/wait_for_selector/wait_timeout_ms on click/type/press/navigate/snapshot. Headroom is +15 s. Readiness and pagination rendering match on both backends, and the schemas and README match the extension's validation.
  5. Mostly holds. The DOM regressions run the real extracted source, and --source reproduces the baseline failures. The suite does not pin either problem above. Test-profile scratch defaults to the repo (R3). The fix is otherwise the smallest complete root fix: no new deps and no new tools.

Findings

  • R1 (High): a role=dialog without aria-modal scopes the snapshot and hides all page controls. chrome-extension/background.js:592. #21 (comment)
  • R2 (Medium): an element's own opacity:0 drops operable transparent inputs (file upload, custom checkbox). chrome-extension/background.js:567. #21 (comment)
  • R3 (Low): the DOM test profile falls back to <repo>/.snapshot-test when TMPDIR is unset. scripts/snapshot-dom.test.mjs:22. #21 (comment)

Comment thread chrome-extension/background.js Outdated
Comment thread chrome-extension/background.js Outdated
Comment thread scripts/snapshot-dom.test.mjs Outdated
Fork-main CI exposed ENOTEMPTY after all DOM assertions passed. A launcher-only kill left POSIX Chromium children writing to the disposable profile. Own a private process group and terminate it even if the launcher has already exited. Four deterministic regressions were proven failing before the repair; run them in CI.

(cherry picked from commit 655fc19)
@sheehanmunim
sheehanmunim merged commit 9916e21 into munimtechnologies:main Oct 8, 2026
6 of 8 checks passed
sheehanmunim added a commit that referenced this pull request Oct 8, 2026
- Snapshot modal scoping kept only controls inside the dialog, so a
  select or combobox whose options render in a portal at the end of
  <body> (Radix, MUI, Headless UI) lost every option. Roots the modal's
  controls point at via aria-controls/aria-owns, open popovers, and
  controls painted on top of the modal stay in scope.
- wait_for_selector refuses selectors that test the value attribute.
  React mirrors controlled inputs' values there, password fields
  included, so met/timeout answered a password one guessed character at
  a time. Escapes and comments are undone before the check.
- null for an optional argument means absent again, as it did before
  offset/limit/wait_for_selector existed.
- A transient evaluate error (a navigation destroying the context)
  polls again until the deadline instead of ending the wait.
- When an extension older than the server ignores the new arguments,
  both hosts say so and ask for a reload instead of printing nothing.
- The native-host relay exits on backend EOF on Linux too (#16): it
  blocks on Chrome's stdin the same way the Windows relay did.
- e2e: click a prompt's button after the evaluation has answered, since
  the click closes the window; report the live targets when no prompt
  answers.
- browser_click / browser_press_key / browser_close_all_tabs
  descriptions cover child tabs and Tab's focus report.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants