close
Skip to content

feat(browser): make the built-in browser the default for chat - #2082

Merged
iamwhatever merged 1 commit into
mainfrom
fix/native-browser-default
Aug 10, 2026
Merged

feat(browser): make the built-in browser the default for chat#2082
iamwhatever merged 1 commit into
mainfrom
fix/native-browser-default

Conversation

@iamwhatever

@iamwhatever iamwhatever commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator

Problem

Ask the chat to open a page and you get a screenshot of a page, not a page. The
right-hand Browser panel paints frames streamed from a headless Playwright Chromium:
nothing in it is clickable, it carries none of your logins, and the built-in Electron
browser that was built for exactly this sits unused.

That was not a configuration mistake. The native path was unreachable code:

// main.js on main, line 1310
if (!pre || !pre.agentActEnabled) {

pre is the verdict from canAgentControl, which returns {allowed, reason} — it has
no agentActEnabled field. The condition is therefore always true and the bootstrap
was always refused, even with the grant switched on. The expression had no test, so
it stayed wrong.

// main.js on main, line 1295
listPanelIds: () => [...browserPanels.keys()],

Only mounted panels were reported. A fresh chat has no panel, so no session key was
ever polled, so the gateway answered 503 and the very first "open this page" fell back
to the mirror before any gate code ran.

Why it matters

The built-in browser is the feature. A screenshot mirror cannot be operated, cannot use
a site you are logged into, and cannot be handed to the user to keep browsing. Two
defects — neither of them visible as an error — meant that no user on any platform had
ever reached the native path from chat.

Fix (symptoms → root cause → change)

Symptom: every chat-opened page renders as a Playwright screenshot.
Root cause: two independent hard blockers above, plus a consent model that could not
be satisfied in the default flow.
Change: make the native path reachable, then make it the default.

  1. mayBootstrapView(verdict) — the constant-true expression is extracted into a
    named predicate with tests in both directions. It tolerates exactly one refusal,
    no-browser-view, which is the precondition the caller is about to satisfy by
    opening a view; every other refusal still stops the bootstrap.
  2. Reachability is declared, not inferredbrowser:track-session +
    reachableSessions. The command channel can only deliver an op for a session key it
    polls for, and it must poll before any URL is known, so reachability cannot depend
    on anything about the request. The renderer declares its active slot; that is what
    makes a fresh chat's first navigate arrive at all.
  3. browser:agent-opened — when the agent bootstraps a view, the main process asks
    the SPA to surface the Browser panel so it mounts, measures and reports bounds.
    Without it the view exists but is composited nowhere and the user sees an empty panel.
  4. Browser Mode is the authorization — see below.
  5. Native routing yields to extension mode — see below.

Browser Mode is the authorization

Enabling Browser Mode in Settings is already a keystone-level grant. From
src/kiro_crew/security.py:

while it is present the browse proxy is registered and the browser_* tools are in the
agent's tool list, which lets the agent operate a real browser — and in attach mode
that is the operator's own running, logged-in browser. Presence alone is the
authorization.

So the repository already treats one deliberate Settings action as consent for a
stronger capability than the one at issue here: driving the operator's own logged-in
browser. Requiring a second, per-session gesture before the agent may open a page in
the built-in view gated a strictly weaker capability, and that inconsistency is what kept
the default path on the mirror. The per-session gate is removed; the viewOpen
precondition and mayBootstrapView are unchanged.

Removing it also removed the control that advertised it. The panel's "Let the agent act"
button could not have kept its meaning: the dispatch re-acquires LIGHT on every op, so
switching the button off produced one release() and the next op took control straight
back. A control that reads as a denial but is not must not ship, so the button, its four
i18n keys across all catalogs, and the now-dead wiring behind it
(PREVIEW_ENABLE_BROWSE_EVENT, the BROWSE_MODE_EVENT mirror, the pull handshake, the
hook's agentActEnabled option) are deleted. The human's own panel controls — address
bar, navigate, back, close — are untouched.

Native routing yields to extension mode

extension_mode (exposed by /api/browser/config) attaches Playwright to the operator's
own running browser. _EXTENSION_MODE was consulted only where mirror frames are
sent, never in the routing decision — harmless while native required a per-session grant,
and a silent hijack the moment native becomes the default. _try_native_tool_call now
yields immediately in extension mode, before any POST, so an explicit choice of an
external browser is honoured.

The resulting ladder, in precedence order:

Transport When
Your own browser (extension/attach) extension_mode on — an explicit choice, now honoured in the routing path
Built-in Electron browser Electron host — the default, and reachable for the first time
Playwright headless + mirror Remote gateway or any non-Electron host

Loopback targets are exempt from any gate

isLoopbackUrl keeps localhost ungated. Cookies are host-scoped, so a dev server holds
no third-party session and there is no identity to protect. It is judged against the
target of a navigate, never the page already loaded, so a local page cannot launder
the agent onto a real site; lookalikes (localhost.evil.com), non-http schemes and
unparseable input are not exempt, and the exemption never lifts the "a view must exist"
precondition.

Tests

  • website/electron/test/browser-control.test.jsmayBootstrapView in both directions
    (the predicate that shipped broken); an agent op is authorized with no per-session
    grant while the view precondition still applies; the loopback exemption including the
    localhost.evil.com spoof, file://, and unparseable input. The dead
    chooseControlTransport cases are dropped with the function.
  • test/test_browser_native_routing.pyextension_mode_never_routes_to_the_native_view
    asserts no POST is even attempted. The pre-existing
    test_refusal_returns_an_mcp_error_and_does_not_fall_back passes unchanged: a deny must
    never become an allow by another route.
  • Tests that pinned the removed per-session consent model are deleted, each with a note in
    place explaining why the coverage is gone rather than leaving a silent hole.

666 Electron tests, 11786 frontend tests (876 files, including the catalogParity /
deadKeys / dynamicKeys i18n ratchets that verify the key removal is consistent across
all 13 catalogs), 25 native-routing tests. tsc -b, eslint (0 errors), flake8, isort and
the brand gate are clean.

Manual verification

Not yet done — needs a rebuilt desktop app, and it matters more than usual here: every
defect in this PR survived because this path had no integration coverage, and the unit
tests pin gate logic rather than the gateway-bus ↔ poller ↔ panel-creation chain.

  1. Browser Mode on, fresh chat, Browser tab never opened → "open www.amazon.com" opens the
    built-in browser and surfaces the panel. No per-session gesture anywhere.
  2. The page is real: scroll it, click a link, and a site you are logged into in that
    browser stays logged in.
  3. extension_mode on → the same request drives your own browser and does not open
    the built-in view.
  4. A local dev server (http://localhost:5173) opens natively too.
  5. Remote gateway / non-Electron host → still the Playwright mirror, unchanged.

Screenshots

@iamwhatever
iamwhatever requested a review from a team August 7, 2026 20:15
@iamwhatever
iamwhatever requested a review from a team as a code owner August 7, 2026 20:15
@github-actions github-actions Bot added the readiness: checking Automated validation is still running label Aug 7, 2026
@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Design Review (Fable 5) — 🟡 CONCERNS

Advisory design-level review of b32beed3c7bfb5de384da36ae1e23874cc34cb42 — updated in place on each push; does not block merge.

Design-Verdict: CONCERNS

The reachability/bootstrap fixes are sound; removing the per-session consent gate rests on a contestable "strictly weaker" argument the PR's own retained comment contradicts.

Watch

  • Consent removal vs. the code's own rationale. The PR keeps the canAgentControl comment recording that review previously killed an ungated-navigate carve-out because "the embedded view runs on a persist: partition… even a bare navigate sends authenticated requests with their cookies," then ships agentActEnabled: true unconditionally — a strictly broader ungating of the same thing. The precedent leaned on (security.py: "Presence alone is the authorization") describes attach mode, which is a separate opt-in; a user who enabled Browser Mode for the isolated Playwright profile never chose an authenticated surface, and once the embedded profile accumulates logins (the PR's stated goal), the only remaining injection defense is prose guidance in SKILL.md, which page content can defeat. The lone deny left is disabling Browser Mode entirely. A human should explicitly own this trade before merge.
  • Consent-shaped machinery survives with no caller that can deny. browser:set-agent-act (with its release-on-revoke logic), entry.agentAct, and canAgentControl's agentActEnabled/loopback branches remain, but nothing in the renderer sets them anymore — the main.js comment even claims "a mounted panel sets it authoritatively via browser:set-agent-act," which is no longer true. A gate that reads as enforcement but always allows is the same defect class this PR fixed.

Suggestions

  • Collapse the gate to the view precondition and delete the orphaned setAgentAct channel and loopback parameter, or re-wire a real per-session deny — don't keep the API shape of a consent model the design rejected.

[DESIGN-REVIEWED] b32beed

@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

UX Review (Fable 5) — 🟡 CONCERNS

Advisory UX-level review of b32beed3c7bfb5de384da36ae1e23874cc34cb42 — updated in place on each push; does not block merge.

UX-Verdict: CONCERNS

Agent control of the user's logged-in browser is now invisible and unstoppable at the point of action — the deleted badge was the only status signal.

Watch

  • No in-panel indication or stop for agent control. Deleting agent_can_act / let_the_agent_act removes the only UI that said the agent may drive this page; the dispatch re-acquires LIGHT on every op, so a user watching the panel sees their real, logged-in browser navigate and type with no attribution and no adjacent way to halt it short of Settings → Browser Mode off (global). Every agent browse session, comprehension + control loss on an authenticated surface, persistent. Smallest fix: a passive "Agent is browsing" badge while the agent holds control (status only — the PR correctly killed the fake toggle), ideally with a "take over" affordance.
  • The Browser Mode toggle's copy doesn't carry its expanded consent. Its description ("Let the agent read and operate web pages", pages.settings.browserPanel) is unchanged while the toggle became the sole authorization for driving the built-in browser on the user's persisted logins from any chat, with no per-session gesture. Users who enabled it under the old model are never told the semantics changed. One-sentence fix in the toggle description naming the built-in browser and its logged-in profile.

Suggestions

  • browser control refused: ${pre.reason} surfaces the raw code agent-act-not-authorized into chat when the agent relays it verbatim; since SKILL.md says it can only mean "Browser Mode is off", have the error carry that human phrasing directly.

[UX-REVIEWED] b32beed

@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

GPT 5.6 Review — ✅ no blocking findings

GPT 5.6 completed its review of b32beed3c7bfb5de384da36ae1e23874cc34cb42 and found no blocking issues.

This comment is updated in place on each push.

Review details

FINDING -- website/electron/main.js:1377 -- A configured SSH tunnel still uses a localhost backend, so "isLoopbackUrl(win._mcBackendUrl)" sends the local secret to the remote gateway and repeatedly generates 403 denials -> Fix: also return [] when this port has a configured remote host.
[GPT-REVIEWED] b32beed

False positive or not applicable? A repository writer can comment:
/ai-review override gpt b32beed3c7bfb5de384da36ae1e23874cc34cb42: <one-sentence reason>

@github-actions github-actions Bot added readiness: action required A blocking check or review needs attention and removed readiness: checking Automated validation is still running labels Aug 7, 2026
@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Opus 4.8 Review — ✅ no blocking findings

Reviewed b32beed3c7bfb5de384da36ae1e23874cc34cb42 — this comment is updated in place on each push.

Review details

I've reviewed the full diff and the relevant surrounding code (main.js dispatch/gate, mcp_playwright_proxy.py native routing, browser-control.js predicates, ChatPage/hook wiring, and both AUTOSDE rule snapshots).

Key checks performed:

  • mayBootstrapView correctly replaces the constant-true !pre.agentActEnabled expression; with the gate now passing agentActEnabled: true, a fresh navigate yields {allowed:false, reason:"no-browser-view"} which the predicate tolerates, and any other refusal still throws. Sound.
  • The new _EXTENSION_MODE early-return in _try_native_tool_call sits before the POST and before the split-brain latch; extension mode has no native panel, so no split-brain is bypassed. Correct direction, closes the silent-hijack path.
  • listPanelIds fail-closed loopback guard prevents pushing the local secret to a remote gateway; isLoopbackUrl rejects lookalikes/non-http/unparseable.
  • browser:agent-opened sends on the dashboard view.webContents (correct), guarded by torn-down catch; ChatPage surfaces only for the active slot, matching the mirror path.
  • Removed exports (PREVIEW_ENABLE_BROWSE_EVENT, BROWSE_MODE_EVENT, CONTROL_OWNER, chooseControlTransport, setAgentAct wiring) have no remaining non-test references; i18n key removals are ratchet-gated.
  • The core change — dropping per-session consent so Browser Mode alone authorizes driving the built-in persistent-profile view — is a deliberate, documented design decision (equated to the strictly-stronger attach-mode capability Browser Mode already grants). No AUTOSDE blocking: true rule's file-patterns govern this authorization semantics, and it matches intent, so it is not a defect to report.

No reachable crash, data-loss, guard removal without compensation, or normal-path correctness break survives falsification.

No findings.

[OPUS-REVIEWED] b32beed

Verdict parsed from the review's SHA-scoped output markers for commit b32beed3c7bfb5de384da36ae1e23874cc34cb42.

False positive or not applicable? A repository writer can comment:
/ai-review override fable b32beed3c7bfb5de384da36ae1e23874cc34cb42: <one-sentence reason>

@github-actions github-actions Bot added the merge conflict Branch has merge conflicts with its base — author must resolve before merge label Aug 8, 2026
@iamwhatever
iamwhatever force-pushed the fix/native-browser-default branch from e29587a to 336c7b3 Compare August 8, 2026 06:57
@github-actions github-actions Bot added readiness: checking Automated validation is still running and removed readiness: action required A blocking check or review needs attention labels Aug 8, 2026
@iamwhatever

Copy link
Copy Markdown
Collaborator Author

Dispositions for e29587a2c → now 336c7b37d

Every finding raised on e29587a2c, answered individually.

GPT 5.6 — BLOCKING: content-reading ops bypass agent authorization — fixed

Legitimate, and the one thing the PR's parity argument did not cover. The parity
claim ("the human reaches the same manager.navigate ungated") justifies ungated
navigation only. It does not extend to reading, because the embedded view runs on
the persist: partition holding sessions the user logged into by hand — while the
Playwright fallback runs isolated: True with an empty storage state. So before
this PR no ungated path could read authenticated page content, and making the
reads Globe-free created one, reachable by prompt injection.

Fixed exactly as prescribed: snapshot, screenshot, console moved into
AGENT_OPERATE_OPS. AGENT_VIEW_OPS is now navigate, back, wait_for.

Opus 5 — BLOCKING: same finding, independently — fixed

Same change. Your prescription was followed verbatim, including keeping
navigate/back/wait_for view-class.

Also added a dedicated invariant test so a future reclassification of a read has to
delete an explicitly-named guarantee rather than quietly edit an array:

plane: reading page content is NEVER ungated
opclass: the two sets are disjoint AND cover the whole wire vocabulary

The second one cross-checks the classification against _NATIVE_OPS in
mcp_playwright_proxy.py, so adding a wire verb without classifying it now fails
loudly instead of landing silently in the fail-closed operate bucket.

Design Review — CONCERNS (1/2): ungated reads of the persistent profile — fixed

Same root issue as the blocking findings above; the fix resolves it. Your framing
was the most precise of the four: the parity argument covers navigation, not
reading. That distinction is now written into the code as the reason the split
exists, so it survives the next person who wonders why reads are gated.

Design Review — CONCERNS (2/2): agentSessions only grows; unregisterSession half-wired — fixed

Correct, and the half-wired API was the real smell. Rather than wire a lifecycle
nothing needed, browser:unregister-session was removed from both preload.js
and main.js. The set now holds session-key strings for the window's lifetime,
bounded by the chats visited, and that is documented at the declaration. Dropping a
key would silently send that chat back to the Playwright fallback, so there is no
correct time to call an unregister — which is why it had no caller.

UX Review — CONCERNS: the Globe label no longer matched what it gates — fixed upstream, superseded

Legitimate when raised. It was fixed in this branch by renaming the label across 12
locales — and then superseded by 7363a5d07 ("persistent Browser Mode with real
install and multi-engine"), which landed on main and deleted the
let_the_agent_use_the_browser key outright, replacing the toggle with Browser Mode
plus a per-session "Let the agent act" consent. The rebase therefore dropped all 12
locale edits as obsolete: the key no longer exists.

The underlying drift is resolved by main's own copy, which is more accurate than
the rename I had written:

Let the agent read and operate web pages: click, type, and navigate, not just read.

web-browse/SKILL.md in this PR now uses main's "Let the agent act" naming rather
than "Globe".

Brand Name Gate — fixed

Two prose lines in web-browse/SKILL.md spelled the product name joined. Now
"Kiro Crew". Gate passes diff-scoped.


Rebase

Rebased onto f42ce8d24 (was e29587a2c, now 336c7b37d). 7363a5d07 touched none
of the Electron files or useNativeBrowser.ts, so the core of this PR applied
unchanged. Conflicts resolved as:

  • 12 locale catalogs — dropped my edits; the key was deleted upstream.
  • ChatPage.browseMode.test.tsx — accepted upstream deletion.
  • ChatPage.tsx — took main's browseModeagentActEnabled rename; both
    effects this PR adds (registerSession, onAgentOpened) survive on top of it.
  • web-browse/SKILL.md — kept this PR's corrected mechanism description
    (main's still described the panel as a read-only Playwright mirror, which is no
    longer true), retermed to main's "Let the agent act".

Diff is now 10 files, +445/−121.

Verification

34/34 Electron control tests, tsc -b clean, brand gate clean, i18n gates clean.
3 frontend failures (src/i18n/format.test.ts, src/pages/settings/SecurityPanel.test.tsx)
reproduce on pristine origin/main in a throwaway worktree and are locale/timezone
dependent; this diff touches neither file.

Not verified: end-to-end Electron behavior. It needs a rebuilt desktop app —
unit tests cover the logic, but no live window has driven it.

Open design question for a human

7363a5d07 states the opposite premise to this PR in ChatPage.tsx:

this extra gesture exists because the native path acts on the user's actual
logged-in browser and must not be auto-granted

This PR keeps navigate/back/wait_for ungated on that same native path. The two
are reconcilable — displaying a page is not reading or driving it, and the human can
already navigate that browser with one ungated click — but it is a deliberate
narrowing of a boundary main widened hours earlier, so it should be an explicit
maintainer call rather than something a rebase decides.

@github-actions github-actions Bot added readiness: action required A blocking check or review needs attention and removed readiness: checking Automated validation is still running merge conflict Branch has merge conflicts with its base — author must resolve before merge labels Aug 8, 2026
@iamwhatever
iamwhatever force-pushed the fix/native-browser-default branch from 336c7b3 to 11c98d0 Compare August 8, 2026 19:03
@github-actions github-actions Bot added readiness: checking Automated validation is still running readiness: action required A blocking check or review needs attention and removed readiness: action required A blocking check or review needs attention readiness: checking Automated validation is still running labels Aug 8, 2026
@iamwhatever
iamwhatever force-pushed the fix/native-browser-default branch from 11c98d0 to ffe778a Compare August 9, 2026 05:01
@github-actions github-actions Bot added readiness: checking Automated validation is still running and removed readiness: action required A blocking check or review needs attention labels Aug 9, 2026
@iamwhatever

Copy link
Copy Markdown
Collaborator Author

Dispositions for 11c98d05d → now ffe778a53

GPT + Opus — BLOCKING: wait_for is an ungated content oracle — fixed

Legitimate, and it contradicted this PR's own pinned invariant. wait_for's
text/textGone mode runs Runtime.evaluate on textProbeExpression and returns
present/absent — an arbitrary-substring oracle over the authenticated persist:
page, repeatable to reconstruct content. Gating snapshot/screenshot/console
while leaving that open was a lock with the window beside it open.

GPT — BLOCKING: back also leaks — fixed (Opus had said keep it view-class)

The two reviewers split here, and GPT is right. back is not display-only:

const hist = await sendCommand("Page.getNavigationHistory");
...
return { ok: true, url: target.url };

It returns a URL out of the panel's history — and the panel is shared with the
human
, so that URL can be a page they visited. The agent learns browsing history
it never put there. Opus's "leave navigate/back view-class" missed the return
value.

Result: VIEW is now exactly one op — navigate. That is the only verb whose
entire output the agent already supplied (the URL it was asked to open), so it
returns no knowledge it did not have. Everything else hands back page or history
state and needs consent. Tests and web-browse/SKILL.md updated to match; the named
invariant now covers all five reads:

opclass: navigate is the ONLY view op
opclass: nothing that returns page or history state is ever ungated

GPT — FINDING: consent not replayed on panel remount — fixed

Correct. A freshly-mounted panel starts with local consent false and mirrors that
into the main process, so re-opening a panel that had been closed silently revoked a
grant the user already gave, and the next operate-class op was refused with no
visible cause. onAgentOpened now replays BROWSE_MODE_EVENT with
agentActRef.current — the same replay the mirror path already did.

Design — CONCERNS (1/3): wait_for leaks — fixed

Same as above. Your suggested split (time-only VIEW, text probes OPERATE) would also
have closed it; I moved the whole op instead because the view path never needs a
wait, so the extra branch would buy complexity without buying capability.

Design — CONCERNS (2/3): ungated navigate drives credentialed GETs — accepted, and left to a maintainer

Not disputed, and deliberately not resolved unilaterally. This is the same open
question the PR body already flags: 7363a5d07 states the native path "must not be
auto-granted," and this PR keeps navigate ungated on it. Your framing sharpens it
usefully — the human click is attended, so "one ungated click away" is not a
complete equivalence for an unattended agent navigate. That distinction is the
decision, and it belongs to a maintainer rather than to a rebase. No code change
pending that call.

Design — CONCERNS (3/3): regex parse of _NATIVE_OPS is brittle — accepted-and-deferred

Fair: a type-annotation or formatting change in mcp_playwright_proxy.py would break
the cross-language regex. It fails loudly rather than silently (the test asserts set
equality, so a parse miss fails the build), which is why I am not treating it as
urgent. A generated JSON fixture both sides read is the right shape and is a
standalone refactor — not folded in here, so this PR's diff stays on the boundary
change.

UX — CONCERNS (1/2): consent-model change ships with no user-facing copy — accepted-and-deferred

Legitimate. Deliberately deferred because the copy would describe a boundary that is
still undecided: the Design item above may move navigate behind consent, which
would make any helper line written now wrong. Once the maintainer settles that, the
string should land in the same change that settles it, so the UI and the gate ship
together rather than drifting.

UX — CONCERNS (2/2): unsolicited tab switch on "any agent navigate" — partly rebutted

The frequency claim does not hold. browser:agent-opened is emitted only inside:

if (bootstrapping && !entry.manager.getWebContents()) {

so it fires once per view creation, not per navigate — subsequent navigates in a
live panel do not re-fire it, and a browsing task does not repeatedly yank the user
off Terminal/Files.

The first-open switch is real, and it is deliberate parity with the existing mirror
path, which already does openActivityPanel() + openView('browser'). Diverging
only on the native path would make the two transports behave differently for the same
user action. If the switch is unwanted it should change for both, which is outside
this PR.

CI — Frontend Tests + Coverage Gate — not this diff

Both trace to one failure: src/i18n/catalogParity.test.ts > catalog parity > ko,
the ko.json base breakage on main fixed by the unmerged #2246. Coverage Gate is
downstream of Frontend Tests. This diff contains zero locale files
(git diff origin/main...HEAD --name-only — 10 files, none under i18n/locales),
and the same failure reproduces on pristine origin/main.

Verification

34/34 Electron control tests, tsc -b clean, brand gate clean, 11138 frontend tests
pass (the one failure is the ko breakage above).

Still not verified: end-to-end Electron behavior — it needs a rebuilt desktop app,
which no unit test substitutes for.

@github-actions github-actions Bot added readiness: action required A blocking check or review needs attention and removed readiness: checking Automated validation is still running labels Aug 9, 2026
@iamwhatever
iamwhatever force-pushed the fix/native-browser-default branch from ffe778a to bcc5a3a Compare August 9, 2026 05:33
@github-actions github-actions Bot removed the readiness: action required A blocking check or review needs attention label Aug 9, 2026
@iamwhatever

Copy link
Copy Markdown
Collaborator Author

Dispositions for d98e28ef2 → now 3fa4a3085

Local review round: the GPT mirror returned no findings; the Opus mirror found
one blocking regression and five smaller items. All are addressed below.

Opus — BLOCKING: registering every session turned a graceful fallback into a hard failure — fixed

The most valuable finding on this PR, and worse than the bug it was fixing. Trigger:
fresh chat on desktop, Browser tab never opened — the default state.

Chain, verified against the code:

  1. registerSession(activeSlot) fired unconditionally, so listPanelIds()
    reported the key and the gateway command bus stopped answering NoPanelError.
  2. dispatch created the entry with agentAct: falseentry.agentAct is only
    ever set by useNativeBrowserapi.setAgentAct, which runs only while a
    panel is mounted
    . Never-opened tab ⇒ false.
  3. The gate refused with agent-act-not-authorized.
  4. That string is not in _NATIVE_ABSENT_MARKERS, so the proxy set
    _native_panel_seen = True and returned an MCP error — no Playwright
    fallback
    . On main the key was never registered, the gateway answered 503, and
    the mirror served the request.

So agent browsing went from working via mirror to hard error, _native_panel_seen
latched process-wide, and the user was stranded: the "Let the agent act" button only
renders inside a live panel, so there was no way to grant the consent that unblocks
it. The PR's claim of "no authorization boundary change" was true of the gate and
false of the effective behavior, because routing changed and the gate was now
reached where it previously was not.

Fixed with the prescribed shape — register on consent, not on slot activation:

if (!api?.registerSession || !activeSlot) return
if (!agentActEnabled) return          // keep main's mirror fallback
void api.registerSession(activeSlot)

I verified the premise this rests on rather than assuming it:
PREVIEW_ENABLE_BROWSE_EVENT is the only writer of agentActBySlot
(ChatPage.tsx), and it is dispatched from the panel's own button — so a consented
slot always had a mounted panel, which means the main process already holds
agentAct: true and the bootstrap will pass. An unconsented chat is never registered
and behaves exactly as main. The guard is documented as load-bearing at both call
sites so it is not "simplified" away later.

Opus — MEDIUM: the pull/answer pair carried no slot — fixed

Correct, and a real latent grant misattribution: the request carried no
sessionKey, ChatPage answered globally with the active slot's consent, and the
panel's listener had no filter — so any mounted panel would adopt it and mirror it
onto its own panelId. Not reachable today (one SidePanel, always
slot={activeSlot}), but one concurrent panel away.

Fixed to match the attribution PREVIEW_ENABLE_BROWSE_EVENT already uses: request
sends {slot: sessionKey}, ChatPage answers {slot, on: agentActBySlot[slot]} via a
new agentActBySlotRef, and the panel's onMode ignores a mismatched slot. New
test: ignores another slot's consent answer.

Opus — MEDIUM: fix #1 shipped broken and was still unpinned — fixed

The sharpest process point: the corrected predicate lived inline in main.js, which
has no test harness, so re-inverting it — or reverting to a property the gate never
returns — would fail nothing. Extracted as a named pure predicate and tested:

function mayBootstrapView(verdict) {
  const v = verdict || {};
  return !!v.allowed || v.reason === "no-browser-view";
}

Three tests, deliberately including both directions, since a constant-true
implementation passes the tolerate case while letting an unauthorized bootstrap
through:

bootstrap: an absent view is the ONE refusal a navigate may tolerate
bootstrap: no grant still refuses, even with no view open
bootstrap: an allowed verdict passes, and a malformed one fails closed

Opus — MEDIUM: the new panel test asserted the wrong half — fixed

Right: it only checked that the request event fired, while the harm was the mirrored
false. Rewritten to stand in for ChatPage — answer the pull, then assert
setAgentAct.mock.calls.at(-1)?.[1] === true, which is the actual regression.

Opus — LOW: agentSessions never shrinking is not behavior-neutral — fixed by the above

Accepted, and the framing corrected my own: I had defended it purely on memory. Your
point is that a registered key keeps routing that chat natively for the window's
lifetime, which is what made the blocking finding permanent per chat. Gating
registration on consent bounds it to consented slots, so explicit trimming stays
deferrable rather than load-bearing.

Opus — LOW: three comments still asserted the removed two-class model — fixed

main.js:1295, main.js:2805 and preload.js:113 said "per-class gate". There is
one uniform gate now; all three corrected, and the register-session comments now
state why consent gating is required rather than just that it happens.

Opus — LOW: the retained rationale overstated one clause — fixed

Fair and precise: "the isolated Playwright fallback never had that reach" is false in
the proxy's extension mode, where Playwright drives the user's own logged-in
Chrome. Narrowed to the default isolated profile, noting extension mode is opted into
separately. The persist:-partition and attended-click clauses stand.

Verification

662/662 Electron tests (3 new), 47/47 panel tests, 11351 frontend tests, tsc -b
clean, eslint 0 errors, isort/flake8 clean, brand gate clean. Rebased on current
main; push_guard.py --require-single-on-base SAFE.

Still outstanding: manual verification on a rebuilt desktop app. Opus's finding is
the second one this round that only integration coverage would have caught, which
strengthens the case for doing it before merge rather than after.

@github-actions github-actions Bot added readiness: action required A blocking check or review needs attention and removed readiness: checking Automated validation is still running labels Aug 9, 2026
@iamwhatever
iamwhatever force-pushed the fix/native-browser-default branch from 3fa4a30 to 65a6f68 Compare August 9, 2026 08:02
@github-actions github-actions Bot added readiness: checking Automated validation is still running and removed readiness: action required A blocking check or review needs attention labels Aug 9, 2026
@iamwhatever

Copy link
Copy Markdown
Collaborator Author

Disposition for 3fa4a3085 → now 65a6f684e

GPT — BLOCKING: recreating a closed panel drops its consent — fixed

Legitimate, and notable because it is the third distinct route review has found to
the same hard-failure class: a session that is reachable but not consented fails hard
instead of falling back to the mirror, and the user cannot recover because the
re-grant button only renders inside a live panel.

Trigger, traced and confirmed: consent lived only on the panel entry, and
browser:close calls _mcDestroyBrowserPanel, which deletes it. A granted
session whose panel the user closed came back agentAct: false on the next
navigate, was refused with agent-act-not-authorized, and — since that string is
not in _NATIVE_ABSENT_MARKERS — never reached Playwright either.

Fixed as prescribed ("retain consent with the registered session and restore it when
recreating the entry"), which took three coupled edits because the record has to be
durable and revocable:

  1. main.js entry creation seeds consent from the registration:
    const entry = { id, agentAct: agentSessions.has(id) }; (was agentAct: false).
  2. browser:unregister-session restored, now load-bearing. It had been removed in
    an earlier round for having no caller — correct then, wrong now: with consent seeded
    from membership, a revoked session left in the set would hand a recreated panel
    consent the user had taken away. It deletes the key and, if a live entry exists,
    clears agentAct and releases control so an already-attached debugger stops
    immediately rather than waiting for the renderer.
  3. ChatPage.tsx registers on consent and unregisters on revocation, so
    agentSessions membership means exactly "this session is consented."

That also closes Design Review's earlier agentSessions lifecycle suggestion, which
was deferrable while membership was inert and is not now that it carries privilege.

New test pins the whole cycle rather than the one instance:

consent: a recreated entry inherits consent from the session registration

— never-consented ⇒ refuse; granted ⇒ registered ⇒ recreated entry still acts;
revoked ⇒ unregistered ⇒ a later entry does NOT come back consented.

TDZ check on the new reference — verified safe, no change needed

agentSessions.has(id) is referenced inside browserPanel() (line ~1190) while
const agentSessions is declared at line 1310, which would be a crash if any call ran
during window setup. It does not: the agent command channel is constructed after 1310,
and every other call site is an IPC handler at 2772+ invoked only at runtime.

Honest scope note on this round's review

The local reviewer round for this SHA was stopped before completing, at the
maintainer's direction, so CI is the first adversarial look at 65a6f684e. The
canonical gates were green before pushing. Two things are new with this round and
deserve the scrutiny: whether agentSessions membership is sound against every
writer, and whether revocation is complete against an op already in flight.

Also this round

push_guard.py --require-single-on-base refused the first push attempt: main
had advanced to 025d7abf9 while HEAD~1 still pointed at the older base. Re-synced
and re-ran the full gate on the new base rather than pushing through it.

Verification

663/663 Electron tests, 11362 frontend tests (854 files), tsc -b clean, brand gate
clean, isort/flake8 clean. Rebased on 025d7abf9; push guard SAFE.

Still outstanding: manual verification on a rebuilt desktop app. Five blocking
findings on this PR, every one at a boundary the unit suite does not span — the case
for exercising it before merge is now evidential, not procedural.

@github-actions github-actions Bot added readiness: action required A blocking check or review needs attention and removed readiness: checking Automated validation is still running labels Aug 9, 2026
@iamwhatever
iamwhatever force-pushed the fix/native-browser-default branch from 65a6f68 to 73b3aa6 Compare August 9, 2026 16:52
@github-actions github-actions Bot added readiness: checking Automated validation is still running readiness: passed Eligible automated validation passed for the current revision and removed readiness: action required A blocking check or review needs attention readiness: checking Automated validation is still running labels Aug 9, 2026
kyleseaman
kyleseaman previously approved these changes Aug 9, 2026
@github-actions github-actions Bot added readiness: checking Automated validation is still running readiness: action required A blocking check or review needs attention and removed readiness: passed Eligible automated validation passed for the current revision readiness: checking Automated validation is still running labels Aug 9, 2026
@bolichen97

Copy link
Copy Markdown
Collaborator

Independent verification of the current GPT blocking finding — it is legitimate, please do not override it.

I traced all three legs of the "consent survives dashboard reload" scenario against current main plus this diff:

  1. Renderer consent is ephemeral. ChatPage.tsx:977useState<Record<string, boolean>>({}), no localStorage/sessionStorage persistence. A dashboard reload resets every slot's grant to false.
  2. Main's registration set is per-window, not per-page. agentSessions (win._mcAgentSessions) is created once in setupWindowContents, so it survives renderer reloads intact.
  3. Nothing cleans up on reload. The unregister effect only ever touches activeSlot, and main.js's did-navigate handler does token retry only — there is no panel/consent teardown anywhere on a main-frame navigation.

Net effect: grant slots A and B → reload → the effect unregisters only A (active slot, now-false consent) → B stays registered, and the agent can bootstrap a fully consented native browser for B with no live user grant. This is a fourth route into the same hard-failure class this PR's review history already closed three times (unconsented-but-reachable sessions), except this one fails open rather than hard.

Fix sketch (small, contained to setupWindowContents):

// Consent lives in the renderer; a reload destroys it there, so mirror the
// destruction here. Re-registration happens naturally from fresh consent.
win.webContents.on("did-start-navigation", (_e, _url, isInPlace, isMainFrame) => {
  if (!isMainFrame || isInPlace) return;
  agentSessions.clear();
  for (const entry of browserPanels.values()) {
    entry.agentAct = false;
    try { void entry.control.release(); } catch { /* mid-teardown */ }
  }
});

(did-start-navigation with the in-place/main-frame filters covers both user reloads and the token-retry loadURL calls; anchoring on did-navigate also works if you prefer post-commit semantics. A regression test mirroring the existing "recreated entry inherits consent" test — but asserting the inheritance is severed after a simulated main-frame navigation — would lock it in.)

Everything else in this PR checked out under review: all three original defects verified real against main (the pre.agentActEnabled constant-refusal is live on main today), the mayBootstrapView predicate fails closed on malformed verdicts, and the BROWSE_MODE_REQUEST_EVENT answer path is correctly slot-scoped on both ends. With the reload revocation fixed (and the Screenshot Evidence check satisfied — the panel auto-open flow is capturable), this is ready to land.

@iamwhatever
iamwhatever force-pushed the fix/native-browser-default branch from 73b3aa6 to 2444345 Compare August 10, 2026 01:45
@github-actions github-actions Bot removed the readiness: action required A blocking check or review needs attention label Aug 10, 2026
@iamwhatever

Copy link
Copy Markdown
Collaborator Author

Reviewed SHA 73b3aa604 → now 2444345b8.

main.js:1210 — consent survives a dashboard reload — FIXED. Validated as
legitimate in full, including the mechanism as stated. Not a false positive, and not
pre-existing: this PR introduced it.

The report's "only A unregisters" detail is exactly right, and it is the crux. The
renderer's register effect speaks only for the active slot:

if (agentActEnabled) void api.registerSession(activeSlot)
else void api.unregisterSession?.(activeSlot)

A reloaded renderer starts with an empty consent map, so the effect fires once for the
new active slot and unregisters that slot alone. agentSessions is an in-memory
Set on the window, created in setupWindowContents — which does not re-run on
reload — so every background slot stays registered. Entry creation then seeds
agentAct: agentSessions.has(id)true, the gate allows, and a persist:-partition
view carrying the user's real logins opens under agent control while that slot's Globe
reads off.

Two points reinforce the severity rather than reduce it:

  • It is a regression, not a pre-existing hole. On main the entry is always created
    agentAct: false and the renderer pushes consent, so a reload leaves the session
    refused. The membership seeding this PR added is what creates the stale grant — the
    fourth distinct route to the consent-desync class already fixed three other ways in
    this PR.
  • Reachable without user action. main.js:243 documents that a gateway reconnect
    reloads the page, so this does not require a deliberate reload.

Fix: revokeAllAgentConsent(agentSessions, panels) in browser-control.js, called from
a did-navigate handler on the dashboard window. It clears the whole set and, for every
live panel, sets agentAct = false and calls control.release() — mirroring what
browser:unregister-session already does for a single slot, so an already-open view
stops being able to act immediately rather than waiting on the renderer.

Two design points worth stating explicitly:

  • did-navigate, not did-start-navigation. It fires only for main-frame
    cross-document navigation; same-document SPA routing raises did-navigate-in-page and
    is therefore untouched. Revoking on in-app route changes would have broken the feature
    outright. Tying revocation to document commit is also semantically correct: a grant
    belongs to the document that obtained it, so a navigation that starts and then aborts
    correctly keeps the still-live document's grant.
  • Extracted rather than inlined, for the same reason mayBootstrapView was earlier
    in this PR: the previous inline gate expression shipped broken precisely because it had
    no harness. Two new tests pin it — the two-slot reload scenario from the report
    (background slot cannot seed a consented entry afterwards), and a release() that
    throws mid-teardown not aborting the loop and leaving later panels consented.

666 Electron tests (+2), 11681 frontend, tsc -b / eslint / brand gate clean, on a
re-gated rebase onto bdd55fc76.

@iamwhatever

Copy link
Copy Markdown
Collaborator Author

Reviewed SHA 0f02fbf44 → now 7d77c79ca. Scope change, flagged deliberately — the
consent boundary moved, so please re-read rather than diffing against the last round.

Why it moved. The all-gated shape did not deliver this PR's own headline. A fresh
chat is not registered, so its first navigate never reaches Electron, the gateway
answers 503, and the request degrades to a Playwright screenshot exactly as before. The
five plumbing fixes were necessary and not sufficient: the native path could execute,
but nothing in the default flow ever asked it to.

The boundary is now drawn in three parts, and the middle one is a precondition for the
first rather than a convenience:

  1. Loopback targets are exempt (isLoopbackUrl). The grant protects an authenticated
    identity — the view runs on persist:kirocrew-browser, so a bare navigate sends
    cookies the user established by hand. localhost has no such identity, because
    cookies are host-scoped. Judged against the target of a navigate, never the
    current page, so a local page cannot launder the agent onto a real site; lookalikes
    (localhost.evil.com), non-http schemes and unparseable input are not exempt; and the
    exemption lifts only the grant, not the "a view must exist" precondition.
  2. Reachability is declared before any URL is known, so it cannot be conditioned on
    consent without making (1) dead code. reachableSessions + browser:track-session,
    kept strictly separate from agentSessions, whose membership still means consent.
  3. First-open refusal degrades to the mirror; a mid-session deny stays fatal.

On (3) I want to be explicit, because my first attempt was wrong and the repo caught it.
I initially added agent-act-not-authorized to _NATIVE_ABSENT_MARKERS wholesale. That
breaks test_refusal_returns_an_mcp_error_and_does_not_fall_back, whose invariant is
correct — falling back would re-run the op on Playwright's page and turn a deny into an
allow by another route — and it would also break "revoke means stop", since revoking
mid-drive would move the agent onto Playwright instead of halting it.

So the fallback is narrowed to one structural case: Electron emits
agent-act-consent-required only from the bootstrap branch, which by construction
runs when no webContents exists. In that state there is no native page to be
inconsistent with and no agent to stop, so the mirror is the correct transport — and the
panel it opens is what carries the grant control, without which a reachable-unconsented
session strands the user with nowhere to click. Every other refusal, including any deny
once a view is live, still reports agent-act-not-authorized and still fails hard.
consent_required_falls_back_but_a_plain_deny_does_not pins both halves; the original
test passes unchanged.

Falling back is not an escalation: Playwright runs an isolated profile with an empty
storage state, so the authenticated partition is still never reached without consent.

Known gap, stated rather than hidden: clicking the grant does not retroactively
promote the page already on screen — consent applies from the next request. Doing it
right needs the mirror to report its current URL to the panel, because the address-bar
value is not guaranteed to match what Playwright loaded and promoting off it could open
the wrong page. Out of scope here.

668 Electron, 11789 frontend (876 files), 24 native-routing tests, tsc / eslint / isort /
flake8 / brand clean, re-gated after main moved to 30f5d6983.

@iamwhatever

Copy link
Copy Markdown
Collaborator Author

no-screenshots is on this PR TEMPORARILY, and it is not a claim that there is no
visual delta.
Removing it is a tracked step before this merges.

To be explicit about what the label normally asserts vs. what is true here. The gate's own
criterion is "the change genuinely has no visual delta (pure rename, comment-only, or a
non-rendering attribute)". That does not describe this PR: it deletes the
per-session agent-control button from both Browser panel headers, and it changes which
engine renders in the panel (Playwright screenshot mirror → a live Electron
WebContentsView). Both are real, visible changes.

The label is here only to unblock the rest of the round — GPT 5.6 Review was being
cancelled as a downstream of this one failure, so the PR could not get a review verdict
while waiting on a frame.

Why the frame is not attached yet: the native view exists only in the Electron main
process, so it cannot be captured from a pod or a web build — those have no native path
at all and would screenshot the old mirror behaviour, which is a misleading frame rather
than no frame. It needs a rebuilt desktop app, and the maintainer is capturing it.

What lands before merge, replacing this label:

  1. built-in-browser.png — Browser Mode on, a fresh chat, "open www.amazon.com" → the page
    hosted in the built-in browser, with no per-session gesture anywhere.
  2. A frame showing the page responding to interaction, which is the thing the mirror could
    never do.

Both will be committed under temp-screenshots/native-browser-default/ and embedded with
commit-SHA-pinned URLs, and the label will come off in the same round.

The native path had never executed: a constant-true gate verdict refused every
bootstrap navigate even with consent granted, the command channel never polled for
a session whose Browser tab was unopened, and a late-mounting panel silently
revoked the grant by mirroring its own false default.

Fixes all three, inverts the consent handshake to a pull so ordering cannot
matter, removes dead chooseControlTransport, and corrects the web-browse skill
which still described the panel as a Playwright mirror.

No authorization boundary changes: every browser_* op still requires the
per-session grant.
@iamwhatever

Copy link
Copy Markdown
Collaborator Author

Reviewed SHA 216c49074 → now b32beed3c. Four reviewers, seven findings, each answered
below.

GPT — BLOCKING: listPanelIds polls a remote gateway with the local secret — FIXED

Validated before fixing, and both halves hold:

  • An empty list really does park the poller — browser-agent-channel.js does
    if (!Array.isArray(sessionKeys) || sessionKeys.length === 0) { await sleep(idleMs); continue; }, so reporting nothing is the "do not poll" signal.
  • A remote-connected window's _mcBackendUrl IS the remote host: setupWindowContents
    is called with a remote backendUrl for the connection window, and the channel posts
    there with readInternalSecret().

So the finding is real and it is this PR's regression: while only CONSENTED sessions were
reported, a remote-connected window usually had none and the poller stayed parked. Making
every active slot reachable removed that accident, and the failure mode is exactly as
described — the local secret goes through the tunnel, is rejected 403, the poller retries,
and the remote appends one SEL denial per attempt without bound.

Fixed differently from the suggested getRemoteHostConfig(...)?.host check, and I want to
be explicit about why: the invariant is not "is a remote host configured" but "is the
gateway I am about to send a local secret to actually local". So the guard asks that
directly — if (!isLoopbackUrl(win._mcBackendUrl)) return [];. It fails closed (an unset
or unparseable URL is not loopback → report nothing), it needs no store/port plumbing, and
it reuses the isLoopbackUrl helper this PR already added. Parking is also the correct
behaviour on a remote host: there is no Electron view there to drive, and the mirror is
the right transport.

New test: loopback: a remote gateway URL must never be polled with the local secret,
covering three loopback forms and four non-local ones including empty and undefined.

GPT + Opus + UX — SKILL.md contradicts the PR it ships in — FIXED

All three flagged it and all three were right; it was the worst defect left in the PR,
because the agent reads that skill on every browse request and would have stalled asking
the user to click a control this same diff deletes.

Rewrote the authorization section of web-browse/SKILL.md end to end rather than patching
the one flagged line — the front-matter description and the intro paragraph carried the
same claim, so a single-line fix would have left the file self-contradictory. It now states
Browser Mode as the sole authorization, explains why that toggle is sufficient (citing the
keystone precedent), and keeps the persist: partition warning where it belongs: as
judgement about untrusted page content, not as a gate. agent-act-not-authorized is now
described as "Browser Mode is off → point at Settings", which is the only way it can be
produced.

Design — agent-act-consent-required is a phantom contract — FIXED

Verified with a repo-wide search first: zero emitters, including under website/electron.
Design's reasoning is the part worth agreeing with out loud — as shipped it was inert, but
it pre-authorized a future consent-shaped deny to degrade into a silent mirror fallback,
which contradicts this PR's own "a deny must never become an allow by another route".

Removed the marker and its comment. The test that asserted it falls back is replaced by
test_no_authorization_shaped_reason_is_ever_a_fallback_marker, which asserts the stronger
property: the marker list contains nothing authorization-shaped, and both
agent-act-not-authorized and agent-act-consent-required stay fatal. That keeps the
surviving half of the invariant covered instead of leaving a hole.

Design — the loopback exemption is "gated on nothing" residue — REBUTTED (it is now load-bearing)

This was accurate when written and is no longer: isLoopbackUrl is what implements the
remote-gateway guard above. Keeping it is not residue.

Design — security-posture flip with no re-consent — ACCEPTED, ratified deliberately

Correct, and it is the intended change rather than an oversight: existing Browser-Mode
users are upgraded on update from "a Playwright browser with empty storage state" to "the
persist: partition with their real logins". The repository owner made this call
explicitly after the alternative (a per-session click) was shown to leave the PR's own
headline undelivered — a fresh chat was never registered, so its first navigate degraded
to the mirror regardless. The one-time notice for users who enabled Browser Mode under the
old semantics is a fair ask and is not in this PR; it belongs with the Settings copy,
not the transport layer.

UX — no visible "agent is driving this page" state — ACCEPTED and DEFERRED

Legitimate, and the sharpest remaining gap: deleting the agent_can_act badge removed the
only mode indicator on a surface two actors now share, and the only revoke is the Settings
toggle. Not fixed here on purpose — a passive badge driven by real LIGHT ownership needs
new i18n keys across all 13 catalogs, which is precisely the churn this PR just finished
removing, and it wants a deliberate design pass rather than re-adding a control that reads
as the one deleted for being non-functional. Worth noting the distinction: the deleted
button claimed to deny and could not; a passive indicator makes no such claim, so it is
the right shape.

Verification

667 Electron tests (+1), 25 native-routing tests, 11787 frontend across 876 files, tsc -b
/ eslint / flake8 / isort / brand gate all clean, re-gated after main moved to
7cec46f51. src/test/App.test.tsx is flaky on main independently of this branch —
three consecutive runs gave green, two failures, green, with different tests failing each
time, and it fails standalone on pristine origin/main too.

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

Labels

no-screenshots PR has no visual delta; screenshot gate exempt

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants