mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
fix(browser): harden extension relay worker recovery
Co-authored-by: codexGW <9350182+codexGW@users.noreply.github.com>
This commit is contained in:
@@ -53,6 +53,7 @@ Docs: https://docs.openclaw.ai
|
|||||||
- Install/Discord Voice: make `@discordjs/opus` an optional dependency so `openclaw` install/update no longer hard-fails when native Opus builds fail, while keeping `opusscript` as the runtime fallback decoder for Discord voice flows. (#23737, #23733, #23703)
|
- Install/Discord Voice: make `@discordjs/opus` an optional dependency so `openclaw` install/update no longer hard-fails when native Opus builds fail, while keeping `opusscript` as the runtime fallback decoder for Discord voice flows. (#23737, #23733, #23703)
|
||||||
- Slack/Upload: resolve bare user IDs (U-prefix) to DM channel IDs via `conversations.open` before calling `files.uploadV2`, which rejects non-channel IDs. `chat.postMessage` tolerates user IDs directly, but `files.uploadV2` → `completeUploadExternal` validates `channel_id` against `^[CGDZ][A-Z0-9]{8,}$`, causing `invalid_arguments` when agents reply with media to DM conversations.
|
- Slack/Upload: resolve bare user IDs (U-prefix) to DM channel IDs via `conversations.open` before calling `files.uploadV2`, which rejects non-channel IDs. `chat.postMessage` tolerates user IDs directly, but `files.uploadV2` → `completeUploadExternal` validates `channel_id` against `^[CGDZ][A-Z0-9]{8,}$`, causing `invalid_arguments` when agents reply with media to DM conversations.
|
||||||
- Browser/Relay: treat extension websocket as connected only when `OPEN`, allow reconnect when a stale `CLOSING/CLOSED` extension socket lingers, and guard stale socket message/close handlers so late events cannot clear active relay state; includes regression coverage for live-duplicate `409` rejection and immediate reconnect-after-close races. (#15099, #18698, #20688)
|
- Browser/Relay: treat extension websocket as connected only when `OPEN`, allow reconnect when a stale `CLOSING/CLOSED` extension socket lingers, and guard stale socket message/close handlers so late events cannot clear active relay state; includes regression coverage for live-duplicate `409` rejection and immediate reconnect-after-close races. (#15099, #18698, #20688)
|
||||||
|
- Browser/Extension Relay: refactor the MV3 worker to preserve debugger attachments across relay drops, auto-reconnect with bounded backoff+jitter, persist and rehydrate attached tab state via `chrome.storage.session`, recover from `target_closed` navigation detaches, guard stale socket handlers, enforce per-tab operation locks and per-request timeouts, and add lifecycle keepalive/badge refresh hooks (`alarms`, `webNavigation`). (#15099, #6175, #8468, #9807)
|
||||||
- Signal/RPC: guard malformed Signal RPC JSON responses with a clear status-scoped error and add regression coverage for invalid JSON responses. (#22995) Thanks @adhitShet.
|
- Signal/RPC: guard malformed Signal RPC JSON responses with a clear status-scoped error and add regression coverage for invalid JSON responses. (#22995) Thanks @adhitShet.
|
||||||
- Gateway/Subagents: guard gateway and subagent session-key/message trim paths against undefined inputs to prevent early `Cannot read properties of undefined (reading 'trim')` crashes during subagent spawn and wait flows.
|
- Gateway/Subagents: guard gateway and subagent session-key/message trim paths against undefined inputs to prevent early `Cannot read properties of undefined (reading 'trim')` crashes during subagent spawn and wait flows.
|
||||||
- Agents/Workspace: guard `resolveUserPath` against undefined/null input to prevent `Cannot read properties of undefined (reading 'trim')` crashes when workspace paths are missing in embedded runner flows.
|
- Agents/Workspace: guard `resolveUserPath` against undefined/null input to prevent `Cannot read properties of undefined (reading 'trim')` crashes when workspace paths are missing in embedded runner flows.
|
||||||
|
|||||||
30
assets/chrome-extension/background-utils.js
Normal file
30
assets/chrome-extension/background-utils.js
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
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 function buildRelayWsUrl(port, gatewayToken) {
|
||||||
|
const token = String(gatewayToken || "").trim();
|
||||||
|
if (!token) {
|
||||||
|
throw new Error(
|
||||||
|
"Missing gatewayToken in extension settings (chrome.storage.local.gatewayToken)",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return `ws://127.0.0.1:${port}/extension?token=${encodeURIComponent(token)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isRetryableReconnectError(err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err || "");
|
||||||
|
if (message.includes("Missing gatewayToken")) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { buildRelayWsUrl, isRetryableReconnectError, reconnectDelayMs } from './background-utils.js'
|
||||||
|
|
||||||
const DEFAULT_PORT = 18792
|
const DEFAULT_PORT = 18792
|
||||||
|
|
||||||
const BADGE = {
|
const BADGE = {
|
||||||
@@ -12,8 +14,6 @@ let relayWs = null
|
|||||||
/** @type {Promise<void>|null} */
|
/** @type {Promise<void>|null} */
|
||||||
let relayConnectPromise = null
|
let relayConnectPromise = null
|
||||||
|
|
||||||
let debuggerListenersInstalled = false
|
|
||||||
|
|
||||||
let nextSession = 1
|
let nextSession = 1
|
||||||
|
|
||||||
/** @type {Map<number, {state:'connecting'|'connected', sessionId?:string, targetId?:string, attachOrder?:number}>} */
|
/** @type {Map<number, {state:'connecting'|'connected', sessionId?:string, targetId?:string, attachOrder?:number}>} */
|
||||||
@@ -26,6 +26,14 @@ const childSessionToTab = new Map()
|
|||||||
/** @type {Map<number, {resolve:(v:any)=>void, reject:(e:Error)=>void}>} */
|
/** @type {Map<number, {resolve:(v:any)=>void, reject:(e:Error)=>void}>} */
|
||||||
const pending = new Map()
|
const pending = new Map()
|
||||||
|
|
||||||
|
// Per-tab operation locks prevent double-attach races.
|
||||||
|
/** @type {Set<number>} */
|
||||||
|
const tabOperationLocks = new Set()
|
||||||
|
|
||||||
|
// Reconnect state for exponential backoff.
|
||||||
|
let reconnectAttempt = 0
|
||||||
|
let reconnectTimer = null
|
||||||
|
|
||||||
function nowStack() {
|
function nowStack() {
|
||||||
try {
|
try {
|
||||||
return new Error().stack || ''
|
return new Error().stack || ''
|
||||||
@@ -55,6 +63,63 @@ function setBadge(tabId, kind) {
|
|||||||
void chrome.action.setBadgeTextColor({ tabId, color: '#FFFFFF' }).catch(() => {})
|
void chrome.action.setBadgeTextColor({ tabId, color: '#FFFFFF' }).catch(() => {})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Persist attached tab state to survive MV3 service worker restarts.
|
||||||
|
async function persistState() {
|
||||||
|
try {
|
||||||
|
const tabEntries = []
|
||||||
|
for (const [tabId, tab] of tabs.entries()) {
|
||||||
|
if (tab.state === 'connected' && tab.sessionId && tab.targetId) {
|
||||||
|
tabEntries.push({ tabId, sessionId: tab.sessionId, targetId: tab.targetId, attachOrder: tab.attachOrder })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await chrome.storage.session.set({
|
||||||
|
persistedTabs: tabEntries,
|
||||||
|
nextSession,
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
// chrome.storage.session may not be available in all contexts.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rehydrate tab state on service worker startup. Fast path — just restores
|
||||||
|
// maps and badges. Relay reconnect happens separately in background.
|
||||||
|
async function rehydrateState() {
|
||||||
|
try {
|
||||||
|
const stored = await chrome.storage.session.get(['persistedTabs', 'nextSession'])
|
||||||
|
if (stored.nextSession) {
|
||||||
|
nextSession = Math.max(nextSession, stored.nextSession)
|
||||||
|
}
|
||||||
|
const entries = stored.persistedTabs || []
|
||||||
|
// Phase 1: optimistically restore state and badges.
|
||||||
|
for (const entry of entries) {
|
||||||
|
tabs.set(entry.tabId, {
|
||||||
|
state: 'connected',
|
||||||
|
sessionId: entry.sessionId,
|
||||||
|
targetId: entry.targetId,
|
||||||
|
attachOrder: entry.attachOrder,
|
||||||
|
})
|
||||||
|
tabBySession.set(entry.sessionId, entry.tabId)
|
||||||
|
setBadge(entry.tabId, 'on')
|
||||||
|
}
|
||||||
|
// Phase 2: validate asynchronously, remove dead tabs.
|
||||||
|
for (const entry of entries) {
|
||||||
|
try {
|
||||||
|
await chrome.tabs.get(entry.tabId)
|
||||||
|
await chrome.debugger.sendCommand({ tabId: entry.tabId }, 'Runtime.evaluate', {
|
||||||
|
expression: '1',
|
||||||
|
returnByValue: true,
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
tabs.delete(entry.tabId)
|
||||||
|
tabBySession.delete(entry.sessionId)
|
||||||
|
setBadge(entry.tabId, 'off')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore rehydration errors.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function ensureRelayConnection() {
|
async function ensureRelayConnection() {
|
||||||
if (relayWs && relayWs.readyState === WebSocket.OPEN) return
|
if (relayWs && relayWs.readyState === WebSocket.OPEN) return
|
||||||
if (relayConnectPromise) return await relayConnectPromise
|
if (relayConnectPromise) return await relayConnectPromise
|
||||||
@@ -63,9 +128,7 @@ async function ensureRelayConnection() {
|
|||||||
const port = await getRelayPort()
|
const port = await getRelayPort()
|
||||||
const gatewayToken = await getGatewayToken()
|
const gatewayToken = await getGatewayToken()
|
||||||
const httpBase = `http://127.0.0.1:${port}`
|
const httpBase = `http://127.0.0.1:${port}`
|
||||||
const wsUrl = gatewayToken
|
const wsUrl = buildRelayWsUrl(port, gatewayToken)
|
||||||
? `ws://127.0.0.1:${port}/extension?token=${encodeURIComponent(gatewayToken)}`
|
|
||||||
: `ws://127.0.0.1:${port}/extension`
|
|
||||||
|
|
||||||
// Fast preflight: is the relay server up?
|
// Fast preflight: is the relay server up?
|
||||||
try {
|
try {
|
||||||
@@ -74,12 +137,6 @@ async function ensureRelayConnection() {
|
|||||||
throw new Error(`Relay server not reachable at ${httpBase} (${String(err)})`)
|
throw new Error(`Relay server not reachable at ${httpBase} (${String(err)})`)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!gatewayToken) {
|
|
||||||
throw new Error(
|
|
||||||
'Missing gatewayToken in extension settings (chrome.storage.local.gatewayToken)',
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const ws = new WebSocket(wsUrl)
|
const ws = new WebSocket(wsUrl)
|
||||||
relayWs = ws
|
relayWs = ws
|
||||||
|
|
||||||
@@ -99,42 +156,142 @@ async function ensureRelayConnection() {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
ws.onmessage = (event) => void onRelayMessage(String(event.data || ''))
|
// Bind permanent handlers. Guard against stale socket: if this WS was
|
||||||
ws.onclose = () => onRelayClosed('closed')
|
// replaced before its close fires, the handler is a no-op.
|
||||||
ws.onerror = () => onRelayClosed('error')
|
ws.onmessage = (event) => {
|
||||||
|
if (ws !== relayWs) return
|
||||||
if (!debuggerListenersInstalled) {
|
void whenReady(() => onRelayMessage(String(event.data || '')))
|
||||||
debuggerListenersInstalled = true
|
}
|
||||||
chrome.debugger.onEvent.addListener(onDebuggerEvent)
|
ws.onclose = () => {
|
||||||
chrome.debugger.onDetach.addListener(onDebuggerDetach)
|
if (ws !== relayWs) return
|
||||||
|
onRelayClosed('closed')
|
||||||
|
}
|
||||||
|
ws.onerror = () => {
|
||||||
|
if (ws !== relayWs) return
|
||||||
|
onRelayClosed('error')
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await relayConnectPromise
|
await relayConnectPromise
|
||||||
|
reconnectAttempt = 0
|
||||||
} finally {
|
} finally {
|
||||||
relayConnectPromise = null
|
relayConnectPromise = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Relay closed — update badges, reject pending requests, auto-reconnect.
|
||||||
|
// Debugger sessions are kept alive so they survive transient WS drops.
|
||||||
function onRelayClosed(reason) {
|
function onRelayClosed(reason) {
|
||||||
relayWs = null
|
relayWs = null
|
||||||
|
|
||||||
for (const [id, p] of pending.entries()) {
|
for (const [id, p] of pending.entries()) {
|
||||||
pending.delete(id)
|
pending.delete(id)
|
||||||
p.reject(new Error(`Relay disconnected (${reason})`))
|
p.reject(new Error(`Relay disconnected (${reason})`))
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const tabId of tabs.keys()) {
|
for (const [tabId, tab] of tabs.entries()) {
|
||||||
void chrome.debugger.detach({ tabId }).catch(() => {})
|
if (tab.state === 'connected') {
|
||||||
setBadge(tabId, 'connecting')
|
setBadge(tabId, 'connecting')
|
||||||
void chrome.action.setTitle({
|
void chrome.action.setTitle({
|
||||||
tabId,
|
tabId,
|
||||||
title: 'OpenClaw Browser Relay: disconnected (click to re-attach)',
|
title: 'OpenClaw Browser Relay: relay reconnecting…',
|
||||||
})
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
tabs.clear()
|
|
||||||
tabBySession.clear()
|
scheduleReconnect()
|
||||||
childSessionToTab.clear()
|
}
|
||||||
|
|
||||||
|
function scheduleReconnect() {
|
||||||
|
if (reconnectTimer) {
|
||||||
|
clearTimeout(reconnectTimer)
|
||||||
|
reconnectTimer = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const delay = reconnectDelayMs(reconnectAttempt)
|
||||||
|
reconnectAttempt++
|
||||||
|
|
||||||
|
console.log(`Scheduling reconnect attempt ${reconnectAttempt} in ${Math.round(delay)}ms`)
|
||||||
|
|
||||||
|
reconnectTimer = setTimeout(async () => {
|
||||||
|
reconnectTimer = null
|
||||||
|
try {
|
||||||
|
await ensureRelayConnection()
|
||||||
|
reconnectAttempt = 0
|
||||||
|
console.log('Reconnected successfully')
|
||||||
|
await reannounceAttachedTabs()
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err)
|
||||||
|
console.warn(`Reconnect attempt ${reconnectAttempt} failed: ${message}`)
|
||||||
|
if (!isRetryableReconnectError(err)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
scheduleReconnect()
|
||||||
|
}
|
||||||
|
}, delay)
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelReconnect() {
|
||||||
|
if (reconnectTimer) {
|
||||||
|
clearTimeout(reconnectTimer)
|
||||||
|
reconnectTimer = null
|
||||||
|
}
|
||||||
|
reconnectAttempt = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-announce all attached tabs to the relay after reconnect.
|
||||||
|
async function reannounceAttachedTabs() {
|
||||||
|
for (const [tabId, tab] of tabs.entries()) {
|
||||||
|
if (tab.state !== 'connected' || !tab.sessionId || !tab.targetId) continue
|
||||||
|
|
||||||
|
// Verify debugger is still attached.
|
||||||
|
try {
|
||||||
|
await chrome.debugger.sendCommand({ tabId }, 'Runtime.evaluate', {
|
||||||
|
expression: '1',
|
||||||
|
returnByValue: true,
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
tabs.delete(tabId)
|
||||||
|
if (tab.sessionId) tabBySession.delete(tab.sessionId)
|
||||||
|
setBadge(tabId, 'off')
|
||||||
|
void chrome.action.setTitle({
|
||||||
|
tabId,
|
||||||
|
title: 'OpenClaw Browser Relay (click to attach/detach)',
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send fresh attach event to relay.
|
||||||
|
try {
|
||||||
|
const info = /** @type {any} */ (
|
||||||
|
await chrome.debugger.sendCommand({ tabId }, 'Target.getTargetInfo')
|
||||||
|
)
|
||||||
|
const targetInfo = info?.targetInfo
|
||||||
|
|
||||||
|
sendToRelay({
|
||||||
|
method: 'forwardCDPEvent',
|
||||||
|
params: {
|
||||||
|
method: 'Target.attachedToTarget',
|
||||||
|
params: {
|
||||||
|
sessionId: tab.sessionId,
|
||||||
|
targetInfo: { ...targetInfo, attached: true },
|
||||||
|
waitingForDebugger: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
setBadge(tabId, 'on')
|
||||||
|
void chrome.action.setTitle({
|
||||||
|
tabId,
|
||||||
|
title: 'OpenClaw Browser Relay: attached (click to detach)',
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
setBadge(tabId, 'on')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await persistState()
|
||||||
}
|
}
|
||||||
|
|
||||||
function sendToRelay(payload) {
|
function sendToRelay(payload) {
|
||||||
@@ -159,10 +316,18 @@ async function maybeOpenHelpOnce() {
|
|||||||
function requestFromRelay(command) {
|
function requestFromRelay(command) {
|
||||||
const id = command.id
|
const id = command.id
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
pending.set(id, { resolve, reject })
|
const timer = setTimeout(() => {
|
||||||
|
pending.delete(id)
|
||||||
|
reject(new Error('Relay request timeout (30s)'))
|
||||||
|
}, 30000)
|
||||||
|
pending.set(id, {
|
||||||
|
resolve: (v) => { clearTimeout(timer); resolve(v) },
|
||||||
|
reject: (e) => { clearTimeout(timer); reject(e) },
|
||||||
|
})
|
||||||
try {
|
try {
|
||||||
sendToRelay(command)
|
sendToRelay(command)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
clearTimeout(timer)
|
||||||
pending.delete(id)
|
pending.delete(id)
|
||||||
reject(err instanceof Error ? err : new Error(String(err)))
|
reject(err instanceof Error ? err : new Error(String(err)))
|
||||||
}
|
}
|
||||||
@@ -233,8 +398,9 @@ async function attachTab(tabId, opts = {}) {
|
|||||||
throw new Error('Target.getTargetInfo returned no targetId')
|
throw new Error('Target.getTargetInfo returned no targetId')
|
||||||
}
|
}
|
||||||
|
|
||||||
const sessionId = `cb-tab-${nextSession++}`
|
const sid = nextSession++
|
||||||
const attachOrder = nextSession
|
const sessionId = `cb-tab-${sid}`
|
||||||
|
const attachOrder = sid
|
||||||
|
|
||||||
tabs.set(tabId, { state: 'connected', sessionId, targetId, attachOrder })
|
tabs.set(tabId, { state: 'connected', sessionId, targetId, attachOrder })
|
||||||
tabBySession.set(sessionId, tabId)
|
tabBySession.set(sessionId, tabId)
|
||||||
@@ -258,11 +424,33 @@ async function attachTab(tabId, opts = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setBadge(tabId, 'on')
|
setBadge(tabId, 'on')
|
||||||
|
await persistState()
|
||||||
|
|
||||||
return { sessionId, targetId }
|
return { sessionId, targetId }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function detachTab(tabId, reason) {
|
async function detachTab(tabId, reason) {
|
||||||
const tab = tabs.get(tabId)
|
const tab = tabs.get(tabId)
|
||||||
|
|
||||||
|
// Send detach events for child sessions first.
|
||||||
|
for (const [childSessionId, parentTabId] of childSessionToTab.entries()) {
|
||||||
|
if (parentTabId === tabId) {
|
||||||
|
try {
|
||||||
|
sendToRelay({
|
||||||
|
method: 'forwardCDPEvent',
|
||||||
|
params: {
|
||||||
|
method: 'Target.detachedFromTarget',
|
||||||
|
params: { sessionId: childSessionId, reason: 'parent_detached' },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
// Relay may be down.
|
||||||
|
}
|
||||||
|
childSessionToTab.delete(childSessionId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send detach event for main session.
|
||||||
if (tab?.sessionId && tab?.targetId) {
|
if (tab?.sessionId && tab?.targetId) {
|
||||||
try {
|
try {
|
||||||
sendToRelay({
|
sendToRelay({
|
||||||
@@ -273,21 +461,17 @@ async function detachTab(tabId, reason) {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// Relay may be down.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (tab?.sessionId) tabBySession.delete(tab.sessionId)
|
if (tab?.sessionId) tabBySession.delete(tab.sessionId)
|
||||||
tabs.delete(tabId)
|
tabs.delete(tabId)
|
||||||
|
|
||||||
for (const [childSessionId, parentTabId] of childSessionToTab.entries()) {
|
|
||||||
if (parentTabId === tabId) childSessionToTab.delete(childSessionId)
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await chrome.debugger.detach({ tabId })
|
await chrome.debugger.detach({ tabId })
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// May already be detached.
|
||||||
}
|
}
|
||||||
|
|
||||||
setBadge(tabId, 'off')
|
setBadge(tabId, 'off')
|
||||||
@@ -295,6 +479,8 @@ async function detachTab(tabId, reason) {
|
|||||||
tabId,
|
tabId,
|
||||||
title: 'OpenClaw Browser Relay (click to attach/detach)',
|
title: 'OpenClaw Browser Relay (click to attach/detach)',
|
||||||
})
|
})
|
||||||
|
|
||||||
|
await persistState()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function connectOrToggleForActiveTab() {
|
async function connectOrToggleForActiveTab() {
|
||||||
@@ -302,33 +488,43 @@ async function connectOrToggleForActiveTab() {
|
|||||||
const tabId = active?.id
|
const tabId = active?.id
|
||||||
if (!tabId) return
|
if (!tabId) return
|
||||||
|
|
||||||
const existing = tabs.get(tabId)
|
// Prevent concurrent operations on the same tab.
|
||||||
if (existing?.state === 'connected') {
|
if (tabOperationLocks.has(tabId)) return
|
||||||
await detachTab(tabId, 'toggle')
|
tabOperationLocks.add(tabId)
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
tabs.set(tabId, { state: 'connecting' })
|
|
||||||
setBadge(tabId, 'connecting')
|
|
||||||
void chrome.action.setTitle({
|
|
||||||
tabId,
|
|
||||||
title: 'OpenClaw Browser Relay: connecting to local relay…',
|
|
||||||
})
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await ensureRelayConnection()
|
const existing = tabs.get(tabId)
|
||||||
await attachTab(tabId)
|
if (existing?.state === 'connected') {
|
||||||
} catch (err) {
|
await detachTab(tabId, 'toggle')
|
||||||
tabs.delete(tabId)
|
return
|
||||||
setBadge(tabId, 'error')
|
}
|
||||||
|
|
||||||
|
// User is manually connecting — cancel any pending reconnect.
|
||||||
|
cancelReconnect()
|
||||||
|
|
||||||
|
tabs.set(tabId, { state: 'connecting' })
|
||||||
|
setBadge(tabId, 'connecting')
|
||||||
void chrome.action.setTitle({
|
void chrome.action.setTitle({
|
||||||
tabId,
|
tabId,
|
||||||
title: 'OpenClaw Browser Relay: relay not running (open options for setup)',
|
title: 'OpenClaw Browser Relay: connecting to local relay…',
|
||||||
})
|
})
|
||||||
void maybeOpenHelpOnce()
|
|
||||||
// Extra breadcrumbs in chrome://extensions service worker logs.
|
try {
|
||||||
const message = err instanceof Error ? err.message : String(err)
|
await ensureRelayConnection()
|
||||||
console.warn('attach failed', message, nowStack())
|
await attachTab(tabId)
|
||||||
|
} catch (err) {
|
||||||
|
tabs.delete(tabId)
|
||||||
|
setBadge(tabId, 'error')
|
||||||
|
void chrome.action.setTitle({
|
||||||
|
tabId,
|
||||||
|
title: 'OpenClaw Browser Relay: relay not running (open options for setup)',
|
||||||
|
})
|
||||||
|
void maybeOpenHelpOnce()
|
||||||
|
const message = err instanceof Error ? err.message : String(err)
|
||||||
|
console.warn('attach failed', message, nowStack())
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
tabOperationLocks.delete(tabId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -337,14 +533,12 @@ async function handleForwardCdpCommand(msg) {
|
|||||||
const params = msg?.params?.params || undefined
|
const params = msg?.params?.params || undefined
|
||||||
const sessionId = typeof msg?.params?.sessionId === 'string' ? msg.params.sessionId : undefined
|
const sessionId = typeof msg?.params?.sessionId === 'string' ? msg.params.sessionId : undefined
|
||||||
|
|
||||||
// Map command to tab
|
|
||||||
const bySession = sessionId ? getTabBySessionId(sessionId) : null
|
const bySession = sessionId ? getTabBySessionId(sessionId) : null
|
||||||
const targetId = typeof params?.targetId === 'string' ? params.targetId : undefined
|
const targetId = typeof params?.targetId === 'string' ? params.targetId : undefined
|
||||||
const tabId =
|
const tabId =
|
||||||
bySession?.tabId ||
|
bySession?.tabId ||
|
||||||
(targetId ? getTabByTargetId(targetId) : null) ||
|
(targetId ? getTabByTargetId(targetId) : null) ||
|
||||||
(() => {
|
(() => {
|
||||||
// No sessionId: pick the first connected tab (stable-ish).
|
|
||||||
for (const [id, tab] of tabs.entries()) {
|
for (const [id, tab] of tabs.entries()) {
|
||||||
if (tab.state === 'connected') return id
|
if (tab.state === 'connected') return id
|
||||||
}
|
}
|
||||||
@@ -434,20 +628,173 @@ function onDebuggerEvent(source, method, params) {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// Relay may be down.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Navigation/reload fires target_closed but the tab is still alive — Chrome
|
||||||
|
// just swaps the renderer process. Suppress the detach event to the relay and
|
||||||
|
// seamlessly re-attach after a short grace period.
|
||||||
function onDebuggerDetach(source, reason) {
|
function onDebuggerDetach(source, reason) {
|
||||||
const tabId = source.tabId
|
const tabId = source.tabId
|
||||||
if (!tabId) return
|
if (!tabId) return
|
||||||
if (!tabs.has(tabId)) return
|
if (!tabs.has(tabId)) return
|
||||||
|
|
||||||
|
if (reason === 'target_closed') {
|
||||||
|
const oldState = tabs.get(tabId)
|
||||||
|
setBadge(tabId, 'connecting')
|
||||||
|
void chrome.action.setTitle({
|
||||||
|
tabId,
|
||||||
|
title: 'OpenClaw Browser Relay: re-attaching after navigation…',
|
||||||
|
})
|
||||||
|
|
||||||
|
setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
// If user manually detached during the grace period, bail out.
|
||||||
|
if (!tabs.has(tabId)) return
|
||||||
|
const tab = await chrome.tabs.get(tabId)
|
||||||
|
if (tab && relayWs?.readyState === WebSocket.OPEN) {
|
||||||
|
console.log(`Re-attaching tab ${tabId} after navigation`)
|
||||||
|
if (oldState?.sessionId) tabBySession.delete(oldState.sessionId)
|
||||||
|
tabs.delete(tabId)
|
||||||
|
await attachTab(tabId, { skipAttachedEvent: false })
|
||||||
|
} else {
|
||||||
|
// Tab gone or relay down — full cleanup.
|
||||||
|
void detachTab(tabId, reason)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`Failed to re-attach tab ${tabId} after navigation:`, err.message)
|
||||||
|
void detachTab(tabId, reason)
|
||||||
|
}
|
||||||
|
}, 500)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Non-navigation detach (user action, crash, etc.) — full cleanup.
|
||||||
void detachTab(tabId, reason)
|
void detachTab(tabId, reason)
|
||||||
}
|
}
|
||||||
|
|
||||||
chrome.action.onClicked.addListener(() => void connectOrToggleForActiveTab())
|
// Tab lifecycle listeners — clean up stale entries.
|
||||||
|
chrome.tabs.onRemoved.addListener((tabId) => void whenReady(() => {
|
||||||
|
if (!tabs.has(tabId)) return
|
||||||
|
const tab = tabs.get(tabId)
|
||||||
|
if (tab?.sessionId) tabBySession.delete(tab.sessionId)
|
||||||
|
tabs.delete(tabId)
|
||||||
|
for (const [childSessionId, parentTabId] of childSessionToTab.entries()) {
|
||||||
|
if (parentTabId === tabId) childSessionToTab.delete(childSessionId)
|
||||||
|
}
|
||||||
|
if (tab?.sessionId && tab?.targetId) {
|
||||||
|
try {
|
||||||
|
sendToRelay({
|
||||||
|
method: 'forwardCDPEvent',
|
||||||
|
params: {
|
||||||
|
method: 'Target.detachedFromTarget',
|
||||||
|
params: { sessionId: tab.sessionId, targetId: tab.targetId, reason: 'tab_closed' },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
// Relay may be down.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
void persistState()
|
||||||
|
}))
|
||||||
|
|
||||||
|
chrome.tabs.onReplaced.addListener((addedTabId, removedTabId) => void whenReady(() => {
|
||||||
|
const tab = tabs.get(removedTabId)
|
||||||
|
if (!tab) return
|
||||||
|
tabs.delete(removedTabId)
|
||||||
|
tabs.set(addedTabId, tab)
|
||||||
|
if (tab.sessionId) {
|
||||||
|
tabBySession.set(tab.sessionId, addedTabId)
|
||||||
|
}
|
||||||
|
for (const [childSessionId, parentTabId] of childSessionToTab.entries()) {
|
||||||
|
if (parentTabId === removedTabId) {
|
||||||
|
childSessionToTab.set(childSessionId, addedTabId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setBadge(addedTabId, 'on')
|
||||||
|
void persistState()
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Register debugger listeners at module scope so detach/event handling works
|
||||||
|
// even when the relay WebSocket is down.
|
||||||
|
chrome.debugger.onEvent.addListener((...args) => void whenReady(() => onDebuggerEvent(...args)))
|
||||||
|
chrome.debugger.onDetach.addListener((...args) => void whenReady(() => onDebuggerDetach(...args)))
|
||||||
|
|
||||||
|
chrome.action.onClicked.addListener(() => void whenReady(() => connectOrToggleForActiveTab()))
|
||||||
|
|
||||||
|
// Refresh badge after navigation completes — service worker may have restarted
|
||||||
|
// during navigation, losing ephemeral badge state.
|
||||||
|
chrome.webNavigation.onCompleted.addListener(({ tabId, frameId }) => void whenReady(() => {
|
||||||
|
if (frameId !== 0) return
|
||||||
|
const tab = tabs.get(tabId)
|
||||||
|
if (tab?.state === 'connected') {
|
||||||
|
setBadge(tabId, relayWs && relayWs.readyState === WebSocket.OPEN ? 'on' : 'connecting')
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Refresh badge when user switches to an attached tab.
|
||||||
|
chrome.tabs.onActivated.addListener(({ tabId }) => void whenReady(() => {
|
||||||
|
const tab = tabs.get(tabId)
|
||||||
|
if (tab?.state === 'connected') {
|
||||||
|
setBadge(tabId, relayWs && relayWs.readyState === WebSocket.OPEN ? 'on' : 'connecting')
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
chrome.runtime.onInstalled.addListener(() => {
|
chrome.runtime.onInstalled.addListener(() => {
|
||||||
// Useful: first-time instructions.
|
|
||||||
void chrome.runtime.openOptionsPage()
|
void chrome.runtime.openOptionsPage()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// MV3 keepalive via chrome.alarms — more reliable than setInterval across
|
||||||
|
// service worker restarts. Checks relay health and refreshes badges.
|
||||||
|
chrome.alarms.create('relay-keepalive', { periodInMinutes: 0.5 })
|
||||||
|
|
||||||
|
chrome.alarms.onAlarm.addListener(async (alarm) => {
|
||||||
|
if (alarm.name !== 'relay-keepalive') return
|
||||||
|
await initPromise
|
||||||
|
|
||||||
|
if (tabs.size === 0) return
|
||||||
|
|
||||||
|
// Refresh badges (ephemeral in MV3).
|
||||||
|
for (const [tabId, tab] of tabs.entries()) {
|
||||||
|
if (tab.state === 'connected') {
|
||||||
|
setBadge(tabId, relayWs && relayWs.readyState === WebSocket.OPEN ? 'on' : 'connecting')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If relay is down and no reconnect is in progress, trigger one.
|
||||||
|
if (!relayWs || relayWs.readyState !== WebSocket.OPEN) {
|
||||||
|
if (!relayConnectPromise && !reconnectTimer) {
|
||||||
|
console.log('Keepalive: WebSocket unhealthy, triggering reconnect')
|
||||||
|
await ensureRelayConnection().catch(() => {
|
||||||
|
// ensureRelayConnection may throw without triggering onRelayClosed
|
||||||
|
// (e.g. preflight fetch fails before WS is created), so ensure
|
||||||
|
// reconnect is always scheduled on failure.
|
||||||
|
if (!reconnectTimer) {
|
||||||
|
scheduleReconnect()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Rehydrate state on service worker startup. Split: rehydration is the gate
|
||||||
|
// (fast), relay reconnect runs in background (slow, non-blocking).
|
||||||
|
const initPromise = rehydrateState()
|
||||||
|
|
||||||
|
initPromise.then(() => {
|
||||||
|
if (tabs.size > 0) {
|
||||||
|
ensureRelayConnection().then(() => {
|
||||||
|
reconnectAttempt = 0
|
||||||
|
return reannounceAttachedTabs()
|
||||||
|
}).catch(() => {
|
||||||
|
scheduleReconnect()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Shared gate: all state-dependent handlers await this before accessing maps.
|
||||||
|
async function whenReady(fn) {
|
||||||
|
await initPromise
|
||||||
|
return fn()
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
"48": "icons/icon48.png",
|
"48": "icons/icon48.png",
|
||||||
"128": "icons/icon128.png"
|
"128": "icons/icon128.png"
|
||||||
},
|
},
|
||||||
"permissions": ["debugger", "tabs", "activeTab", "storage"],
|
"permissions": ["debugger", "tabs", "activeTab", "storage", "alarms", "webNavigation"],
|
||||||
"host_permissions": ["http://127.0.0.1/*", "http://localhost/*"],
|
"host_permissions": ["http://127.0.0.1/*", "http://localhost/*"],
|
||||||
"background": { "service_worker": "background.js", "type": "module" },
|
"background": { "service_worker": "background.js", "type": "module" },
|
||||||
"action": {
|
"action": {
|
||||||
|
|||||||
77
src/browser/chrome-extension-background-utils.test.ts
Normal file
77
src/browser/chrome-extension-background-utils.test.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
buildRelayWsUrl,
|
||||||
|
isRetryableReconnectError,
|
||||||
|
reconnectDelayMs,
|
||||||
|
} from "../../assets/chrome-extension/background-utils.js";
|
||||||
|
|
||||||
|
describe("chrome extension background utils", () => {
|
||||||
|
it("builds websocket url with encoded gateway token", () => {
|
||||||
|
const url = buildRelayWsUrl(18792, "abc/+= token");
|
||||||
|
expect(url).toBe("ws://127.0.0.1:18792/extension?token=abc%2F%2B%3D%20token");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws when gateway token is missing", () => {
|
||||||
|
expect(() => buildRelayWsUrl(18792, "")).toThrow(/Missing gatewayToken/);
|
||||||
|
expect(() => buildRelayWsUrl(18792, " ")).toThrow(/Missing gatewayToken/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses exponential backoff from attempt index", () => {
|
||||||
|
expect(reconnectDelayMs(0, { baseMs: 1000, maxMs: 30000, jitterMs: 0, random: () => 0 })).toBe(
|
||||||
|
1000,
|
||||||
|
);
|
||||||
|
expect(reconnectDelayMs(1, { baseMs: 1000, maxMs: 30000, jitterMs: 0, random: () => 0 })).toBe(
|
||||||
|
2000,
|
||||||
|
);
|
||||||
|
expect(reconnectDelayMs(4, { baseMs: 1000, maxMs: 30000, jitterMs: 0, random: () => 0 })).toBe(
|
||||||
|
16000,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("caps reconnect delay at max", () => {
|
||||||
|
const delay = reconnectDelayMs(20, {
|
||||||
|
baseMs: 1000,
|
||||||
|
maxMs: 30000,
|
||||||
|
jitterMs: 0,
|
||||||
|
random: () => 0,
|
||||||
|
});
|
||||||
|
expect(delay).toBe(30000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adds jitter using injected random source", () => {
|
||||||
|
const delay = reconnectDelayMs(3, {
|
||||||
|
baseMs: 1000,
|
||||||
|
maxMs: 30000,
|
||||||
|
jitterMs: 1000,
|
||||||
|
random: () => 0.25,
|
||||||
|
});
|
||||||
|
expect(delay).toBe(8250);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sanitizes invalid attempts and options", () => {
|
||||||
|
expect(reconnectDelayMs(-2, { baseMs: 1000, maxMs: 30000, jitterMs: 0, random: () => 0 })).toBe(
|
||||||
|
1000,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
reconnectDelayMs(Number.NaN, {
|
||||||
|
baseMs: Number.NaN,
|
||||||
|
maxMs: Number.NaN,
|
||||||
|
jitterMs: Number.NaN,
|
||||||
|
random: () => 0,
|
||||||
|
}),
|
||||||
|
).toBe(1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks missing token errors as non-retryable", () => {
|
||||||
|
expect(
|
||||||
|
isRetryableReconnectError(
|
||||||
|
new Error("Missing gatewayToken in extension settings (chrome.storage.local.gatewayToken)"),
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps transient network errors retryable", () => {
|
||||||
|
expect(isRetryableReconnectError(new Error("WebSocket connect timeout"))).toBe(true);
|
||||||
|
expect(isRetryableReconnectError(new Error("Relay server not reachable"))).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
29
src/browser/chrome-extension-manifest.test.ts
Normal file
29
src/browser/chrome-extension-manifest.test.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
type ExtensionManifest = {
|
||||||
|
background?: { service_worker?: string; type?: string };
|
||||||
|
permissions?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
function readManifest(): ExtensionManifest {
|
||||||
|
const path = resolve(process.cwd(), "assets/chrome-extension/manifest.json");
|
||||||
|
return JSON.parse(readFileSync(path, "utf8")) as ExtensionManifest;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("chrome extension manifest", () => {
|
||||||
|
it("keeps background worker configured as module", () => {
|
||||||
|
const manifest = readManifest();
|
||||||
|
expect(manifest.background?.service_worker).toBe("background.js");
|
||||||
|
expect(manifest.background?.type).toBe("module");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes resilience permissions", () => {
|
||||||
|
const permissions = readManifest().permissions ?? [];
|
||||||
|
expect(permissions).toContain("alarms");
|
||||||
|
expect(permissions).toContain("webNavigation");
|
||||||
|
expect(permissions).toContain("storage");
|
||||||
|
expect(permissions).toContain("debugger");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user