openclaw
Version:
Multi-channel AI gateway with extensible messaging integrations
2,728 lines • 101 kB
JavaScript
import { a as asOptionalRecord } from "./record-coerce-DItp3I4t.js";
import { At as boolean, Bt as discriminatedUnion, Ln as strictObject, Rn as string, Zn as unknown, dn as literal, wn as number } from "./schemas-zxit8y5H.js";
import { t as createSubsystemLogger } from "./subsystem-Dy2tqXOS.js";
import { s as readRequestBodyWithLimit } from "./http-body-D3IMwTJJ.js";
import { t as safeEqualSecret } from "./secret-equal-DRsL8lKD.js";
import { s as isLoopbackHost } from "./net-DbNPs6Xm.js";
import "./string-coerce-runtime-GQa0ehRA.js";
import { n as rawDataToString } from "./ws-C3ckvj65.js";
import "./ssrf-runtime-Bum5C6NN.js";
import "./security-runtime-Ckf0kc0h.js";
import { t as resolveRequestClientIp } from "./webhook-ingress-CpRSOilv.js";
import { t as WEBHOOK_BODY_READ_DEFAULTS } from "./webhook-request-guards-BoKMzZgq.js";
import "./subsystem-DbD06g1M.js";
import { n as readExtensionRelayToken } from "./relay-auth-DPRfu8tu.js";
import { a as getBrowserRelayAuthV2Authority, c as parseRelayAuthHello, d as parseRelayHttpCompleteRequest, f as parseStrictJsonObject, g as randomRelayId, l as parseRelayAuthResponse, o as invalidateBrowserRelayAuthV2Authority, r as BROWSER_RELAY_CHALLENGE_TTL_MS, s as parseExtensionRelayResource, u as parseRelayHttpChallengeRequest } from "./auth-v2-B-IXoFi_.js";
import crypto, { randomUUID } from "node:crypto";
import { addAbortListener, once } from "node:events";
import { Duplex } from "node:stream";
import { WebSocketServer } from "ws";
import http from "node:http";
const MAX_WEBSOCKET_PREAUTH_WIRE_BYTES = 17408;
function boundedRawDataByteLength(data, limit) {
if (!Array.isArray(data)) return data.byteLength;
let length = 0;
for (const chunk of data) {
length += chunk.byteLength;
if (length > limit) break;
}
return length;
}
var PreAuthWebSocketTransport = class extends Duplex {
constructor(rawSocket, headBytes) {
super();
this.rawSocket = rawSocket;
this.guardActive = true;
this.removeGuard = () => {
this.guardActive = false;
};
this.onRawData = (chunk) => {
if (this.guardActive) {
this.wireBytes += chunk.byteLength;
if (this.wireBytes > MAX_WEBSOCKET_PREAUTH_WIRE_BYTES) {
this.destroy();
return;
}
}
if (!this.push(chunk)) this.rawSocket.pause();
};
this.onRawEnd = () => this.push(null);
this.onRawClose = () => {
this.rawSocket.off("error", this.onRawError);
this.destroy();
};
this.onRawError = (error) => this.destroy(error);
this.wireBytes = headBytes;
rawSocket.on("data", this.onRawData);
rawSocket.once("end", this.onRawEnd);
rawSocket.once("close", this.onRawClose);
rawSocket.once("error", this.onRawError);
}
_read() {
this.rawSocket.resume();
}
_write(chunk, encoding, callback) {
this.rawSocket.write(chunk, encoding, callback);
}
_final(callback) {
this.rawSocket.end(callback);
}
_destroy(error, callback) {
this.rawSocket.off("data", this.onRawData);
this.rawSocket.off("end", this.onRawEnd);
this.rawSocket.destroy();
callback(error);
}
};
/** Withhold pre-auth bytes from `ws`; successful proof makes the transport transparent. */
function handlePreAuthWebSocketUpgrade(params) {
if (params.head.byteLength > MAX_WEBSOCKET_PREAUTH_WIRE_BYTES) return false;
const transport = new PreAuthWebSocketTransport(params.socket, params.head.byteLength);
try {
params.wss.handleUpgrade(params.req, transport, params.head, (ws) => {
params.onUpgrade(ws, transport.removeGuard);
});
} catch (err) {
transport.destroy();
throw err;
}
return true;
}
//#endregion
//#region extensions/browser/src/browser/extension-relay/auth-v2-websocket.ts
const log$2 = createSubsystemLogger("browser").child("extension-relay");
function authenticateExtensionWebSocket(params) {
const { ws, authority } = params;
ws.on("error", (err) => log$2.warn(`relay socket error: ${String(err)}`));
let stage = "hello";
let preAuthGuardActive = true;
const removePreAuthGuard = () => {
if (!preAuthGuardActive) return;
preAuthGuardActive = false;
params.removePreAuthGuard?.();
};
const closePreAuthSocket = (code, reason) => {
ws.close(code, reason);
setTimeout(() => ws.terminate(), 100).unref?.();
};
const timer = setTimeout(() => {
stage = "failed";
ws.off("message", onMessage);
ws.close(4008, "browser relay auth timeout");
ws.terminate();
}, BROWSER_RELAY_CHALLENGE_TTL_MS);
timer.unref?.();
const release = () => {
clearTimeout(timer);
removePreAuthGuard();
authority.releaseConnection(ws);
};
if (!authority.registerPendingConnection(ws, () => {
ws.close(4003, "browser relay key rotated");
}, params.source)) {
clearTimeout(timer);
closePreAuthSocket(4013, "browser relay auth capacity reached");
return;
}
ws.once("close", release);
const fail = (code, reason) => {
if (stage === "failed") return;
stage = "failed";
clearTimeout(timer);
ws.off("message", onMessage);
closePreAuthSocket(code, reason);
};
const onMessage = (data, isBinary) => {
if (isBinary) {
fail(4003, "binary browser relay auth frames are not allowed");
return;
}
if (boundedRawDataByteLength(data, 16384) > 16384) {
fail(4003, "browser relay auth frame is too large");
return;
}
const raw = rawDataToString(data);
const parsed = parseStrictJsonObject(raw);
if (stage === "hello") {
const hello = parseRelayAuthHello(parsed);
if (!hello) {
fail(4003, "invalid browser relay auth hello");
return;
}
const challenge = authority.issueChallenge(ws, hello, {
role: params.binding?.role ?? "extension",
transport: "websocket",
method: "GET",
resource: params.resource,
flow: params.binding?.flow ?? "extension"
});
if (!challenge) {
fail(4003, "browser relay auth rejected");
return;
}
stage = "response";
ws.send(JSON.stringify(challenge));
return;
}
if (stage === "response") {
const response = parseRelayAuthResponse(parsed);
if (!response) {
fail(4003, "invalid browser relay auth response");
return;
}
const completed = authority.completeChallenge(ws, response);
if (!completed) {
fail(4003, "browser relay auth proof failed");
return;
}
stage = "authenticated";
clearTimeout(timer);
removePreAuthGuard();
params.prepareAuthenticated().then((attach) => {
if (ws.readyState !== 1) return;
ws.off("message", onMessage);
attach();
ws.send(JSON.stringify(completed.ok), (err) => {
if (err) ws.close(1011, "browser relay auth acknowledgement failed");
});
}).catch((err) => {
log$2.warn(`browser relay post-auth preparation failed: ${String(err)}`);
fail(1011, "browser relay unavailable after authentication");
});
return;
}
fail(4003, "unexpected browser relay auth frame");
};
ws.on("message", onMessage);
}
//#endregion
//#region extensions/browser/src/browser/extension-relay/owner-protocol.ts
const RELAY_OWNER_PATH = "/_openclaw/relay/owner";
const RELAY_OPERATION_TTL_MS = 3e5;
function relayOwnerResource(port, profile) {
return `${RELAY_OWNER_PATH}?port=${port}&profile=${encodeURIComponent(profile)}`;
}
const id = number().int().positive();
const reference = string().min(1).max(64);
const relayOwnerRequest = discriminatedUnion("op", [
strictObject({
id,
op: literal("ready"),
timeoutMs: number().int().min(0).max(1e4)
}),
strictObject({
id,
op: literal("capture"),
targetId: string().min(1).max(1024)
}),
strictObject({
id,
op: literal("resolve"),
ref: reference
}),
strictObject({
id,
op: literal("release"),
ref: reference
}),
strictObject({
id,
op: literal("cdp.open"),
ref: reference.optional()
}),
strictObject({
id,
op: literal("ingress.open")
}),
strictObject({
id,
op: literal("stream.close"),
stream: id
}),
strictObject({
id,
op: literal("close")
})
]);
const relayOwnerFrame = strictObject({
stream: id,
frame: string()
});
const relayOwnerReply = strictObject({
id,
result: unknown().optional(),
error: string().optional()
});
const relayOwnerStreamClosed = strictObject({
stream: id,
closed: literal(true)
});
const relayOwnerStatus = strictObject({
ready: boolean(),
allowLegacyAuth: boolean(),
identity: strictObject({
browserVersion: string(),
userAgent: string(),
extensionVersion: string()
}).nullable(),
generation: number().int().nonnegative()
});
const relayOwnerRetired = strictObject({ retired: literal(true) });
//#endregion
//#region extensions/browser/src/browser/extension-relay/relay-protocol.ts
function hasExactOwnKeys(value, keys) {
return Object.keys(value).length === keys.length && keys.every((key) => Object.hasOwn(value, key));
}
function isRelayTabInfo(value) {
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
if (!hasExactOwnKeys(value, [
"tabId",
"url",
"title",
"active"
])) return false;
const tab = value;
return Number.isSafeInteger(tab.tabId) && tab.tabId >= 0 && typeof tab.url === "string" && tab.url.length <= 16384 && typeof tab.title === "string" && tab.title.length <= 4096 && typeof tab.active === "boolean";
}
function isRelayTabInfoArray(value) {
if (!Array.isArray(value) || value.length > 1e3 || !value.every(isRelayTabInfo)) return false;
return new Set(value.map((tab) => tab.tabId)).size === value.length;
}
function isNonNegativeSafeInteger(value) {
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
}
function isExtensionHelloMessage(value) {
if (!hasExactOwnKeys(value, [
"type",
"userAgent",
"browserVersion",
"extensionVersion",
"tabs"
])) return false;
const hello = value;
if (hello.type !== "hello" || typeof hello.userAgent !== "string" || hello.userAgent.length === 0 || hello.userAgent.length > 2048 || typeof hello.browserVersion !== "string" || hello.browserVersion.length === 0 || hello.browserVersion.length > 512 || typeof hello.extensionVersion !== "string" || hello.extensionVersion.length === 0 || hello.extensionVersion.length > 128 || !isRelayTabInfoArray(hello.tabs)) return false;
return true;
}
function isExtensionTabsMessage(msg) {
return msg.type === "tabs" && isRelayTabInfoArray(msg.tabs);
}
function isExtensionCdpEventMessage(msg) {
return msg.type === "cdpEvent" && isNonNegativeSafeInteger(msg.tabId) && (msg.sessionId === void 0 || typeof msg.sessionId === "string") && typeof msg.method === "string";
}
function isExtensionResultMessage(msg) {
return msg.type === "result" && isNonNegativeSafeInteger(msg.seq);
}
function isExtensionErrorMessage(msg) {
return msg.type === "error" && isNonNegativeSafeInteger(msg.seq) && typeof msg.message === "string";
}
function isExtensionDetachedMessage(msg) {
return msg.type === "detached" && isNonNegativeSafeInteger(msg.tabId) && typeof msg.reason === "string";
}
function isExtensionPongMessage(msg) {
return msg.type === "pong";
}
/** Parse one extension frame; returns null for malformed input. */
function parseExtensionMessage(raw) {
let parsed;
try {
parsed = JSON.parse(raw);
} catch {
return null;
}
if (!parsed || typeof parsed !== "object") return null;
const msg = parsed;
switch (msg.type) {
case "hello": return isExtensionHelloMessage(msg) ? msg : null;
case "tabs": return isExtensionTabsMessage(msg) ? msg : null;
case "cdpEvent": return isExtensionCdpEventMessage(msg) ? msg : null;
case "result": return isExtensionResultMessage(msg) ? msg : null;
case "error": return isExtensionErrorMessage(msg) ? msg : null;
case "detached": return isExtensionDetachedMessage(msg) ? msg : null;
case "pong": return isExtensionPongMessage(msg) ? msg : null;
default: return null;
}
}
//#endregion
//#region extensions/browser/src/browser/extension-relay/owner-server.ts
/** All references and streams belong to this authenticated connection, never to a token holder. */
function attachRelayOwner(params) {
const { ws, bridge } = params;
const controller = new AbortController();
const captures = /* @__PURE__ */ new Map();
const streams = /* @__PURE__ */ new Map();
let nextStream = 0;
let pending = 0;
let closing;
const assertCurrent = () => {
controller.signal.throwIfAborted();
if (!params.isCurrent()) throw new Error("Relay owner retired");
};
const send = (value) => {
if (ws.readyState === 1) ws.send(JSON.stringify(value));
};
const captureFor = (ref) => {
assertCurrent();
const capture = captures.get(ref);
if (!capture || capture.expires <= Date.now()) {
captures.delete(ref);
return;
}
return capture.isCurrent() ? capture : void 0;
};
const closeStream = async (id) => {
const stream = streams.get(id);
if (!stream) return;
await (stream.closing ??= stream.close());
if (streams.get(id) === stream) streams.delete(id);
send({
stream: id,
closed: true
});
};
const close = () => {
controller.abort(/* @__PURE__ */ new Error("Relay owner connection closed"));
for (const capture of captures.values()) clearTimeout(capture.timer);
captures.clear();
return closing ??= Promise.all([...streams.keys()].map(closeStream)).then(() => {});
};
ws.once("close", () => {
close().catch(() => {});
});
ws.on("message", (raw) => {
const message = parseStrictJsonObject(rawDataToString(raw));
const streamFrame = relayOwnerFrame.safeParse(message);
if (streamFrame.success) {
const stream = streams.get(streamFrame.data.stream);
try {
assertCurrent();
if (!stream || stream.closing || stream.ref && !captureFor(stream.ref)) throw new Error("Relay operation reference is no longer current");
stream.onMessage(streamFrame.data.frame);
} catch {
closeStream(streamFrame.data.stream).catch(() => ws.close(1011, "relay cleanup failed"));
}
return;
}
const request = relayOwnerRequest.safeParse(message);
if (!request.success || pending >= 64) {
ws.close(4003, "invalid relay owner request");
return;
}
pending += 1;
const requestId = request.data.id;
(async () => {
const req = request.data;
if (req.op !== "close" && req.op !== "stream.close" && req.op !== "release") assertCurrent();
switch (req.op) {
case "ready":
await bridge.waitForExtensionConnection(controller.signal, req.timeoutMs);
assertCurrent();
return {
ready: bridge.extensionConnected,
identity: bridge.identity,
generation: bridge.extensionGeneration,
allowLegacyAuth: params.allowLegacyAuth
};
case "capture": {
const captured = bridge.captureOperationTarget(req.targetId);
if (!captured) return null;
if (captures.size >= 64) throw new Error("Relay operation capacity reached");
const ref = randomRelayId();
const timer = setTimeout(() => {
captures.delete(ref);
for (const [id, stream] of streams) if (stream.ref === ref) closeStream(id).catch(() => ws.close(1011, "relay cleanup failed"));
}, RELAY_OPERATION_TTL_MS);
timer.unref?.();
captures.set(ref, {
resolve: captured,
isCurrent: captured.isCurrent,
expires: Date.now() + RELAY_OPERATION_TTL_MS,
timer
});
return ref;
}
case "resolve": return captureFor(req.ref)?.resolve() ?? null;
case "release": {
const capture = captures.get(req.ref);
if (capture) clearTimeout(capture.timer);
captures.delete(req.ref);
await Promise.all([...streams].filter(([, stream]) => stream.ref === req.ref).map(([id]) => closeStream(id)));
return null;
}
case "cdp.open":
case "ingress.open": {
if (streams.size >= 64) throw new Error("Relay stream capacity reached");
if (req.op === "cdp.open" && req.ref && !captureFor(req.ref)) throw new Error("Relay operation reference is no longer current");
const id = ++nextStream;
const socket = {
send: (frame) => send({
stream: id,
frame
}),
close: () => {
closeStream(id).catch(() => ws.close(1011, "relay cleanup failed"));
}
};
if (req.op === "cdp.open") {
const handlers = bridge.attachCdpClientSocket(socket);
streams.set(id, {
onMessage: handlers.onMessage,
close: handlers.onClose,
ref: req.ref
});
} else {
const handlers = bridge.attachExtensionSocket(socket);
const timer = setTimeout(() => socket.close(), 1e4);
timer.unref?.();
streams.set(id, {
onMessage: (frame) => {
if (parseExtensionMessage(frame)?.type === "hello") clearTimeout(timer);
handlers.onMessage(frame);
},
close: async () => {
clearTimeout(timer);
handlers.onClose();
}
});
}
return id;
}
case "stream.close":
await closeStream(req.stream);
return null;
case "close":
await close();
return null;
}
throw new Error("Unsupported relay owner operation");
})().then((result) => send({
id: requestId,
result
}), () => send({
id: requestId,
error: "Relay owner operation failed or was superseded"
})).finally(() => {
pending -= 1;
});
});
return async () => {
await close();
if (ws.readyState === 1) await new Promise((resolve) => {
ws.send(JSON.stringify({ retired: true }), () => resolve());
});
};
}
//#endregion
//#region extensions/browser/src/browser/extension-relay/create-target-params.ts
function resolveCreateTargetParams(params) {
const background = params?.background;
const focus = params?.focus;
if (background === true && focus === true) throw new Error("Target.createTarget does not support background=true with focus=true");
const resolvedBackground = focus === void 0 ? background !== false : background === true && focus === false;
return {
background: resolvedBackground,
focus: focus === true || focus === void 0 && !resolvedBackground
};
}
//#endregion
//#region extensions/browser/src/browser/extension-relay/relay-fetch.ts
const STREAM_PREFIX = "openclaw-fetch-stream:";
const REQUEST_COMMANDS = /* @__PURE__ */ new Set([
"Fetch.continueRequest",
"Fetch.continueResponse",
"Fetch.continueWithAuth",
"Fetch.failRequest",
"Fetch.fulfillRequest",
"Fetch.getResponseBody",
"Fetch.takeResponseBodyAsStream"
]);
/** One physical Fetch domain; exact logical-session objects own its exclusive lease. */
var RelayFetch = class {
constructor(send) {
this.send = send;
this.instanceId = randomUUID();
this.nextLease = 0;
this.nextStream = 0;
this.state = { kind: "idle" };
this.streams = /* @__PURE__ */ new Map();
this.closedOwners = /* @__PURE__ */ new WeakSet();
}
command(owner, emit, method, params) {
const input = asOptionalRecord(params);
const handle = input?.handle;
const ownedIO = (method === "IO.read" || method === "IO.close") && typeof handle === "string" && (handle.startsWith(STREAM_PREFIX) || [...this.streams.values()].some((stream) => stream.nativeHandle === handle));
if (!method.startsWith("Fetch.") && !ownedIO) return;
try {
if (this.state.kind === "gone" || this.state.kind === "retiring" || this.closedOwners.has(owner)) throw new Error("Fetch session detached");
if (ownedIO && typeof handle === "string") return this.streamCommand(owner, method, handle, input);
if (method === "Fetch.enable") return this.enable(owner, emit, input);
if (method === "Fetch.disable") {
if (!("lease" in this.state) || this.state.lease.owner !== owner) return Promise.resolve({});
return this.release(this.state.lease, "disable").then(() => ({}));
}
if (!REQUEST_COMMANDS.has(method)) throw new Error(`Unsupported Fetch command: ${method}`);
const lease = this.ownedLease(owner);
const requestId = input?.requestId;
const nativeId = typeof requestId === "string" && requestId.startsWith(lease.prefix) ? requestId.slice(lease.prefix.length) : void 0;
const pause = nativeId === void 0 ? void 0 : lease.pauses.get(nativeId);
if (!pause) throw new Error("Invalid Fetch requestId for this session");
this.validatePause(pause, method, input);
return this.runPause(lease, pause, method, input);
} catch (error) {
return Promise.reject(error instanceof Error ? error : new Error(String(error)));
}
}
event(method, params) {
if (method !== "Fetch.requestPaused" && method !== "Fetch.authRequired") return false;
const input = asOptionalRecord(params);
const state = this.state;
const lease = "lease" in state ? state.lease : void 0;
if (!lease?.acceptEvents || typeof input?.requestId !== "string") return true;
const kind = method === "Fetch.authRequired" ? "auth" : input.responseStatusCode !== void 0 || input.responseErrorReason !== void 0 ? "response" : "request";
const nativeId = input.requestId;
lease.pauses.set(nativeId, {
nativeId,
kind
});
if (this.state.kind === "owned" && !this.closedOwners.has(lease.owner)) {
const redirectedRequestId = typeof input.redirectedRequestId === "string" ? lease.prefix + input.redirectedRequestId : void 0;
lease.emit(method, {
...input,
requestId: lease.prefix + nativeId,
...redirectedRequestId === void 0 ? {} : { redirectedRequestId }
});
}
return true;
}
async close(owner) {
this.closedOwners.add(owner);
const state = this.state;
if (state.kind === "uncertain" && state.lease.owner === owner) throw state.error;
const lease = "lease" in state ? state.lease : void 0;
if (lease?.owner === owner) await this.release(lease, "close");
const errors = await this.closeStreamSnapshot([...this.streams].filter(([, stream]) => stream.owner === owner));
if (errors.length > 0) throw new AggregateError(errors, "Fetch stream cleanup failed");
}
/**
* Stop admission and run bounded best-effort cleanup before the physical owner detaches.
* Existing hung commands are skipped; transport loss cannot guarantee fail-closed cancellation.
*/
prepareRetirement(timeoutMs) {
if (this.retirement) return this.retirement;
const lease = "lease" in this.state ? this.state.lease : void 0;
if (lease) {
this.closedOwners.add(lease.owner);
lease.acceptEvents = false;
}
const pauses = lease ? [...lease.pauses.values()].filter((pause) => !pause.pending) : [];
lease?.pauses.clear();
const streams = [...this.streams];
this.state = {
kind: "retiring",
...lease ? { lease } : {}
};
this.retirement = Promise.resolve().then(async () => {
const errors = [];
const cleanup = [...pauses.map((pause) => this.sendPauseCleanup(pause)), ...streams.map(([handle, stream]) => this.closeStream(handle, stream))];
const settled = Promise.all(cleanup.map(async (operation) => {
try {
await operation;
} catch (error) {
errors.push(error);
}
}));
let timer;
const timedOut = await Promise.race([settled.then(() => false), new Promise((resolve) => {
timer = setTimeout(() => resolve(true), timeoutMs);
timer.unref?.();
})]);
if (timer) clearTimeout(timer);
return { errors: [...errors, ...timedOut ? [/* @__PURE__ */ new Error(`Fetch retirement cleanup timed out after ${timeoutMs}ms`)] : []] };
});
return this.retirement;
}
/** Physical loss cannot be canceled through a successor transport. */
dispose() {
const state = this.state;
const lease = "lease" in state ? state.lease : void 0;
if (lease) {
lease.acceptEvents = false;
lease.pauses.clear();
}
this.streams.clear();
this.state = { kind: "gone" };
}
ownedLease(owner) {
if (this.state.kind === "uncertain") throw this.state.error;
if (this.state.kind !== "owned" || this.state.lease.owner !== owner) throw new Error("Fetch interception is owned by another session or is being released");
return this.state.lease;
}
assertLiveLease(lease) {
if (!("lease" in this.state) || this.state.lease !== lease || this.state.kind === "retiring") throw new Error("Fetch attachment or lease retired");
if (this.state.kind === "uncertain") throw this.state.error;
}
fence(lease, error) {
if ("lease" in this.state && this.state.lease === lease && this.state.kind !== "retiring") {
lease.acceptEvents = false;
this.state = {
kind: "uncertain",
lease,
error
};
}
}
async nativeFetch(lease, method, params) {
this.assertLiveLease(lease);
try {
const result = await this.send(method, params);
this.assertLiveLease(lease);
return result;
} catch (error) {
this.fence(lease, error);
throw error;
}
}
enable(owner, emit, params) {
if (this.state.kind === "idle") this.state = {
kind: "owned",
lease: {
owner,
emit,
prefix: `openclaw-fetch:${this.instanceId}:${++this.nextLease}:`,
pauses: /* @__PURE__ */ new Map(),
operations: /* @__PURE__ */ new Set(),
control: Promise.resolve(),
acceptEvents: true
}
};
const lease = this.ownedLease(owner);
const operation = lease.control.then(async () => {
this.assertLiveLease(lease);
if (this.state.kind !== "owned") throw new Error("Fetch enable superseded by release");
const result = await this.nativeFetch(lease, "Fetch.enable", params);
this.assertLiveLease(lease);
if (this.state.kind !== "owned" || this.closedOwners.has(owner)) throw new Error("Fetch enable superseded by release");
return result;
});
this.trackOperation(lease, operation);
lease.control = operation.then(() => void 0, () => void 0);
return operation;
}
validatePause(pause, method, params) {
if (pause.pending) throw new Error("Fetch request already has a command in flight");
const auth = method === "Fetch.continueWithAuth";
const body = method === "Fetch.getResponseBody" || method === "Fetch.takeResponseBodyAsStream";
const response = pause.kind === "response" || pause.kind === "buffered";
if (auth !== (pause.kind === "auth") || (body || method === "Fetch.continueResponse") && !response || method === "Fetch.takeResponseBodyAsStream" && pause.kind !== "response" || pause.kind === "stream" && method !== "Fetch.failRequest" && !(method === "Fetch.fulfillRequest" && typeof params?.body === "string")) throw new Error(`Invalid Fetch request stage for ${method}`);
}
runPause(lease, pause, method, params) {
const operation = Promise.resolve().then(async () => {
this.assertLiveLease(lease);
if (lease.pauses.get(pause.nativeId) !== pause) throw new Error("Fetch pause retired before command dispatch");
let result = await this.nativeFetch(lease, method, {
...params,
requestId: pause.nativeId
});
this.assertLiveLease(lease);
if (method === "Fetch.takeResponseBodyAsStream") {
const response = asOptionalRecord(result);
if (typeof response?.stream !== "string") {
const error = /* @__PURE__ */ new Error("Fetch stream response did not contain a stream handle");
this.fence(lease, error);
throw error;
}
const handle = `${STREAM_PREFIX}${this.instanceId}:${++this.nextStream}`;
this.streams.set(handle, {
owner: lease.owner,
nativeHandle: response.stream,
readable: true,
reading: false
});
result = {
...response,
stream: handle
};
}
if (lease.pauses.get(pause.nativeId) === pause) {
if (method === "Fetch.getResponseBody") pause.kind = "buffered";
else if (method === "Fetch.takeResponseBodyAsStream") pause.kind = "stream";
else lease.pauses.delete(pause.nativeId);
}
pause.pending = void 0;
if (this.closedOwners.has(lease.owner) || this.state.kind === "retiring") throw new Error("Fetch session closed during command");
return result;
});
pause.pending = operation;
this.trackOperation(lease, operation);
return operation;
}
trackOperation(lease, operation) {
lease.operations.add(operation);
operation.finally(() => {
lease.operations.delete(operation);
}).catch(() => {});
}
release(lease, reason) {
this.assertLiveLease(lease);
if (this.state.kind === "releasing") {
if (reason === "close") this.state.reason = "close";
return this.state.done;
}
const done = Promise.resolve().then(async () => {
await lease.control;
await Promise.allSettled(lease.operations);
this.assertLiveLease(lease);
const closeRequested = this.state.kind === "releasing" && this.state.reason === "close";
if (!closeRequested && [...lease.pauses.values()].some((pause) => pause.kind === "stream")) {
this.state = {
kind: "owned",
lease
};
throw new Error("Fetch.disable requires streamed responses to be failed or fulfilled first");
}
lease.acceptEvents = false;
if (closeRequested) {
const pauses = [...lease.pauses.values()];
const streams = [...this.streams].filter(([, stream]) => stream.owner === lease.owner);
lease.pauses.clear();
const errors = (await Promise.allSettled([...pauses.map((pause) => this.sendPauseCleanup(pause)), ...streams.map(([handle, stream]) => this.closeStream(handle, stream))])).flatMap((result) => result.status === "rejected" ? [result.reason] : []);
if (errors.length > 0) {
const error = new AggregateError(errors, "Fetch owner cleanup failed");
this.fence(lease, error);
throw error;
}
await this.nativeFetch(lease, "Fetch.disable");
this.state = { kind: "idle" };
return;
}
await this.nativeFetch(lease, "Fetch.disable");
lease.pauses.clear();
this.state = { kind: "idle" };
});
this.state = {
kind: "releasing",
lease,
reason,
done
};
return done;
}
sendPauseCleanup(pause) {
return pause.kind === "auth" ? this.send("Fetch.continueWithAuth", {
requestId: pause.nativeId,
authChallengeResponse: { response: "CancelAuth" }
}) : this.send("Fetch.failRequest", {
requestId: pause.nativeId,
errorReason: "Aborted"
});
}
streamCommand(owner, method, handle, params) {
const stream = this.streams.get(handle);
if (!stream || stream.owner !== owner || stream.closing) throw new Error("Invalid Fetch stream handle for this session");
if (method === "IO.close") return this.closeStream(handle, stream);
if (!stream.readable) throw new Error("Fetch stream is no longer readable");
if (stream.reading) throw new Error("Fetch stream already has a read in flight");
stream.reading = true;
return this.send(method, {
...params,
handle: stream.nativeHandle
}).then((result) => {
if (this.streams.get(handle) !== stream || this.closedOwners.has(owner)) throw new Error("Fetch stream retired during read");
return result;
}).catch((error) => {
stream.readable = false;
throw error;
}).finally(() => {
stream.reading = false;
});
}
closeStream(handle, stream) {
stream.readable = false;
return stream.closing ??= this.send("IO.close", { handle: stream.nativeHandle }).then((result) => {
this.streams.delete(handle);
return result;
});
}
async closeStreamSnapshot(streams) {
return (await Promise.allSettled(streams.map(([handle, stream]) => this.closeStream(handle, stream)))).flatMap((result) => result.status === "rejected" ? [result.reason] : []);
}
};
//#endregion
//#region extensions/browser/src/browser/extension-relay/relay-runtime.ts
/** One physical Runtime, subscribed by the same exact logical owners as Fetch. */
var RelayRuntime = class {
constructor(active, sendBinding) {
this.active = active;
this.sendBinding = sendBinding;
this.contexts = /* @__PURE__ */ new Map();
this.subscribers = /* @__PURE__ */ new Map();
this.bindings = /* @__PURE__ */ new Map();
this.bindingQueue = Promise.resolve();
}
binding(owner, send, method, params) {
if (!params || typeof params.name !== "string") return Promise.reject(/* @__PURE__ */ new Error("Binding name must be a string"));
const name = params.name;
let state = this.bindings.get(owner);
if (!state) {
state = {
send,
names: /* @__PURE__ */ new Set(),
retired: false
};
this.bindings.set(owner, state);
}
const binding = state;
return this.enqueueBinding(async () => {
if (binding.retired) throw new Error("Runtime session detached");
if (method === "Runtime.removeBinding") return this.removeBinding(binding, name);
const result = await this.sendBinding(method, params);
this.active.throwIfAborted();
binding.names.add(name);
if (binding.retired) throw new Error("Runtime session detached");
return result;
});
}
enqueueBinding(operation) {
const result = this.bindingQueue.then(() => {
this.active.throwIfAborted();
return operation();
});
this.bindingQueue = result.catch(() => {});
return result;
}
async removeBinding(binding, name) {
if (!binding.names.has(name)) return {};
let result = {};
if (![...this.bindings.values()].some((peer) => peer !== binding && peer.names.has(name))) {
result = await this.sendBinding("Runtime.removeBinding", { name });
this.active.throwIfAborted();
}
binding.names.delete(name);
return result;
}
retire(owner) {
this.disable(owner);
const binding = this.bindings.get(owner);
if (binding) binding.retired = true;
}
close(owner) {
const binding = this.bindings.get(owner);
if (!binding) return Promise.resolve();
return this.enqueueBinding(async () => {
for (const name of binding.names) await this.removeBinding(binding, name);
this.bindings.delete(owner);
});
}
async enable(owner, send, admit) {
this.active.throwIfAborted();
let subscription = this.subscribers.get(owner);
if (!subscription) {
subscription = {
send,
pending: 0,
delivered: /* @__PURE__ */ new Set()
};
this.subscribers.set(owner, subscription);
}
subscription.pending++;
try {
await admit();
this.active.throwIfAborted();
if (this.subscribers.get(owner) !== subscription) throw new Error("Runtime session detached or disabled");
if (subscription.delivered) {
for (const [id, params] of this.contexts) if (!subscription.delivered.has(id)) subscription.send("Runtime.executionContextCreated", params);
subscription.delivered = void 0;
}
} finally {
subscription.pending--;
if (subscription.delivered && subscription.pending === 0 && this.subscribers.get(owner) === subscription) this.subscribers.delete(owner);
}
}
disable(owner) {
this.subscribers.delete(owner);
}
event(method, params) {
if (this.active.aborted) return;
if (method === "Runtime.bindingCalled") {
const name = asOptionalRecord(params)?.name;
if (typeof name === "string") {
for (const binding of this.bindings.values()) if (!binding.retired && binding.names.has(name)) binding.send(method, params);
}
return;
}
const createdId = asOptionalRecord(asOptionalRecord(params)?.context)?.id;
const destroyedId = asOptionalRecord(params)?.executionContextId;
if (method === "Runtime.executionContextCreated" && typeof createdId === "number") this.contexts.set(createdId, params);
else if (method === "Runtime.executionContextDestroyed" && typeof destroyedId === "number") this.contexts.delete(destroyedId);
else if (method === "Runtime.executionContextsCleared") this.contexts.clear();
for (const subscription of this.subscribers.values()) {
if (method === "Runtime.executionContextCreated" && typeof createdId === "number") subscription.delivered?.add(createdId);
else if (method === "Runtime.executionContextDestroyed" && typeof destroyedId === "number") subscription.delivered?.delete(destroyedId);
else if (method === "Runtime.executionContextsCleared") subscription.delivered?.clear();
subscription.send(method, params);
}
}
dispose() {
this.contexts.clear();
this.subscribers.clear();
this.bindings.clear();
}
};
//#endregion
//#region extensions/browser/src/browser/extension-relay/relay-target.ts
const DEFAULT_FILTER = [
{
type: "browser",
exclude: true
},
{
type: "tab",
exclude: true
},
{}
];
const matches = (filter, type) => {
const entry = filter.find((candidate) => candidate.type === void 0 || candidate.type === type);
return entry !== void 0 && entry.exclude !== true;
};
/** One native Target policy, projected onto exact logical parent interests. */
var RelayTarget = class {
constructor(send, reconcile, assertActive) {
this.send = send;
this.reconcile = reconcile;
this.assertActive = assertActive;
this.interests = /* @__PURE__ */ new Map();
this.tail = Promise.resolve();
this.waiting = false;
}
enqueue(operation) {
const pending = this.tail.then(() => {
this.assertActive();
return operation();
});
this.tail = pending.catch(() => {});
return pending;
}
interest(owner, type) {
const interest = this.interests.get(owner);
return interest && matches(interest.filter, type) ? interest : void 0;
}
wanted(type) {
return [...this.interests.values()].some((interest) => matches(interest.filter, type));
}
params() {
if (!this.interests.size) return {
autoAttach: false,
waitForDebuggerOnStart: false,
flatten: true
};
const filters = [...this.interests.values()].map((interest) => interest.filter);
const fallback = filters.some((filter) => matches(filter));
const union = [...new Set(filters.flatMap((filter) => filter.flatMap((entry) => entry.type === void 0 ? [] : [entry.type])))].toSorted().flatMap((type) => {
const include = filters.some((filter) => matches(filter, type));
return include === fallback ? [] : [{
type,
exclude: !include
}];
});
if (fallback) union.push({});
return {
autoAttach: true,
waitForDebuggerOnStart: this.waiting,
flatten: true,
filter: union
};
}
command(owner, params, assertCurrent) {
if (typeof params?.autoAttach !== "boolean" || typeof params.waitForDebuggerOnStart !== "boolean" || params.flatten !== void 0 && typeof params.flatten !== "boolean") throw new Error("Invalid Target auto-attach parameters");
let filter = DEFAULT_FILTER;
if (params.filter !== void 0) {
if (!Array.isArray(params.filter)) throw new Error("Invalid Target filter");
filter = params.filter.map((value) => {
const entry = asOptionalRecord(value);
if (!entry || entry.type !== void 0 && typeof entry.type !== "string" || entry.exclude !== void 0 && typeof entry.exclude !== "boolean") throw new Error("Invalid Target filter entry");
return {
...typeof entry.type === "string" ? { type: entry.type } : {},
...typeof entry.exclude === "boolean" ? { exclude: entry.exclude } : {}
};
});
}
if (!params.autoAttach && params.filter !== void 0 && filter.length) throw new Error("Target filter should be empty when disabling auto-attach");
const waiting = params.waitForDebuggerOnStart;
const next = params.autoAttach ? {
filter,
flatten: params.flatten === true,
admitted: false
} : void 0;
return this.enqueue(async () => {
assertCurrent();
if (next) this.interests.set(owner, next);
else this.interests.delete(owner);
this.waiting = waiting;
try {
await this.reconcile();
const result = await this.send(this.params());
assertCurrent();
if (next && this.interests.get(owner) === next) next.admitted = true;
await this.reconcile();
return result;
} catch (error) {
if (this.interests.get(owner) === next) this.interests.delete(owner);
throw error;
}
});
}
remove(owner) {
if (!this.interests.delete(owner)) return;
return this.enqueue(async () => {
await this.reconcile();
await this.send(this.params());
});
}
dispose() {
this.interests.clear();
}
};
//#endregion
//#region extensions/browser/src/browser/extension-relay/relay-session-owner.ts
const SESSION_RETIREMENT_MS = 1e3;
/** Owns physical debugger lifetimes and their per-connection logical sessions. */
var RelaySessionOwner = class {
constructor(clients, retireAttachment, report, hasPendingClaims) {
this.clients = clients;
this.retireAttachment = retireAttachment;
this.report = report;
this.hasPendingClaims = hasPendingClaims;
this.physical = /* @__PURE__ */ new Map();
this.nextAlias = 1;
}
registerRoot(tabId, nativeTargetId, sessionId, transport) {
this.register({
tabId,
nativeTargetId,
rootSessionId: sessionId,
transport
});
}
register(scope) {
const active = new AbortController();
const physical = {
...scope,
id: scope.childSessionId ?? scope.rootSessionId,
runtime: new RelayRuntime(active.signal, (method, params) => this.send(physical, method, params)),
target: new RelayTarget((params) => this.send(physical, "Target.setAutoAttach", params, "target"), () => this.reconcileChildren(physical), () => active.signal.throwIfAborted()),
children: /* @__PURE__ */ new Set(),
subscribers: /* @__PURE__ */ new Set(),
active,
lifetime: new AbortController(),
fetch: new RelayFetch((method, params) => this.send(physical, method, params, "fetch"))
};
physical.parent?.children.add(physical);
this.physical.set(scope.childSessionId ?? scope.rootSessionId, physical);
return physical;
}
async send(physical, method, params, domain) {
const assertCurrent = () => {
physical.lifetime.signal.throwIfAborted();
if (this.physical.get(physical.id) !== physical) throw new Error("Physical session detached");
};
assertCurrent();
const signal = physical.active.signal.aborted ? physical.lifetime.signal : physical.active.signal;
if (domain !== "fetch") physical.active.signal.throwIfAborted();
try {
const result = await physical.transport(physical.childSessionId, method, params, signal);
assertCurrent();
signal.throwIfAborted();
if (method === "Runtime.runIfWaitingForDebugger" && physical.attached) physical.attached.waitingForDebugger = false;
return result;
} catch (error) {
if (domain && !physical.active.signal.aborted) this.retireAttachment(physical.rootSessionId).catch(this.report);
throw error;
}
}
announce(client, sessionId, physicalId, params, parentSessionId, flat = true) {
const physical = this.physical.get(physicalId);
if (!physical || physical.active.signal.aborted || !this.clients.has(client) || client.sessions.has(sessionId)) return;
const parent = parentSessionId ? client.sessions.get(parentSessionId) : void 0;
const session = {
physical,
client,
id: sessionId,
parentSessionId,
parent,
children: /* @__PURE__ */ new Map(),
flat,
detachedChildren: /* @__PURE__ */ new Set(),
runtimeGeneration: 0
};
client.sessions.set(sessionId, session);
physical.subscribers.add(session);
parent?.children.set(physical, session);
this.parentEvent(session, "Target.attachedToTarget", params);
}
deliver(session, payload, flatId = session.id) {
if (session.parent && !session.flat) {
const message = flatId === session.id ? payload : {
...payload,
sessionId: flatId
};
this.deliver(session.parent, {
method: "Target.receivedMessageFromTarget",
params: {
sessionId: session.id,
targetId: session.physical.nativeTargetId,
message: JSON.stringify(message)
}
});
} else if (session.parent) this.deliver(session.parent, payload, flatId);
else session.client.socket.send(JSON.stringify({
...payload,
sessionId: flatId
}));
}
child(parent, params) {
const child = params?.sessionId !== void 0 ? typeof params.sessionId === "string" ? parent.client.sessions.get(params.sessionId) : void 0 : [...parent.children.values()].find((candidate) => candidate.physical.nativeTargetId === params?.targetId);
if (!child || child.parent !== parent) throw new Error("Target child session not found");
return child;
}
parentEvent(session, method, params) {
if (session.parent) this.deliver(session.parent, {
method,
params
});
else session.client.socket.send(JSON.stringify({
sessionId: session.parentSessionId,
method,
params
}));
}
emit(session, payload) {
if (this.clients.has(session.client) && session.client.sessions.get(session.id) === session) this.deliver(session, payload);
}
childWanted(child) {
const parent = child.parent;
return Boolean(parent && [...parent.subscribers].some((session) => !session.detachedChildren.has(child) && parent.target.interest(session, String(child.attached?.targetInfo.type))));
}
projectChild(parent, child) {
const info = child.attached;
const interest = parent.physical.target.interest(parent, String(info?.targetInfo.type));
if (!info || !interest?.admitted || parent.children.has(child) || parent.detachedChildren.has(child)) return;
const id = `openclaw-child-${this.nextAlias++}`;
this.announce(parent.client, id, child.id, {
...info,
sessionId: id
}, parent.id, interest.flatten);
}
async reconcileChildren(physical) {
if (physical.active.signal.aborted) return;
const removed = [];
for (const parent of physical.subscribers) for (const child of physical.children) {
const info = child.attached;
if (!info || child.active.signal.aborted) continue;
const interest = physical.target.interest(parent, String(info.targetInfo.type));
const existing = parent.children.get(child);
if (existing && (!interest || existing.flat !== interest.flatten)) removed.push(...this.remove(existing.client, existing.id));
this.projectChild(parent, child);
}
await Promise.all([...physical.children].map(async (child) => {
if (!child.subscribers.size && !this.childWanted(child)) await this.retire(child.id, () => this.send(physical, "Target.detachFromTarget", { sessionId: child.childSessionId }, "target").then(() => {}));
}));
await this.release(removed, physical);
}
remove(client, sessionId) {
const session = client.sessions.get(sessionId);
if (!session) return [];
session.physical.runtime.retire(session);
client.sessions.delete(sessionId);
session.physical.subscribers.delete(session);
session.parent?.children.delete(session.physical);
if (this.clients.has(client)) this.parentEvent(session, "Target.detachedFromTarget", {
sessionId,
targetId: session.physical.nativeTargetId
});
return [session, ...[...session.children.values()].flatMap((child) => this.remove(client, child.id))];
}
detach(client, sessionId) {
const session = client.sessions.get(sessionId);
session?.parent?.detachedChildren.add(session.physical);
return this.release(this.remove(client, sessionId));
}
detachChildren(client, parentSessionId) {
return this.release([...client.sessions].filter(([, session]) => session.parentSessionId === parentSessionId).flatMap(([id]) => this.remove(client, id)));
}
async release(sessions, reconciling) {
await Promise.all(sessions.map(async (session) => {
const physical = session.physical;
if (physical.active.signal.aborted) return;
if (!this.hasRootSessions(physical.rootSessionId) && !this.hasPendingClaims(physical.tabId)) {
await this.retireAttachment(physical.rootSessionId);
return;
}
const parent = physical.parent;
if (parent && !physical.subscribers.size && !this.childWanted(physical) && parent !== reconciling) {
await parent.target.enqueue(() => this.reconcileChildren(parent));
return;
}
const targetCleanup = physical.target.remove(session);
let timer;
try {
await Promise.all([targetCleanup, Promise.race([Promise.all([physical.fetch.close(session), physical.runtime.close(session)]), new Promise((_resolve, reject) => {
timer = setTimeout(() => reject(/* @__PURE__ */ new Error("Session owner cleanup timed out")), SESSION_RETIREMENT_MS);
timer.unref?.();
})])]);
} catch (error) {
await this.retireAttachment(physical.rootSessionId);
throw error;
} finally {
clearTimeout(timer);
}
if (parent && parent !== reconciling && !physical.subscribers.size) await parent.target.enqueue(() => this.reconcileChildren(parent));
}));
}
retire(sessionId, detach) {
const physical = this.physical.get(sessionId);
if (!physical) return Promise.resolve();
const descendants = (scope) => [scope, ...[...scope.children].flatMap(descendants)];
const scopes = descendants(physical);
for (const scope of scopes) {
scope.active.abort(/* @__PURE__ */ new Error("Physical session detached"));
scope.runtime.dispose();
scope.target.dispose();
for (const session of scope.subscribers) this.remove(session.client, session.id);
}
const dispose = () => {
for (const scope of scopes) {
scope.lifetime.abort(/* @__PURE__ */ new Error("Physical session detached"));
scope.fetch.dispose();
scope.parent?.children.delete(scope);
for (const parent of scope.parent?.subscribers ?? []) parent.detachedChildren.delete(scope);
const id = scope.childSessionId ?? scope.rootSessionId;
if (this.physical.get(id) === scope) this.physical.delete(id);
}
};
if (!detach) {
dispose();
return physical.retiring ?? Promise.resolve();
}
if (physical.retiring) return physical.retiring;
const preparations = scopes.map((scope) => scope.fetch.prepareRetirement(SESSION_RETIREMENT_MS));
return physical.retiring = (async () => {
try {
const results = await Promise.all(preparations);
for (const result of results) for (const error of result.errors) this.report(error);
} finally {
const needsDetach = !physical.lifetime.signal.aborted;
dispose();
if (needsDetach) await detach();
}
})();
}
retireTab(tabId) {
for (const [id, scope] of this.physical) if (scope.tabId === tabId) this.retire(id);
}
hasRootSessions(id) {
return (this.physical.get(id)?.subscribers.size ?? 0) > 0;
}
close(client) {
return this.release([...client.sessions.keys()].flatMap((id) => this.remove(client, id)));
}
forward(rootSessionId, childSessionId, method, params) {
const sessionId = childSessionId ?? rootSessionId;
const physical = this.physical.get(sessionId);
if (!physical || physical.active.signal.aborted || physical.rootSessionId !== rootSessionId) return;
if (physical.fetch.event(method, params)) return;
if (method.startsWith("Runtime.")) {
physical.runtime.event(method, params);
return;
}
if (method === "Target.detachedFromTarget") {
const detached = asOptionalRecord(params);
if (typeof detached?.sessionId === "string" && this.physical.get(detached.sessionId)?.parent === physical) this.retire(detached.sessionId);
return;
}
if (method === "Target.targetInfoChanged") {
const info = asOptionalRecord(asOptionalRecord(params)?.targetInfo);
const child = [...physical.children].find((candidate) => candidate.nativeTargetId === info?.targetId);
if (child?.attached && info && typeof info.type === "string") {
child.attached.targetInfo = info;
for (const parent of physical.subscribers) if (parent.children.has(child) && physical.target.interest(parent, info.type)?.admitted) this.emit(parent, {
method,
params
});
physical.target.enqueue(() => this.reconcileChildren(physical)).catch(this.report);
return;
}
}
if (method === "Target.attachedToTarget") {
const attached = asOptionalRecord(params);
const childId = attached?.sessionId;
const nativeTargetId = asOptionalRecord(attached?.targetInfo)?.targetId;
if (typeof childId !== "string" || typeof nativeTargetId !== "string") {
this.report(/* @__PURE__ */ new Error("Native child attachment is missing its session or target identity"));
return;
}
const targetInfo = asOptionalRecord(attached?.targetInfo);
if (!targetInfo || typeof targetInfo.type !== "string" || !nativeTargetId) {
this.report(/* @__PURE__ */ new Error("Native child attachment is missing its target type"));
return;
}
if (!this.physical.has(childId)) {
const child = this.register({
tabId: physical.tabId,
nativeTargetId,
rootSessionId,
childSessionId: childId,
parent: physical,
transport: physical.transport
});
child.attached = {
targetInfo,
waitingForDebugger: attached?.waitingForDebugger === true
};
}
const child = this.physical.get(childId);
if (!child || child.parent !== physical || child.active.signal.aborted) return;
for (const parent of physical.subscribers) this.projectChild(parent, child);
if (!physical.target.wanted(targetInfo.type)) physical.target.enqueue(() => this.reconcileChildren(physical)).catch(this.report);
return;
}
for (const session of physical.subscribers) this.emit(session, {
method,
params
});
}
dispose() {
for (const id of this.physical.keys()) this.retire(id);
for (const client of this.clients) client.sessions.clear();
}
};
//#endregion
//#region extensions/browser/src/browser/extension-relay/relay-bridge.ts
/**
* Extension relay CDP bridge.
*
* Presents a CDP browser endpoint (compatible with Playwright connectOverCDP)
* on one side and the OpenClaw Chrome extension's chrome.debugger transport on
* the other. The bridge owns all Target.* synthesis so the extension stays a
* thin forwarder — the old assets/chrome-extension put this logic in an
* untestable MV3 service worker, which is why it rotted and was removed.
*/
const log$1 = createSubsystemLogger("browser").child("extension-relay");
/** Default timeout for commands forwarded to the extension. */
const EXTENSION_COMMAND_TIMEOUT_MS = 15e3;
/** App-level keepalive interval; message traffic keeps the MV3 worker alive. */
const EXTENSION_PING_INTERVAL_MS = 2e4;
/** Synthetic targetId for the emulated browser target. */
const BROWSER_TARGET_ID = "openclaw-extension-relay";
/** Playwright requires every attached page target to identify its browser context. */
const BROWSER_CONTEXT_ID = "openclaw-extension-context";
function toErrorPayload(id, sessionId, message, code = -32e3) {
return JSON.stringify({
id,
...sessionId ? { sessionId } : {},
error: {
code,
message
}
});
}
/**
* One relay bridge per extension-driver profile. Accepts at most one extension
* connection (a newer one replaces the old — MV3 workers restart freely) and
* any number of CDP clients (pw-session caches one per cdpUrl in practice).
*/
var ExtensionRelayBridge = class {
constructor(opts = {}) {
this.extension = null;
this.extensionGeneration = 0;
this.extensionCandidates = /* @__PURE__ */ new Set();
this.clients = /* @__PURE__ */ new Set();
this.tabs = /* @__PURE__ */ new Map();
this.browserSessions = /* @__PURE__ */ new Map();
this.sessions = new RelaySessionOwner(this.clients, (root) => this.retireAttachment(root), (error) => log$1.warn(`Debugger cleanup incomplete: ${String(error)}`), (tabId) => (this.tabs.get(tabId)?.claimants.size ?? 0) > 0);
this.pendingExtension = /* @__PURE__ */ new Map();
this.nextSeq = 1;
this.nextSessionOrdinal = 1;
this.nextExtensionCandidateOrdinal = 1;
this.latestPromotedCandidateOrdinal = 0;
this.pingTimer = null;
this.missedPongs = 0;
this.connectionEvents = new EventTarget();
this.onStateChange = opts.onStateChange;
}
/** True once an extension socket completed its hello handshake. */
get extensionConnected() {
return this.extension !== null;
}
/** Wait for an authenticated extension hello without polling its CDP endpoint. */
async waitForExtensionConnection(signal, timeoutMs) {
if (this.extensionConnected) return true;
const timeout = new AbortController();
const timer = setTimeout(() => timeout.abort(), timeoutMs);
try {
await once(this.connectionEvents, "ready", { signal: AbortSignal.any([signal, timeout.signal]) });
return this.extensionConnected;
} catch (error) {
signal.throwIfAborted();
if (timeout.signal.aborted) return false;
throw error;
} finally {
clearTimeout(timer);
}
}
/** Identity of the paired browser, when connected. */
get identity() {
return this.extension?.identity ?? null;
}
/** Tabs currently reported as accessible by the extension. */
accessibleTabs() {
return [...this.tabs.values()].map((tab) => tab.info);
}
/** Capture the exact extension connection and tab instance for one browser operation. */
captureOperationTarget(targetId) {
const extension = this.extension;
const target = this.tabByTargetId(targetId);
if (!extension || !target) return;
const isCurrent = () => this.extension === extension && this.tabs.get(target.tabId) === target.tab;
return Object.assign(() => isCurrent() && target.tab.target?.sessionId ? target.tab.target.id : void 0, { isCurrent });
}
/**
* DevTools-style descriptors for `/json/list`: RelayTabInfo plus the `id`
* and `type` fields CDP discovery clients expect. `id` is the live debugger
* targetId once a tab is attached; before that it is the same `tab-<tabId>`
* discovery-only placeholder; native attachment never fabricates an identity.
* No per-target webSocketDebuggerUrl: all CDP traffic multiplexes over the
* single browser endpoint (`/cdp`).
*/
devtoolsTargetDescriptors() {
return [...this.tabs.values()].map((tab) => ({
tabId: tab.info.tabId,
url: tab.info.url,
title: tab.info.title,
active: tab.info.active,
id: tab.target?.id ?? `tab-${tab.info.tabId}`,
type: "page"
}));
}
/** Number of connected CDP clients (diagnostics). */
get cdpClientCount() {
return this.clients.size;
}
/** Wire up a newly accepted extension WebSocket. */
attachExtensionSocket(socket) {
const candidateOrdinal = this.nextExtensionCandidateOrdinal++;
let candidateState = "awaiting-hello";
this.extensionCandidates.add(socket);
const rejectCandidate = (code, reason) => {
candidateState = "rejected";
this.extensionCandidates.delete(socket);
socket.close(code, reason);
};
const onMessage = (raw) => {
if (candidateState === "rejected") return;
const msg = parseExtensionMessage(raw);
if (candidateState === "awaiting-hello") {
if (msg?.type !== "hello") {
rejectCandidate(4001, "expected valid hello");
return;
}
if (candidateOrdinal < this.latestPromotedCandidateOrdinal) {
rejectCandidate(4e3, "superseded by newer extension connection");
return;
}
candidateState = "active";
this.extensionCandidates.delete(socket);
this.latestPromotedCandidateOrdinal = candidateOrdinal;
if (this.extension) {
log$1.info("extension reconnected; replacing previous relay connection");
const previous = this.extension;
previous.socket.close(4e3, "replaced by newer extension connection");
if (this.extension === previous) this.handleExtensionGone();
}
this.extensionGeneration += 1;
this.extension = {
socket,
identity: {
userAgent: msg.userAgent,
browserVersion: msg.browserVersion,
extensionVersion: msg.extensionVersion
}
};
this.syncTabs(msg.tabs);
this.startPing();
this.connectionEvents.dispatchEvent(new Event("ready"));
this.onStateChange?.();
return;
}
if (this.extension?.socket !== socket) return;
if (!msg) {
log$1.warn("dropping malformed extension relay frame");
return;
}
this.handleExtensionMessage(msg);
};
const onClose = () => {
candidateState = "rejected";
this.extensionCandidates.delete(socket);
if (this.extension?.socket === socket) {
this.handleExtensionGone();
this.onStateChange?.();
}
};
return {
onMessage,
onClose
};
}
handleExtensionMessage(msg) {
switch (msg.type) {
case "result": {
const pending = this.pendingExtension.get(msg.seq);
if (pending) {
this.pendingExtension.delete(msg.seq);
clearTimeout(pending.timer);
pending.resolve(msg.result);
}
return;
}
case "error": {
const pending = this.pendingExtension.get(msg.seq);
if (pending) {
this.pendingExtension.delete(msg.seq);
clearTimeout(pending.timer);
pending.reject(new Error(msg.message));
}
return;
}
case "cdpEvent": {
const root = this.tabs.get(msg.tabId)?.target?.sessionId;
if (root) this.sessions.forward(root, msg.sessionId, msg.method, msg.params);
return;
}
case "tabs":
this.syncTabs(msg.tabs);
return;
case "detached": {
const tab = this.tabs.get(msg.tabId);
if (tab) tab.attaching = void 0;
this.sessions.retireTab(msg.tabId);
if (tab?.target) tab.target.sessionId = void 0;
break;
}
case "pong": this.missedPongs = 0;
}
}
handleExtensionGone() {
this.extension = null;
this.stopPing();
for (const pending of this.pendingExtension.values()) {
clearTimeout(pending.timer);
pending.reject(/* @__PURE__ */ new Error("extension disconnected"));
}
this.pendingExtension.clear();
this.sessions.dispose();
for (const tab of this.tabs.values()) {
tab.restoreAttachment ||= tab.target?.sessionId !== void 0 || tab.attaching !== void 0;
tab.attaching = void 0;
tab.retiring = void 0;
tab.target = void 0;
}
}
startPing() {
this.stopPing();
const owner = this.extension;
this.pingTimer = setInterval(() => {
if (!owner || this.extension !== owner) return;
if (++this.missedPongs > 2) {
owner.socket.close(4e3, "extension heartbeat timeout");
if (this.extension === owner) {
this.handleExtensionGone();
this.onStateChange?.();
}
return;
}
this.sendToExtension({ type: "ping" });
}, EXTENSION_PING_INTERVAL_MS);
this.pingTimer.unref?.();
}
stopPing() {
this.missedPongs = 0;
if (this.pingTimer) {
clearInterval(this.pingTimer);
this.pingTimer = null;
}
}
sendToExtension(msg) {
if (!this.extension) throw new Error("OpenClaw Chrome extension is not connected to the relay");
this.extension.socket.send(JSON.stringify(msg));
}
callExtension(command, timeoutMs = EXTENSION_COMMAND_TIMEOUT_MS, signal) {
signal?.throwIfAborted();
const seq = this.nextSeq++;
let abortListener;
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
this.pendingExtension.delete(seq);
reject(/* @__PURE__ */ new Error(`extension relay command timed out: ${command.type}`));
}, timeoutMs);
timer.unref?.();
this.pendingExtension.set(seq, {
resolve,
reject,
timer
});
if (signal) abortListener = addAbortListener(signal, () => {
this.pendingExtension.delete(seq);
clearTimeout(timer);
reject(/* @__PURE__ */ new Error("Physical session detached"));
});
try {
this.sendToExtension({
...command,
seq
});
} catch (err) {
this.pendingExtension.delete(seq);
clearTimeout(timer);
reject(err instanceof Error ? err : new Error(String(err)));
}
}).finally(() => abortListener?.[Symbol.dispose]());
}
syncTabs(tabs) {
const nextIds = new Set(tabs.map((tab) => tab.tabId));
const shouldAutoAttach = [...this.clients].some((client) => client.autoAttach);
for (const tabId of this.tabs.keys()) if (!nextIds.has(tabId)) {
this.sessions.retireTab(tabId);
this.tabs.delete(tabId);
}
for (const info of tabs) {
const existing = this.tabs.get(info.tabId);
const shouldAttach = !existing || existing.restoreAttachment;
if (existing) existing.info = info;
else this.tabs.set(info.tabId, {
info,
claimants: /* @__PURE__ */ new Set(),
restoreAttachment: false
});
if (shouldAutoAttach && shouldAttach) for (const client of this.clients) {
if (!client.autoAttach) continue;
this.withAttachedTab(client, info.tabId, (attached) => {
if (client.autoAttach) this.announceAttachedTab(info.tabId, attached, [client]);
}).catch((err) => log$1.warn(`auto-attach of accessible tab ${info.tabId} failed: ${String(err)}`));
}
}
}
async withAttachedTab(client, tabId, use, createdTargetId) {
const tab = this.tabs.get(tabId);
const extension = this.extension;
if (!tab) throw new Error(`tab ${tabId} is not available to OpenClaw`);
const claimant = { client };
tab.claimants.add(claimant);
try {
const attached = await this.ensureTabAttached(tabId, createdTargetId);
if (!this.clients.has(client) || this.tabs.get(tabId) !== tab || this.extension !== extension) throw new Error("Target claimant retired");
return use(attached);
} finally {
tab.claimants.delete(claimant);
this.detachUnusedAttachments();
}
}
async ensureTabAttached(tabId, createdTargetId) {
const extension = this.extension;
const tab = this.tabs.get(tabId);
if (!tab) throw new Error(`tab ${tabId} is not available to OpenClaw`);
if (tab.retiring) {
await tab.retiring;
if (this.tabs.get(tabId) !== tab || this.extension !== extension) throw new Error(`tab ${tabId} closed during retirement`);
}
if (tab.target?.sessionId) return {
targetId: tab.target.id,
sessionId: tab.target.sessionId
};
if (tab.attaching) return await tab.attaching;
const attaching = (createdTargetId !== void 0 ? Promise.resolve({ targetId: createdTargetId }) : this.callExtension({
type: "attach",
tabId
})).then(async (response) => {
const targetId = asOptionalRecord(response)?.targetId;
if (typeof targetId !== "string" || !targetId) {
if (this.extension === extension && this.tabs.get(tabId) === tab && tab.attaching === attaching) await this.callExtension({
type: "detach",
tabId
});
throw new Error("Extension did not return a native target identity");
}
const sessionId = `openclaw-tab-${tabId}-${this.nextSessionOrdinal++}`;
const attached = {
targetId,
sessionId
};
const current = this.tabs.get(tabId);
if (current !== tab || this.extension !== extension || tab.attaching !== attaching) throw new Error(`tab ${tabId} closed during attach`);
current.target = {
id: targetId,
sessionId
};
this.sessions.registerRoot(tabId, targetId, sessionId, async (childSessionId, method, params, signal) => {
const assertCurrent = () => {
signal.throwIfAborted();
if (this.extension !== extension || this.tabs.get(tabId) !== tab) throw new Error("Extension or tab generation retired");
};
assertCurrent();
const commandResult = await this.callExtension({
type: "cdp",
tabId,
...childSessionId ? { sessionId: childSessionId } : {},
method,
params
}, EXTENSION_COMMAND_TIMEOUT_MS, signal);
assertCurrent();
return commandResult;
});
current.restoreAttachment = false;
return attached;
});
tab.attaching = attaching;
try {
return await attaching;
} finally {
if (tab.attaching === attaching) tab.attaching = void 0;
}
}
targetInfoForTab(tab, targetId) {
return {
targetId,
type: "page",
title: tab.info.title,
url: tab.info.url,
browserContextId: BROWSER_CONTEXT_ID,
attached: Boolean(tab.target?.sessionId),
canAccessOpener: false
};
}
enumerateTargetInfos() {
if (!this.extensionConnected) return {
status: "unavailable",
reason: "extension-disconnected"
};
if ([...this.tabs.values()].some((tab) => !tab.target)) return {
status: "unavailable",
reason: "target-identity-unresolved"
};
return {
status: "available",
targetInfos: [...this.tabs.values()].map((tab) => this.targetInfoForTab(tab, tab.target?.id ?? ""))
};
}
announceAttachedTab(tabId, attached, recipients) {
const tab = this.tabs.get(tabId);
const { targetId, sessionId } = attached;
if (tab?.target?.sessionId !== sessionId) return;
const params = {
sessionId,
targetInfo: this.targetInfoForTab(tab, targetId),
waitingForDebugger: false
};
for (const client of recipients) this.sessions.announce(client, sessionId, sessionId, params);
}
/** Wire up a newly accepted CDP client WebSocket. */
attachCdpClientSocket(socket) {
const client = {
socket,
autoAttach: false,
sessions: /* @__PURE__ */ new Map(),
creating: /* @__PURE__ */ new Set()
};
this.clients.add(client);
const onMessage = (raw) => {
if (!this.clients.has(client)) return;
let parsed;
try {
parsed = JSON.parse(raw);
} catch {
client.socket.send(toErrorPayload(null, void 0, "Parse error", -32700));
return;
}
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
client.socket.send(toErrorPayload(null, void 0, "Invalid request", -32600));
return;
}
const request = parsed;
if (typeof request.id !== "number" || typeof request.method !== "string") {
const id = typeof request.id === "number" ? request.id : null;
const sessionId = typeof request.sessionId === "string" ? request.sessionId : void 0;
client.socket.send(toErrorPayload(id, sessionId, "Invalid request", -32600));
return;
}
this.handleCdpRequest(client, request);
};
const onClose = async () => {
this.clients.delete(client);
const acquisitions = [...client.creating];
for (const tab of this.tabs.values()) for (const claim of tab.claimants) if (claim.client === client) {
tab.claimants.delete(claim);
if (tab.attaching) acquisitions.push(tab.attaching.then(() => this.detachUnusedAttachments()));
}
const cleanup = this.sessions.close(client);
for (const [sessionId, owner] of this.browserSessions) if (owner === client) this.browserSessions.delete(sessionId);
await Promise.all([
cleanup,
...acquisitions,
this.detachUnusedAttachments()
]);
};
return {
onMessage,
onClose
};
}
/**
* Release a tab only after its last pending or delivered logical claimant leaves.
*/
detachUnusedAttachments() {
if (!this.extension) return Promise.resolve();
const retirements = [];
for (const tab of this.tabs.values()) if (tab.retiring) retirements.push(tab.retiring);
else if (tab.target?.sessionId && tab.claimants.size === 0 && !this.sessions.hasRootSessions(tab.target.sessionId)) retirements.push(this.retireAttachment(tab.target.sessionId));
const cleanup = Promise.all(retirements).then(() => {});
cleanup.catch((error) => log$1.warn(`Debugger retirement failed: ${String(error)}`));
return cleanup;
}
retireAttachment(rootSessionId) {
const entry = [...this.tabs].find(([, tab]) => tab.target?.sessionId === rootSessionId);
if (!entry) return Promise.resolve();
const [tabId, tab] = entry;
const extension = this.extension;
const target = tab.target;
if (!target) return Promise.resolve();
target.sessionId = void 0;
const retiring = this.sessions.retire(rootSessionId, async () => {
if (this.extension === extension && this.tabs.get(tabId) === tab) await this.callExtension({
type: "detach",
tabId
});
});
tab.retiring = retiring;
retiring.finally(() => {
if (tab.retiring === retiring) tab.retiring = void 0;
}).catch(() => {});
return retiring;
}
respond(client, request, result) {
if (!this.clients.has(client)) return;
const logical = request.sessionId ? client.sessions.get(request.sessionId) : void 0;
if (logical) {
this.sessions.emit(logical, {
id: request.id,
result: result ?? {}
});
return;
}
client.socket.send(JSON.stringify({
id: request.id,
...request.sessionId ? { sessionId: request.sessionId } : {},
result: result ?? {}
}));
}
respondError(client, request, message, code = -32e3) {
if (this.clients.has(client)) {
const logical = request.sessionId ? client.sessions.get(request.sessionId) : void 0;
if (logical) this.sessions.emit(logical, {
id: request.id,
error: {
code,
message
}
});
else client.socket.send(toErrorPayload(request.id, request.sessionId, message, code));
}
}
tabByTargetId(targetId) {
for (const [tabId, tab] of this.tabs) if (tab.target?.id === targetId) return {
tabId,
tab
};
return null;
}
async createTarget(client, request) {
const extension = this.extension;
const url = typeof request.params?.url === "string" && request.params.url ? request.params.url : "about:blank";
const command = {
type: "createTab",
url,
...resolveCreateTargetParams(request.params)
};
const created = await this.callExtension(command);
if (this.extension !== extension) return;
if (typeof created?.tabId !== "number") {
this.respondError(client, request, "extension did not return a tabId for createTab");
return;
}
const tabId = created.tabId;
if (!this.clients.has(client) && !this.tabs.has(tabId)) {
if (typeof created.targetId === "string") await this.callExtension({
type: "detach",
tabId
});
return;
}
if (!this.tabs.has(tabId)) this.tabs.set(tabId, {
info: {
tabId,
url,
title: "",
active: false
},
claimants: /* @__PURE__ */ new Set(),
restoreAttachment: false
});
await this.withAttachedTab(client, tabId, (attached) => {
const recipients = [...this.clients].filter((recipient) => recipient.autoAttach);
this.announceAttachedTab(tabId, attached, recipients);
this.announceAttachedTab(tabId, attached, [client]);
this.respond(client, request, { targetId: attached.targetId });
}, typeof created.targetId === "string" ? created.targetId : void 0);
}
handleCdpRequest(client, request) {
const session = request.sessionId ? client.sessions.get(request.sessionId) : void 0;
const completed = (request.sessionId && this.browserSessions.get(request.sessionId) !== client ? this.handleSessionScopedRequest(client, request) : this.handleBrowserScopedRequest(client, request)).catch((err) => {
this.respondError(client, request, err instanceof Error ? err.message : String(err));
});
if (session && request.method === "Page.getFrameTree") {
const ready = Promise.all([session.frameTreeRead, completed]).then(() => {});
session.frameTreeRead = ready;
ready.then(() => {
if (session.frameTreeRead === ready) session.frameTreeRead = void 0;
});
}
return completed;
}
async handleSessionScopedRequest(client, request) {
const sessionId = request.sessionId;
const session = client.sessions.get(sessionId);
if (!session) {
this.respondError(client, request, `Session not found: ${sessionId}`, -32001);
return;
}
if (request.method === "Target.detachFromTarget") {
const child = this.sessions.child(session, request.params);
await this.sessions.detach(client, child.id);
this.respond(client, request, {});
return;
}
if (request.method === "Target.sendMessageToTarget") {
const child = this.sessions.child(session, request.params);
if (child.flat || typeof request.params?.message !== "string") throw new Error("Non-flat Target child session not found");
const nested = asOptionalRecord(JSON.parse(request.params.message));
if (!nested || typeof nested.id !== "number" || typeof nested.method !== "string") throw new Error("Invalid Target message");
const target = nested.sessionId === void 0 ? child : typeof nested.sessionId === "string" ? client.sessions.get(nested.sessionId) : void 0;
let ancestor = target;
while (ancestor && ancestor !== child && ancestor.flat) ancestor = ancestor.parent;
if (!target || ancestor !== child) throw new Error("Nested Target session not found");
this.handleCdpRequest(client, {
id: nested.id,
method: nested.method,
params: asOptionalRecord(nested.params),
sessionId: target.id
});
this.respond(client, request, {});
return;
}
if (request.method === "Target.setAutoAttach") {
const result = await session.physical.target.command(session, request.params, () => {
if (client.sessions.get(sessionId) !== session || !this.clients.has(client)) throw new Error("Target parent detached");
});
this.respond(client, request, result);
return;
}
const { runtime, fetch } = session.physical;
const emit = (method, params) => this.sessions.emit(session, {
method,
params
});
const fetchResult = fetch.command(session, emit, request.method, request.params);
if (fetchResult) {
const result = await fetchResult;
if (client.sessions.get(sessionId) !== session) throw new Error(`Session detached: ${sessionId}`);
this.respond(client, request, result);
return;
}
if (request.method === "Runtime.disable") {
session.runtimeGeneration++;
runtime.disable(session);
this.respond(client, request, {});
return;
}
if (request.method === "Runtime.enable" && session.frameTreeRead) {
const generation = session.runtimeGeneration;
await session.frameTreeRead;
if (!this.clients.has(client) || client.sessions.get(sessionId) !== session || session.runtimeGeneration !== generation) throw new Error("Runtime session detached or disabled");
}
const send = () => this.sessions.send(session.physical, request.method, request.params, request.method === "Runtime.runIfWaitingForDebugger" ? "target" : void 0);
const result = request.method === "Runtime.enable" ? await runtime.enable(session, emit, send) : request.method === "Runtime.addBinding" || request.method === "Runtime.removeBinding" ? await runtime.binding(session, emit, request.method, request.params) : await send();
if (client.sessions.get(sessionId) !== session) throw new Error(`Session detached: ${sessionId}`);
this.respond(client, request, result);
}
async handleBrowserScopedRequest(client, request) {
switch (request.method) {
case "Browser.getVersion": {
const identity = this.extension?.identity;
this.respond(client, request, {
protocolVersion: "1.3",
product: identity?.browserVersion ?? "Chrome/unknown",
revision: "openclaw-extension-relay",
userAgent: identity?.userAgent ?? "unknown",
jsVersion: ""
});
return;
}
case "Browser.close":
this.respond(client, request, {});
client.socket.close(1e3, "Browser.close");
return;
case "Browser.setDownloadBehavior":
case "Target.setDiscoverTargets":
this.respond(client, request, {});
return;
case "Target.getTargetInfo": {
const targetId = request.params?.targetId;
if (!targetId || targetId === BROWSER_TARGET_ID) {
this.respond(client, request, { targetInfo: {
targetId: BROWSER_TARGET_ID,
type: "browser",
title: "OpenClaw Extension Relay",
url: "",
attached: true,
canAccessOpener: false
} });
return;
}
const found = this.tabByTargetId(targetId);
if (!found) {
this.respondError(client, request, `No target with given id found: ${targetId}`, -32602);
return;
}
this.respond(client, request, { targetInfo: this.targetInfoForTab(found.tab, targetId) });
return;
}
case "Target.getTargets": {
const enumeration = this.enumerateTargetInfos();
if (enumeration.status === "unavailable") {
const message = enumeration.reason === "extension-disconnected" ? "Extension is disconnected" : "Target identities are unavailable";
this.respondError(client, request, message, -32002);
return;
}
this.respond(client, request, { targetInfos: enumeration.targetInfos });
return;
}
case "Target.attachToBrowserTarget": {
const sessionId = `openclaw-browser-${this.nextSessionOrdinal++}`;
this.browserSessions.set(sessionId, client);
this.respond(client, request, { sessionId });
return;
}
case "Target.setAutoAttach": {
const autoAttach = request.params?.autoAttach !== false;
client.autoAttach = autoAttach;
if (autoAttach) {
const attachResults = await Promise.allSettled([...this.tabs.keys()].map((tabId) => this.withAttachedTab(client, tabId, (attached) => {
if (client.autoAttach) this.announceAttachedTab(tabId, attached, [client]);
})));
for (const settled of attachResults) if (settled.status === "rejected") log$1.warn(`setAutoAttach attach failed: ${String(settled.reason)}`);
}
this.respond(client, request, {});
return;
}
case "Target.attachToTarget": {
const targetId = request.params?.targetId;
const found = targetId ? this.tabByTargetId(targetId) : null;
if (!found && targetId) {
this.respondError(client, request, `No target with given id found: ${targetId}`, -32602);
return;
}
if (!found) {
this.respondError(client, request, "targetId is required", -32602);
return;
}
await this.withAttachedTab(client, found.tabId, (attached) => {
if (attached.targetId !== targetId) throw new Error("Requested native target changed during attachment");
if (found.tab.target?.sessionId !== attached.sessionId || request.sessionId && this.browserSessions.get(request.sessionId) !== client) throw new Error("Target attachment retired");
const sessionId = `openclaw-tab-${found.tabId}-${this.nextSessionOrdinal++}`;
this.sessions.announce(client, sessionId, attached.sessionId, {
sessionId,
targetInfo: this.targetInfoForTab(found.tab, attached.targetId),
waitingForDebugger: false
}, request.sessionId);
this.respond(client, request, { sessionId });
});
return;
}
case "Target.detachFromTarget": {
const sessionId = request.params?.sessionId;
if (sessionId && this.browserSessions.get(sessionId) === client) {
this.browserSessions.delete(sessionId);
await this.sessions.detachChildren(client, sessionId);
} else {
const session = sessionId ? client.sessions.get(sessionId) : void 0;
if (!sessionId || !session) {
this.respondError(client, request, `Session not found: ${String(sessionId)}`, -32001);
return;
}
await this.sessions.detach(client, sessionId);
}
this.respond(client, request, {});
return;
}
case "Target.createTarget": {
const creating = this.createTarget(client, request);
client.creating.add(creating);
try {
await creating;
} finally {
client.creating.delete(creating);
}
return;
}
case "Target.closeTarget": {
const targetId = request.params?.targetId;
const found = targetId ? this.tabByTargetId(targetId) : null;
if (!found) {
this.respondError(client, request, `No target with given id found: ${String(targetId)}`, -32602);
return;
}
await this.callExtension({
type: "closeTab",
tabId: found.tabId
});
this.respond(client, request, { success: true });
return;
}
case "Target.activateTarget": {
const targetId = request.params?.targetId;
const found = targetId ? this.tabByTargetId(targetId) : null;
if (!found) {
this.respondError(client, request, `No target with given id found: ${String(targetId)}`, -32602);
return;
}
await this.callExtension({
type: "activateTab",
tabId: found.tabId
});
this.respond(client, request, {});
return;
}
case "Target.getBrowserContexts":
this.respond(client, request, { browserContextIds: [] });
return;
case "Target.createBrowserContext":
this.respondError(client, request, "The OpenClaw extension relay drives the user's real browser profile; isolated browser contexts are not supported.");
return;
default: this.respondError(client, request, `'${request.method}' wasn't found`, -32601);
}
}
/** Close all sockets and reject pending work (relay shutdown). */
dispose() {
this.stopPing();
for (const pending of this.pendingExtension.values()) {
clearTimeout(pending.timer);
pending.reject(/* @__PURE__ */ new Error("extension relay stopped"));
}
this.pendingExtension.clear();
for (const candidate of this.extensionCandidates) candidate.close(1001, "relay stopped");
this.extensionCandidates.clear();
this.extension?.socket.close(1001, "relay stopped");
this.extension = null;
this.connectionEvents.dispatchEvent(new Event("ready"));
this.sessions.dispose();
for (const client of this.clients) client.socket.close(1001, "relay stopped");
this.clients.clear();
this.browserSessions.clear();
this.tabs.clear();
}
};
//#endregion
//#region extensions/browser/src/browser/extension-relay/relay-request.ts
const LEGACY_EXTENSION_RELAY_PROTOCOL = "openclaw-extension-relay";
const LEGACY_EXTENSION_RELAY_TOKEN_PROTOCOL_PREFIX = "openclaw-extension-token.";
function firstHeader(value) {
return Array.isArray(value) ? value[0] ?? "" : value ?? "";
}
function requestProtocols(req) {
return firstHeader(req.headers["sec-websocket-protocol"]).split(",").map((value) => value.trim()).filter(Boolean);
}
function requestExtensionProtocolToken(req) {
const protocols = requestProtocols(req);
if (!protocols.includes("openclaw-extension-relay")) return "";
return protocols.find((value) => value.startsWith(LEGACY_EXTENSION_RELAY_TOKEN_PROTOCOL_PREFIX))?.slice(25) ?? "";
}
function isAllowedExtensionOrigin(req) {
const origin = firstHeader(req.headers.origin);
return origin === "" || origin.startsWith("chrome-extension://");
}
//#endregion
//#region extensions/browser/src/browser/extension-relay/relay-server.ts
/** Loopback extension relay with connection-bound Browser Relay Authentication v2. */
const log = createSubsystemLogger("browser").child("extension-relay");
const INTERNAL_CDP_USERNAME = "openclaw-internal";
const MAX_AUTH_BODY_BYTES = 8192;
const EXTENSION_RELAY_MAX_PAYLOAD_BYTES = 67108864;
function decodeBasic(req) {
const auth = firstHeader(req.headers.authorization);
if (!auth.startsWith("Basic ")) return null;
try {
const decoded = Buffer.from(auth.slice(6), "base64").toString("utf8");
const separator = decoded.indexOf(":");
return separator < 0 ? {
username: "",
password: decoded
} : {
username: decoded.slice(0, separator),
password: decoded.slice(separator + 1)
};
} catch {
return null;
}
}
function isAuthorizedInternal(req, internalToken) {
const basic = decodeBasic(req);
return basic?.username === INTERNAL_CDP_USERNAME && safeEqualSecret(internalToken, basic.password);
}
function isAuthorizedLegacy(req, token, allowLegacyAuth) {
if (!allowLegacyAuth) return false;
const auth = firstHeader(req.headers.authorization);
if (auth.startsWith("Bearer ") && safeEqualSecret(token, auth.slice(7).trim())) return true;
const basic = decodeBasic(req);
if (basic && safeEqualSecret(token, basic.password)) return true;
const protocolToken = requestExtensionProtocolToken(req);
return protocolToken.length > 0 && safeEqualSecret(token, protocolToken);
}
function hasLoopbackHostHeader(req) {
const host = firstHeader(req.headers.host);
if (!host) return true;
try {
return isLoopbackHost(new URL(`http://${host}`).hostname);
} catch {
return false;
}
}
function destroySocket(socket, response) {
try {
socket.write(response);
} finally {
socket.destroy();
}
}
function writeJson(res, status, value, headers = {}) {
const body = JSON.stringify(value);
res.writeHead(status, {
"Content-Type": "application/json",
"Content-Length": String(Buffer.byteLength(body)),
...headers
});
res.end(body);
}
function rejectHttp(res, status, message) {
res.once("finish", () => res.socket?.destroy());
writeJson(res, status, { error: message }, { Connection: "close" });
}
async function readAuthBody(req) {
try {
return await readRequestBodyWithLimit(req, {
...WEBHOOK_BODY_READ_DEFAULTS.preAuth,
maxBytes: MAX_AUTH_BODY_BYTES,
destroyOnLimit: false
});
} catch {
return null;
}
}
function bindSocket(ws, handlers) {
ws.on("message", (data) => handlers.onMessage(rawDataToString(data)));
ws.on("close", () => {
Promise.resolve(handlers.onClose()).catch((error) => log.warn(`Client cleanup incomplete: ${String(error)}`));
});
ws.on("error", (err) => log.warn(`relay socket error: ${String(err)}`));
}
function trackAuthenticatedSocket(authority, ws) {
if (!authority.registerAuthenticatedConnection(ws, () => ws.close(4003, "browser relay key rotated"))) {
ws.terminate();
return false;
}
ws.once("close", () => authority.releaseConnection(ws));
return true;
}
/** Wire an already-v2-authenticated extension socket to the bridge. */
function attachExtensionWebSocket(bridge, ws) {
const handlers = bridge.attachExtensionSocket(ws);
let helloSeen = false;
const helloTimer = setTimeout(() => {
ws.close(4008, "extension hello timeout");
ws.terminate();
}, BROWSER_RELAY_CHALLENGE_TTL_MS);
helloTimer.unref?.();
bindSocket(ws, {
onMessage: (raw) => {
if (!helloSeen && parseExtensionMessage(raw)?.type === "hello") {
helloSeen = true;
clearTimeout(helloTimer);
}
handlers.onMessage(raw);
},
onClose: () => {
clearTimeout(helloTimer);
handlers.onClose();
}
});
}
async function startExtensionRelayServer(params) {
const allowLegacyAuth = params.allowLegacyAuth ?? true;
const internalToken = crypto.randomBytes(32).toString("base64url");
const owner = randomRelayId();
let retired = false;
if (readExtensionRelayToken() === params.token) getBrowserRelayAuthV2Authority(params.token);
const bridge = new ExtensionRelayBridge({ onStateChange: params.onStateChange });
const wss = new WebSocketServer({
noServer: true,
maxPayload: EXTENSION_RELAY_MAX_PAYLOAD_BYTES
});
const httpStates = /* @__PURE__ */ new WeakMap();
const socketAuthorities = /* @__PURE__ */ new WeakMap();
const authSockets = /* @__PURE__ */ new Set();
const ownerConnections = /* @__PURE__ */ new Map();
const currentAuthority = () => {
const liveToken = readExtensionRelayToken();
if (!liveToken) {
invalidateBrowserRelayAuthV2Authority();
return null;
}
return getBrowserRelayAuthV2Authority(liveToken);
};
const clearSocketState = (socket) => {
const state = httpStates.get(socket);
if (state && "timer" in state) clearTimeout(state.timer);
httpStates.delete(socket);
authSockets.delete(socket);
const authority = socketAuthorities.get(socket);
socketAuthorities.delete(socket);
authority?.releaseConnection(socket);
};
const armSocketTimer = (socket) => {
const timer = setTimeout(() => socket.destroy(), BROWSER_RELAY_CHALLENGE_TTL_MS);
timer.unref?.();
return timer;
};
const registerHttpSocket = (socket, authority, source) => {
if (authSockets.has(socket)) return true;
if (!authority.registerPendingConnection(socket, () => socket.destroy(), source)) return false;
authSockets.add(socket);
socketAuthorities.set(socket, authority);
socket.once("close", () => clearSocketState(socket));
return true;
};
const versionPayload = () => ({
Browser: bridge.identity?.browserVersion ?? "Chrome/unknown",
"Protocol-Version": "1.3",
"User-Agent": bridge.identity?.userAgent ?? "unknown",
webSocketDebuggerUrl: `ws://127.0.0.1:${resolvedPort()}/cdp`
});
const server = http.createServer((req, res) => {
(async () => {
if (!hasLoopbackHostHeader(req)) {
rejectHttp(res, 403, "Forbidden");
return;
}
const path = (req.url ?? "/").split("?")[0];
const socket = req.socket;
const source = resolveRequestClientIp(req) ?? "unknown";
const existingState = httpStates.get(socket);
const authority = currentAuthority();
if (path === "/_openclaw/relay/auth/v2/challenge") {
if (req.url !== "/_openclaw/relay/auth/v2/challenge" || req.method !== "POST" || existingState || !authority || !registerHttpSocket(socket, authority, source)) {
rejectHttp(res, existingState ? 409 : 400, "Invalid relay auth sequence");
return;
}
const pending = { stage: "busy" };
httpStates.set(socket, pending);
const raw = await readAuthBody(req);
const request = raw === null ? null : parseRelayHttpChallengeRequest(parseStrictJsonObject(raw));
if (!request || request.keyId !== authority.keyId) {
clearSocketState(socket);
rejectHttp(res, 400, "Invalid relay auth challenge request");
return;
}
const challenge = authority.issueChallenge(socket, {
type: "auth.hello",
v: 2,
keyId: request.keyId,
clientNonce: request.clientNonce
}, {
role: request.role,
transport: request.transport,
method: request.method,
resource: request.resource,
flow: request.flow
});
if (!challenge) {
clearSocketState(socket);
rejectHttp(res, 401, "Relay auth challenge rejected");
return;
}
res.once("finish", () => {
if (!socket.destroyed && httpStates.get(socket) === pending) httpStates.set(socket, {
stage: "challenged",
flow: request.flow,
authority,
timer: armSocketTimer(socket)
});
});
writeJson(res, 200, challenge);
return;
}
if (path === "/_openclaw/relay/auth/v2/complete") {
if (req.url !== "/_openclaw/relay/auth/v2/complete" || req.method !== "POST" || existingState?.stage !== "challenged") {
rejectHttp(res, 409, "Invalid relay auth sequence");
return;
}
clearTimeout(existingState.timer);
const pending = { stage: "busy" };
httpStates.set(socket, pending);
const raw = await readAuthBody(req);
const request = raw === null ? null : parseRelayHttpCompleteRequest(parseStrictJsonObject(raw));
const completed = request ? existingState.authority.completeChallenge(socket, {
type: "auth.response",
...request
}) : null;
if (!completed) {
clearSocketState(socket);
rejectHttp(res, 401, "Relay auth proof failed");
return;
}
res.once("finish", () => {
if (!socket.destroyed && httpStates.get(socket) === pending) httpStates.set(socket, {
stage: "authenticated",
flow: existingState.flow,
authority: existingState.authority,
timer: armSocketTimer(socket)
});
});
writeJson(res, 200, completed.ok);
return;
}
if (existingState?.stage === "authenticated") {
clearTimeout(existingState.timer);
const pending = { stage: "busy" };
httpStates.set(socket, pending);
if (existingState.flow === "cdp" && req.method === "GET" && req.url === "/json/version") {
if (!bridge.extensionConnected) {
clearSocketState(socket);
rejectHttp(res, 503, "OpenClaw Chrome extension is not connected");
return;
}
res.once("finish", () => {
if (!socket.destroyed && httpStates.get(socket) === pending) httpStates.set(socket, {
stage: "awaiting-upgrade",
authority: existingState.authority,
timer: armSocketTimer(socket)
});
});
writeJson(res, 200, versionPayload());
return;
}
if (existingState.flow === "json-list" && req.method === "GET" && req.url === "/json/list") {
clearSocketState(socket);
res.once("finish", () => socket.destroy());
writeJson(res, 200, bridge.devtoolsTargetDescriptors(), { Connection: "close" });
return;
}
clearSocketState(socket);
rejectHttp(res, 409, "Invalid relay auth sequence");
return;
}
if (existingState) {
clearSocketState(socket);
rejectHttp(res, 409, "Invalid relay auth sequence");
return;
}
if (!(isAuthorizedInternal(req, internalToken) || authority !== null && isAuthorizedLegacy(req, readExtensionRelayToken() ?? "", allowLegacyAuth))) {
rejectHttp(res, 401, "Unauthorized");
return;
}
if (req.method === "GET" && (path === "/json/version" || path === "/json/version/")) {
if (!bridge.extensionConnected) {
writeJson(res, 503, { error: "OpenClaw Chrome extension is not connected. Install the extension and pair it with `openclaw browser extension pair`." });
return;
}
writeJson(res, 200, versionPayload());
return;
}
if (req.method === "GET" && (path === "/json" || path === "/json/list")) {
writeJson(res, 200, bridge.devtoolsTargetDescriptors());
return;
}
rejectHttp(res, 404, "Not found");
})().catch((err) => {
log.warn(`relay HTTP request failed: ${String(err)}`);
if (!res.headersSent) rejectHttp(res, 500, "Relay request failed");
else res.destroy();
});
});
server.on("upgrade", (req, socket, head) => {
const path = (req.url ?? "/").split("?")[0];
const source = resolveRequestClientIp(req) ?? "unknown";
if (retired) {
destroySocket(socket, "HTTP/1.1 503 Service Unavailable\r\nConnection: close\r\n\r\n");
return;
}
if (!hasLoopbackHostHeader(req)) {
destroySocket(socket, "HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n");
return;
}
if (path === "/_openclaw/relay/owner") {
const resource = params.profileName ? relayOwnerResource(resolvedPort(), params.profileName) : null;
const authority = currentAuthority();
const protocols = requestProtocols(req);
if (!resource || req.url !== resource || !authority || readExtensionRelayToken() !== params.token || retired || protocols.length !== 1 || protocols[0] !== "openclaw-extension-relay.v2") {
destroySocket(socket, "HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n");
return;
}
handlePreAuthWebSocketUpgrade({
wss,
req,
socket,
head,
onUpgrade: (ws, removePreAuthGuard) => authenticateExtensionWebSocket({
ws,
authority,
source,
resource: `${resource}&owner=${owner}`,
binding: {
role: "cdp",
flow: "owner"
},
removePreAuthGuard,
prepareAuthenticated: async () => () => {
if (retired || readExtensionRelayToken() !== params.token) throw new Error("Relay owner retired");
const closeOwner = attachRelayOwner({
ws,
bridge,
allowLegacyAuth,
isCurrent: () => !retired && readExtensionRelayToken() === params.token
});
ownerConnections.set(ws, closeOwner);
ws.once("close", () => {
closeOwner().then(() => ownerConnections.delete(ws), () => {});
});
}
})
});
return;
}
if (path === "/extension") {
if (!isAllowedExtensionOrigin(req)) {
destroySocket(socket, "HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n");
return;
}
const protocols = requestProtocols(req);
const resource = parseExtensionRelayResource(req.url ?? "/", "/extension");
if (protocols.length === 1 && protocols[0] === "openclaw-extension-relay.v2" && resource) {
const authority = currentAuthority();
if (!authority) {
destroySocket(socket, "HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n");
return;
}
if (!handlePreAuthWebSocketUpgrade({
wss,
req,
socket,
head,
onUpgrade: (ws, removePreAuthGuard) => {
authenticateExtensionWebSocket({
ws,
authority,
source,
resource,
removePreAuthGuard,
prepareAuthenticated: async () => () => {
attachExtensionWebSocket(bridge, ws);
log.info("extension authenticated and connected to relay");
}
});
}
})) destroySocket(socket, "HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n");
return;
}
if (protocols.includes("openclaw-extension-relay.v2")) {
destroySocket(socket, "HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n");
return;
}
const liveToken = readExtensionRelayToken();
if (!liveToken || !isAuthorizedLegacy(req, liveToken, allowLegacyAuth)) {
destroySocket(socket, "HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n");
return;
}
const authority = getBrowserRelayAuthV2Authority(liveToken);
wss.handleUpgrade(req, socket, head, (ws) => {
if (!trackAuthenticatedSocket(authority, ws)) return;
attachExtensionWebSocket(bridge, ws);
log.warn("legacy extension relay authentication accepted");
});
return;
}
if (path === "/cdp") {
const state = httpStates.get(socket);
if (req.url === "/cdp" && state?.stage === "awaiting-upgrade") {
clearTimeout(state.timer);
httpStates.delete(socket);
wss.handleUpgrade(req, socket, head, (ws) => bindSocket(ws, bridge.attachCdpClientSocket(ws)));
return;
}
if (!isAuthorizedInternal(req, internalToken) && !isAuthorizedLegacy(req, readExtensionRelayToken() ?? "", allowLegacyAuth)) {
destroySocket(socket, "HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n");
return;
}
const authority = currentAuthority();
if (!authority) {
destroySocket(socket, "HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n");
return;
}
wss.handleUpgrade(req, socket, head, (ws) => trackAuthenticatedSocket(authority, ws) ? bindSocket(ws, bridge.attachCdpClientSocket(ws)) : void 0);
return;
}
destroySocket(socket, "HTTP/1.1 404 Not Found\r\nConnection: close\r\n\r\n");
});
await new Promise((resolve, reject) => {
server.once("error", reject);
server.listen(params.port, "127.0.0.1", () => resolve());
});
const resolvedPort = () => {
const address = server.address();
return typeof address === "object" && address ? address.port : params.port;
};
return {
ownership: "owned",
port: resolvedPort(),
token: params.token,
allowLegacyAuth,
internalToken,
bridge,
close: async () => {
retired = true;
try {
await Promise.all([...ownerConnections.values()].map((closeOwner) => closeOwner()));
} finally {
for (const socket of authSockets) {
clearSocketState(socket);
socket.destroy();
}
for (const client of wss.clients) client.terminate();
bridge.dispose();
wss.close();
await new Promise((resolve) => {
server.close(() => resolve());
});
}
}
};
}
//#endregion
export { isAllowedExtensionOrigin as a, relayOwnerFrame as c, relayOwnerRetired as d, relayOwnerStatus as f, handlePreAuthWebSocketUpgrade as h, LEGACY_EXTENSION_RELAY_PROTOCOL as i, relayOwnerReply as l, authenticateExtensionWebSocket as m, attachExtensionWebSocket as n, requestExtensionProtocolToken as o, relayOwnerStreamClosed as p, startExtensionRelayServer as r, requestProtocols as s, EXTENSION_RELAY_MAX_PAYLOAD_BYTES as t, relayOwnerResource as u };