Skip to content

Commit 7d84824

Browse files
authored
feat: agents never steal focus in BrowserClaw (#2531)
* feat(claw-mcp): tabs new and pages.newPage always open in the background Agents can no longer request a foreground tab. The background field is still accepted and ignored so clients holding the old schema keep working, but it is hidden from the tool schema. Conformance cases stop assuming an agent-opened page becomes the active tab. * feat(patches): automation never steals focus pref and Browser gates Adds browseros.automation_never_steals_focus (default on for BrowserClaw). With it on, a tab with a DevTools client attached cannot switch the user's active tab or raise the window through Browser::ActivateContents, and tabs or popups its pages open after an agent click land in the background (Browser::AddNewContents, mirroring the upstream actor gate). * feat(patches): Browser.createTab defaults to background; activate commands honour the focus pref createTab now opens tabs in the background unless background=false is passed, and an explicit false only selects the tab within its window. Under browseros.automation_never_steals_focus, activateTab stops raising the window, activateWindow becomes a no-op, and createWindow plus setWindowVisibility(activate) show windows inactive. * test(claw-mcp): retired tabs background field stays accepted but inert
1 parent 16afe60 commit 7d84824

12 files changed

Lines changed: 200 additions & 77 deletions

File tree

packages/browseros-agent/contracts/claw-mcp/tests/cases-snapshot-concurrency.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -452,7 +452,6 @@ export const snapshotConcurrencyCases: ContractCase[] = [
452452
const result = await ctx.mcp.callTool('tabs', {
453453
action: 'new',
454454
url: mixedFrameUrl(ctx),
455-
background: false,
456455
})
457456
const text = expectOk(result, 'tabs new mixed-frame auto-context')
458457
page = parsePageId(result)

packages/browseros-agent/contracts/claw-mcp/tests/cases-tabs.ts

Lines changed: 17 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,12 @@ function parseGroupId(text: string): string {
2121

2222
export const tabsCases: ContractCase[] = [
2323
{
24-
name: 'tabs: new foreground page opens with auto-context',
24+
name: 'tabs: new page opens with auto-context',
2525
smoke: true,
2626
async run(ctx) {
2727
const result = await ctx.mcp.callTool('tabs', {
2828
action: 'new',
2929
url: ctx.fixture('/links.html'),
30-
background: false,
3130
})
3231
const text = expectOk(result, 'tabs new')
3332
const page = parsePageId(result)
@@ -51,35 +50,31 @@ export const tabsCases: ContractCase[] = [
5150
},
5251
},
5352
{
54-
name: 'tabs: active reports the focused page',
53+
name: 'tabs: active reports the user tab, never an agent-opened page',
5554
async run(ctx) {
55+
// Agents cannot request a foreground tab, so the page opened here must
56+
// stay out of `tabs active`; the user's tab keeps that role.
5657
const page = await ctx.openPage(ctx.fixture('/form.html'))
57-
let text = ''
58-
await waitUntil(async () => {
59-
text = expectOk(
60-
await ctx.mcp.callTool('tabs', { action: 'active' }),
61-
'tabs active',
62-
)
63-
return text.includes('/form.html') || text.includes(`${page}`)
64-
}, 'active tab to report the focused form page')
58+
const text = expectOk(
59+
await ctx.mcp.callTool('tabs', { action: 'active' }),
60+
'tabs active',
61+
)
62+
if (!text.startsWith('Active page:')) {
63+
throw new Error(`tabs active did not report a page: ${text}`)
64+
}
65+
if (text.includes(`[${page}]`)) {
66+
throw new Error(`agent-opened page became active: ${text}`)
67+
}
6568
},
6669
},
6770
{
68-
name: 'tabs: background page stays inactive and hidden is rejected',
71+
name: 'tabs: new page stays inactive and hidden is rejected',
6972
async run(ctx) {
70-
const focused = await ctx.openPage(ctx.fixture('/form.html'))
71-
await waitUntil(async () => {
72-
const active = expectOk(
73-
await ctx.mcp.callTool('tabs', { action: 'active' }),
74-
)
75-
return active.includes(`[${focused}]`)
76-
}, 'the foreground page to receive focus')
7773
const result = await ctx.mcp.callTool('tabs', {
7874
action: 'new',
7975
url: ctx.fixture('/links.html'),
80-
background: true,
8176
})
82-
expectOk(result, 'tabs new background')
77+
expectOk(result, 'tabs new')
8378
const opened = parsePageId(result)
8479
const active = expectOk(
8580
await ctx.mcp.callTool('tabs', { action: 'active' }),
@@ -95,9 +90,8 @@ export const tabsCases: ContractCase[] = [
9590
}),
9691
'tabs new hidden',
9792
)
98-
// Track for cleanup — openPage was bypassed to control background creation.
93+
// Track for cleanup — openPage was bypassed to call the tool directly.
9994
await ctx.mcp.callTool('tabs', { action: 'close', page: opened })
100-
await ctx.mcp.callTool('tabs', { action: 'close', page: focused })
10195
},
10296
},
10397
{

packages/browseros-agent/contracts/claw-mcp/tests/rust-conformance.test.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,6 @@ function makeContext(run: ServerRun): CaseContext {
117117
const result = await session.callTool('tabs', {
118118
action: 'new',
119119
url,
120-
background: false,
121120
})
122121
if (result.isError) {
123122
throw new Error(`tabs new failed: ${textOf(result)}`)

packages/browseros-agent/crates/browseros-mcp/src/tests.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -489,6 +489,9 @@ fn tab_and_window_schemas_omit_hidden_controls() {
489489
let tabs = tool_by_name("tabs");
490490
let tabs_schema = Value::Object(tabs.input_schema.as_ref().clone());
491491
assert!(tabs_schema.pointer("/properties/hidden").is_none());
492+
// Focus is the user's call: agents cannot ask for a foreground tab.
493+
assert!(tabs_schema.pointer("/properties/background").is_none());
494+
assert!(!tabs.description.contains("foreground"));
492495

493496
let windows = tool_by_name("windows");
494497
let windows_schema = Value::Object(windows.input_schema.as_ref().clone());

packages/browseros-agent/crates/browseros-mcp/src/tools/mod.rs

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,3 @@ fn metadata_for_tool(name: &str) -> ToolMetadata {
111111
),
112112
}
113113
}
114-
115-
fn default_true() -> bool {
116-
true
117-
}

packages/browseros-agent/crates/browseros-mcp/src/tools/run.rs

Lines changed: 8 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ const DESCRIPTION: &str = r#"The primary way to drive the browser - prefer run f
3535
The return shapes below are stable. Do NOT probe them at runtime (no typeof / Object.keys / getOwnPropertyNames) and do NOT re-open a page to inspect what a call returned; that just piles up duplicate tabs. Reuse a pageId across steps.
3636
3737
Pages (pageId is a NUMBER):
38-
browser.pages.newPage(url) -> pageId (number). Use it directly; it is not an object. Opens in the background so it does not steal the user's focus; pass { background: false } only when the user asks to bring the tab to the front.
38+
browser.pages.newPage(url) -> pageId (number). Use it directly; it is not an object. Always opens in the background; it never switches the user's tab.
3939
browser.pages.close(pageId) -> undefined. Closes a page you own.
4040
browser.pages.list() -> [{ pageId, url, title, ownership, ownerLabel, ... }] for EVERY open tab in the browser, including the user's and other agents'. `ownership` is "mine" | "user" | "other-agent"; "other-agent" tabs also carry ownerLabel. Act only on your own ("mine") tabs. Leave "user" and "other-agent" tabs alone unless the user explicitly asks you to work on one.
4141
browser.pages.getInfo(pageId)-> { pageId, url, title, ... } or null
@@ -655,12 +655,10 @@ impl BrowserBridge {
655655
.race(self.ctx.session.pages.new_page(
656656
&url,
657657
NewPageOptions {
658-
// Default to a background tab so a working agent does
659-
// not steal the user's focus, matching the granular
660-
// tabs-new default. An explicit background:false opens
661-
// it active, which the agent should do only when the
662-
// user asks to bring a tab to the front.
663-
background: optional_bool_field(opts, "background")?.or(Some(true)),
658+
// Always a background tab: an agent must never switch
659+
// the user's tab. A `background` option is ignored
660+
// rather than rejected so older scripts keep working.
661+
background: Some(true),
664662
window_id,
665663
tab_group_id,
666664
},
@@ -1045,17 +1043,6 @@ fn optional_object_arg(
10451043
}
10461044
}
10471045

1048-
fn optional_bool_field(
1049-
object: Option<&Map<String, Value>>,
1050-
name: &str,
1051-
) -> Result<Option<bool>, String> {
1052-
match object.and_then(|object| object.get(name)) {
1053-
None | Some(Value::Null) => Ok(None),
1054-
Some(Value::Bool(value)) => Ok(Some(*value)),
1055-
Some(_) => Err(format!("{name} must be a boolean")),
1056-
}
1057-
}
1058-
10591046
fn optional_i64_field(
10601047
object: Option<&Map<String, Value>>,
10611048
name: &str,
@@ -2159,7 +2146,7 @@ return { pageId: page.pageId, tabId: page.tabId, url: page.url, title: page.titl
21592146
let result = run_tool_with_ctx(
21602147
r#"
21612148
return await browser.pages.newPage('https://new.example', {
2162-
background: true,
2149+
background: false,
21632150
windowId: 88,
21642151
tabGroupId: 'group-opts',
21652152
});
@@ -2184,6 +2171,8 @@ return await browser.pages.newPage('https://new.example', {
21842171
create_params.first().and_then(|params| params.get("url")),
21852172
Some(&json!("https://new.example"))
21862173
);
2174+
// `background: false` is accepted but ignored: agents never get a
2175+
// foreground tab.
21872176
assert_eq!(
21882177
create_params
21892178
.first()

packages/browseros-agent/crates/browseros-mcp/src/tools/tabs.rs

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ use serde_json::json;
99

1010
const DESCRIPTION: &str = "\
1111
Manage browser tabs: list open pages (with their page ids), show the active page, \
12-
open a new page (snapshot attached), or close one. \
12+
open a new page in the background (snapshot attached), or close one. \
1313
Use the returned page id with snapshot/act/navigate.";
1414

1515
#[derive(Debug, Clone, Default, Deserialize, JsonSchema)]
@@ -29,9 +29,12 @@ struct TabsArgs {
2929
action: TabsAction,
3030
/// URL for action="new" (defaults to about:blank).
3131
url: Option<String>,
32-
/// Open without stealing focus for action="new".
33-
#[serde(default = "super::default_true")]
34-
background: bool,
32+
/// Retired: new pages always open in the background so an agent never
33+
/// switches the user's tab. Still accepted, and ignored, so clients that
34+
/// cached the old schema do not trip `deny_unknown_fields`.
35+
#[serde(default, rename = "background")]
36+
#[schemars(skip)]
37+
_background: Option<bool>,
3538
/// Page id for action="close".
3639
page: Option<u32>,
3740
}
@@ -87,7 +90,9 @@ fn handler<'a>(
8790
.new_page(
8891
args.url.as_deref().unwrap_or("about:blank"),
8992
NewPageOptions {
90-
background: Some(args.background),
93+
// Never foreground: focus decisions belong to the user
94+
// (cockpit Watch), not to the agent.
95+
background: Some(true),
9196
window_id: ctx.defaults.default_window_id.clone(),
9297
tab_group_id: ctx.defaults.default_tab_group_id.clone(),
9398
},
@@ -118,3 +123,26 @@ fn format_page_line(page: &browseros_core::pages::PageInfo) -> String {
118123
format!("[{}] {} ({})", page.page_id.0, page.url, page.title)
119124
}
120125
}
126+
127+
#[cfg(test)]
128+
mod tests {
129+
use super::{TabsAction, TabsArgs};
130+
use serde_json::json;
131+
132+
#[test]
133+
fn retired_background_field_is_accepted_and_ignored() -> anyhow::Result<()> {
134+
// Clients that cached the old schema still send it; it must not trip
135+
// `deny_unknown_fields`, and it must not influence the tab's focus.
136+
let args: TabsArgs =
137+
serde_json::from_value(json!({ "action": "new", "background": false }))?;
138+
assert!(matches!(args.action, TabsAction::New));
139+
assert_eq!(args._background, Some(false));
140+
Ok(())
141+
}
142+
143+
#[test]
144+
fn unknown_fields_are_still_rejected() {
145+
let result = serde_json::from_value::<TabsArgs>(json!({ "action": "new", "hidden": true }));
146+
assert!(result.is_err_and(|error| error.to_string().contains("hidden")));
147+
}
148+
}

packages/browseros/chromium_patches/chrome/browser/browseros/core/browseros_prefs.cc

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
diff --git a/chrome/browser/browseros/core/browseros_prefs.cc b/chrome/browser/browseros/core/browseros_prefs.cc
22
new file mode 100644
3-
index 0000000000000..274fa0c0a3d79
3+
index 0000000000000000000000000000000000000000..68597d68ae413015f8783404d822d8a3f7dacb44
44
--- /dev/null
55
+++ b/chrome/browser/browseros/core/browseros_prefs.cc
6-
@@ -0,0 +1,125 @@
6+
@@ -0,0 +1,133 @@
77
+// Copyright 2025 The Chromium Authors
88
+// Use of this source code is governed by a BSD-style license that can be
99
+// found in the LICENSE file.
@@ -39,6 +39,10 @@ index 0000000000000..274fa0c0a3d79
3939
+
4040
+ registry->RegisterBooleanPref(prefs::kNtpFocusContent, false);
4141
+ registry->RegisterBooleanPref(prefs::kOnboardingCompleted, false);
42+
+ // BrowserClaw is a browser for agents: they work in the background by
43+
+ // default. BrowserOS keeps stock focus behaviour.
44+
+ registry->RegisterBooleanPref(prefs::kAutomationNeverStealsFocus,
45+
+ IsBrowserClawProduct());
4246
+}
4347
+
4448
+bool ShouldShowLLMChat(PrefService* pref_service) {
@@ -104,6 +108,10 @@ index 0000000000000..274fa0c0a3d79
104108
+ return pref_service->GetBoolean(prefs::kNtpFocusContent);
105109
+}
106110
+
111+
+bool AutomationNeverStealsFocus(PrefService* pref_service) {
112+
+ return pref_service->GetBoolean(prefs::kAutomationNeverStealsFocus);
113+
+}
114+
+
107115
+const char* GetVisibilityPrefForAction(actions::ActionId id) {
108116
+ switch (id) {
109117
+ case kActionSidePanelShowThirdPartyLlm:

packages/browseros/chromium_patches/chrome/browser/browseros/core/browseros_prefs.h

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
diff --git a/chrome/browser/browseros/core/browseros_prefs.h b/chrome/browser/browseros/core/browseros_prefs.h
22
new file mode 100644
3-
index 0000000000000..b04a6ef039a6b
3+
index 0000000000000000000000000000000000000000..893ade589e58d07c85848b790079481c2452b9e7
44
--- /dev/null
55
+++ b/chrome/browser/browseros/core/browseros_prefs.h
6-
@@ -0,0 +1,111 @@
6+
@@ -0,0 +1,125 @@
77
+// Copyright 2025 The Chromium Authors
88
+// Use of this source code is governed by a BSD-style license that can be
99
+// found in the LICENSE file.
@@ -58,6 +58,16 @@ index 0000000000000..b04a6ef039a6b
5858
+
5959
+inline constexpr char kOnboardingCompleted[] = "browseros.onboarding_completed";
6060
+
61+
+// Boolean: Automation-driven tabs never pull the user's attention. A tab counts
62+
+// as automation-driven while a DevTools client is attached to it, which is
63+
+// every tab the claw-server (or any CDP client) acts on. With the pref on such
64+
+// a tab cannot switch the user's active tab or raise the window, and tabs or
65+
+// popups its pages open land in the background. Gates live in
66+
+// Browser::ActivateContents, Browser::AddNewContents and the DevTools
67+
+// BrowserHandler. Default: true for BrowserClaw, false for BrowserOS.
68+
+inline constexpr char kAutomationNeverStealsFocus[] =
69+
+ "browseros.automation_never_steals_focus";
70+
+
6171
+} // namespace prefs
6272
+
6373
+// Registers BrowserOS profile preferences.
@@ -109,6 +119,10 @@ index 0000000000000..b04a6ef039a6b
109119
+// Check if NTP content should receive focus instead of the omnibox.
110120
+bool IsNtpFocusContentEnabled(PrefService* pref_service);
111121
+
122+
+// Check if automation-driven tabs must never steal focus. Callers decide per
123+
+// tab by combining this with content::DevToolsAgentHost::IsDebuggerAttached().
124+
+bool AutomationNeverStealsFocus(PrefService* pref_service);
125+
+
112126
+// Get the visibility pref key for an action, or nullptr if none exists.
113127
+const char* GetVisibilityPrefForAction(actions::ActionId id);
114128
+

0 commit comments

Comments
 (0)