Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
c53154b
feat: add private browser takeover
QueryPlanner Sep 13, 2026
82347dd
fix: preserve uv lockfile contents
QueryPlanner Sep 13, 2026
8d705dd
fix: satisfy browser takeover type checks
QueryPlanner Sep 13, 2026
40ed6fe
fix: type-check test_service.py
QueryPlanner Sep 13, 2026
57d0cf8
fix: type-check test_tools.py
QueryPlanner Sep 13, 2026
56a6f25
fix: type fake sandbox command options
QueryPlanner Sep 13, 2026
120cc0d
chore: remove browser takeover test suite
QueryPlanner Sep 13, 2026
915db25
chore: remove browser takeover test suite
QueryPlanner Sep 13, 2026
3989064
chore: remove browser takeover test suite
QueryPlanner Sep 13, 2026
2c14a90
chore: remove browser takeover test suite
QueryPlanner Sep 13, 2026
caf70a3
chore: remove browser takeover test suite
QueryPlanner Sep 13, 2026
bbc7d98
chore: remove browser takeover test additions
QueryPlanner Sep 13, 2026
aab7425
chore: remove browser takeover test additions
QueryPlanner Sep 13, 2026
b2e6bdd
fix: restore takeover coverage and cleanup
QueryPlanner Sep 13, 2026
46a9a96
fix: require explicit browser takeover completion
QueryPlanner Sep 15, 2026
a647074
test: keep websocket disconnects incomplete
QueryPlanner Sep 15, 2026
0953db0
fix: separate takeover completion from cleanup
QueryPlanner Sep 16, 2026
4d91b6e
fix: harden browser takeover controls
QueryPlanner Sep 16, 2026
793df30
test: cover takeover terminal states
QueryPlanner Sep 16, 2026
668ebdc
test: cover takeover protocol hardening
QueryPlanner Sep 16, 2026
eac8a23
docs: clarify takeover trust and deployment limits
QueryPlanner Sep 16, 2026
2442caf
chore: print pinned Ruff formatting diff
QueryPlanner Sep 16, 2026
3ddab93
chore: trigger formatter diagnostic
QueryPlanner Sep 16, 2026
5a3df7a
chore: restore code quality workflow
QueryPlanner Sep 16, 2026
a27106b
chore: let pinned Ruff format PR files
QueryPlanner Sep 16, 2026
4b3f256
chore: trigger pinned formatter
QueryPlanner Sep 16, 2026
bc598a3
style: apply pinned Ruff formatter
github-actions[bot] Sep 16, 2026
04bf194
chore: restore code quality checks
QueryPlanner Sep 16, 2026
b1cddfe
chore: remove formatter trigger
QueryPlanner Sep 16, 2026
f762211
chore: apply MyPy narrowing fix with CI toolchain
QueryPlanner Sep 16, 2026
2f472a6
chore: trigger type-fix runner
QueryPlanner Sep 16, 2026
f8715ed
fix: type browser protocol validators
github-actions[bot] Sep 16, 2026
bd2a27f
chore: restore code quality workflow
QueryPlanner Sep 16, 2026
d477292
chore: remove CI type-fix trigger
QueryPlanner Sep 16, 2026
cd204b2
test: cover browser takeover review paths
QueryPlanner Sep 16, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,13 @@ ZEPTO_MCP_ENABLED=false
# API key for production servers (optional for local dev)
# SANDBOX_API_KEY=
#
# Private Agent Browser takeover. Keep this URL reachable only through
# Tailscale or another authenticated HTTPS reverse proxy. The fragment token
# is sent directly to Telegram and never returned to the model.
# BROWSER_TAKEOVER_PUBLIC_URL=https://blacki.example.ts.net/browser-takeover
# BROWSER_TAKEOVER_TTL_SECONDS=300
# BROWSER_TAKEOVER_STREAM_PORT=9223
#
# Sandbox TTL in minutes (default: 30)
# SANDBOX_TIMEOUT_MINUTES=30
#
Expand Down
22 changes: 22 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,28 @@ An authenticated capability must be implemented as a separately authorized,
short-lived broker operation. Adding a standing environment variable is not an
acceptable opt-in path.

### Browser authentication takeover

When `BROWSER_TAKEOVER_PUBLIC_URL` is configured, the private Telegram root
agent can pause an Agent Browser operation and hand the live browser viewport
to the authenticated Telegram user. Blacki sends the one-time link directly
through the Telegram API; the tool result returned to the model never contains
the link or its token.

The browser page redeems the fragment token once, replaces it with an
HttpOnly, SameSite cookie, and proxies only Agent Browser's viewport and input
protocol. Passwords, OTPs, and typed text travel as transient browser
input events. They are not ADK messages, tool arguments, session events, or log
fields. Completing the takeover closes the stream before the model resumes.

