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:
leeguooooo
2026-06-09 16:39:38 +09:00
parent 7f672494c1
commit 528de4230f
9 changed files with 535 additions and 1291 deletions
+289
View File
@@ -0,0 +1,289 @@
//! `agent-browser connect` — zero-confirmation control of the user's real,
//! logged-in Chrome via the `ab-connect` MV3 extension over Chrome **native
//! messaging** (no localhost port, no token; Chrome authenticates the extension
//! to this host by id).
//!
//! Two pieces live here:
//! - `run_connect` — `--install` writes the native-messaging host manifest (and
//! a tiny launcher) so Chrome will spawn us; with no flag it reports status.
//! - `run_nm_host` — the hidden `__nm-host` mode Chrome launches: it speaks the
//! native-messaging stdio framing (4-byte little-endian length + JSON).
//!
//! This step wires the transport end-to-end (Chrome ⇄ host). Bridging the host
//! to the daemon's relay + CdpClient is layered on next.
use std::io::{Read, Write};
use std::path::PathBuf;
/// Native-messaging host name; must match `HOST_NAME` in the extension and the
/// manifest filename.
pub const HOST_NAME: &str = "com.agent_browser.connect";
/// Stable id of the `ab-connect` extension, pinned by the `key` in its
/// manifest.json. Chrome only lets that extension talk to this host.
pub const EXTENSION_ID: &str = "bdoiejojpjogcjojeladhioioijhgade";
/// `agent-browser extension <install|uninstall|status>` (local; no daemon).
/// `args` is the cleaned argv including the leading "extension".
pub fn run_connect(args: &[String], json: bool) {
let install = args.iter().any(|a| a == "--install" || a == "install");
let uninstall = args.iter().any(|a| a == "--uninstall" || a == "uninstall");
if uninstall {
let removed = remove_host_manifests();
report(json, true, &format!("removed {removed} native-host manifest(s)"));
return;
}
if install {
match install_native_host() {
Ok(paths) => {
if json {
println!(
"{}",
serde_json::to_string(&serde_json::json!({
"success": true,
"data": { "installed": paths, "extensionId": EXTENSION_ID }
}))
.unwrap_or_default()
);
} else {
println!("✓ native-messaging host installed:");
for p in &paths {
println!(" {p}");
}
println!(
"\nNext: load the ab-connect extension in Chrome (chrome://extensions →\n\
Developer mode → Load unpacked → extensions/ab-connect), then this host\n\
is reachable with no token and no per-use confirmation."
);
}
}
Err(e) => report(json, false, &format!("install failed: {e}")),
}
return;
}
// Status.
let manifest = host_manifest_path_for_chrome();
let installed = manifest.as_ref().map(|p| p.exists()).unwrap_or(false);
if json {
println!(
"{}",
serde_json::to_string(&serde_json::json!({
"success": true,
"data": {
"installed": installed,
"manifest": manifest.as_ref().map(|p| p.display().to_string()),
"extensionId": EXTENSION_ID,
}
}))
.unwrap_or_default()
);
} else if installed {
println!("✓ native-messaging host installed ({HOST_NAME}).");
println!(" Load the ab-connect extension and it connects automatically.");
} else {
println!("✗ not installed. Run: agent-browser connect --install");
}
}
/// Write the launcher script + native-messaging host manifest(s).
fn install_native_host() -> Result<Vec<String>, String> {
let home = dirs::home_dir().ok_or("no home dir")?;
let ab_dir = home.join(".agent-browser");
std::fs::create_dir_all(&ab_dir).map_err(|e| e.to_string())?;
// Chrome execs the manifest `path` directly with the calling extension's
// origin as argv[1]; a launcher lets us run the binary in __nm-host mode
// regardless of how/where agent-browser is installed.
let exe = std::env::current_exe().map_err(|e| e.to_string())?;
let launcher = ab_dir.join("nm-host.sh");
let script = format!(
"#!/bin/sh\n# agent-browser native-messaging host launcher (auto-generated)\nexec \"{}\" __nm-host \"$@\"\n",
exe.display()
);
std::fs::write(&launcher, script).map_err(|e| e.to_string())?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(&launcher, std::fs::Permissions::from_mode(0o755));
}
let manifest = serde_json::json!({
"name": HOST_NAME,
"description": "agent-browser connect — native messaging host",
"path": launcher.display().to_string(),
"type": "stdio",
"allowed_origins": [format!("chrome-extension://{EXTENSION_ID}/")],
});
let body = serde_json::to_string_pretty(&manifest).map_err(|e| e.to_string())?;
let mut written = Vec::new();
for dir in native_messaging_dirs() {
if let Some(parent) = dir.parent() {
if !parent.exists() {
continue; // that browser isn't installed
}
}
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
let path = dir.join(format!("{HOST_NAME}.json"));
std::fs::write(&path, &body).map_err(|e| e.to_string())?;
written.push(path.display().to_string());
}
if written.is_empty() {
return Err("no Chrome/Chromium NativeMessagingHosts directory found".into());
}
Ok(written)
}
fn remove_host_manifests() -> usize {
let mut n = 0;
for dir in native_messaging_dirs() {
let path = dir.join(format!("{HOST_NAME}.json"));
if path.exists() && std::fs::remove_file(&path).is_ok() {
n += 1;
}
}
n
}
/// Per-OS NativeMessagingHosts directories for Chrome + Chromium-family browsers.
fn native_messaging_dirs() -> Vec<PathBuf> {
let mut dirs_out = Vec::new();
#[cfg(target_os = "macos")]
{
if let Some(app_support) = dirs::config_dir() {
for sub in [
"Google/Chrome",
"Google/Chrome Beta",
"Google/Chrome Canary",
"Chromium",
"Microsoft Edge",
"BraveSoftware/Brave-Browser",
] {
dirs_out.push(app_support.join(sub).join("NativeMessagingHosts"));
}
}
}
#[cfg(all(unix, not(target_os = "macos")))]
{
if let Some(config) = dirs::config_dir() {
for sub in ["google-chrome", "chromium", "microsoft-edge", "BraveSoftware/Brave-Browser"] {
dirs_out.push(config.join(sub).join("NativeMessagingHosts"));
}
}
}
dirs_out
}
fn host_manifest_path_for_chrome() -> Option<PathBuf> {
native_messaging_dirs()
.into_iter()
.map(|d| d.join(format!("{HOST_NAME}.json")))
.find(|p| p.exists())
.or_else(|| {
native_messaging_dirs()
.into_iter()
.next()
.map(|d| d.join(format!("{HOST_NAME}.json")))
})
}
fn report(json: bool, ok: bool, msg: &str) {
if json {
println!(
"{}",
serde_json::to_string(&serde_json::json!({ "success": ok, "error": if ok { serde_json::Value::Null } else { serde_json::json!(msg) }, "message": msg }))
.unwrap_or_default()
);
} else if ok {
println!("{msg}");
} else {
eprintln!("{msg}");
}
if !ok {
std::process::exit(1);
}
}
// ---- native messaging host (`__nm-host`) ----------------------------------
/// Read one native-messaging frame from stdin: 4-byte native-endian length,
/// then that many bytes of UTF-8 JSON. Returns `None` on clean EOF.
fn read_frame(stdin: &mut impl Read) -> std::io::Result<Option<Vec<u8>>> {
let mut len_buf = [0u8; 4];
match stdin.read_exact(&mut len_buf) {
Ok(()) => {}
Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => return Ok(None),
Err(e) => return Err(e),
}
let len = u32::from_ne_bytes(len_buf) as usize;
let mut buf = vec![0u8; len];
stdin.read_exact(&mut buf)?;
Ok(Some(buf))
}
/// Write one native-messaging frame to stdout.
fn write_frame(stdout: &mut impl Write, payload: &[u8]) -> std::io::Result<()> {
let len = payload.len() as u32;
stdout.write_all(&len.to_ne_bytes())?;
stdout.write_all(payload)?;
stdout.flush()
}
/// Hidden `__nm-host` mode: launched by Chrome for the ab-connect extension.
///
/// Step 1 (this commit): speak the framing correctly and log what the extension
/// sends to `~/.agent-browser/nm-host.log`, replying `pong` to `ping`. The next
/// step bridges these frames to the daemon's relay + CdpClient.
pub fn run_nm_host() {
let log_path = dirs::home_dir()
.map(|h| h.join(".agent-browser").join("nm-host.log"))
.unwrap_or_else(|| PathBuf::from("/tmp/ab-nm-host.log"));
if let Some(p) = log_path.parent() {
let _ = std::fs::create_dir_all(p);
}
let mut log = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&log_path)
.ok();
let mut logln = |s: &str| {
if let Some(f) = log.as_mut() {
let _ = writeln!(f, "{s}");
let _ = f.flush();
}
};
logln(&format!(
"[nm-host] started argv={:?}",
std::env::args().skip(1).collect::<Vec<_>>()
));
let mut stdin = std::io::stdin().lock();
let mut stdout = std::io::stdout().lock();
let mut count = 0usize;
loop {
match read_frame(&mut stdin) {
Ok(Some(bytes)) => {
count += 1;
let text = String::from_utf8_lossy(&bytes);
// Log a compact summary (method + sizes) without flooding.
let summary: String = text.chars().take(300).collect();
logln(&format!("[nm-host] recv #{count} ({} bytes): {summary}", bytes.len()));
if let Ok(v) = serde_json::from_slice::<serde_json::Value>(&bytes) {
if v.get("method").and_then(|m| m.as_str()) == Some("ping") {
let _ = write_frame(&mut stdout, br#"{"method":"pong"}"#);
}
}
}
Ok(None) => {
logln("[nm-host] stdin EOF — Chrome closed the port");
break;
}
Err(e) => {
logln(&format!("[nm-host] read error: {e}"));
break;
}
}
}
}
+16
View File
@@ -2,6 +2,7 @@ mod chat;
mod color;
mod commands;
mod connection;
mod connect;
mod doctor;
mod findurl;
mod flags;
@@ -503,6 +504,14 @@ fn main() {
env::set_var("MSYS2_ARG_CONV_EXCL", "*");
}
// Native-messaging host mode: Chrome launches `agent-browser __nm-host
// <extension-origin> [...]` for the ab-connect extension. Must run before
// ANY stdout write — stdout is the Chrome native-messaging channel.
if env::args().nth(1).as_deref() == Some("__nm-host") {
connect::run_nm_host();
return;
}
// Native daemon mode: when AGENT_BROWSER_DAEMON is set, run as the daemon process
if env::var("AGENT_BROWSER_DAEMON").is_ok() {
// Ignore SIGPIPE so the daemon isn't killed when the parent drops
@@ -641,6 +650,13 @@ fn main() {
return;
}
// Handle extension (doesn't need daemon): native-messaging host install/status
// for the ab-connect extension. (`connect <port>` stays the CDP-attach command.)
if clean.first().map(|s| s.as_str()) == Some("extension") {
connect::run_connect(&clean, flags.json);
return;
}
// Handle session separately (doesn't need daemon)
if clean.first().map(|s| s.as_str()) == Some("session") {
run_session(&clean, &flags.session, flags.json);
+8 -7
View File
@@ -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.
-48
View File
@@ -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;
}
File diff suppressed because it is too large Load Diff
+5 -14
View File
@@ -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.`,
}
}
-200
View File
@@ -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 OpenClaws 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:&lt;port&gt;/</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>
-74
View File
@@ -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()