Bitwarden unlock now uses the CLI's documented non-interactive channel: `bw unlock --raw --nointeraction --passwordenv VAR`, VAR set on the child environment only (bw 2026.x rejects a piped password with "Master password is required"). Verified against the real published binary. Manager session tokens are keyed by (profile home, backend): a Desktop gateway hosting several profiles can no longer reuse or lock another profile's session. Status probes (`vault.sources`, is_unlocked) no longer refresh the idle TTL; only real manager calls do. Gateway session teardown locks the profile's managers (a per-session unlock ends with the session). 1Password service-account token comes from the profile-scoped secret store (get_secret), not ambient os.environ. `vault.source.set` no longer references a module constant (bind_module rebinding dropped it → NameError on every Settings toggle). Fill target binding: inspection stamps each input with a per-inspection slot attribute; the fill resolves by stamp and requires type=password, then strips every stamp. A DOM reflow between inspect and fill can no longer redirect the password into a text field (reproduced in real Chrome before, 0 filled after). Redaction boundary: no 4-char floor, CR/LF-normalized form registered (what a text input actually stores), JSON object KEYS scrubbed in both browser redactors; longest value first. Docs now state the real trust model: accidental-disclosure protection, not an execution sandbox. Desktop: the mid-turn card sends the master password through the owning session's socket (requestForOwnedSession), never the ambient foreground gateway; `vault.unlock.expire` clears a stale card; Settings keeps the master password out of react-query mutation variables (ref consumed by the mutationFn). One renderer invariant test for the routing.
126 lines
5.2 KiB
Python
126 lines
5.2 KiB
Python
"""Snapshot post-processing for the browser tools: truncate-and-store of oversized
|
|
accessibility trees, model-boundary secret redaction, screenshot-path recovery.
|
|
|
|
Facade-owned state is read through ``_bt`` (``tools.browser_tool``, resolved per call) — no import cycle.
|
|
"""
|
|
|
|
import re
|
|
from typing import Any, Optional
|
|
from tools.browser_tool_origin import origin as _bt
|
|
|
|
|
|
_SCREENSHOT_PATH_PATTERNS = (
|
|
r"Screenshot saved to ['\"](?P<path>/[^'\"]+?\.png)['\"]",
|
|
r"Screenshot saved to (?P<path>/\S+?\.png)(?:\s|$)",
|
|
r"(?P<path>/\S+?\.png)(?:\s|$)",
|
|
)
|
|
|
|
|
|
def _extract_screenshot_path_from_text(text: str) -> Optional[str]:
|
|
"""Extract a screenshot file path from agent-browser human-readable output."""
|
|
if not text:
|
|
return None
|
|
|
|
for pattern in _SCREENSHOT_PATH_PATTERNS:
|
|
match = re.search(pattern, text)
|
|
if match:
|
|
path = match.group("path").strip().strip("'\"")
|
|
if path:
|
|
return path
|
|
return None
|
|
|
|
|
|
def _store_full_snapshot(snapshot_text: str) -> Optional[str]:
|
|
"""Write a full snapshot to cache/web and return its path (None on failure — best-effort).
|
|
|
|
Mirrors ``web_tools._store_full_text``: cache/web is mounted read-only into
|
|
remote backends, so read_file can page through the complete tree on any
|
|
backend. The stored copy is force-redacted (page-rendered secrets must not
|
|
hit disk unmasked) and named by content hash so identical snapshots dedupe.
|
|
"""
|
|
try:
|
|
import hashlib
|
|
from hermes_constants import get_hermes_dir
|
|
from agent.redact import redact_sensitive_text
|
|
|
|
content = redact_sensitive_text(snapshot_text, force=True)
|
|
if len(content) > _bt.MAX_STORED_SNAPSHOT_CHARS:
|
|
content = (
|
|
content[:_bt.MAX_STORED_SNAPSHOT_CHARS]
|
|
+ f"\n\n[... stored copy truncated at {_bt.MAX_STORED_SNAPSHOT_CHARS:,} chars "
|
|
f"of {len(content):,} ...]"
|
|
)
|
|
from tools.spill_safety import ensure_spill_dir, write_text_exclusive
|
|
|
|
cache_dir = get_hermes_dir("cache/web", "web_cache")
|
|
ensure_spill_dir(cache_dir, private=False)
|
|
digest = hashlib.sha256(content.encode("utf-8")).hexdigest()[:10]
|
|
path = cache_dir / f"browser-snapshot-{digest}.txt"
|
|
# Deterministic filename in a well-known dir: refuse symlinks (lstat-unlink +
|
|
# exclusive create); same-content re-snapshots legitimately overwrite. Not
|
|
# private: cache/web is bind-mounted into remote backends' container UID.
|
|
write_text_exclusive(path, content, private=False, overwrite=True)
|
|
return str(path)
|
|
except Exception as exc: # noqa: BLE001
|
|
_bt.logger.debug("Failed to store full browser snapshot: %s", exc)
|
|
return None
|
|
|
|
|
|
def _truncate_snapshot(snapshot_text: str, max_chars: Optional[int] = None) -> str:
|
|
"""Truncate a snapshot at line boundaries (never mid-element) to ``max_chars``.
|
|
|
|
Defaults to ``browser.snapshot_threshold``. The full snapshot is stored to
|
|
cache/web and the appended note tells the agent how to page through it
|
|
with read_file — element refs beyond the cut are in the file, not lost.
|
|
"""
|
|
if max_chars is None:
|
|
max_chars = _bt.get_browser_snapshot_threshold()
|
|
if len(snapshot_text) <= max_chars:
|
|
return snapshot_text
|
|
|
|
stored_path = _store_full_snapshot(snapshot_text)
|
|
if stored_path:
|
|
# Agent-visible path: read_file runs inside the active backend (#72389).
|
|
from tools.credential_files import to_agent_visible_cache_path
|
|
stored_path = to_agent_visible_cache_path(stored_path)
|
|
|
|
lines = snapshot_text.split('\n')
|
|
result: list[str] = []
|
|
chars = 0
|
|
# Reserve space for the truncation note (the stored-path variant is the
|
|
# longer of the two). Clamp so tiny max_chars values still keep content.
|
|
reserve = min(110 + len(stored_path or ""), max_chars // 2)
|
|
for line in lines:
|
|
if chars + len(line) + 1 > max_chars - reserve:
|
|
break
|
|
result.append(line)
|
|
chars += len(line) + 1
|
|
remaining = len(lines) - len(result)
|
|
if remaining > 0:
|
|
if stored_path:
|
|
next_line = len(result) + 1
|
|
result.append(
|
|
f'\n[... {remaining} more lines truncated — full snapshot: '
|
|
f'read_file path="{stored_path}" offset={next_line} limit=200]'
|
|
)
|
|
else:
|
|
result.append(f'\n[... {remaining} more lines truncated, use browser_snapshot for full content]')
|
|
return '\n'.join(result)
|
|
|
|
|
|
def _redact_browser_output(value: Any) -> Any:
|
|
"""Force-redact secrets in browser-originated data (snapshots, console, eval
|
|
results can carry page-rendered keys/cookies/tokens). Tool output is a model
|
|
boundary, so this applies even when global log redaction is disabled."""
|
|
from agent.redact import redact_sensitive_text
|
|
|
|
if isinstance(value, str):
|
|
return redact_sensitive_text(value, force=True)
|
|
if isinstance(value, list):
|
|
return [_redact_browser_output(item) for item in value]
|
|
if isinstance(value, tuple):
|
|
return tuple(_redact_browser_output(item) for item in value)
|
|
if isinstance(value, dict):
|
|
return {_redact_browser_output(key): _redact_browser_output(item) for key, item in value.items()}
|
|
return value
|