Takeover state is process-local, hashed, bound to one private Telegram
conversation, and expires after five minutes by default. The upstream sandbox
endpoint and routing headers are never sent to the browser client. The public
URL must sit behind authenticated HTTPS, normally a Tailscale ACL. Browser
cookies and local storage remain sensitive state inside the session sandbox.
Do not enable traces, screenshots, HAR capture, or video recording during a
takeover.

## Health semantics

`/live` is side-effect-free and process-only. `/ready` checks the already
Expand Down
9 changes: 9 additions & 0 deletions docs/base-infra/environment-variables.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,13 +99,22 @@ its presence changes model routing.
| `CLOUDFLARE_API_TOKEN` | unset | Cloudflare Workers AI API token for Telegram voice transcription |
| `KOKORO_TTS_BASE_URL` | unset | Register private Kokoro speech delivery for Telegram |
| `KOKORO_TTS_VOICE` | `af_heart` | Kokoro voice ID used for generated MP3 audio |
| `BROWSER_TAKEOVER_PUBLIC_URL` | unset | HTTPS URL for private Agent Browser takeover |
| `BROWSER_TAKEOVER_TTL_SECONDS` | `300` | One-time takeover lifetime, from 60 to 900 seconds |
| `BROWSER_TAKEOVER_STREAM_PORT` | `9223` | Agent Browser stream port inside each sandbox |

The token is required and format-validated when Telegram is enabled. When
`TELEGRAM_ACCESS_CODE` is set, new users enter it with `/start <access-code>`;
historical private chats with existing Blacki sessions remain authorized, while
groups and topics are rejected. Rotating the code requires code-authorized
users to authenticate again without deleting their stored Blacki data.

Browser takeover is enabled only when `BROWSER_TAKEOVER_PUBLIC_URL` is set.
Put that route behind an authenticated HTTPS reverse proxy on Tailscale. The
tool sends its one-time fragment link directly to the private Telegram chat,
waits for the user to return control, and never includes the link in model
context. The sandbox image must provide the `agent-browser` executable.

`KOKORO_TTS_BASE_URL` is an optional HTTP or HTTPS base URL without a path to
`/v1/audio/speech`; Blacki appends that fixed endpoint. The URL must be
reachable from inside the Blacki container. Do not use `localhost` for a
Expand Down
66 changes: 66 additions & 0 deletions docs/browser-takeover.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# Private browser takeover

Browser takeover lets the private Telegram user type passwords and one-time
codes into Agent Browser without sending those values through Telegram messages
or ADK model context.

## How it works

1. The root agent calls `start_browser_takeover` with an HTTPS login page.
2. Blacki starts Agent Browser's interactive stream in the existing session
sandbox.
3. Blacki sends a single-use link directly to the same private Telegram chat.
The model receives only the eventual completion status.
4. The link opens a live canvas. Mouse, touch, keyboard, and scroll events pass
through Blacki to the Agent Browser stream without entering ADK session
history.
5. The takeover page shows the expected login origin and the current browser
origin outside the streamed page. If navigation changes origin, the page
warns the user to verify the new site before typing sensitive data.
6. Clicking **Done** disables the stream and resumes the waiting tool call.
Disconnects, expiry, replacement, and process shutdown do not count as
successful human completion.

The URL fragment is exchanged once for an HttpOnly, SameSite cookie. Blacki
stores only token hashes, sandbox routing details, the expected origin, and
completion state in process memory. Restarting Blacki invalidates every
takeover.

## Configure

The sandbox image must contain a compatible `agent-browser` executable. Set:

```dotenv
BROWSER_TAKEOVER_PUBLIC_URL=https://blacki.example.ts.net/browser-takeover
BROWSER_TAKEOVER_TTL_SECONDS=300
BROWSER_TAKEOVER_STREAM_PORT=9223
```

The public URL must end with `/browser-takeover`. HTTPS is mandatory except on
loopback during development. Keep the endpoint on your tailnet and restrict it
with Tailscale ACLs or equivalent device authentication. The reverse proxy must
forward WebSocket upgrades on `/browser-takeover/ws`.

Blacki's production Compose override publishes the application on
`127.0.0.1` by default. Point Caddy or Tailscale Serve at that loopback port;
do not publish the application port on every host interface.

## Operational limits

- One takeover can run per Telegram conversation.
- Links are single-use and expire after five minutes by default.
- Only matching private-chat sender and chat identities can create a takeover.
- A model turn remains paused until the user clicks **Done** or the link expires.
- Starting a new Telegram turn may cancel the waiting model turn.
- Takeover capability state is process-local. Run one application worker and
one replica, or guarantee sticky routing for the originating tool execution,
`/redeem`, `/ws`, and `/complete`. A multi-worker or multi-replica deployment
without shared takeover state will intermittently reject valid links.
- Cookies and local storage created by the target website remain inside the
session sandbox and are as sensitive as the original password.

