From b9805ec2fa252ea2b5442b40ff532d5579cc857e Mon Sep 17 00:00:00 2001
From: CorsenAI <272485939+CorsenAI@users.noreply.github.com>
Date: Mon, 3 Aug 2026 02:03:41 +0100
Subject: [PATCH 1/4] fix: harden connector routing and ship verified 0.2.2
---
PRIVACY.md | 4 +-
PUBLISHING.md | 9 +-
README.md | 16 +-
docs/ACCEPTANCE.md | 2 +-
docs/ARCHITECTURE-V2.md | 2 +-
docs/TEST-EVIDENCE.md | 82 +-
docs/index.html | 22 +-
docs/privacy/index.html | 2 +-
docs/sitemap.xml | 6 +-
docs/support/index.html | 8 +-
extension/manifest.json | 2 +-
extension/src/background.js | 291 +++++-
extension/src/sidepanel.html | 103 +-
extension/src/sidepanel.js | 498 ++++++++--
hermes-plugin/__init__.py | 19 +-
hermes-plugin/bridge_client.py | 18 +-
hermes-plugin/broker.py | 67 +-
hermes-plugin/plugin.yaml | 2 +-
marketing/video/.gitignore | 1 +
marketing/video/STORYBOARD.md | 87 +-
marketing/video/captions-en.srt | 27 -
marketing/video/hermes-connector-demo.gif | Bin 840421 -> 0 bytes
.../video/hermes-connector-real-e2e-1080p.mp4 | Bin 0 -> 716975 bytes
.../hermes-connector-thumbnail-1280x720.png | Bin 220122 -> 0 bytes
marketing/video/overlay-horizontal.ass | 39 -
marketing/video/overlay-vertical.ass | 37 -
marketing/video/render.ps1 | 259 -----
marketing/video/thumbnail.ass | 21 -
scripts/build_release.py | 13 +
scripts/install_companion.py | 15 +
store/ASSETS.md | 40 +-
store/LISTING.md | 16 +-
store/privacy.html | 2 +-
store/screenshot-product-1280x800.png | Bin 185343 -> 91315 bytes
tests/background-bindings.test.mjs | 15 +-
tests/background-session.test.mjs | 25 +-
tests/background-tabs.test.mjs | 322 +++++++
tests/capture_store_screenshot.py | 54 +-
tests/e2e_chromium.py | 201 +++-
tests/e2e_installer_posix.sh | 4 +-
tests/e2e_multi_browser.py | 2 +-
tests/e2e_packaged_release.py | 37 +-
tests/e2e_real_hermes.py | 886 ++++++++++++++++++
tests/fixtures/store-demo.html | 2 +-
tests/test_broker.py | 108 +++
tests/test_installer.py | 162 ++++
tests/test_plugin_routing.py | 24 +
47 files changed, 2839 insertions(+), 713 deletions(-)
delete mode 100644 marketing/video/captions-en.srt
delete mode 100644 marketing/video/hermes-connector-demo.gif
create mode 100644 marketing/video/hermes-connector-real-e2e-1080p.mp4
delete mode 100644 marketing/video/hermes-connector-thumbnail-1280x720.png
delete mode 100644 marketing/video/overlay-horizontal.ass
delete mode 100644 marketing/video/overlay-vertical.ass
delete mode 100644 marketing/video/render.ps1
delete mode 100644 marketing/video/thumbnail.ass
create mode 100644 tests/background-tabs.test.mjs
create mode 100644 tests/e2e_real_hermes.py
diff --git a/PRIVACY.md b/PRIVACY.md
index 18bcac2..61761e7 100644
--- a/PRIVACY.md
+++ b/PRIVACY.md
@@ -1,6 +1,6 @@
# Privacy Policy — Hermes Connector (unofficial · by Corsen AI)
-_Last updated: 2026-08-02_
+_Last updated: 2026-08-03_
Hermes Connector lets a Hermes agent running on your computer read and control
Chrome tabs that you explicitly attach. Corsen AI does not operate a relay,
@@ -13,7 +13,7 @@ To provide its single browser-control purpose, the extension can handle:
- website content from attached tabs, including visible text, accessibility
structure, element labels, and screenshots you request;
- browsing activity for attached tabs, including their URL and title; when you
- open **Choose tabs**, current tab titles and URLs are displayed locally so you
+ open **Tabs**, current tab titles and URLs are displayed locally so you
can choose which tabs to attach;
- text and navigation instructions sent by your locally configured Hermes agent;
- Hermes profile and session identifiers and titles read from your local Hermes
diff --git a/PUBLISHING.md b/PUBLISHING.md
index 69705cc..4603ef0 100644
--- a/PUBLISHING.md
+++ b/PUBLISHING.md
@@ -28,7 +28,7 @@ separately under the same release version.
- Privacy: `https://corsenai.github.io/hermes-connector/privacy/`
- Support and installation: `https://corsenai.github.io/hermes-connector/support/`
-- Companion 0.2.1: `https://github.com/CorsenAI/hermes-connector/releases/download/v0.2.1/hermes-connector-0.2.1-companion.zip`
+- Companion 0.2.2: `https://github.com/CorsenAI/hermes-connector/releases/download/v0.2.2/hermes-connector-0.2.2-companion.zip`
- Source: `https://github.com/CorsenAI/hermes-connector`
- Support email: `hello@corsen.ai`
@@ -58,7 +58,8 @@ Prepared assets:
- `store/store-icon-128.png` — 128×128;
- `store/promo-small-440x280.png` — 440×280;
- `store/screenshot-product-1280x800.png` — 1280×800 real headed-Chrome
- capture with isolated test data.
+ capture with an isolated real Hermes model, exact attached `example.com` tab,
+ and verified `bridge_status` → `bridge_current_url` → `bridge_read` calls.
Generation/capture provenance and the reproducible screenshot command are in
`store/ASSETS.md`.
@@ -76,7 +77,7 @@ Declare the data the extension handles even though processing is local:
- **Website content**: visible text, accessibility structure, element labels,
and requested screenshots from attached tabs.
- **Web history**: URLs and titles of attached tabs; current tab titles/URLs are
- shown locally only after the user opens Choose tabs.
+ shown locally only after the user opens Tabs.
- **Authentication information**: the persistent local Connector pairing
credential stored in Chrome local storage, plus the ephemeral Hermes dashboard
session token read into memory. Only HMAC proofs—not the pairing credential—go
@@ -149,7 +150,7 @@ so use deferred publishing and answer reviewer questions promptly.
## 8. Post-publication verification
-- [ ] Confirm the public Store endpoint serves version 0.2.1 for the existing
+- [ ] Confirm the public Store endpoint serves version 0.2.2 for the existing
extension ID.
- [ ] Smoke-test the Store-installed build in the intended signed-in Chrome
profile and confirm an upgraded 0.2.0 profile receives the companion
diff --git a/README.md b/README.md
index da3f69c..44c128f 100644
--- a/README.md
+++ b/README.md
@@ -7,16 +7,18 @@ automation browser or guessing which tab belongs to which task.

