Skip to content

fix(browser): harden the real-profile copy path (follow-up to #95620) - #95658

Closed
GottZ wants to merge 12 commits into
NousResearch:feat/real-profile-cdpfrom
GottZ:browser-profile-copy-hardening
Closed

GottZ wants to merge 12 commits into
NousResearch:feat/real-profile-cdpfrom
GottZ:browser-profile-copy-hardening

Conversation

@GottZ

@GottZ GottZ commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

Stacked on feat/real-profile-cdp (#95620), one commit per finding from my review over there. The copy-based design is right — everything here is inside it, nothing re-litigates the approach.

Two of these are the E2E bugs @kshitijk4poor reported on #95620 and #95520, verified against the current head dc5ba5d5 rather than the reported symptom.

Commit Finding
489fcd15 macOS 26 detection is broken. _launchservices_https_handler() reads the role out of the nested LSHandlerPreferredVersions block, so it returns "7559.97" instead of "com.google.chrome" and the feature fails at consent resolution. The entry scanner splits at brace depth 1, so the nested dict is still in the entry; the only filter was != "-", which older macOS happened to satisfy. Flattens nested dicts before reading the role.
3d563e01 The agent drives the wrong profile. Chrome opens Default in a user-data-dir unless --profile-directory says otherwise, and agent-browser only emits that flag when --profile is a profile name (is_chrome_profile_name) — the real-profile path hands it a path. A user whose session lives in Profile 6 gets a signed-out session. Mirrors profile.last_used into the copy's Default slot on both the copy and refresh paths.
c4080acf Snapshot permissions were asserted once, at creation. _secure_snapshot_root() sat inside the fresh-copy branch, and _secure_dir is best-effort (swallows + debug-logs), so a first attempt that failed left a credential store loose permanently. Moves it ahead of the fresh/refresh split and secures the browser-profile/ parent too.
bd26e21c Security: the private-URL sidecar got the real cookie jar. _create_local_session() resolved the real-profile CDP unconditionally, so the sidecar that auto_local_for_private_urls creates for a LAN/loopback host visited it with the user's full authenticated cookies. That sidecar exists to keep an internal host off the cloud backend; handing it the user's logins is a strictly larger exposure than the routing rule was protecting against. Bare local sessions are unchanged.
35297291 Lightpanda regression. _real_profile_cdp() launches with --profile, which agent-browser rejects for Lightpanda (Profiles are not supported with Lightpanda, cli/src/native/browser.rs:86). A user with browser.engine: lightpanda and consent on gets a generic start failure naming neither the setting nor the fix. The in-place implementation caught this; restored against the new entry point.

Tests

New coverage per finding, all against real file I/O where the code does real I/O:

  • the macOS 26 LSHandlerPreferredVersions shape, for a Chromium and a non-Chromium default (the existing helper only ever fed the "-" placeholder, which is why this shape was uncovered)
  • active_profile_dir() including both fallbacks (unparsable Local State, a last_used naming a directory that is not there), the non-Default active profile landing in Default on the copy path and staying there across a refresh, and Default being left alone when it already is the active profile
  • permissions re-asserted on the refresh path and on the parent dir
  • the sidecar opting out end to end through _get_session_info("t1::local"), plus a bare session still opting in, plus a broken real-profile setup no longer breaking private-URL routing
  • the Lightpanda guard firing before detection, and not firing on the Chrome engine

124 passed across test_browser_real_profile.py, test_browser_connect_default_chromium.py, test_browser_open_timeout.py, test_browser_extension_router_wiring.py, test_browser_secret_exfil.py and both footgun suites. ruff check clean on all touched files.

Not addressed here

local_browser=False survives in two test spies (test_browser_extension_router_wiring.py:42, test_browser_open_timeout.py:100) although neither browser_navigate nor _navigation_session_key carries that parameter any more. Harmless — an unused default — but it is dead surface from the salvaged commits, and I left it alone rather than touch files this stack has no other reason to open.

teknium1 and others added 12 commits August 26, 2026 08:03
…_browser in test spies

The two default-browser detectors call subprocess.run(text=True) without an
explicit encoding, which the Windows-footgun linter (and its full-repo test,
tests/scripts/test_footgun_subprocess_encoding.py) rejects. Pass
encoding='utf-8', errors='replace' like the rest of the tree.

Two existing tests replace browser_navigate / _navigation_session_key with
positional-only lambdas; both callables now receive local_browser= from the
registry handler and browser_navigate, so the spies raised TypeError. Accept
the keyword with its default.

Fixes the three CI failures on the PR head (Windows footguns lint,
test_browser_extension_router_wiring x2, test_browser_open_timeout).
…talled-browser fallback

_detect_default_darwin matched a Chromium bundle id and the literal 'https'
anywhere in the whole LSHandlers dump, so a browser registered for ftp or a
content type was reported as the https default, and map order decided ties.
When nothing matched it fell back to the first installed Chromium app — with
Safari or Firefox as the actual default that drove a browser the user never
consented to, contradicting the docstring, the config comment and the desktop
copy ('a non-Chromium default fails with a clear message').

Parse the dump entry by entry, take the LSHandlerRoleAll/Viewer of the entry
whose LSHandlerURLScheme is https, and fail closed on anything else — an empty
handler list is what macOS stores while Safari is still the implicit default.

Tests feed real 'defaults read' output shapes instead of patching the detector
(reviewer fixture from the PR discussion: Safari on https, Chrome on ftp).
real_profile_data_dir hard-wired Linux to $XDG_CONFIG_HOME/<name>, and the
xdg fragment map only knew the native package names. Ubuntu's default snap
Chromium (xdg reports chromium_chromium.desktop, profile under
~/snap/chromium/common/chromium) and Flatpak builds (~/.var/app/<id>/config/…)
therefore ended in 'profile directory was not found' for a browser the user
runs every day, and Flatpak Chrome (com.google.Chrome.desktop) was reported as
'not a supported Chromium browser'.

Try the native, snap and Flatpak locations and return the first that exists;
fall back to the native path so the error message still names a concrete
directory. Map the Flatpak application ids in the xdg lookup.

Tests cover the xdg names for all four browsers in native and Flatpak form,
and the directory preference order with a temp HOME.
…e existing local session

_navigation_session_key returned the ::local sidecar key for local_browser
before the cloud-provider and auto_local_for_private_urls checks. Two
consequences:

- Every private-URL gate in browser_navigate (credential-bearing query,
  _is_safe_url pre-nav, post-redirect) is keyed off the sidecar key, so a
  model-supplied browser_navigate(url, local_browser=True) opened LAN
  addresses in a host-side Chromium — with the real profile's cookies — even
  when the user had set browser.auto_local_for_private_urls: false. Consent to
  the profile is not consent to override the LAN routing opt-out; a private
  URL now follows auto_local_for_private_urls exactly as without the flag.

- Without a cloud provider the bare session already is the local Chromium
  (and already carries the real profile when consented), so the flag created a
  second session for the same task on the same user-data-dir, which Chromium's
  process singleton refuses. The flag is now a no-op there.

The existing consent/CDP-precedence tests keep their assertions; the sidecar
test now states the cloud-provider precondition it silently relied on.
…-use CDP

Copy the user's default-Chromium profile (auth state only) into a managed
snapshot, launch Hermes' packaged Chromium on it via agent-browser, and hand
the CDP endpoint to the Browser Use CLI (and built-in tools) to drive. The
snapshot is a non-default dir, so it sidesteps Chrome 136+'s default-profile
remote-debugging block and never contends with the user's running browser;
launched without mock-keychain switches so keyring-encrypted cookies decrypt.

- consent-gated browser_exec 'local' arg (schema only appears with consent)
- fail-closed on non-Chromium default / snapshot failure
- stale-session guard: reuse only when the live session is on our copy dir
- snapshot excludes extensions/service-workers (renderer wedge) + caches
…reserve channel identity

Addresses two P1 review blockers (kshitij / @kxee) on the real-profile feature:

Credential-store lifecycle for ~/.hermes/browser-profile/ (copied Cookies/
Login Data):
- exclude the singular 'browser-profile' dir from backup AND import
  (_EXCLUDED_DIRS drives both) — was silently archiving cookies/logins
- add a browser-profile/ directory-PREFIX read-deny to agent/file_safety.py,
  same class as auth.json / mcp-tokens
- secure the snapshot dir through the canonical hermes_cli.config._secure_dir
  (honors managed/NixOS group-share + HERMES_UID/GID), not a bespoke chmod

Channel identity (NousResearch#95549 invariant — never normalize Beta/Dev/Canary to
stable, which would drive a different account's profile):
- detect recognized pre-release channels FIRST (Win ProgIds, macOS bundle ids,
  Linux .desktop) and return UNSUPPORTED_CHANNEL
- macOS bundle match is now EXACT (was startswith); Linux/Win channel-before-
  stable ordering; real_profile_data_dir/chromium_executable reject the sentinel
- _real_profile_cdp fails closed with a channel-specific message, never snapshots

Tests: channel-not-normalized (linux/darwin/windows), wrong-principal fail-closed,
backup exclusion, read-guard block/allow, snapshot dir secured. 187 browser +
222 backup/file_safety pass. Live re-verified: real Gmail inbox still loads.
…sions

The entry scanner splits the LSHandlers dump at brace depth 1, so a
handler entry still contains its nested LSHandlerPreferredVersions
dictionary. That block repeats LSHandlerRoleAll one level down with a
value that is not a bundle id, and the role scan took the first non-"-"
match it found.

Older macOS wrote "-" there, which the filter happened to skip. macOS 26
writes the real preferred version, so the scan returns "7559.97" instead
of "com.google.chrome": detect_default_chromium("Darwin") yields None and
real-profile browsing fails closed at consent resolution on a machine
that does have a Chromium default.

Flatten nested dictionaries out of the entry before reading the role, so
only keys at the entry's own level are considered. The test helper only
ever fed the "-" placeholder, which is why this shape was not covered;
a versioned helper now exercises both the Chromium and the non-Chromium
default.

Reported by @kshitijk4poor on NousResearch#95620 (macOS 26, Chrome 152). The regex
came in with NousResearch#95549, so the same defect is present there.
The snapshot reproduces the source layout, so a user whose session lives
in "Profile 6" gets a copy whose Default is some other, usually
signed-out profile. Chrome opens Default in a user-data-dir unless
--profile-directory says otherwise, and agent-browser only emits that
flag when --profile is a profile NAME (is_chrome_profile_name, no slash)
— the real-profile path hands it a PATH, so the flag is never added.

Result: the launch succeeds, cookies are present, and the agent is
signed out anyway, because it is reading a profile the user does not use.

Read profile.last_used from Local State and mirror that profile's auth
files into the copy's Default slot, on both the full-copy and the
refresh path. The original directories are left in place. Falls back to
Default when Local State is missing, unparsable, or names a directory
that is not there.

Reported by @kshitijk4poor on NousResearch#95620 (LinkedIn login page instead of the
feed, session in a non-Default profile).
_secure_snapshot_root() only ran inside the fresh-copy branch, so the
permissions on a credential-bearing directory were asserted exactly once,
at creation. _secure_dir is deliberately best-effort — the wrapper
swallows and debug-logs — so a first attempt that failed left the copied
Cookies / Login Data tree at whatever the umask gave it, permanently and
silently. A snapshot dir created by an earlier build was never revisited
either.

Move the call ahead of the fresh/refresh split so it runs on every
consented launch, and secure the browser-profile/ parent as well:
makedirs creates it under the process umask, and it enumerates every
browser Hermes holds cookies for.
_create_local_session() resolved the real-profile CDP unconditionally, so
every local session got the user's cookie jar — including the hybrid
sidecar that auto_local_for_private_urls creates.

That sidecar exists for one reason: a URL resolved to a LAN/loopback host
and must not be handed to the cloud backend. Routing it into the
real-profile copy-browser instead means the agent visits an internal
host — a router UI, a local dashboard, whatever the URL pointed at — with
the user's full authenticated cookie jar attached. That is a strictly
larger exposure than the routing rule was protecting against, and it
happens without the user asking for a real-profile session on that URL.

Pass allow_real_profile=False on the sidecar branch, where the caller
already knows it is a sidecar (force_local). Bare local sessions are
unchanged: consent still means the real profile there. A broken
real-profile setup also no longer breaks private-URL routing, since the
sidecar never calls the resolver.
…ally

_real_profile_cdp() launches agent-browser with --profile, and
agent-browser rejects that outright for the Lightpanda engine
("Profiles are not supported with Lightpanda",
cli/src/native/browser.rs:86).

A user with browser.engine: lightpanda and the consent toggle on
therefore gets "the real-profile browser failed to start: <last stderr
line>" — a message that names neither the setting that caused it nor the
fix. The in-place implementation caught this case explicitly; the
copy-based redo does not, so the guard is restored here against the new
entry point.

Checked before detection so a machine with no default browser at all
still reports the engine conflict, which is the actionable one.
Copilot AI lite review requested due to automatic review settings August 26, 2026 15:30
@alt-glitch alt-glitch added type/bug Something isn't working comp/cli CLI entry point, hermes_cli/, setup wizard tool/browser Browser automation (CDP, Playwright) area/profiles Multi-profile isolation, HERMES_HOME scoping P3 Low — cosmetic, nice to have sweeper:risk-security-boundary Sweeper risk: may affect sandboxing, auth, credentials, or sensitive data labels Aug 26, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Note

Copilot was unable to run its full agentic suite in this review.

This PR improves “real-profile” and hybrid local browsing behavior by preventing unsafe real-profile reuse for private-URL sidecars, improving macOS default-Chromium detection, and ensuring the real-profile snapshot opens the user’s active Chromium profile.

Changes:

  • Add an explicit configuration error when browser.use_real_profile is enabled with the Lightpanda engine.
  • Prevent hybrid private-URL sidecar sessions (::local) from attaching to the user’s real-profile snapshot.
  • Improve Chromium profile snapshot correctness (mirror active profile into Default) and harden snapshot directory permissions on every refresh.
  • Fix macOS LaunchServices parsing to avoid confusing “preferred version” strings with bundle IDs; add tests.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 3 comments.

File Description
tools/browser_tool.py Adds Lightpanda guard for real-profile, and adds an allow_real_profile switch so ::local sidecars never use real-profile cookies.
hermes_cli/browser_connect.py Fixes macOS handler parsing, determines “active” Chromium profile via Local State, mirrors active auth files into Default, and re-secures snapshot dirs on each launch.
tests/tools/test_browser_real_profile.py Expands coverage for active-profile mirroring, Lightpanda conflict messaging, and sidecar real-profile opt-out.
tests/hermes_cli/test_browser_connect_default_chromium.py Adds coverage for macOS LaunchServices dumps that include version strings in LSHandlerPreferredVersions.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +578 to +589
for rel in _AUTH_REFRESH_FILES:
if "<profile>" not in rel:
continue
s = os.path.join(src, rel.replace("<profile>", active))
if not os.path.isfile(s):
continue
d = os.path.join(dst, rel.replace("<profile>", "Default"))
try:
os.makedirs(os.path.dirname(d), exist_ok=True)
shutil.copy2(s, d)
except OSError as e:
logger.debug("real-profile: could not mirror %s as Default: %s", rel, e)
Comment on lines +368 to +370
flat = re.sub(r"\{[^{}]*\}", "", low)
while re.search(r"\{[^{}]*\}", flat):
flat = re.sub(r"\{[^{}]*\}", "", flat)
Comment thread tools/browser_tool.py
Comment on lines +1564 to +1565
"browser.use_real_profile needs the Chrome engine: agent-browser "
"does not support profiles with browser.engine: lightpanda. Set "
@teknium1
teknium1 force-pushed the feat/real-profile-cdp branch from dc5ba5d to 4b734e2 Compare August 26, 2026 15:58
@GottZ

GottZ commented Aug 26, 2026

Copy link
Copy Markdown
Contributor Author

Superseded by 4b734e2, which implements all five findings from the review — thanks for picking them up. Closing this stack as redundant.

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

Labels

area/profiles Multi-profile isolation, HERMES_HOME scoping comp/cli CLI entry point, hermes_cli/, setup wizard P3 Low — cosmetic, nice to have sweeper:risk-security-boundary Sweeper risk: may affect sandboxing, auth, credentials, or sensitive data tool/browser Browser automation (CDP, Playwright) type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants