- hub-bridge.ts: ensureStreamerBinary() checks local install, PATH,
then auto-downloads platform-specific binary from COS
- Dockerfile: single-stage build, downloads pre-built bb-viewer and
Chrome from COS instead of compiling Go in multi-stage
Pre-built binaries uploaded to COS:
releases/bb-viewer/latest/bb-viewer-darwin-arm64
releases/bb-viewer/latest/bb-viewer-linux-amd64
releases/chrome/chrome-linux64.zip
Headless mode lacks full GUI APIs on Linux and gets flagged by anti-bot
systems. On macOS, Chrome renders off-screen without a visible window
when there is no focused user session, so headed mode works everywhere.
Remove CHROME_HEADED env toggle — headed is now the only mode.
Linux --headless=new lacks full GUI APIs (WebGL, plugins, screen info)
that anti-bot systems like Google's use to detect automation. macOS
headless works because Chrome still links the full Cocoa/CoreGraphics
stack even in headless mode.
Changes:
- Dockerfile: add Xvfb, entrypoint starts virtual framebuffer before
daemon; pre-install Chrome for Testing in image; use China mirrors
for apt/Go/npm to speed up builds
- chrome.ts: CHROME_HEADED=1 env skips --headless=new and --disable-gpu
flags, letting Chrome render on the Xvfb display
Video stream still uses CDP screencast → bb-viewer → WebRTC.
Stealth injection (Emulation overrides, addScriptToEvaluateOnNewDocument)
is itself detectable by Google and other anti-bot systems. CDP alone
without stealth works fine for Google login.
- Disable stealth for all Chrome types (full Chrome doesn't need it,
headless-shell is inherently detectable regardless)
- Update Dockerfile: static-linked bb-viewer, no .so dependencies
- Clean up Dockerfile for full Chrome for Testing
Stealth injections (Emulation.setUserAgentOverride, setDeviceMetricsOverride,
Page.addScriptToEvaluateOnNewDocument) are themselves detectable by Google's
anti-bot systems. CDP alone is NOT detected — the stealth overrides were
causing the detection.
Now only inject stealth when running headless-shell (product starts with
'HeadlessChrome'). Full Chrome (headed or --headless=new) works fine
with just CDP, no stealth needed.
Three headless fingerprints that Google detects:
1. screen.width/height was 800x600 (headless default) — now overridden
to 1920x1080 via Emulation.setDeviceMetricsOverride
2. navigator.webdriver was undefined (stealth bug) — normal Chrome
returns false, not undefined
3. navigator.plugins.length was 3 — normal Chrome has 5 PDF-related
plugins
Browser.getVersion is a browser-level CDP command, not a session-level
one. Using sessionCommand returned null/error, causing fallback to
hardcoded 149.0.7827.22 — creating a version mismatch with the real
Chrome (148.0.7778.179) that Google detects as bot fingerprint.
Switch to browserCommand which correctly returns the real product
version string.
- Add Dockerfile for containerized deployment
- chrome.ts: prefer system Chrome, auto-download Chrome for Testing
(full browser, not headless-shell) when no system Chrome found
- hub-bridge: handle bb-viewer spawn ENOENT gracefully instead of
crashing the entire daemon process
headless-shell is an stripped-down Chrome that lacks many browser APIs
(Bluetooth, USB, SharedWorker, etc.), making it easily detectable by
anti-bot systems. Switch to using full Chrome:
1. System Chrome: prefer already-installed Google Chrome/Chromium/Edge
2. Chrome for Testing: auto-download full browser if no system Chrome
3. headless-shell: removed as a fallback (no longer needed)
Also fix UA override to dynamically detect the real Chrome version from
Browser.getVersion, so User-Agent, Sec-Ch-Ua, and Sec-Ch-Ua-Full-Version
are always consistent — no more version mismatch fingerprint.
Sec-Ch-Ua-Full-Version was leaking the real Chrome version (149) while
User-Agent and Sec-Ch-Ua claimed 148 — this version mismatch is a
strong anti-bot signal. Align all version strings to match the actual
headless-shell binary version.
Also fix Accept-Language format.
Previously stealth (UA override, navigator.webdriver, canvas/WebGL/audio
fingerprint noise, chrome.runtime shim, etc.) was only injected by
bb-viewer's inputConn. The daemon's controlConn — used by site adapters,
CLI commands, and agent operations — had no stealth protection, exposing
HeadlessChrome UA and automation markers to anti-bot systems.
Now injects Emulation.setUserAgentOverride + Page.addScriptToEvaluateOnNewDocument
in attachAndEnable(), so every tab gets stealth from the moment it's
attached, regardless of whether a stream is active.
stream.close was timing out (15s) when no streamer process existed,
because it unconditionally sent an HTTP request to the streamer port.
Now returns {ok: true} immediately if streamer is not running.
- Remove pointer-events:none from dimmed tab/url bar so tabs remain
clickable even when not connected to a stream.
- Update activeTabId and re-render immediately on click for responsive
UI, then async invoke stream.switch only if connected.
- Skip stream.switch call when disconnected to avoid errors.
Daemon now automatically downloads and launches Chrome headless-shell
when started. No need to install Chrome separately.
- Downloads from Pinix COS (unified source for all platforms)
- Supports mac-arm64, mac-x64, linux64, linux-arm64
- Stores at ~/.bb-browser/browser/chrome-headless-shell-<platform>/
- Manages Chrome lifecycle: start on daemon start, kill on shutdown
- --no-chrome flag to skip (use external Chrome)
- Tested on macOS ARM64, Docker linux/amd64, Docker linux/arm64
Site adapters like twitter/search.js reference shared functions defined
in _helper.js (e.g. findTransactionIdGenerator). The runtime was not
loading this helper file, causing ReferenceError at runtime.
Now checks for _helper.js in the same directory as the adapter and
prepends it into the evaluation scope.
* feat(view): heartbeat ping + idle timeout + visibility-based disconnect
- Send OpPing (0x08) heartbeat every 5s (active) / 10s (idle)
- Visibility change: hidden starts 30s idle countdown, visible cancels
- cleanup(reason): distinct overlay messages for user/idle/network disconnect
- Start heartbeat on DataChannel open, stop on cleanup
* fix(view): use polling instead of setTimeout for idle timeout in hidden tabs
Browsers throttle setTimeout in hidden tabs to >=1 minute (Chrome).
The 30s idle timeout was never firing because the timer was delayed.
Fix: record hiddenSince timestamp on visibilitychange, check elapsed
time on each heartbeat interval tick instead of relying on setTimeout.
* fix(view): fire-and-forget stream.close to prevent disconnect from hanging
disconnect() was awaiting invoke('stream.close') which could hang if
the server is slow or the connection is degraded. This blocked cleanup()
from running, so the UI stayed in 'connected' state and heartbeat
continued indefinitely.
Fix: fire stream.close without awaiting, then immediately cleanup.
Streamer returns {result: {...}} but hub-bridge was passing the
entire envelope to Hub invoke. Frontend received
{result: {offer_sdp: ...}} instead of {offer_sdp: ...}, causing
offer_sdp to be undefined and video blackscreen.
Unwrap resp.result for stream.start/answer/close/switch.
Single source of truth for all command metadata:
- ParamDef with type, required, position, description
- CommandDef with method, group, description, requiresTab, params
- commandToJsonSchema() for Hub registration
- getCommand() / getCommandsByGroup() helpers
Hub registration now uses COMMANDS directly instead of
hardcoded arrays. Removed zod dependency from shared package.
CLI and daemon dispatch unchanged — they can consume COMMANDS
in a future refactor.
Daemon --hub mode replaces the standalone provider. All Hub
connection, clip registration, and invoke handling is now in
packages/daemon/src/hub-bridge.ts.
Also remove provider entry from tsup release build.
- New: packages/daemon/src/site-adapter.ts — site adapter scanning,
@meta parsing, domain-based tab matching, and JS evaluation via CDP
- Daemon: add site_run, site_list, site_info, site_search actions
- CLI: site commands now send HTTP to daemon instead of executing locally
- Provider: executeSiteCommand uses daemon site_run instead of shelling
out to CLI subprocess (removes execFile dependency)
- Protocol: add siteName, siteArgs, query fields to Request type
Site adapter execution is now a single path: CLI/Provider → daemon
→ CDP evaluate. No more three-layer process nesting.
Fix left-click being sent as middle-click: mapButton was converting
Web API button 0 (left) to 1, but bb-viewer uses 0-indexed buttons
(0=left, 1=middle, 2=right). Middle-click on links opens new tabs
in Chrome, which was the root cause of the 'links open in new tab'
bug.
Also remove unused URL bar composition tracking variables and
debug-only url_keydown logging.
Three fixes for modifier keys getting stuck:
- URL input capture-phase keyup no longer stops propagation for
modifier keys (Shift/Ctrl/Alt/Meta), allowing them to reach the
document-level onKeyUp handler
- Clicking the video overlay sends keyUp for all modifiers to clear
any stuck state from URL bar interactions
- Window focus event resets all modifiers to handle Cmd+Tab scenarios
Also add version indicator (v9) for debugging cache issues.
Route tab_list, tab_switch, tab_new, tab_close, navigate, back,
forward, reload through bb-viewer API instead of daemon. This ensures
tab operations and video stream operate on the same Chrome instance
and CDP session.
Also fix URL bar: skip polling overwrite when input is focused,
and simplify Enter key handling to avoid IME false suppression.
When CDP_PORT is set, forward it as --cdp-port to the daemon process
so both daemon (tab operations) and bb-viewer (screencast) connect
to the same Chrome instance. Without this, daemon defaults to 19825
(GUI Chrome) while bb-viewer uses the CDP_PORT value, causing tab
list and video stream to reference different Chrome instances.
- Green connect button, red disconnect state
- Video overlay with centered status text when disconnected
- Input overlay with crosshair cursor for mouse capture
- Tab/URL bar dimmed when not connected
- Slimmer toolbar (32px), webkit scrollbar styling
- Status resolution aligned right
* feat(daemon): expose CDP host/port in daemon.json and /status
- Add cdpHost, cdpPort to DaemonInfo (optional in shared, required in daemon)
- Write cdpHost/cdpPort from discovered cdpEndpoint to daemon.json
- Return cdpHost/cdpPort in GET /status response
- Enables external tools (bb-viewer) to discover the Chrome CDP connection
* feat(provider): add view.start/answer/close commands + bb-viewer sidecar management
- Register view.start, view.answer, view.close commands in browser clip
- Viewer sidecar: auto-spawn bb-viewer on first view.start, kill on exit
- Read CDP info from daemon.json (cdpHost/cdpPort) for viewer config
- TURN config from env vars: TURN_URL, TURN_USER, TURN_CRED
- bb-viewer binary lookup: ~/.bb-browser/bin/bb-viewer or PATH fallback
- Sidecar health check with 10s startup timeout
* feat(provider): add Clip Web UI for remote browser viewing
- web/view.html: remote viewer page using @pinixai/core/web invoke()
- WebRTC video with playoutDelayHint=0 for low latency
- DataChannel binary input: mouse, keyboard, scroll, IME
- X11 keysym mapping, composition handling
- Connect/Disconnect with auto-cleanup
- provider: set hasWeb=true for browser clip
- provider: handle GetClipWebCommand to serve web/ files
- MIME type detection, ETag, range request support
- Path security validation
* feat(provider): TURN ephemeral credentials via TURN REST API
- Generate time-limited TURN credentials using HMAC-SHA1 shared secret
- Credentials valid for 24h, auto-generated at viewer sidecar spawn
- Config: TURN_URL + TURN_SECRET env vars (no user-facing config needed)
- Replaces static TURN_USER/TURN_CRED with secure ephemeral credentials
* fix(web): replace esm.sh import with direct /api/ fetch
@pinixai/core is a private package, not available on esm.sh.
Use direct fetch to /api/{command} which the Hub's subdomain
handler proxies to provider invoke.
Screenshot command now writes PNG to ~/.pinix/data/browser/screenshots/
and returns a pinix:// URI by default. Base64 dataUrl is only included
when explicitly requested (includeBase64 flag), keeping the response
lightweight for cross-clip consumption.
CLI screenshot command sets includeBase64 for local file saving.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
bb-browser provider now processes DataCommand from the Hub, supporting
read/write/list/delete/stat on per-clip data directories (~/.pinix/data/).
This enables pinix://browser/... URIs to work for cross-clip file sharing.
Updated @pinixai/hub-client to ^0.2.0 for Data protocol types.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Highlights accumulated across multiple snapshot calls because the
cleanup function was commented out. Now buildDomTree clears previous
overlays at the start, and screenshot removes them before capture.
Closes#173
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(cli): daemon self-healing on CDP disconnect + cookie persistence
- ensureDaemon() now checks cdpConnected from /status response; when
Chrome is killed and daemon loses its CDP WebSocket, the stale daemon
is stopped and a fresh daemon+Chrome is spawned automatically instead
of returning 503 on every command.
- daemon status command shows actionable hints: how to start when not
running, how to stop when running, and a warning with recovery steps
when CDP is disconnected.
- Add --use-mock-keychain to managed Chrome launch args so cookie
encryption uses a fixed key instead of macOS Keychain. This fixes
cookie persistence on headless/remote machines (e.g. Mac Mini via SSH)
where Keychain access is unavailable (errSecInteractionNotAllowed).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat(cli): add `daemon start` subcommand for explicit background startup
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>