Do not enable screenshots, video, HAR files, browser command tracing, or debug
payload logging while a takeover is active. Blacki does not record the input
events, but the target website and Telegram still have their ordinary service
metadata. This feature keeps credentials out of LLM and Blacki content logs; it
does not claim that no system involved in the login has operational logs.
1 change: 1 addition & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ nav:
- Telegram setup: telegram-setup.md
- Zepto MCP: zepto-mcp.md
- Gmail API: gmail.md
- Private browser takeover: browser-takeover.md
- Local development: development.md
- Operate:
- Configuration: base-infra/environment-variables.md
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ dependencies = [
"mem0ai>=0.1.0,<1.0.0",
"opensandbox-code-interpreter>=0.1.2",
"boto3>=1.40.0,<2.0.0",
"websockets>=15.0.1,<16.0.0",
]

[project.scripts]
Expand Down
20 changes: 20 additions & 0 deletions src/blacki/browser_takeover/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
"""Private human takeover for Agent Browser sessions."""

from .config import BrowserTakeoverConfig, BrowserTakeoverConfigurationError
from .service import (
BrowserTakeoverError,
BrowserTakeoverLease,
BrowserTakeoverService,
get_browser_takeover_service,
reset_browser_takeover_service,
)

__all__ = [
"BrowserTakeoverConfig",
"BrowserTakeoverConfigurationError",
"BrowserTakeoverError",
"BrowserTakeoverLease",
"BrowserTakeoverService",
"get_browser_takeover_service",
"reset_browser_takeover_service",
]
87 changes: 87 additions & 0 deletions src/blacki/browser_takeover/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
"""Configuration for the private browser takeover endpoint."""

from __future__ import annotations

import os
from dataclasses import dataclass
from urllib.parse import urlsplit


class BrowserTakeoverConfigurationError(ValueError):
"""Raised when browser takeover configuration is unsafe or incomplete."""


@dataclass(frozen=True)
class BrowserTakeoverConfig:
"""Validated browser takeover configuration."""

public_url: str
ttl_seconds: int = 300
stream_port: int = 9223

@classmethod
def from_environment(cls) -> BrowserTakeoverConfig | None:
"""Build configuration when the optional takeover URL is present."""
public_url = os.getenv("BROWSER_TAKEOVER_PUBLIC_URL", "").strip()
if not public_url:
return None

try:
ttl_seconds = int(os.getenv("BROWSER_TAKEOVER_TTL_SECONDS", "300"))
stream_port = int(os.getenv("BROWSER_TAKEOVER_STREAM_PORT", "9223"))
except ValueError as exc:
raise BrowserTakeoverConfigurationError(
"Browser takeover TTL and stream port must be integers"
) from exc

config = cls(
public_url=public_url.rstrip("/"),
ttl_seconds=ttl_seconds,
stream_port=stream_port,
)
config.validate()
return config

def validate(self) -> None:
"""Reject public URLs and limits that could expose a browser session."""
parsed = urlsplit(self.public_url)
is_loopback_http = parsed.scheme == "http" and parsed.hostname in {
"127.0.0.1",
"localhost",
}
if parsed.scheme != "https" and not is_loopback_http:
raise BrowserTakeoverConfigurationError(
"BROWSER_TAKEOVER_PUBLIC_URL must use HTTPS"
)
if not parsed.netloc or parsed.username or parsed.password:
raise BrowserTakeoverConfigurationError(
"BROWSER_TAKEOVER_PUBLIC_URL must be an absolute URL "
"without credentials"
)
if parsed.query or parsed.fragment:
raise BrowserTakeoverConfigurationError(
"BROWSER_TAKEOVER_PUBLIC_URL cannot contain a query or fragment"
)
if parsed.path != "/browser-takeover":
raise BrowserTakeoverConfigurationError(
"BROWSER_TAKEOVER_PUBLIC_URL must end with /browser-takeover"
)
if not 60 <= self.ttl_seconds <= 900:
raise BrowserTakeoverConfigurationError(
"BROWSER_TAKEOVER_TTL_SECONDS must be between 60 and 900"
)
if not 1024 <= self.stream_port <= 65535:
raise BrowserTakeoverConfigurationError(
"BROWSER_TAKEOVER_STREAM_PORT must be between 1024 and 65535"
)

@property
def public_origin(self) -> str:
"""Return the exact origin allowed to redeem takeover tokens."""
parsed = urlsplit(self.public_url)
return f"{parsed.scheme}://{parsed.netloc}"

@property
def secure_cookie(self) -> bool:
"""Require Secure cookies outside loopback development."""
return urlsplit(self.public_url).scheme == "https"
Loading
Loading