+
+
+[**Watch the verified 9-second real-product demo**](marketing/video/hermes-connector-real-e2e-1080p.mp4)
+
[**Install from the Chrome Web Store**](https://chromewebstore.google.com/detail/hermes-connector-%E2%80%94-by-cor/cdhaldcgafmkcnpanlmmpaebabnlledm)
· [Setup guide](https://corsenai.github.io/hermes-connector/)
-· [Download companion 0.2.1](https://github.com/CorsenAI/hermes-connector/releases/download/v0.2.1/hermes-connector-0.2.1-companion.zip)
+· [Download companion 0.2.2](https://github.com/CorsenAI/hermes-connector/releases/download/v0.2.2/hermes-connector-0.2.2-companion.zip)
· [Get support](https://corsenai.github.io/hermes-connector/support/)
> Unofficial community project by Corsen AI. Not affiliated with or endorsed
> by Nous Research or Google.
-
-
## Your Hermes session, inside the Chrome you already use
Hermes Connector embeds the real local Hermes dashboard in Chrome's side panel.
@@ -24,8 +26,6 @@ You decide which tabs each Hermes session may access, and the connector keeps
that scope exact even when several projects, sessions, or Chrome profiles are
running at the same time.
-
-
With an attached tab, Hermes can:
- inspect and read the visible page;
@@ -73,7 +73,7 @@ It does not send this check to the internet.
- Otherwise, use the panel's **Download companion** button and extract the ZIP.
The current matching download is
-[`hermes-connector-0.2.1-companion.zip`](https://github.com/CorsenAI/hermes-connector/releases/download/v0.2.1/hermes-connector-0.2.1-companion.zip).
+[`hermes-connector-0.2.2-companion.zip`](https://github.com/CorsenAI/hermes-connector/releases/download/v0.2.2/hermes-connector-0.2.2-companion.zip).
### 3. Install the companion once
@@ -97,7 +97,7 @@ In the Hermes Connector side panel:
1. Give this Chrome profile a recognizable name.
2. Paste the pairing code printed by the installer and save.
3. Select the Hermes profile and session you want to use.
-4. Choose **Attach active tab** or **Choose tabs**.
+4. Choose **Attach current** or open **Tabs**.
5. Ask Hermes to work with the selected page.
For example:
@@ -112,7 +112,7 @@ or:
Click the requested action in my attached tab, then explain what changed.
```
-Tab titles and URLs are displayed locally when you open **Choose tabs** so that
+Tab titles and URLs are displayed locally when you open **Tabs** so that
you can make the selection yourself.
## Why a companion is required
diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md
index bc2b056..9c66c81 100644
--- a/docs/ACCEPTANCE.md
+++ b/docs/ACCEPTANCE.md
@@ -53,7 +53,7 @@ the final packaged bits.
- [x] End-to-end runs are completed in one and two isolated Chrome profiles.
- [ ] A pre-submit end-to-end run loads the exact packaged ZIP in the intended
signed-in Google Chrome profile.
-- [ ] After publication, the existing Store ID serves 0.2.1 and its installed
+- [ ] After publication, the existing Store ID serves 0.2.2 and its installed
build passes a signed-in-profile smoke test.
Evidence for checked gates is recorded in `TEST-EVIDENCE.md`.
diff --git a/docs/ARCHITECTURE-V2.md b/docs/ARCHITECTURE-V2.md
index d1a5940..d091b61 100644
--- a/docs/ARCHITECTURE-V2.md
+++ b/docs/ARCHITECTURE-V2.md
@@ -89,7 +89,7 @@ last-used, or first controllable tab.
- A full Chrome restart preserves browser identity and session preference. Any
tab that cannot be proven to be the same restored tab must be attached again.
- Broker or Hermes restarts do not rotate the pairing code.
-- When Chrome updates an older build to 0.2.1, it migrates only the legacy
+- When Chrome updates an older build to a protocol-4 release (0.2.1+), it migrates only the legacy
default 8765 address to 8766 and keeps a local notice visible until the user
confirms the separately downloaded companion was reinstalled. The new port
prevents a detached 0.2.0 broker from silently serving the hardened build.
diff --git a/docs/TEST-EVIDENCE.md b/docs/TEST-EVIDENCE.md
index 7caeb12..147f930 100644
--- a/docs/TEST-EVIDENCE.md
+++ b/docs/TEST-EVIDENCE.md
@@ -1,6 +1,37 @@
-# Test evidence — 2026-08-02 — 0.2.1 hotfix
-
-## Why 0.2.1 was required
+# Test evidence — 2026-08-03 — 0.2.2 release candidate
+
+## Why 0.2.2 was required
+
+The public Store endpoint still served 0.2.0 while the 0.2.1 submission was
+being evaluated. A deeper audit found that 0.2.1 was not ready to be treated as
+the final corrective release:
+
+- the side panel called the browser "paired" when only Chrome and the local
+ broker were authenticated; it did not prove that the selected Hermes profile
+ had loaded the Connector tools;
+- successful Connector responses included `"error": null`, which Hermes' generic
+ tool-result detector could misclassify as a failed browser call;
+- switching Chrome tabs did not change the deliberately fixed Hermes target,
+ but the panel did not explain that distinction and could leave users looking
+ at a different page from the one Hermes controlled;
+- internal, blank, loading, local-file, and Chrome Web Store pages did not all
+ produce an early, plain-language refusal;
+- the tab/session controls consumed too much side-panel height and reduced the
+ actual Hermes chat;
+- the first promotional video combined real extension chrome with a fixture
+ page and a prewritten Hermes result. It was rejected and removed from public
+ presentation rather than represented as live evidence.
+
+0.2.2 makes readiness depend on pairing, the selected profile appearing in the
+broker's authenticated `agentProfiles`, and a real active tab binding; removes
+false `error` keys from successful tool
+results, reports and refreshes the real active Chrome tab without silently
+changing authorization, blocks restricted/pending pages before execution, and
+moves secondary controls into compact accessible overlays. The installer now
+verifies every installed companion file byte-for-byte and rolls back on any
+post-publication verification failure.
+
+## Why 0.2.1 was required (historical)
The public 0.2.0 package was installable, but final use exposed production
paths that the isolated release tests had not covered:
@@ -90,10 +121,10 @@ exact broker process owning its random test port, kept the same `BridgeClient`
alive, and observed a distinct replacement broker plus a restored authenticated
connection. All temporary processes and data were removed afterward.
-The 0.2.1 broker uses protocol 4 on the new default port 8766. Extension tests
+The 0.2.2 broker uses protocol 4 on the default port 8766. Extension tests
verify a one-shot migration of all legacy default 8765 URL forms before the
first connection, while preserving a genuinely custom port. This prevents the
-detached 0.2.0 broker from silently serving 0.2.1 after a normal Hermes restart;
+detached 0.2.0 broker from silently serving a protocol-4 release after a normal Hermes restart;
an incompatible protocol is rejected rather than used as a fallback.
Broker persistence tests also verify that protocol 4 imports a validated
legacy owner only once, ignores a late write from a still-running protocol-3
@@ -102,9 +133,9 @@ complete temporary files for concurrent atomic saves.
## Multi-profile installation
-The 0.2.1 source installer was run locally without printing the pairing secret.
+The 0.2.2 source installer is tested without printing the pairing secret.
The shared home and every discovered named profile report
-`hermes-connector` 0.2.1 enabled. The known conflicting legacy
+`hermes-connector` 0.2.2 enabled. The known conflicting legacy
`agent-bridge` payload is preserved but disabled only where its exact legacy
signature is detected.
@@ -115,19 +146,36 @@ named `hermes` cannot break Python discovery. The same Windows gate executes
the normal-user `Install Hermes Connector.cmd` double-click path. Users must re-run the companion
installer after creating a new named profile.
-## Store artwork
-
-`store/screenshot-product-1280x800.png` was recaptured from the real 0.2.1
-extension panel at exactly 1280×800 with isolated fixture data. The icon,
-440×280 tile, 1400×560 marquee, and screenshot pass the metadata and leakage
-gates.
+## Store artwork and verified live capture
+
+`store/screenshot-product-1280x800.png` is an exact 1280×800 OS-window capture
+from the real 0.2.2 candidate. In an isolated profile, a real Hermes model called
+`bridge_status`, `bridge_current_url`, and `bridge_read` against the exact
+attached `https://example.com/` Chrome tab. The acceptance required
+session-scoped `INFO … tool … completed` records for all three calls,
+independently verified the exact Chrome URL/title and binding, then opened the
+real side panel and captured only after that proven transcript was visible with
+`Ready · Hermes + Chrome`. SHA-256:
+`8F5DEAA207A2F513D5D9B6C5EF9E88CEBC44C9344209670095A71953AC7D3585`.
+
+The icon, 440×280 tile, 1400×560 marquee, and live capture pass the metadata and
+leakage gates. The old prewritten fixture renderer now writes only under
+`tests/artifacts/` by default and cannot overwrite the public product capture.
+
+The accepted 9.30-second 1920×1080 product video is generated from the same
+proof-gated run after the transcript becomes visible. It shows the exact target,
+the three real Connector tool calls, the real result, and the single authorized
+tab popup without any marketing overlay or fake UI. Three decoded frames are
+checked for valid contrast, the real Tabs open/close transition, and agreement
+with the unobstructed final OS-window capture. SHA-256:
+`DE91F39FC9E5CE225097F255CB620D324B47A436BD9BA034D1C608A4F01F239D`.
## Remaining external release evidence
-- build the exact clean 0.2.1 archives and record their SHA-256 values;
-- publish the matching GitHub 0.2.1 companion before changing the Store item;
-- upload the 0.2.1 Chrome ZIP and updated screenshot to the Store;
+- build the exact clean 0.2.2 archives and record their SHA-256 values;
+- publish the matching GitHub 0.2.2 companion before changing the Store item;
+- upload the 0.2.2 Chrome ZIP and verified replacement screenshot to the Store;
- complete a pre-submit pass from the exact extracted ZIP in the intended
signed-in Chrome profile;
-- after publication, confirm the existing Store ID serves 0.2.1 and smoke-test
+- after publication, confirm the existing Store ID serves 0.2.2 and smoke-test
that Store-installed build.
diff --git a/docs/index.html b/docs/index.html
index aeca4a9..43c8286 100644
--- a/docs/index.html
+++ b/docs/index.html
@@ -34,12 +34,12 @@
"alternateName": "Hermes Connector",
"url": "https://corsenai.github.io/hermes-connector/",
"installUrl": "https://chromewebstore.google.com/detail/hermes-connector-%E2%80%94-by-cor/cdhaldcgafmkcnpanlmmpaebabnlledm",
- "downloadUrl": "https://github.com/CorsenAI/hermes-connector/releases/download/v0.2.1/hermes-connector-0.2.1-companion.zip",
+ "downloadUrl": "https://github.com/CorsenAI/hermes-connector/releases/download/v0.2.2/hermes-connector-0.2.2-companion.zip",
"applicationCategory": "BrowserApplication",
"operatingSystem": "Google Chrome 120+ on Windows, macOS, and Linux",
- "softwareVersion": "0.2.1",
+ "softwareVersion": "0.2.2",
"description": "An unofficial Chrome browser extension that connects exact local Hermes Agent sessions to user-selected tabs in a real signed-in Chrome profile.",
- "image": "https://raw.githubusercontent.com/CorsenAI/hermes-connector/main/store/store-icon-128.png",
+ "image": "https://raw.githubusercontent.com/CorsenAI/hermes-connector/main/store/screenshot-product-1280x800.png",
"softwareRequirements": "Google Chrome 120 or later, a local Hermes installation, and the Hermes Connector companion",
"publisher": {
"@type": "Organization",
@@ -113,7 +113,7 @@
.nav-links a:hover { color: #fff; }
.hero { padding: 86px 0 54px; }
- .hero-grid { display: grid; grid-template-columns: 1.02fr .98fr; align-items: center; gap: clamp(38px, 7vw, 88px); }
+ .hero-grid { display: grid; grid-template-columns: minmax(0, .82fr) minmax(520px, 1.18fr); gap: 48px; align-items: center; }
.eyebrow { display: inline-flex; align-items: center; gap: 9px; margin: 0 0 18px; color: #8ce8fb; font-size: 13px; font-weight: 850; letter-spacing: .13em; text-transform: uppercase; }
.eyebrow::before { content: ""; width: 8px; height: 8px; border-radius: 999px; background: var(--green); box-shadow: 0 0 17px var(--green); }
h1 { max-width: 790px; margin: 0; font-size: clamp(43px, 6.2vw, 76px); line-height: .98; letter-spacing: -.057em; }
@@ -247,14 +247,18 @@
Your Hermes session. Your chosen tabs.Hermes Connector links exact local Hermes Agent sessions to only the Chrome tabs you attach—inside the signed-in browser profile you already use.
Hermes Connector lets a Hermes agent running on your computer read and control Chrome tabs that you explicitly attach. Corsen AI does not operate a relay, analytics service, account system, or telemetry endpoint for the extension.
Data the extension handles
Visible website content, accessibility structure, element labels, and requested screenshots from attached tabs.
-
URLs and titles of attached tabs. Opening Choose tabs displays current tab titles and URLs locally so you can decide what to attach.
+
URLs and titles of attached tabs. Opening Tabs displays current tab titles and URLs locally so you can decide what to attach.
Instructions from your local Hermes agent and local Hermes profile/session identifiers and titles.
A random browser identifier, local addresses, tab bindings, and one persistent local Connector pairing credential (Authentication information) stored in Chrome local storage. Only HMAC proofs—not the credential itself—travel to the companion.
An ephemeral Hermes dashboard session token (Authentication information), read into memory and returned only to that same loopback dashboard HTTP API. It is never stored by the extension.
On Windows, double-click Install Hermes Connector.cmd (or run .\install.ps1 in PowerShell). On macOS/Linux, run ./install.sh.
Restart any running Hermes dashboard, gateway, or chat process. Re-run the installer after creating a new named Hermes profile.
Open the Chrome side panel, paste the private pairing code printed locally, select a Hermes session, and attach only the tabs it may control.
-
Upgrading from an older version
Chrome updates the extension automatically, but it cannot update the local companion. Download and run the 0.2.1 companion installer again, then restart every running Hermes dashboard, gateway, and chat process. Your pairing code stays the same. For normal/default installations, the extension safely migrates the old local port 8765 to the isolated 0.2.1 port 8766 once; no manual port change is needed.
Custom bridge ports only: custom values are preserved, but a detached 0.2.0 broker may continue owning that same custom port after Hermes closes. After installing 0.2.1, reboot the computer once (or safely stop only the known legacy broker process), then start Hermes again. A normal/default installation does not need this extra reboot.
Version 0.2.1 stops the repeated local-storage writes found in 0.2.0, but Chrome may not reclaim an already enlarged LevelDB journal immediately. First close every Chrome window completely and reopen Chrome. If disk usage remains abnormally high, removing and reinstalling Hermes Connector safely clears only this extension’s local settings; you will need to paste the pairing code and attach your sessions/tabs again. Do not manually delete arbitrary Chrome profile files.
+
Upgrading from an older version
Chrome updates the extension automatically, but it cannot update the local companion. Download and run the 0.2.2 companion installer again, then restart every running Hermes dashboard, gateway, and chat process. Your pairing code stays the same. For normal/default installations, the extension safely migrates the old local port 8765 to protocol 4 on port 8766 once; no manual port change is needed.
Custom bridge ports only: custom values are preserved, but a detached 0.2.0 broker may continue owning that same custom port after Hermes closes. After installing 0.2.2, reboot the computer once (or safely stop only the known legacy broker process), then start Hermes again. A normal/default installation does not need this extra reboot.
Version 0.2.1 and later stop the repeated local-storage writes found in 0.2.0, but Chrome may not reclaim an already enlarged LevelDB journal immediately. First close every Chrome window completely and reopen Chrome. If disk usage remains abnormally high, removing and reinstalling Hermes Connector safely clears only this extension’s local settings; you will need to paste the pairing code and attach your sessions/tabs again. Do not manually delete arbitrary Chrome profile files.
Troubleshooting
Companion offline: confirm Hermes restarted after installation and port 127.0.0.1:8766 is available.
-
Pairing rejected: open companion settings and generate/show the current local code, then save it again in the extension.
+
Pairing rejected: run the companion installer again; it safely displays the existing local code. Paste that code in the extension settings and save.
Action refused: select the exact Hermes session and attach a permitted web tab. Restricted Chrome pages cannot be controlled.
Debugging banner: disable Trusted input unless native input or dialog handling is needed.
diff --git a/extension/manifest.json b/extension/manifest.json
index 3f38010..2d4af61 100644
--- a/extension/manifest.json
+++ b/extension/manifest.json
@@ -1,7 +1,7 @@
{
"manifest_version": 3,
"name": "Hermes Connector — by Corsen AI",
- "version": "0.2.1",
+ "version": "0.2.2",
"description": "Attach real Hermes sessions to chosen tabs in your signed-in Chrome profile. Local, unofficial, by Corsen AI.",
"homepage_url": "https://corsenai.github.io/hermes-connector/",
"minimum_chrome_version": "120",
diff --git a/extension/src/background.js b/extension/src/background.js
index 08bb571..c5ae165 100644
--- a/extension/src/background.js
+++ b/extension/src/background.js
@@ -17,10 +17,10 @@ let reconnectTimer = null;
let pingTimer = null; // 20s WebSocket keepalive while the socket is open (Chrome docs)
let actionQueue = Promise.resolve(); // serialize agent actions so they never race each other
let queueDepth = 0; // bound the pending queue so a runaway/hostile agent can't OOM the worker
+const EXTENSION_VERSION = chrome.runtime.getManifest().version;
const COMPANION_REINSTALL_NOTICE = Object.freeze({
- id: "companion-reinstall-0.2.1",
- previousVersion: "0.2.0",
- extensionVersion: "0.2.1",
+ id: `companion-reinstall-${EXTENSION_VERSION}`,
+ extensionVersion: EXTENSION_VERSION,
});
const LEGACY_DEFAULT_BRIDGE_URLS = new Set([
"ws://127.0.0.1:8765",
@@ -28,17 +28,34 @@ const LEGACY_DEFAULT_BRIDGE_URLS = new Set([
]);
let sessionGen = 0; // bumped on disconnect/unpair -> already-queued actions are cancelled
const MAX_QUEUED = 32;
+const MAX_UNPAIRED_PENDING_FRAMES = 8;
+const MAX_UNPAIRED_PENDING_CHARS = 2_500_000;
+const MAX_PAIRED_PENDING_FRAMES = 32;
+const MAX_PAIRED_PENDING_CHARS = 8_000_000;
let desiredName = "Chrome"; // browser name to announce once the agent challenges us
let bindingTransaction = Promise.resolve(); // prevent concurrent read/modify/write updates from losing tabs
let authorizationGen = 0; // binding/trusted-input revocations cancel in-flight action authority
const activationGeneration = new Map(); // windowId -> every active-tab change, including A→B→A
+let activeTabEventGeneration = 0; // suppress stale async UI events after rapid activation/navigation
+const attachedTabEventGeneration = new Map(); // tabId -> newest navigation/status event
-chrome.tabs.onActivated.addListener(({ windowId }) => {
+chrome.tabs.onActivated.addListener(({ tabId, windowId }) => {
if (Number.isInteger(windowId)) {
activationGeneration.set(windowId, (activationGeneration.get(windowId) || 0) + 1);
}
+ scheduleActiveTabBroadcast(tabId);
});
+chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
+ if (!(Object.hasOwn(changeInfo || {}, "url") || Object.hasOwn(changeInfo || {}, "pendingUrl") ||
+ Object.hasOwn(changeInfo || {}, "status"))) return;
+ scheduleAttachedTabRefresh(tabId);
+ if (tab && tab.active) scheduleActiveTabBroadcast(tabId, tab);
+});
+
+// Focusing another Chrome window changes the user-visible active tab without firing tabs.onActivated.
+chrome.windows.onFocusChanged.addListener(() => scheduleActiveTabBroadcast(null));
+
// ---- mutual auth (challenge-response over the shared pairing code) -----------
async function getPairingCode() {
@@ -128,7 +145,7 @@ async function validateBindings(sync = true) {
for (const tabId of binding.tabIds) {
try {
const tab = await chrome.tabs.get(tabId);
- if (!isControllableTab(tab)) next = removeTabEverywhere(next, tabId);
+ if (!isRetainableTab(tab)) next = removeTabEverywhere(next, tabId);
} catch (_) {
next = removeTabEverywhere(next, tabId);
}
@@ -220,7 +237,7 @@ async function ensureBridgeMigration() {
await chrome.storage.local.set(updates);
} catch (_) {
// Even if local persistence is temporarily unavailable, never make the
- // first 0.2.1 connection to the detached 0.2.0 default broker.
+ // first protocol-4 connection to the detached 0.2.0 default broker.
}
return { url: migrated ? DEFAULT_BRIDGE_URL : (current || DEFAULT_BRIDGE_URL),
migrated, customBridge };
@@ -233,6 +250,7 @@ async function bridgeUrl() {
function invalidateSession(sock = null) {
sessionGen++;
paired = false;
+ brokerState = { protocol: PROTOCOL_VERSION, browsers: [], agentProfiles: [] };
if (sock) sock._paired = false;
}
@@ -276,10 +294,38 @@ async function connectBridge(probeOnly = false) {
// Size gate BEFORE JSON.parse: never parse an unbounded payload — a rogue local server could
// otherwise OOM the worker with one giant frame, no pairing needed. Legit messages are tiny.
if (typeof ev.data !== "string" || ev.data.length > 2000000) { try { sock.close(); } catch (_) {} return; }
- let m; try { m = JSON.parse(ev.data); } catch (_) { return; }
- // onHostMessage is async — a sync try/catch would miss post-await errors, so catch the promise.
- // Pass THIS socket so the handler can bail if it's superseded across an await (connection race).
- Promise.resolve(onHostMessage(m, sock)).catch((e) => console.error("agent-bridge:", e));
+ let m;
+ try { m = JSON.parse(ev.data); } catch (_) {
+ // Invalid frames are a protocol violation. Dropping them silently would
+ // let a fake local endpoint bypass the bounded async queue and burn CPU.
+ try { sock.close(); } catch (_) {}
+ return;
+ }
+ const pendingFrames = (sock._pendingFrames || 0) + 1;
+ const pendingChars = (sock._pendingChars || 0) + ev.data.length;
+ const authenticated = sock._paired === true;
+ const maxFrames = authenticated ? MAX_PAIRED_PENDING_FRAMES : MAX_UNPAIRED_PENDING_FRAMES;
+ const maxChars = authenticated ? MAX_PAIRED_PENDING_CHARS : MAX_UNPAIRED_PENDING_CHARS;
+ if (pendingFrames > maxFrames || pendingChars > maxChars) {
+ // Never let an unauthenticated (or compromised local) endpoint build an
+ // unbounded Promise/object backlog while async HMAC/storage work runs.
+ try { sock.close(); } catch (_) {}
+ return;
+ }
+ sock._pendingFrames = pendingFrames;
+ sock._pendingChars = pendingChars;
+ const frameChars = ev.data.length;
+ // Preserve WebSocket frame order across async handshake work. Without this queue a
+ // broker_state frame immediately following paired can overtake the HMAC/storage awaits and be
+ // discarded as unauthenticated, leaving the panel with a stale readiness state.
+ // Pass THIS socket so the handler can still bail if it is superseded across an await.
+ sock._messageChain = (sock._messageChain || Promise.resolve())
+ .then(() => onHostMessage(m, sock))
+ .catch((e) => console.error("agent-bridge:", e))
+ .finally(() => {
+ sock._pendingFrames = Math.max(0, (sock._pendingFrames || 1) - 1);
+ sock._pendingChars = Math.max(0, (sock._pendingChars || frameChars) - frameChars);
+ });
};
sock.onclose = () => {
if (ws !== sock) return; // a newer connection owns the state now
@@ -396,6 +442,7 @@ async function onHostMessage(msg, sock) {
// ON the socket (sock._extNonce), not a global, so two connections never cross wires.
const alive = () => ws === sock;
const authenticated = () => alive() && paired && sock._paired === true;
+ if (!alive()) return;
switch (msg.type) {
case IN.CHALLENGE: {
// One challenge per connection: a rogue server can't flood challenges to burn HMAC/storage work.
@@ -543,17 +590,137 @@ async function onHostMessage(msg, sock) {
// ---- action execution -------------------------------------------------------
-function isControllableTab(t) {
- if (!t || !Number.isInteger(t.id)) return false;
- const url = t.pendingUrl || t.url || "";
- if (url === "about:blank") return true;
- return !/^(chrome|edge|about|devtools|view-source):/.test(url) &&
- !/^chrome-extension:\/\//.test(url);
+function effectiveTabUrl(tab) {
+ return String((tab && (tab.pendingUrl || tab.url)) || "").trim();
+}
+
+function urlControlReason(rawUrl) {
+ let url;
+ try { url = new URL(rawUrl); } catch (_) {
+ return "This tab does not have a valid controllable URL.";
+ }
+ // Hermes deliberately supports opening a new empty tab before navigating it.
+ // Permit exactly about:blank; every other browser-internal about: page remains blocked.
+ if (url.href.toLowerCase() === "about:blank") return null;
+ const protocol = url.protocol.toLowerCase();
+ const blockedProtocols = {
+ "chrome:": "Chrome internal pages cannot be controlled by extensions.",
+ "chrome-search:": "Chrome internal pages cannot be controlled by extensions.",
+ "chrome-untrusted:": "Chrome internal pages cannot be controlled by extensions.",
+ "edge:": "Edge internal pages cannot be controlled by extensions.",
+ "about:": "Browser internal pages cannot be controlled by extensions.",
+ "devtools:": "Developer Tools pages cannot be controlled by extensions.",
+ "view-source:": "View-source pages cannot be controlled by extensions.",
+ "chrome-extension:": "Extension pages cannot be controlled by other extensions.",
+ "edge-extension:": "Extension pages cannot be controlled by other extensions.",
+ "file:": "Local file pages are not supported because Chrome file access requires a separate browser setting.",
+ };
+ if (blockedProtocols[protocol]) return blockedProtocols[protocol];
+ if (!["http:", "https:"].includes(protocol)) {
+ return `Pages using the ${protocol.replace(/:$/, "")} scheme cannot be controlled.`;
+ }
+ const hostname = url.hostname.toLowerCase();
+ const pathname = url.pathname.toLowerCase();
+ if (hostname === "chromewebstore.google.com" ||
+ (hostname === "chrome.google.com" && (pathname === "/webstore" || pathname.startsWith("/webstore/")))) {
+ return "Chrome Web Store pages cannot be controlled by extensions.";
+ }
+ return null;
+}
+
+function tabControlStatus(tab) {
+ if (!tab || !Number.isInteger(tab.id)) {
+ return { controllable: false, reason: "No active Chrome tab is available." };
+ }
+ const currentUrl = String(tab.url || "").trim();
+ const pendingUrl = String(tab.pendingUrl || "").trim();
+ if (!currentUrl && !pendingUrl) {
+ return { controllable: false, reason: tab.status === "loading"
+ ? "Wait for the active page to finish loading before attaching it."
+ : "This tab has no controllable URL yet." };
+ }
+ const pendingReason = pendingUrl ? urlControlReason(pendingUrl) : null;
+ if (pendingReason) return { controllable: false, reason: pendingReason };
+ if (pendingUrl) {
+ return { controllable: false,
+ reason: "Wait for the active page to finish loading before attaching it." };
+ }
+ const currentReason = currentUrl ? urlControlReason(currentUrl) : null;
+ if (currentReason) return { controllable: false, reason: currentReason };
+ return { controllable: true, reason: null };
+}
+
+function isControllableTab(tab) {
+ return tabControlStatus(tab).controllable;
+}
+
+function isRetainableTab(tab) {
+ if (!tab || !Number.isInteger(tab.id)) return false;
+ const pendingUrl = String(tab.pendingUrl || "").trim();
+ // Preserve the exact tab-id authorization while a safe navigation is pending, but keep actions
+ // blocked through tabControlStatus until a controllable document is actually committed.
+ if (pendingUrl) return !urlControlReason(pendingUrl);
+ // Chrome can expose a freshly-created about:blank tab with an empty URL even
+ // after status briefly says complete. Keeping that exact existing tab ID is
+ // safe (all page actions remain blocked) and prevents a concurrent UI render
+ // from revoking the NEW_TAB action before about:blank becomes observable.
+ if (!String(tab.url || "").trim()) return true;
+ return isControllableTab(tab);
+}
+
+function describeTab(tab, owner = null) {
+ const control = tabControlStatus(tab);
+ if (!tab) {
+ return { tabId: null, windowId: null, index: null, title: "No active tab", url: "",
+ active: false, owner: null, ...control };
+ }
+ return { tabId: tab.id, windowId: tab.windowId, index: tab.index,
+ title: tab.title || "Untitled", url: sanitizeUrl(effectiveTabUrl(tab)),
+ active: !!tab.active, owner, ...control };
+}
+
+function scheduleActiveTabBroadcast(tabId, knownTab = null) {
+ const eventGeneration = ++activeTabEventGeneration;
+ Promise.resolve().then(async () => {
+ let tab = knownTab;
+ if (!tab && Number.isInteger(tabId)) {
+ try { tab = await chrome.tabs.get(tabId); } catch (_) { tab = null; }
+ }
+ if (!tab) {
+ try { [tab] = await chrome.tabs.query({ active: true, lastFocusedWindow: true }); } catch (_) {}
+ }
+ if (eventGeneration !== activeTabEventGeneration) return;
+ // An onUpdated lookup can finish after focus moved elsewhere. Never publish it as the active tab.
+ if (tab && !tab.active) return;
+ broadcast({ cmd: "activeTabChanged", activeTab: describeTab(tab || null) });
+ }).catch(() => {});
+}
+
+function scheduleAttachedTabRefresh(tabId) {
+ if (!Number.isInteger(tabId)) return;
+ const eventGeneration = (attachedTabEventGeneration.get(tabId) || 0) + 1;
+ attachedTabEventGeneration.set(tabId, eventGeneration);
+ mutateBindings(async (registry) => {
+ const attached = Object.values(registry).some((binding) => binding.tabIds.includes(tabId));
+ if (!attached || attachedTabEventGeneration.get(tabId) !== eventGeneration) return registry;
+ let tab;
+ try { tab = await chrome.tabs.get(tabId); } catch (_) {
+ return removeTabEverywhere(registry, tabId);
+ }
+ if (attachedTabEventGeneration.get(tabId) !== eventGeneration) return registry;
+ return isRetainableTab(tab) ? registry : removeTabEverywhere(registry, tabId);
+ }, true, () => attachedTabEventGeneration.get(tabId) === eventGeneration)
+ .then((registry) => {
+ if (attachedTabEventGeneration.get(tabId) !== eventGeneration) return;
+ const retained = Object.values(registry).some((binding) => binding.tabIds.includes(tabId));
+ if (retained) broadcast({ cmd: "attachedTabChanged", tabId });
+ })
+ .catch(() => {});
}
// Exact target only. An unbound or stale scope fails instead of falling back to whichever page the
// user most recently focused — the central wrong-tab guarantee of protocol v4.
-async function getTargetTab(msg) {
+async function getRetainableTargetTab(msg) {
const scope = msg && msg.scope;
if (!scope || !scope.profileId || !scope.sessionId) throw new Error("action has no Hermes session scope");
if (!Number.isInteger(msg.targetTabId)) throw new Error("action has no explicit target tab");
@@ -567,7 +734,16 @@ async function getTargetTab(msg) {
await mutateBindings((current) => removeTabEverywhere(current, msg.targetTabId));
throw new Error("attached target tab no longer exists");
}
- if (!isControllableTab(tab)) throw new Error("attached target tab cannot be controlled");
+ if (!isRetainableTab(tab)) {
+ throw new Error(`attached target tab cannot be retained: ${tabControlStatus(tab).reason}`);
+ }
+ return tab;
+}
+
+async function getTargetTab(msg) {
+ const tab = await getRetainableTargetTab(msg);
+ const control = tabControlStatus(tab);
+ if (!control.controllable) throw new Error(`attached target tab cannot be controlled: ${control.reason}`);
return tab;
}
@@ -596,7 +772,11 @@ async function getScopeTabs(scope) {
for (const tabId of binding.tabIds) {
try {
const tab = await chrome.tabs.get(tabId);
- if (isControllableTab(tab)) tabs.push(tab);
+ // Keep an explicitly authorized tab addressable while a safe HTTP(S)
+ // navigation is pending. Page actions still fail closed in getTargetTab()
+ // until the document is controllable, but list/switch/close and the
+ // new-tab return index must not lose the exact tab during that interval.
+ if (isRetainableTab(tab)) tabs.push(tab);
} catch (_) {}
}
tabs.sort((a, b) => (a.windowId - b.windowId) || (a.index - b.index));
@@ -617,15 +797,30 @@ function waitComplete(tabId, ms) {
async function handleAction(msg, sock, epoch) {
const a = msg.action || {};
+ const pageIndependentAction = new Set([
+ ACTION.NEW_TAB,
+ ACTION.LIST_TABS,
+ ACTION.SWITCH_TAB,
+ ACTION.CLOSE_TAB,
+ ACTION.CURRENT_URL,
+ ACTION.WAIT,
+ ]).has(a.kind);
+ // Navigation is also safe from a freshly-created URL-less/about:blank tab:
+ // it mutates only the exact authorized tab ID and validates the destination
+ // before Chrome receives it. All DOM/input actions still require a fully
+ // controllable committed document.
+ const allowPendingTarget = pageIndependentAction || a.kind === ACTION.NAVIGATE;
let tab;
try {
- tab = await getTargetTab(msg);
+ tab = await (allowPendingTarget ? getRetainableTargetTab(msg) : getTargetTab(msg));
} catch (error) {
sessionSocketSend(sock, epoch.session,
{ type: OUT.ACTION_RESULT, id: msg.id, ok: false, error: String(error.message || error) });
return;
}
- const settings = await getSettings();
+ const settings = pageIndependentAction
+ ? { trustedInput: false, showOverlay: false }
+ : await getSettings();
requireActionAuthorized(sock, epoch);
// "Trusted input" mode: real browser input events via CDP only if the user enabled it. The required
// manifest permission is present, but the debugger transport stays detached otherwise (no debugger banner).
@@ -640,6 +835,8 @@ async function handleAction(msg, sock, epoch) {
case ACTION.NAVIGATE: {
const u = String(a.url || "");
if (!/^https?:\/\//i.test(u)) { data = { ok: false, error: "only http/https URLs are allowed" }; break; }
+ const policyError = urlControlReason(u);
+ if (policyError) { data = { ok: false, error: policyError }; break; }
// Trusted-input mode: attach BEFORE navigating so a dialog fired during load (beforeunload,
// onload alert) is auto-handled instead of freezing the page with nobody attached yet.
if (useCdp) {
@@ -730,7 +927,7 @@ async function handleAction(msg, sock, epoch) {
break;
case ACTION.CURRENT_URL:
requireActionAuthorized(sock, epoch);
- data = { url: sanitizeUrl(tab.url), title: tab.title };
+ data = { url: sanitizeUrl(effectiveTabUrl(tab)), title: tab.title };
break;
case ACTION.WAIT:
await new Promise((r) => setTimeout(r, Math.min(a.ms || 0, 15000)));
@@ -814,6 +1011,8 @@ async function handleAction(msg, sock, epoch) {
data = { ok: false, error: "only http/https URLs are allowed" };
break;
}
+ const policyError = urlControlReason(nu);
+ if (policyError) { data = { ok: false, error: policyError }; break; }
requireActionAuthorized(sock, epoch);
const t = await chrome.tabs.create({ url: nu, windowId: tab.windowId, active: true });
let attached = false;
@@ -837,13 +1036,18 @@ async function handleAction(msg, sock, epoch) {
case ACTION.LIST_TABS: {
requireActionAuthorized(sock, epoch);
const scoped = await getScopeTabs(msg.scope);
- data = { tabs: scoped.tabs.map((t, index) => ({
- index,
- tabId: t.id,
- title: t.title,
- url: sanitizeUrl(t.url),
- active: t.id === scoped.binding.activeTabId,
- })) };
+ data = { tabs: scoped.tabs.map((t, index) => {
+ const control = tabControlStatus(t);
+ return {
+ index,
+ tabId: t.id,
+ title: t.title,
+ url: sanitizeUrl(effectiveTabUrl(t)),
+ active: t.id === scoped.binding.activeTabId,
+ controllable: control.controllable,
+ ...(control.reason ? { reason: control.reason } : {}),
+ };
+ }) };
break;
}
case ACTION.SWITCH_TAB: {
@@ -1032,20 +1236,34 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
);
rawTabs = settled.filter((item) => item.status === "fulfilled").map((item) => item.value);
}
- const tabs = rawTabs.filter(isControllableTab)
+ let activeChromeTab = null;
+ try { [activeChromeTab] = await chrome.tabs.query({ active: true, lastFocusedWindow: true }); } catch (_) {}
+ // The explicit picker may explain why any local tab is unavailable. Normal rendering must
+ // also retain an already-authorized tab while a safe navigation is pending so the user can
+ // still see and revoke that authorization; actions remain blocked by `controllable:false`.
+ const tabs = rawTabs.filter((tab) => msg.includeAvailable || isRetainableTab(tab))
.sort((a, b) => (a.windowId - b.windowId) || (a.index - b.index))
- .map((tab) => ({ tabId: tab.id, windowId: tab.windowId, index: tab.index,
- title: tab.title || "Untitled", url: sanitizeUrl(tab.url || tab.pendingUrl),
- active: tab.active, owner: owners.get(tab.id) || null }));
- sendResponse({ ok: true, tabs, bindings: registry });
+ .map((tab) => describeTab(tab, owners.get(tab.id) || null));
+ sendResponse({ ok: true, tabs, bindings: registry,
+ activeTab: describeTab(activeChromeTab || null,
+ activeChromeTab ? (owners.get(activeChromeTab.id) || null) : null) });
break;
}
case "attachActiveTab": {
if (!paired) { sendResponse({ ok: false, error: "pair this Chrome profile before attaching tabs" }); break; }
const scope = { profileId: String(msg.profileId || ""), sessionId: String(msg.sessionId || "") };
scopeKey(scope.profileId, scope.sessionId);
+ if (!Number.isInteger(msg.expectedTabId)) {
+ sendResponse({ ok: false, error: "refresh the panel before attaching the current tab" });
+ break;
+ }
const [tab] = await chrome.tabs.query({ active: true, lastFocusedWindow: true });
- if (!isControllableTab(tab)) { sendResponse({ ok: false, error: "the active tab cannot be controlled" }); break; }
+ if (!tab || tab.id !== msg.expectedTabId) {
+ sendResponse({ ok: false, error: "the active Chrome tab changed; review it and press Attach current again" });
+ break;
+ }
+ const control = tabControlStatus(tab);
+ if (!control.controllable) { sendResponse({ ok: false, error: control.reason }); break; }
const bindings = await attachScopeTab(scope, tab.id);
sendResponse({ ok: true, tabId: tab.id, bindings });
break;
@@ -1055,7 +1273,8 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
const scope = { profileId: String(msg.profileId || ""), sessionId: String(msg.sessionId || "") };
scopeKey(scope.profileId, scope.sessionId);
const tab = await chrome.tabs.get(msg.tabId);
- if (!isControllableTab(tab)) { sendResponse({ ok: false, error: "the tab cannot be controlled" }); break; }
+ const control = tabControlStatus(tab);
+ if (!control.controllable) { sendResponse({ ok: false, error: control.reason }); break; }
const bindings = await attachScopeTab(scope, tab.id);
sendResponse({ ok: true, tabId: tab.id, bindings });
break;
diff --git a/extension/src/sidepanel.html b/extension/src/sidepanel.html
index 8becc0c..af83ac8 100644
--- a/extension/src/sidepanel.html
+++ b/extension/src/sidepanel.html
@@ -8,14 +8,18 @@
html, body { margin: 0; height: 100%; background: #0b0f14; color: #eaeaee;
font-family: -apple-system, "Segoe UI", Roboto, sans-serif; }
body { display: flex; flex-direction: column; overflow: hidden; }
- header { display: flex; align-items: center; gap: 8px; padding: 6px 10px;
+ header { display: flex; align-items: center; gap: 7px; min-height: 32px; padding: 0 9px;
border-bottom: 1px solid #23262b; font-size: 13px; flex: none; }
.dot { width: 9px; height: 9px; border-radius: 50%; background: #666; flex: none; }
- .dot.on { background: #39d353; } .dot.off { background: #666; } .dot.err { background: #e5534b; }
- #status { color: #9a9aa2; font-size: 12px; margin-left: auto; }
+ .dot.on { background: #39d353; } .dot.warn { background: #d29922; }
+ .dot.off { background: #666; } .dot.err { background: #e5534b; }
+ #status { color: #9a9aa2; font-size: 11px; margin-left: auto; overflow: hidden;
+ text-overflow: ellipsis; white-space: nowrap; }
#gear { background: none; border: 0; color: #9a9aa2; cursor: pointer; font-size: 14px; padding: 2px 4px; }
- #cfg { display: none; flex-direction: column; gap: 6px; padding: 8px 10px;
- border-bottom: 1px solid #23262b; flex: none; max-height: 55vh; overflow: auto; }
+ #cfg { display: none; position: fixed; z-index: 90; top: 38px; left: 8px; right: 8px;
+ flex-direction: column; gap: 6px; padding: 10px; max-height: calc(100vh - 58px); overflow: auto;
+ border: 1px solid #3b4658; border-radius: 9px; background: #10151d;
+ box-shadow: 0 12px 36px rgba(0, 0, 0, .55); }
#cfg.open { display: flex; }
#cfg label { display: flex; flex-direction: column; gap: 3px; font-size: 11px; color: #9a9aa2; }
#cfg input { background: #1a1f27; color: #eaeaee; border: 1px solid #23262b;
@@ -23,26 +27,41 @@
#cfg label.chk { flex-direction: row; align-items: flex-start; gap: 6px; color: #c7cdda; line-height: 1.35; }
#cfg label.chk input { width: auto; margin-top: 2px; flex: none; }
#cfg button { background: #3159d8; color: #fff; border: 0; border-radius: 6px; padding: 6px 10px; cursor: pointer; margin-top: 2px; }
- #scope { display: flex; flex-direction: column; gap: 6px; padding: 8px 10px;
+ #scope { position: relative; display: flex; flex-direction: column; gap: 4px; padding: 5px 8px 6px;
border-bottom: 1px solid #23262b; background: #10151d; flex: none; }
.scopeRow { display: flex; gap: 5px; }
#sessionSelect { flex: 1; min-width: 0; background: #1a1f27; color: #eaeaee;
- border: 1px solid #343b48; border-radius: 6px; padding: 6px; }
+ border: 1px solid #343b48; border-radius: 6px; padding: 4px 6px; min-height: 27px; }
.smallBtn { background: #283142; color: #eaeaee; border: 1px solid #3b4658;
- border-radius: 6px; padding: 5px 7px; cursor: pointer; white-space: nowrap; }
+ border-radius: 6px; padding: 4px 7px; min-height: 27px; cursor: pointer; white-space: nowrap; }
.smallBtn.primary { background: #3159d8; border-color: #3159d8; }
.smallBtn:disabled { opacity: .45; cursor: default; }
- #scopeInfo { color: #9a9aa2; font-size: 11px; min-height: 14px; }
- #privacyNote { color: #8b95a8; font-size: 10px; line-height: 1.35; }
- #attachedTabs, #tabPicker { display: flex; flex-direction: column; gap: 4px; max-height: 128px; overflow: auto; }
- #tabPicker { border-top: 1px solid #23262b; padding-top: 5px; }
+ #scopeInfo { color: #9a9aa2; font-size: 10px; line-height: 1.25; min-height: 13px;
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+ #attachedTabs { display: flex; min-width: 0; flex: 1; overflow: hidden; }
+ #attachedTabs:empty::before { content: "No target attached"; color: #8b95a8; font-size: 11px;
+ align-self: center; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+ #tabPicker, #privacyPopover { position: fixed; z-index: 70; top: 101px; left: 8px; right: 8px;
+ max-height: min(52vh, 360px); overflow: auto; padding: 8px; border: 1px solid #3b4658;
+ border-radius: 9px; background: #10151d; box-shadow: 0 12px 36px rgba(0, 0, 0, .55); }
+ #tabPicker { display: flex; flex-direction: column; gap: 5px; }
+ #tabPicker[hidden], #privacyPopover[hidden] { display: none; }
+ #privacyPopover { color: #c7cdda; font-size: 11px; line-height: 1.45; }
+ #privacyPopover p { margin: 0 0 7px; }
+ #privacyPopover p:last-child { margin-bottom: 0; }
+ #dataNotice { color: #8b95a8; font-size: 9px; line-height: 1.2; overflow: hidden;
+ text-overflow: ellipsis; white-space: nowrap; }
.tabRow { display: flex; align-items: center; gap: 5px; min-width: 0; font-size: 11px; }
+ #attachedTabs .tabRow { flex: 1; }
.tabTitle { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #c7cdda; }
.tabRow.current .tabTitle { color: #7ee787; font-weight: 600; }
iframe { width: 100%; min-height: 0; flex: 1; border: 0; display: block; background: #fff; }
- #hint { padding: 20px; color: #9a9aa2; font-size: 13px; line-height: 1.5; flex: 1; }
- #setupNotice { flex: none; padding: 10px; background: #10243a;
- border-bottom: 1px solid #285a86; color: #dceeff; font-size: 12px; }
+ #hint { padding: 8px 10px; color: #c7cdda; background: #151b24; border-top: 1px solid #343b48;
+ font-size: 11px; line-height: 1.4; flex: none; }
+ #setupNotice { position: fixed; z-index: 80; top: 38px; left: 8px; right: 8px;
+ max-height: calc(100vh - 58px); overflow: auto; padding: 10px; background: #10243a;
+ border: 1px solid #285a86; border-radius: 9px; color: #dceeff; font-size: 12px;
+ box-shadow: 0 12px 36px rgba(0, 0, 0, .55); }
#setupNotice[hidden] { display: none; }
#setupNotice p { margin: 5px 0; line-height: 1.4; }
#setupNotice ol { margin: 6px 0 8px; padding-left: 20px; line-height: 1.5; }
@@ -52,8 +71,10 @@
text-decoration: none; border-radius: 6px; padding: 6px 8px; }
.setupActions button { background: #1c3853; color: #dceeff; border: 1px solid #3b6d99;
border-radius: 6px; padding: 5px 7px; cursor: pointer; }
- #upgradeNotice { flex: none; padding: 10px; background: #2b2110;
- border-bottom: 1px solid #76551f; color: #f6dda5; font-size: 12px; }
+ #upgradeNotice { position: fixed; z-index: 75; top: 38px; left: 8px; right: 8px;
+ max-height: calc(100vh - 58px); overflow: auto; padding: 10px; background: #2b2110;
+ border: 1px solid #76551f; border-radius: 9px; color: #f6dda5; font-size: 12px;
+ box-shadow: 0 12px 36px rgba(0, 0, 0, .55); }
#upgradeNotice[hidden] { display: none; }
#upgradeNotice p { margin: 5px 0 8px; line-height: 1.4; }
.upgradeActions { display: flex; align-items: center; gap: 8px; }
@@ -67,9 +88,9 @@
Hermes
- connecting…
+ connecting…
-