Skip to content

feat(claw): use the original Default profile when a task has no target - #2919

Merged
Nikhil (shadowfax92) merged 3 commits into
mainfrom
feat/default-browser-profile
Oct 14, 2026
Merged

Nikhil (shadowfax92) merged 3 commits into
mainfrom
feat/default-browser-profile

Conversation

@shadowfax92

@shadowfax92 Nikhil (shadowfax92) commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Follow-up to #2918. On a native with profile targeting, a task with no saved browser_target now runs in the browser's original Chromium profile: catalog directory "Default", never its display name. It no longer lands in whatever window happened to be focused last.

An explicit target still wins, and clearing it returns later calls to Default. There is no UI yet; the choice lives in one selector so a later setting can replace it.

 BrowserView::capture(binding)
   native without profile targeting -> unbound routing (PR #2918 path)  [unchanged]
   Some(saved binding) -> scoped capture, TargetSource::Explicit        [unchanged]
-  None -> unbound (last-active window across all profiles)
+  None -> capture_default():
+          getProfiles -> default_profile(): directory == "Default"
+          unloaded? -> Browser.loadProfile (existing profile only, opens no window;
+                       never for management: capture_for_management)
+          effective binding (live handle, revision 0, never saved)
+          -> same scoped capture, TargetSource::Default
+          failure -> default_profile_unavailable (no fallback; management still admitted)
 new_page / raw Browser.createTab with no placement window
-  scoped: no_profile_window
+  Default scope: Browser.createWindow {} (normal, inactive, Default context)
+                 -> Browser.createTab {…, windowId} in it (page identified by its own reply)
+  explicit scope: no_profile_window                                    [unchanged]

One decision for every path. Every host already captures through BrowserView::capture: claw service.rs, the standalone BrowserMcpService and the Browser facade. So granular tools, run direct primitives, tool:*, helpers and raw CDP all inherit the same Default scope.

browser_target get with nothing stored returns:

{ "bound": false, "default": { "directory": "Default", "status": { "profile": …, "windows": […], "placementWindowId": …, "error": … } } }

The schema gains only that optional default object. The profile guidance and the run/tabs/windows/browser_target notes now describe the Default rule.

Evidence

  • Before (1638e4a9d): a capable native with no target ran unbound: tabs new / browser.open used the last-active window across all profiles, and windows create used that window's profile. That is the source audit browser-opening-profile-audit-20261014.

  • After (mocked-CDP behavior tests through the real interfaces):

    • core:
      • no_target_uses_the_original_default_profile_not_focus_or_display_name (renamed Default; another profile display-named "Default"; the sales window focused): listing, windows list, new_page, create_window, raw createTab and History all use Default's window/context, and another profile's page → page_out_of_scope.
      • default_without_a_window_opens_an_inactive_normal_window_there: listing opens nothing, then createWindow {context, normal} and createTab {url, windowId}. Raw createTab {pinned} keeps its arguments. An explicit target with no window still gives no_profile_window.
      • unloaded_default_is_loaded_existing_only_before_admission
      • unusable_default_fails_admission_without_using_another_profile (missing / load failed: zero creations, management works)
      • explicit_target_wins_over_the_default
      • default_handles_are_fresh_after_a_native_restart (a stale explicit handle still gives profile_gone)
      • admitted_default_view_fails_closed_after_a_reconnect
      • natives_without_profile_targeting_keep_unbound_routing_with_no_target (zero getProfiles / loadProfile / context params)
      • management_capture_never_loads_the_default
      • facade_never_falls_back_to_global_routing_without_a_default
      • failed_default_view_exposes_no_global_events
      • Red check: with the Default branch disabled, 7 of these fail.
    • mcp/run:
      • tools_and_run_share_the_default_profile_without_a_selection (tabs new, browser.open, pages.newPage, pages.list, windows, history, raw History)
      • default_without_a_window_gets_one_from_tabs_and_run
      • explicit_selection_overrides_default_and_clear_restores_it (strict pin stays strict)
      • an_admitted_default_view_ignores_a_later_selection
      • missing_default_refuses_work_but_selection_recovers
      • profile_management_never_loads_an_unloaded_default
      • updated target_output_schema_… readback and bound_raw_policy_… (clear returns to the Default scope, not raw passthrough)
    • claw:
      • unselected_run_uses_the_default_profile_and_keeps_it_after_rebind (run creation and detached grouping land in Default's window after a later set; the binding store holds only the explicit set)
      • HTTP browser_target_wire (get reports Default before set and after clear)
      • the profile guidance test
    • Fixtures: the routes MockCdp serves no profile catalog, windows or identity sidecars, so those session/tab tests now say it is a pre-0.51.1.3 native.
  • Verification:

    • cargo fmt --all -- --check
    • cargo clippy --workspace --all-targets --locked -- -D warnings
    • cargo test --workspace --locked: 1103 passed, 0 failed
    • git diff --check
  • Not claimed: no live native acceptance was run; the evidence is fixtures plus CI. On natives without profile targeting (< 0.51.1.3 or unknown), Default routing is not enforced; they keep the feat(claw): gate native profile features on the BrowserOS version #2918 unbound path.

  • Independent review: one Codex round on 85e4cf6a3 raised 4 Important findings; all were accepted and fixed in 66344d475.

    • A failed Default could take the Browser facade's unbound shortcut. The same pattern was found and fixed in scoped_events.
    • The first Default window adopted its page via a later getActiveTab. It is now window first, then createTab.
    • Raw createTab dropped its args on that path. Only the window is supplied now.
    • Management capture loaded Default. Management now uses capture_for_management, which never loads.
    • Red → green tests are listed above.
  • Greptile P2 (this PR): each page opened its own window when Default had none. The admitted view now opens at most one, single-flight (2011462f8). Covered by one_admitted_default_view_opens_at_most_one_window and one_run_without_a_default_window_opens_one_window_for_all_pages.

Merge Danger

Door: two-way. This is a code-only routing change. Nothing new is persisted, and saved explicit bindings are untouched.

Blast Radius:

  • On capable natives, every untargeted call is now scoped to the Default profile's normal windows. Work that relied on the focused window of another profile now needs browser_target set.
  • There is no browser-wide mode on capable natives any more: clear returns to Default. OAuth / window.open popups (non-normal windows) were already outside explicit targets, and are now also outside the default scope.
  • A browser without a Default directory, or one whose Default fails to load, refuses ordinary calls with default_profile_unavailable until a profile is selected.
  • An unloaded Default is loaded (existing profile only) by the first ordinary call. Profile management never loads it.
  • When Default has no normal window, the inactive window opened for it keeps the browser's own initial tab next to the agent's page. This happens once.

🤖 Generated with Claude Code

On a native with profile targeting, a task with no saved browser_target now runs in
the browser's original Chromium profile (catalog directory "Default", never its
display name), instead of whatever window was last focused. An explicit target still
wins, and clearing it returns later calls to Default.

- BrowserView::capture resolves the default from the live catalog on every capture,
  loads that existing profile if needed (opens no window), and reuses the scoped
  execution path, so tools, run primitives, tool:* and raw CDP share one decision.
  The effective binding is never persisted (revision 0) and its handles stay fresh.
- With no Default window, opening a page creates one inactive normal Default window
  with the page as its first tab. An explicit target without a window keeps
  no_profile_window.
- A missing, ineligible or unloadable Default gives default_profile_unavailable
  with no fallback to another profile. Management still works; an epoch change fails
  the admitted default view closed.
- browser_target get reports the Default in use when nothing is selected. Profile
  guidance and the run, tabs and windows notes say so.
- Natives without profile targeting keep the PR #2918 unbound path unchanged.
- A failed Default capture never widens to global routing. The Browser facade and the
  scoped event stream take the unbound path only for a genuinely unbound view.
- With no Default window, open an inactive normal Default window and create the page
  in it with Browser.createTab, so the page is identified by its own creation reply.
  Raw createTab keeps every caller argument and only gets the window.
- Profile management (browser_profiles, browser_target) is admitted through
  BrowserView::capture_for_management and never loads an unloaded Default.
@greptile-apps

greptile-apps Bot commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

RetriggerView in GreptileConfidence Score: 4/5

[Medium impact] Changes how browser profile targeting works when no explicit selection exists.

The PR appears safe to merge, with a non-blocking issue that opens extra windows during repeated page creation.

Findings

  1. P2 Each page opens another window ▶
Fix with agent prompt
### Issue 1
packages/browseros-agent/crates/browseros-core/src/target.rs:1050-1052
When Default starts without a normal window, two sequential `browser.open` calls in one `run` each open a separate window. The first creation never updates `placement_window`, so both the typed and raw creation paths keep treating Default as windowless. This leaves users with extra windows instead of tabs sharing the first one.

Reuse the first automatically created window within the captured view, and add a test that opens two pages through that same view.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Summary

Untargeted calls on capable browsers now use the original Default profile instead of the last-focused profile.

  • Untargeted calls on capable browsers now use the original Default profile.
  • Calls create a Default window when none is available.
  • browser_target get reports the Default profile when no selection is saved.

Diagram

%%{init: {'theme': 'neutral'}}%%
flowchart TD
  A[Capture call] --> B{Profile targeting supported?}
  B -->|No| C[Legacy unbound routing]
  B -->|Yes| D{Saved target?}
  D -->|Yes| E[Capture explicit profile]
  D -->|No| F[Find directory Default]
  F --> G{Management call?}
  G -->|Yes| H[Capture without loading]
  G -->|No| I[Load existing profile if needed]
  I --> J{Capture succeeds?}
  J -->|No| K[Refuse ordinary work]
  J -->|Yes| L[Scoped Default work]
  L --> M{Page creation lacks a window?}
  M -->|Yes| N[Create Default window]
  M -->|No| O[Use captured window]
  N --> P[Create tab]
  O --> P
Loading

Reviews (1) · Last reviewed commit: "fix(claw): harden default-profile captur..." · Reviewed by Greptile

Comment thread packages/browseros-agent/crates/browseros-core/src/target.rs
@github-actions

github-actions Bot commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

✅ Tests passed: 1256/1256

Ran 3 of 16 suites (13 not affected by this change).

Suite Passed Failed Skipped
⏩ server-agent n/a n/a not affected
⏩ server-api n/a n/a not affected
⏩ server-tools n/a n/a not affected
⏩ server-browser n/a n/a not affected
⏩ server-integration n/a n/a not affected
⏩ server-lib n/a n/a not affected
⏩ server-root n/a n/a not affected
⏩ agent n/a n/a not affected
⏩ claw-app n/a n/a not affected
⏩ claw-onboard n/a n/a not affected
⏩ app-onboard n/a n/a not affected
⏩ build n/a n/a not affected
⏩ release n/a n/a not affected
✅ claw-server 1103/1103 0 0
✅ claw-server-quality passed 0 0
✅ claw-mcp 153/153 0 0

passed = ran successfully but emits no JUnit counts (a lint/format gate).

View workflow run

@graphify-labs graphify-labs Bot 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.

Graphify reviewed this change.

Worth a look — the grounded gate found no coupling regressions or blocking issues, but 2 advisory finding(s) below merit a look before merge.

Not checked: tests were not run; formal verification proved 0 of 23 changed function(s) (23 not verified).

Formal verification. PR-changed functions: 0/23 verified (0 proven, 0 may-equivalent, 0 distinguished) · 23 not verified (23 unsupported).

Not verified on this run: profile\_guidance\_is\_offered\_only\_to\_capable\_natives (unsupported), call\_tool (unsupported), get\_active\_page\_for\_window (unsupported), new\_page (unsupported), capture (unsupported), capture\_scope (unsupported), check\_epoch (unsupported), epoch\_guard (unsupported), new\_page (unsupported), status (unsupported), and 13 more.


Graphify review — findings

Adds a default-profile rule for tasks with no browser_target selection: calls run in the browser's original Default profile (directory "Default"), not the focused window. A selection made after admission doesn't move the admitted run or its detached grouping, and the default is never saved for the task. Unbound calls now resolve the default profile at admission, so a missing Default profile fails admission with default_profile_unavailable instead of falling back to the old unbound routing, and an explicit window new_page now resolves default scope before it honors window_id. Profile-management tools now capture their view through capture_for_management, so admission never loads the Default profile as a side effect; if the default capture fails, target_source still reports Default while binding() is None. The MCP instructions teach the default rule and action="clear" returning the task to Default, only for natives that support profile targeting, and older natives see no mention of it.

Worth a look

  • Unbound calls now fail admission instead of using unbound routing — packages/browseros-agent/crates/browseros-core/src/target.rs:300 · Escalate · medium
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
  • Explicit window new_page now resolves default scope before honoring window_id — packages/browseros-agent/crates/browseros-core/src/browser.rs:110 · Escalate · medium
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review

Review partial — this diff was larger than one review pass covers, so later files were not reviewed; some findings may be missing.

Analysis details — impact, health, verification

Impact & health

Graphify review

Impact — 812 functions depend on the 718 functions this change touches.

Health — this change adds coupling hotspots:

  • worse: execute_with_cancellation() — 9 callers, 6 callees

Verification — 812 functions in the blast radius were not formally verified this run (proofs are advisory here).

Gate & verification

graphify gate

PASS — objectively clean (no health regressions, tests not run — proofs not run this pass (advisory)). Grounded, not self-assessed.

Advisory (not blocking):

  • verification_scope: 812 function(s) in the blast radius were not formally verified this run

Risky patterns (advisory)

  • medium no-unbounded-fetch in packages/browseros-agent/crates/browseros-core/src/target.rs:319: unbounded read (no limit/bound): .read()

Docs that may be stale (advisory)

Formal verification

Could not verify: Could not verify profile\_guidance\_is\_offered\_only\_to\_capable\_natives.

The verifier did not have enough to check profile\_guidance\_is\_offered\_only\_to\_capable\_natives, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: unit-returning functions have no value to compare

Could not verify: Could not verify call\_tool.

The verifier did not have enough to check call\_tool, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: unsupported: method receiver/self type not synthesizable from the simple-type literal pools — abstaining rather than guessing a receiver

Could not verify: Could not verify get\_active\_page\_for\_window.

The verifier did not have enough to check get\_active\_page\_for\_window, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: unsupported: method receiver/self type not synthesizable from the simple-type literal pools — abstaining rather than guessing a receiver

Could not verify: Could not verify new\_page.

The verifier did not have enough to check new\_page, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: unsupported: method receiver/self type not synthesizable from the simple-type literal pools — abstaining rather than guessing a receiver

Could not verify: Could not verify capture.

The verifier did not have enough to check capture, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: unsupported return type: Self

Could not verify: Could not verify capture\_scope.

The verifier did not have enough to check capture\_scope, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: unsupported: method receiver/self type not synthesizable from the simple-type literal pools — abstaining rather than guessing a receiver

Could not verify: Could not verify check\_epoch.

The verifier did not have enough to check check\_epoch, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: unsupported: method receiver/self type not synthesizable from the simple-type literal pools — abstaining rather than guessing a receiver

Could not verify: Could not verify epoch\_guard.

The verifier did not have enough to check epoch\_guard, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: unsupported: method receiver/self type not synthesizable from the simple-type literal pools — abstaining rather than guessing a receiver

Could not verify: Could not verify new\_page.

The verifier did not have enough to check new\_page, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: unsupported: method receiver/self type not synthesizable from the simple-type literal pools — abstaining rather than guessing a receiver

Could not verify: Could not verify status.

The verifier did not have enough to check status, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: unsupported: method receiver/self type not synthesizable from the simple-type literal pools — abstaining rather than guessing a receiver

Could not verify: Could not verify unbound.

The verifier did not have enough to check unbound, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: unsupported return type: Self

Could not verify: Could not verify validate\_live.

The verifier did not have enough to check validate\_live, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: unsupported: method receiver/self type not synthesizable from the simple-type literal pools — abstaining rather than guessing a receiver

Could not verify: Could not verify scoped\_root\_cdp.

The verifier did not have enough to check scoped\_root\_cdp, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: unsupported: method receiver/self type not synthesizable from the simple-type literal pools — abstaining rather than guessing a receiver

Could not verify: Could not verify scoped\_events.

The verifier did not have enough to check scoped\_events, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: unsupported: method receiver/self type not synthesizable from the simple-type literal pools — abstaining rather than guessing a receiver

Could not verify: Could not verify reply.

The verifier did not have enough to check reply, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: unsupported: method receiver/self type not synthesizable from the simple-type literal pools — abstaining rather than guessing a receiver

Could not verify: Could not verify execute\_registered\_tool.

The verifier did not have enough to check execute\_registered\_tool, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: unsupported: method receiver/self type not synthesizable from the simple-type literal pools — abstaining rather than guessing a receiver

Could not verify: Could not verify tool\_ctx.

The verifier did not have enough to check tool\_ctx, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: unsupported: method receiver/self type not synthesizable from the simple-type literal pools — abstaining rather than guessing a receiver

Could not verify: Could not verify bound\_raw\_policy\_and\_explicit\_window\_group\_conflicts\_fail\_before\_dispatch.

The verifier did not have enough to check bound\_raw\_policy\_and\_explicit\_window\_group\_conflicts\_fail\_before\_dispatch, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: unsupported return type: anyhow::Result<()>

Could not verify: Could not verify capable\_native\_exposes\_run\_profile\_exports.

The verifier did not have enough to check capable\_native\_exposes\_run\_profile\_exports, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: unsupported return type: anyhow::Result<()>

Could not verify: Could not verify discovery\_omits\_profile\_surfaces\_for\_older\_natives.

The verifier did not have enough to check discovery\_omits\_profile\_surfaces\_for\_older\_natives, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: unit-returning functions have no value to compare

Could not verify: Could not verify reply.

The verifier did not have enough to check reply, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: unsupported: method receiver/self type not synthesizable from the simple-type literal pools — abstaining rather than guessing a receiver

Could not verify: Could not verify target\_output\_schema\_preserves\_full\_readback\_through\_content\_gate.

The verifier did not have enough to check target\_output\_schema\_preserves\_full\_readback\_through\_content\_gate, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: unsupported return type: anyhow::Result<()>

Could not verify: Could not verify handler.

The verifier did not have enough to check handler, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: unsupported return type: BoxFuture<'a,ToolExecResult<Option<ToolResult>>>

· 1 more finding(s) on lines outside this diff (see the check run).

Placement is captured once, so pages opened through one call (several browser.open in
a run, or Promise.all) each opened a new window when Default had none. The admitted
view now remembers the window it opened, single-flight across clones, and reuses it
while it still validates.
@shadowfax92
Nikhil (shadowfax92) merged commit b5ddc31 into main Oct 14, 2026
14 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant