feat(connect): native-messaging transport — zero-token connect to real Chrome
Optimal architecture (chosen over the WS+token copy): the ab-connect extension talks to a local agent-browser native-messaging host. No localhost port, no token — Chrome authenticates the extension to the host by id. This is the codex/claude-style "install once, no per-use confirmation" model. - extensions/ab-connect: rewritten transport WebSocket+token → native messaging (chrome.runtime.connectNative). Pinned the extension id via a manifest `key` (→ bdoiejojpjogcjojeladhioioijhgade) so the host manifest can authorize it. Kept the proven chrome.debugger attach + Target.attachedToTarget emulation; dropped WS/token/options. Rebranded to "agent-browser connect". - cli connect.rs: `agent-browser extension install` writes the native-messaging host manifest (Chrome/Chromium/Edge/Brave) + a launcher; hidden `__nm-host` speaks the 4-byte-length native-messaging framing. Validated end-to-end on real Chrome: Chrome spawned the host (origin matched the pinned id) and the extension attached the user's real logged-in tabs, streaming Target.attachedToTarget over native messaging — zero token, zero port. Next: bridge the host to the daemon relay (relay.rs) + CdpClient so `agent-browser click/eval/...` drives those tabs.
This commit is contained in:
@@ -1,10 +1,11 @@
|
||||
# Attribution
|
||||
|
||||
The `ab-connect` extension is adapted from **openclaw-browser-relay**
|
||||
by chengyixu — https://github.com/chengyixu/openclaw-browser-relay
|
||||
(MIT License, per its README).
|
||||
The chrome.debugger attach + CDP Target handling in `background.js` is adapted
|
||||
from **openclaw-browser-relay** by chengyixu
|
||||
(https://github.com/chengyixu/openclaw-browser-relay, MIT per its README).
|
||||
|
||||
Changes for agent-browser-stealth: rebranded; points at the agent-browser
|
||||
daemon's local relay endpoint instead of the OpenClaw gateway; protocol
|
||||
otherwise preserved (connect handshake, forwardCDPCommand/forwardCDPEvent,
|
||||
ping/pong).
|
||||
Changes for agent-browser-stealth: rebranded to "agent-browser connect"; the
|
||||
transport is rewritten from a localhost WebSocket + shared token to Chrome
|
||||
**native messaging** (host `com.agent_browser.connect`) — no port, no token,
|
||||
Chrome authenticates the extension to the host by id. WebSocket/token/options
|
||||
code removed.
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
export function reconnectDelayMs(
|
||||
attempt,
|
||||
opts = { baseMs: 1000, maxMs: 30000, jitterMs: 1000, random: Math.random },
|
||||
) {
|
||||
const baseMs = Number.isFinite(opts.baseMs) ? opts.baseMs : 1000;
|
||||
const maxMs = Number.isFinite(opts.maxMs) ? opts.maxMs : 30000;
|
||||
const jitterMs = Number.isFinite(opts.jitterMs) ? opts.jitterMs : 1000;
|
||||
const random = typeof opts.random === "function" ? opts.random : Math.random;
|
||||
const safeAttempt = Math.max(0, Number.isFinite(attempt) ? attempt : 0);
|
||||
const backoff = Math.min(baseMs * 2 ** safeAttempt, maxMs);
|
||||
return backoff + Math.max(0, jitterMs) * random();
|
||||
}
|
||||
|
||||
export async function deriveRelayToken(gatewayToken, port) {
|
||||
const enc = new TextEncoder();
|
||||
const key = await crypto.subtle.importKey(
|
||||
"raw",
|
||||
enc.encode(gatewayToken),
|
||||
{ name: "HMAC", hash: "SHA-256" },
|
||||
false,
|
||||
["sign"],
|
||||
);
|
||||
const sig = await crypto.subtle.sign(
|
||||
"HMAC",
|
||||
key,
|
||||
enc.encode(`openclaw-extension-relay-v1:${port}`),
|
||||
);
|
||||
return [...new Uint8Array(sig)].map((b) => b.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
export async function buildRelayWsUrl(port, gatewayToken) {
|
||||
const token = String(gatewayToken || "").trim();
|
||||
if (!token) {
|
||||
throw new Error(
|
||||
"Missing gatewayToken in extension settings (chrome.storage.local.gatewayToken)",
|
||||
);
|
||||
}
|
||||
const relayToken = await deriveRelayToken(token, port);
|
||||
return `ws://127.0.0.1:${port}/extension?token=${encodeURIComponent(relayToken)}`;
|
||||
}
|
||||
|
||||
export function isRetryableReconnectError(err) {
|
||||
const message = err instanceof Error ? err.message : String(err || "");
|
||||
if (message.includes("Missing gatewayToken")) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
+217
-891
File diff suppressed because it is too large
Load Diff
@@ -1,25 +1,16 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "agent-browser connect",
|
||||
"version": "0.1.0",
|
||||
"description": "Let agent-browser drive your existing logged-in Chrome tab via a local relay — install once, no per-use confirmation.",
|
||||
"version": "0.2.0",
|
||||
"description": "Let agent-browser drive your logged-in Chrome — install once, no token, no per-use confirmation.",
|
||||
"key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAvPGcSmMx7dSfq9gRBDbQuAgx/+TEavrDxP/4jLa2+Ycexf/FEmq1MN8gAHoTRjSyp66YKD+1qI1CF6bk0rH5ZtxpRO7DYRTUPcsA1IpHbgEn5mppx3YxNGZilfkEZyrxdhBqUIzq3J74+/kpZzEVsO+DQTbAZSsFfdUkoCb5mbJid2VQYeqeBnYGAhbpGvN1P99jdT9EA1nKINb3ji6tLobCpyQ1fjf2uWm4mUirWkkF/nbUFVFEAh33Q/IYZmtUHgDYea5LsM9xH4KAG2kxMvFGj6vHR39sZd5/+gnvwScTcItUWQ9lFIyWYiwrSB25Lu0FshfllevXUFrG5vrvRwIDAQAB",
|
||||
"icons": {
|
||||
"16": "icons/icon16.png",
|
||||
"32": "icons/icon32.png",
|
||||
"48": "icons/icon48.png",
|
||||
"128": "icons/icon128.png"
|
||||
},
|
||||
"permissions": ["debugger", "tabs", "activeTab", "storage", "alarms", "webNavigation"],
|
||||
"host_permissions": ["http://127.0.0.1/*", "http://localhost/*"],
|
||||
"permissions": ["debugger", "tabs", "nativeMessaging", "storage", "alarms", "webNavigation"],
|
||||
"background": { "service_worker": "background.js", "type": "module" },
|
||||
"action": {
|
||||
"default_title": "agent-browser connect (auto-attach enabled)",
|
||||
"default_icon": {
|
||||
"16": "icons/icon16.png",
|
||||
"32": "icons/icon32.png",
|
||||
"48": "icons/icon48.png",
|
||||
"128": "icons/icon128.png"
|
||||
}
|
||||
},
|
||||
"options_ui": { "page": "options.html", "open_in_tab": true }
|
||||
"action": { "default_title": "agent-browser connect" }
|
||||
}
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
const PORT_GUIDANCE = 'Use gateway port + 3 (for gateway 18789, relay is 18792).'
|
||||
|
||||
function hasCdpVersionShape(data) {
|
||||
return !!data && typeof data === 'object' && 'Browser' in data && 'Protocol-Version' in data
|
||||
}
|
||||
|
||||
export function classifyRelayCheckResponse(res, port) {
|
||||
if (!res) {
|
||||
return { action: 'throw', error: 'No response from service worker' }
|
||||
}
|
||||
|
||||
if (res.status === 401) {
|
||||
return { action: 'status', kind: 'error', message: 'Gateway token rejected. Check token and save again.' }
|
||||
}
|
||||
|
||||
if (res.error) {
|
||||
return { action: 'throw', error: res.error }
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
return { action: 'throw', error: `HTTP ${res.status}` }
|
||||
}
|
||||
|
||||
const contentType = String(res.contentType || '')
|
||||
if (!contentType.includes('application/json')) {
|
||||
return {
|
||||
action: 'status',
|
||||
kind: 'error',
|
||||
message: `Wrong port: this is likely the gateway, not the relay. ${PORT_GUIDANCE}`,
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasCdpVersionShape(res.json)) {
|
||||
return {
|
||||
action: 'status',
|
||||
kind: 'error',
|
||||
message: `Wrong port: expected relay /json/version response. ${PORT_GUIDANCE}`,
|
||||
}
|
||||
}
|
||||
|
||||
return { action: 'status', kind: 'ok', message: `Relay reachable and authenticated at http://127.0.0.1:${port}/` }
|
||||
}
|
||||
|
||||
export function classifyRelayCheckException(err, port) {
|
||||
const message = String(err || '').toLowerCase()
|
||||
if (message.includes('json') || message.includes('syntax')) {
|
||||
return {
|
||||
kind: 'error',
|
||||
message: `Wrong port: this is not a relay endpoint. ${PORT_GUIDANCE}`,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'error',
|
||||
message: `Relay not reachable/authenticated at http://127.0.0.1:${port}/. Start OpenClaw browser relay and verify token.`,
|
||||
}
|
||||
}
|
||||
@@ -1,200 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>OpenClaw Browser Relay</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
--accent: #ff5a36;
|
||||
--panel: color-mix(in oklab, canvas 92%, canvasText 8%);
|
||||
--border: color-mix(in oklab, canvasText 18%, transparent);
|
||||
--muted: color-mix(in oklab, canvasText 70%, transparent);
|
||||
--shadow: 0 10px 30px color-mix(in oklab, canvasText 18%, transparent);
|
||||
font-family: ui-rounded, system-ui, -apple-system, BlinkMacSystemFont, "SF Pro Rounded",
|
||||
"SF Pro Display", "Segoe UI", sans-serif;
|
||||
line-height: 1.4;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
background:
|
||||
radial-gradient(1000px 500px at 10% 0%, color-mix(in oklab, var(--accent) 30%, transparent), transparent 70%),
|
||||
radial-gradient(900px 450px at 90% 0%, color-mix(in oklab, var(--accent) 18%, transparent), transparent 75%),
|
||||
canvas;
|
||||
color: canvasText;
|
||||
}
|
||||
.wrap {
|
||||
max-width: 820px;
|
||||
margin: 36px auto;
|
||||
padding: 0 24px 48px 24px;
|
||||
}
|
||||
header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.logo {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 14px;
|
||||
background: color-mix(in oklab, var(--accent) 18%, transparent);
|
||||
border: 1px solid color-mix(in oklab, var(--accent) 35%, transparent);
|
||||
box-shadow: var(--shadow);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
.logo img {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
h1 {
|
||||
font-size: 20px;
|
||||
margin: 0;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.subtitle {
|
||||
margin: 2px 0 0 0;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 14px;
|
||||
}
|
||||
.card {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 16px;
|
||||
padding: 16px;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.card h2 {
|
||||
margin: 0 0 10px 0;
|
||||
font-size: 14px;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
.card p {
|
||||
margin: 8px 0 0 0;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
input {
|
||||
width: 160px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--border);
|
||||
background: color-mix(in oklab, canvas 92%, canvasText 8%);
|
||||
color: canvasText;
|
||||
outline: none;
|
||||
}
|
||||
input:focus {
|
||||
border-color: color-mix(in oklab, var(--accent) 70%, transparent);
|
||||
box-shadow: 0 0 0 4px color-mix(in oklab, var(--accent) 20%, transparent);
|
||||
}
|
||||
button {
|
||||
padding: 10px 14px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid color-mix(in oklab, var(--accent) 55%, transparent);
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
color-mix(in oklab, var(--accent) 80%, white 20%),
|
||||
var(--accent)
|
||||
);
|
||||
color: white;
|
||||
font-weight: 650;
|
||||
letter-spacing: 0.01em;
|
||||
cursor: pointer;
|
||||
}
|
||||
button:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
.hint {
|
||||
margin-top: 10px;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
}
|
||||
code {
|
||||
font-family: ui-monospace, Menlo, Monaco, Consolas, "SF Mono", monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
a {
|
||||
color: color-mix(in oklab, var(--accent) 85%, canvasText 15%);
|
||||
}
|
||||
.status {
|
||||
margin-top: 10px;
|
||||
font-size: 12px;
|
||||
color: color-mix(in oklab, var(--accent) 70%, canvasText 30%);
|
||||
min-height: 16px;
|
||||
}
|
||||
.status[data-kind='ok'] {
|
||||
color: color-mix(in oklab, #16a34a 75%, canvasText 25%);
|
||||
}
|
||||
.status[data-kind='error'] {
|
||||
color: color-mix(in oklab, #ef4444 75%, canvasText 25%);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<header>
|
||||
<div class="logo" aria-hidden="true">
|
||||
<img src="icons/icon128.png" alt="" />
|
||||
</div>
|
||||
<div>
|
||||
<h1>OpenClaw Browser Relay</h1>
|
||||
<p class="subtitle">Click the toolbar button on a tab to attach / detach.</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="grid">
|
||||
<div class="card">
|
||||
<h2>Getting started</h2>
|
||||
<p>
|
||||
If you see a red <code>!</code> badge on the extension icon, the relay server is not reachable.
|
||||
Start OpenClaw’s browser relay on this machine (Gateway or node host), then click the toolbar button again.
|
||||
</p>
|
||||
<p>
|
||||
Full guide (install, remote Gateway, security): <a href="https://docs.openclaw.ai/tools/chrome-extension" target="_blank" rel="noreferrer">docs.openclaw.ai/tools/chrome-extension</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Relay connection</h2>
|
||||
<label for="port">Port</label>
|
||||
<div class="row">
|
||||
<input id="port" inputmode="numeric" pattern="[0-9]*" />
|
||||
</div>
|
||||
<label for="token" style="margin-top: 10px">Gateway token</label>
|
||||
<div class="row">
|
||||
<input id="token" type="password" autocomplete="off" style="width: min(520px, 100%)" />
|
||||
<button id="save" type="button">Save</button>
|
||||
</div>
|
||||
<div class="hint">
|
||||
Default port: <code>18792</code>. Extension connects to: <code id="relay-url">http://127.0.0.1:<port>/</code>.
|
||||
Gateway token must match <code>gateway.auth.token</code> (or <code>OPENCLAW_GATEWAY_TOKEN</code>).
|
||||
</div>
|
||||
<div class="status" id="status"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module" src="options.js"></script>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,74 +0,0 @@
|
||||
import { deriveRelayToken } from './background-utils.js'
|
||||
import { classifyRelayCheckException, classifyRelayCheckResponse } from './options-validation.js'
|
||||
|
||||
const DEFAULT_PORT = 18792
|
||||
|
||||
function clampPort(value) {
|
||||
const n = Number.parseInt(String(value || ''), 10)
|
||||
if (!Number.isFinite(n)) return DEFAULT_PORT
|
||||
if (n <= 0 || n > 65535) return DEFAULT_PORT
|
||||
return n
|
||||
}
|
||||
|
||||
function updateRelayUrl(port) {
|
||||
const el = document.getElementById('relay-url')
|
||||
if (!el) return
|
||||
el.textContent = `http://127.0.0.1:${port}/`
|
||||
}
|
||||
|
||||
function setStatus(kind, message) {
|
||||
const status = document.getElementById('status')
|
||||
if (!status) return
|
||||
status.dataset.kind = kind || ''
|
||||
status.textContent = message || ''
|
||||
}
|
||||
|
||||
async function checkRelayReachable(port, token) {
|
||||
const url = `http://127.0.0.1:${port}/json/version`
|
||||
const trimmedToken = String(token || '').trim()
|
||||
if (!trimmedToken) {
|
||||
setStatus('error', 'Gateway token required. Save your gateway token to connect.')
|
||||
return
|
||||
}
|
||||
try {
|
||||
const relayToken = await deriveRelayToken(trimmedToken, port)
|
||||
// Delegate the fetch to the background service worker to bypass
|
||||
// CORS preflight on the custom x-openclaw-relay-token header.
|
||||
const res = await chrome.runtime.sendMessage({
|
||||
type: 'relayCheck',
|
||||
url,
|
||||
token: relayToken,
|
||||
})
|
||||
const result = classifyRelayCheckResponse(res, port)
|
||||
if (result.action === 'throw') throw new Error(result.error)
|
||||
setStatus(result.kind, result.message)
|
||||
} catch (err) {
|
||||
const result = classifyRelayCheckException(err, port)
|
||||
setStatus(result.kind, result.message)
|
||||
}
|
||||
}
|
||||
|
||||
async function load() {
|
||||
const stored = await chrome.storage.local.get(['relayPort', 'gatewayToken'])
|
||||
const port = clampPort(stored.relayPort)
|
||||
const token = String(stored.gatewayToken || '').trim()
|
||||
document.getElementById('port').value = String(port)
|
||||
document.getElementById('token').value = token
|
||||
updateRelayUrl(port)
|
||||
await checkRelayReachable(port, token)
|
||||
}
|
||||
|
||||
async function save() {
|
||||
const portInput = document.getElementById('port')
|
||||
const tokenInput = document.getElementById('token')
|
||||
const port = clampPort(portInput.value)
|
||||
const token = String(tokenInput.value || '').trim()
|
||||
await chrome.storage.local.set({ relayPort: port, gatewayToken: token })
|
||||
portInput.value = String(port)
|
||||
tokenInput.value = token
|
||||
updateRelayUrl(port)
|
||||
await checkRelayReachable(port, token)
|
||||
}
|
||||
|
||||
document.getElementById('save').addEventListener('click', () => void save())
|
||||
void load()
|
||||
Reference in New Issue
Block a user