乐于分享
好东西不私藏

OpenClaw Docker容器环境浏览器超时问题解决方案

OpenClaw Docker容器环境浏览器超时问题解决方案

📋 问题概述

问题现象

在 Docker 容器环境中使用浏览器工具时,频繁出现超时错误:

[tools] browser failed: Can't reach the OpenClaw browser control service (timed out after 15000ms)

环境信息

  • 「部署方式」:Docker Compose(openclaw-gateway + openclaw-sandbox-browser)
  • 「网络拓扑」:各个容器间通过 Docker 内网通信
  • 「CDP 地址」:http://172.20.0.4:9222(容器 IP)
  • 「Bridge 地址」:http://172.20.0.2:39893(Gateway 容器 IP)

🔍 问题排查过程

阶段 1:定位超时环节

通过在关键路径添加日志:

「src/browser/client-fetch.ts」

export async function fetchBrowserJson<T>(...): Promise<T> {  console.log(`[fetchBrowserJson] === START ===`);  console.log(`[fetchBrowserJson] Called with URL: ${url}`);  console.log(`[fetchBrowserJson] timeoutMs: ${timeoutMs}`);  // ... 其他日志}

「发现」:请求在 openTab 阶段超时。


阶段 2:追踪 openTab 执行路径

在 src/browser/server-context.tab-ops.ts 添加日志:

const openTab = async (url: string): Promise<BrowserTab> => {  console.log(`[openTab] Opening URL: ${url}`);  console.log(`[openTab] Profile: ${profile.name}, cdpUrl: ${profile.cdpUrl}, isLoopback: ${profile.cdpIsLoopback}`);  console.log(`[openTab] Checking capabilities: usesChromeMcp=${capabilities.usesChromeMcp}, usesPersistentPlaywright=${capabilities.usesPersistentPlaywright}`);  // ...}

「关键发现」:

[openTab] cdpUrl: http://172.20.0.4:9222, isLoopback: false[openTab] usesPersistentPlaywright: true

阶段 3:定位 Playwright 超时原因

查看 profile-capabilities.ts 的判断逻辑:

if (!profile.cdpIsLoopback) {  return {    mode: "remote-cdp",    usesPersistentPlaywright: true,  // ← 触发 Playwright 路径  };}

查看 pw-session.ts 的超时配置:

async function connectBrowser(cdpUrl: string): Promise<ConnectedBrowser> {  for (let attempt = 0; attempt < 3; attempt += 1) {    const timeout = 5000 + attempt * 2000;  // 5s + 7s + 9s = 21s    const browser = await chromium.connectOverCDP(endpoint, { timeout, headers });  }}

「根本原因」:

  1. 「容器 IP 不是 loopback」 → cdpIsLoopback = false
  2. 「触发 Playwright 路径」 → usesPersistentPlaywright = true
  3. 「Playwright 连接重试总时长」:21秒
  4. 「外层 HTTP 请求超时」:15秒
  5. 「必然超时失败」

✅ 解决方案

方案概述

为 CDP 和 Playwright 操作增加动态超时配置,根据连接类型(本地/远程)使用不同的超时时间。


修改 1:增加远程超时常量

「文件:src/browser/cdp-timeouts.ts」

export const CDP_HTTP_REQUEST_TIMEOUT_MS = 1500;export const CDP_WS_HANDSHAKE_TIMEOUT_MS = 5000;export const CDP_JSON_NEW_TIMEOUT_MS = 1500;+export const CDP_JSON_NEW_REMOTE_TIMEOUT_MS = 15000; // 容器/远程环境

修改 2:支持超时参数传递

「文件:src/browser/cdp.ts」

export async function createTargetViaCdp(opts: {   cdpUrl: string;   url: string;   ssrfPolicy?: SsrFPolicy;+  timeoutMs?: number; }): Promise<{ targetId: string }> {+  console.log(`[createTargetViaCdp] cdpUrl: ${opts.cdpUrl}, timeoutMs: ${opts.timeoutMs}`);   let wsUrl: string;   if (isWebSocketUrl(opts.cdpUrl)) {     wsUrl = opts.cdpUrl;   } else {+    const versionTimeout = opts.timeoutMs ?? 1500;     const version = await fetchJson<{ webSocketDebuggerUrl?: string }>(       appendCdpPath(opts.cdpUrl, "/json/version"),-      1500,+      versionTimeout,     );     // ...   }+  const handshakeTimeout = opts.timeoutMs ? Math.max(opts.timeoutMs * 2, 5000) : 5000;-  return await withCdpSocket(wsUrl, async (send) => {+  return await withCdpSocket(wsUrl, async (send) => {     // ...-  });+  }, { handshakeTimeoutMs: handshakeTimeout }); }

修改 3:优化 Playwright 超时逻辑

「文件:src/browser/pw-session.ts」

export async function createPageViaPlaywright(opts: {   cdpUrl: string;   url: string;   ssrfPolicy?: SsrFPolicy;+  timeoutMs?: number; }): Promise<{...}> {+  console.log(`[createPageViaPlaywright] Starting, timeoutMs: ${opts.timeoutMs}`);-  const { browser } = await connectBrowser(opts.cdpUrl);+  const connectTimeout = opts.timeoutMs ? Math.floor(opts.timeoutMs * 0.4) : undefined;+  const { browser } = await connectBrowser(opts.cdpUrl, connectTimeout);   // ...+  const gotoTimeout = opts.timeoutMs ? Math.floor(opts.timeoutMs * 0.5) : 30_000;-  const response = await page.goto(targetUrl, { timeout: 30_000 });+  const response = await page.goto(targetUrl, { timeout: gotoTimeout }); }-async function connectBrowser(cdpUrl: string): Promise<ConnectedBrowser> {+async function connectBrowser(cdpUrl: string, timeoutMs?: number): Promise<ConnectedBrowser> {   // ...   const connectWithRetry = async (): Promise<ConnectedBrowser> => {     let lastErr: unknown;+    const maxAttempts = timeoutMs ? 1 : 3;  // 指定超时时不重试-    for (let attempt = 0; attempt < 3; attempt += 1) {+    for (let attempt = 0; attempt < maxAttempts; attempt += 1) {       try {-        const timeout = 5000 + attempt * 2000;+        const timeout = timeoutMs ?? (5000 + attempt * 2000);         // ...       }     }   } }

修改 4:传递超时参数

「文件:src/browser/server-context.tab-ops.ts」

+import { CDP_JSON_NEW_TIMEOUT_MS, CDP_JSON_NEW_REMOTE_TIMEOUT_MS } from "./cdp-timeouts.js"; const openTab = async (url: string): Promise<BrowserTab> => {+  // 提前计算超时时间(Playwright 和 CDP 分支都会用到)+  const cdpNewTimeout = profile.cdpIsLoopback+    ? CDP_JSON_NEW_TIMEOUT_MS+    : CDP_JSON_NEW_REMOTE_TIMEOUT_MS;+  console.log(`[openTab] CDP timeout: ${cdpNewTimeout}ms (isLoopback: ${profile.cdpIsLoopback})`);   const ssrfPolicyOpts = withBrowserNavigationPolicy(state().resolved.ssrfPolicy);   if (capabilities.usesChromeMcp) {     // ... Chrome MCP 路径   }   if (capabilities.usesPersistentPlaywright) {     const mod = await getPwAiModule({ mode: "strict" });     const createPageViaPlaywright = (mod as Partial<PwAiModule> | null)?.createPageViaPlaywright;     if (typeof createPageViaPlaywright === "function") {       const page = await createPageViaPlaywright({         cdpUrl: profile.cdpUrl,         url,         ...ssrfPolicyOpts,+        timeoutMs: cdpNewTimeout,  // 传递超时参数       });       // ...     }   }   // CDP 路径   const createdViaCdp = await createTargetViaCdp({     cdpUrl: profile.cdpUrl,     url,     ...ssrfPolicyOpts,+    timeoutMs: cdpNewTimeout,  // 传递超时参数   });   // ... /json/new 路径-  const created = await fetchJson<CdpTarget>(endpoint, CDP_JSON_NEW_TIMEOUT_MS, {+  const created = await fetchJson<CdpTarget>(endpoint, cdpNewTimeout, {     method: "PUT",   }).catch(async (err) => {     if (String(err).includes("HTTP 405")) {-      return await fetchJson<CdpTarget>(endpoint, CDP_JSON_NEW_TIMEOUT_MS);+      return await fetchJson<CdpTarget>(endpoint, cdpNewTimeout);     }     throw err;   }); }

📊 效果对比

修改前

[openTab] cdpUrl: http://172.20.0.4:9222, isLoopback: false[openTab] usesPersistentPlaywright: true[connectBrowser] Attempt 1/3, timeout: 5000ms[connectBrowser] Attempt 2/3, timeout: 7000ms[connectBrowser] Attempt 3/3, timeout: 9000ms... (总计 21 秒,超过 15 秒超时)[tools] browser failed: timed out after 15000ms

修改后

[openTab] CDP timeout: 15000ms (isLoopback: false)[openTab] usesPersistentPlaywright: true[createPageViaPlaywright] Starting, timeoutMs: 15000[createPageViaPlaywright] Connecting to browser, timeout: 6000ms[connectBrowser] cdpUrl: http://172.20.0.4:9222, maxAttempts: 1[connectBrowser] Attempt 1/1, timeout: 6000ms[connectBrowser] ✅ Connected successfully[createPageViaPlaywright] Navigating to http://123571.xyz, timeout: 7500ms[createPageViaPlaywright] ✅ Page created: targetId=44438DD2EBF4275DF26C8EBF37C0BF9E[openTab] ✅ Playwright page created

「超时时间分配(15 秒总预算):」

  • 连接浏览器:6秒(40%)
  • 页面导航:7.5秒(50%)
  • 其他开销:1.5秒(10%)

🎯 关键技术点

1. 超时时间分配策略

// 连接超时:总预算的 40%const connectTimeout = timeoutMs * 0.4;// 导航超时:总预算的 50%const gotoTimeout = timeoutMs * 0.5;// WebSocket 握手:总预算的 2倍(或最少 5秒)const handshakeTimeout = Math.max(timeoutMs * 2, 5000);

2. 重试策略优化

// 有明确超时要求时,不重试(避免累积延迟)const maxAttempts = timeoutMs ? 1 : 3;

3. 日志追踪

在关键路径添加结构化日志:

  • [openTab] - 顶层操作
  • [createPageViaPlaywright] - Playwright 路径
  • [createTargetViaCdp] - CDP 路径
  • [connectBrowser] - 浏览器连接

🔧 可选优化方案

方案 A:容器内强制使用 CDP 路径

「适用场景」:容器间通信稳定、不需要 Playwright 特性

「文件:src/agents/sandbox/browser.ts」

import { isRunningInContainer } from "./docker.js";function buildSandboxBrowserResolvedConfig(params: {...}): ResolvedBrowserConfig {  const cdpHost = params.cdpHost;  const isStandardLoopback = cdpHost === "127.0.0.1" || cdpHost === "::1" || cdpHost === "localhost";  // 容器内的 CDP 连接视为本地快速连接,避免使用 Playwright  const cdpIsLoopback = isStandardLoopback || isRunningInContainer();  // ...}

「优点」:

  • 更快的响应速度(避免 Playwright 开销)
  • 更简单的执行路径

「缺点」:

  • 失去 Playwright 的导航检测能力
  • 不适用于真实远程 CDP

方案 B:智能判断网络类型

function isPrivateNetwork(host: string): boolean {  if (host.startsWith("172.")) return true;  if (host.startsWith("10.")) return true;  if (host.startsWith("192.168.")) return true;  return false;}const inContainerPrivateNetwork = isRunningInContainer() && isPrivateNetwork(cdpHost);const cdpIsLoopback = isStandardLoopback || inContainerPrivateNetwork;

「优点」:

  • 容器内私有网络 → 使用 CDP
  • 容器内公网 IP → 使用 Playwright
  • 兼顾速度和功能

📚 相关文件清单

文件路径
修改内容
src/browser/cdp-timeouts.ts
新增远程超时常量
src/browser/cdp.tscreateTargetViaCdp
 支持 timeoutMs
src/browser/pw-session.tscreatePageViaPlaywright
 和 connectBrowser 支持超时
src/browser/server-context.tab-ops.ts
传递超时参数,添加诊断日志
src/browser/client-fetch.ts
添加调试日志

✅ 验证清单

  • [x] 容器环境下浏览器工具不再超时
  • [x] CDP 路径使用 15 秒超时
  • [x] Playwright 路径合理分配超时时间
  • [x] 日志完整可追踪
  • [x] 不影响本地开发环境

📖 经验总结

  1. 「超时配置应该分层」:网络层、连接层、操作层各有独立超时
  2. 「容器环境需要特殊处理」:IP 判断不能简单依赖 loopback
  3. 「重试策略要合理」:有明确超时要求时,重试会导致累积延迟
  4. 「日志是排查利器」:结构化日志 + 标签前缀便于过滤
  5. 「超时时间要留余地」:总预算不能全部分配完,需要预留 10% 开销

🔗 附录:完整代码差异

A. cdp-timeouts.ts

export const CDP_HTTP_REQUEST_TIMEOUT_MS = 1500;export const CDP_WS_HANDSHAKE_TIMEOUT_MS = 5000;export const CDP_JSON_NEW_TIMEOUT_MS = 1500;export const CDP_JSON_NEW_REMOTE_TIMEOUT_MS = 15000; // 新增:容器/远程环境export const CHROME_REACHABILITY_TIMEOUT_MS = 500;

B. cdp.ts 核心修改

export async function createTargetViaCdp(opts: {  cdpUrl: string;  url: string;  ssrfPolicy?: SsrFPolicy;  timeoutMs?: number; // 新增参数}): Promise<{ targetId: string }> {  console.log(`[createTargetViaCdp] cdpUrl: ${opts.cdpUrl}, timeoutMs: ${opts.timeoutMs}`);  await assertBrowserNavigationAllowed({    url: opts.url,    ...withBrowserNavigationPolicy(opts.ssrfPolicy),  });  let wsUrl: string;  if (isWebSocketUrl(opts.cdpUrl)) {    console.log(`[createTargetViaCdp] Using direct WS URL`);    wsUrl = opts.cdpUrl;  } else {    const versionTimeout = opts.timeoutMs ?? 1500;    console.log(`[createTargetViaCdp] Fetching /json/version, timeout: ${versionTimeout}ms`);    const version = await fetchJson<{ webSocketDebuggerUrl?: string }>(      appendCdpPath(opts.cdpUrl, "/json/version"),      versionTimeout,    );    const wsUrlRaw = String(version?.webSocketDebuggerUrl ?? "").trim();    wsUrl = wsUrlRaw ? normalizeCdpWsUrl(wsUrlRaw, opts.cdpUrl) : "";    if (!wsUrl) {      console.error(`[createTargetViaCdp] No webSocketDebuggerUrl`);      throw new Error("CDP /json/version missing webSocketDebuggerUrl");    }    console.log(`[createTargetViaCdp] WebSocket URL: ${wsUrl}`);  }  const handshakeTimeout = opts.timeoutMs ? Math.max(opts.timeoutMs * 2, 5000) : 5000;  console.log(`[createTargetViaCdp] Opening WebSocket with handshake timeout: ${handshakeTimeout}ms`);  return await withCdpSocket(wsUrl, async (send) => {    const created = (await send("Target.createTarget", { url: opts.url })) as {      targetId?: string;    };    const targetId = String(created?.targetId ?? "").trim();    if (!targetId) {      console.error(`[createTargetViaCdp] No targetId in response`);      throw new Error("CDP Target.createTarget returned no targetId");    }    console.log(`[createTargetViaCdp] ✅ Created targetId: ${targetId}`);    return { targetId };  }, { handshakeTimeoutMs: handshakeTimeout });}

C. pw-session.ts 核心修改

async function connectBrowser(cdpUrl: string, timeoutMs?: number): Promise<ConnectedBrowser> {  const normalized = normalizeCdpUrl(cdpUrl);  const cached = cachedByCdpUrl.get(normalized);  if (cached) {    return cached;  }  const connecting = connectingByCdpUrl.get(normalized);  if (connecting) {    return await connecting;  }  const connectWithRetry = async (): Promise<ConnectedBrowser> => {    let lastErr: unknown;    const maxAttempts = timeoutMs ? 1 : 3;  // 指定超时时不重试    console.log(`[connectBrowser] cdpUrl: ${normalized}, timeoutMs: ${timeoutMs}, maxAttempts: ${maxAttempts}`);    for (let attempt = 0; attempt < maxAttempts; attempt += 1) {      try {        const timeout = timeoutMs ?? (5000 + attempt * 2000);        console.log(`[connectBrowser] Attempt ${attempt + 1}/${maxAttempts}, timeout: ${timeout}ms`);        const wsUrl = await getChromeWebSocketUrl(normalized, timeout).catch(() => null);        const endpoint = wsUrl ?? normalized;        const headers = getHeadersWithAuth(endpoint);        const browser = await withNoProxyForCdpUrl(endpoint, () =>          chromium.connectOverCDP(endpoint, { timeout, headers }),        );        const onDisconnected = () => {          const current = cachedByCdpUrl.get(normalized);          if (current?.browser === browser) {            cachedByCdpUrl.delete(normalized);          }        };        const connected: ConnectedBrowser = { browser, cdpUrl: normalized, onDisconnected };        cachedByCdpUrl.set(normalized, connected);        browser.on("disconnected", onDisconnected);        observeBrowser(browser);        console.log(`[connectBrowser] ✅ Connected successfully`);        return connected;      } catch (err) {        console.error(`[connectBrowser] Attempt ${attempt + 1} failed:`, err);        lastErr = err;        const errMsg = err instanceof Error ? err.message : String(err);        if (errMsg.includes("rate limit")) {          break;        }      }    }    throw lastErr;  };  const promise = connectWithRetry();  connectingByCdpUrl.set(normalized, promise);  try {    return await promise;  } finally {    connectingByCdpUrl.delete(normalized);  }}export async function createPageViaPlaywright(opts: {  cdpUrl: string;  url: string;  ssrfPolicy?: SsrFPolicy;  timeoutMs?: number;}): Promise<{  targetId: string;  title: string;  url: string;  type: string;}> {  console.log(`[createPageViaPlaywright] Starting, cdpUrl: ${opts.cdpUrl}, timeoutMs: ${opts.timeoutMs}`);  const connectTimeout = opts.timeoutMs ? Math.floor(opts.timeoutMs * 0.4) : undefined;  console.log(`[createPageViaPlaywright] Connecting to browser, timeout: ${connectTimeout}ms`);  const { browser } = await connectBrowser(opts.cdpUrl, connectTimeout);  const context = browser.contexts()[0] ?? (await browser.newContext());  ensureContextState(context);  const page = await context.newPage();  ensurePageState(page);  const targetUrl = opts.url.trim() || "about:blank";  if (targetUrl !== "about:blank") {    const navigationPolicy = withBrowserNavigationPolicy(opts.ssrfPolicy);    await assertBrowserNavigationAllowed({      url: targetUrl,      ...navigationPolicy,    });    const gotoTimeout = opts.timeoutMs ? Math.floor(opts.timeoutMs * 0.5) : 30_000;    console.log(`[createPageViaPlaywright] Navigating to ${targetUrl}, timeout: ${gotoTimeout}ms`);    const response = await page.goto(targetUrl, { timeout: gotoTimeout }).catch((err) => {      console.error(`[createPageViaPlaywright] Navigation failed:`, err);      return null;    });    await assertBrowserNavigationRedirectChainAllowed({      request: response?.request(),      ...navigationPolicy,    });    await assertBrowserNavigationResultAllowed({      url: page.url(),      ...navigationPolicy,    });  }  const tid = await pageTargetId(page).catch(() => null);  if (!tid) {    console.error(`[createPageViaPlaywright] Failed to get targetId`);    throw new Error("Failed to get targetId for new page");  }  console.log(`[createPageViaPlaywright] ✅ Page created: targetId=${tid}`);  return {    targetId: tid,    title: await page.title().catch(() => ""),    url: page.url(),    type: "page",  };}

D. server-context.tab-ops.ts 核心修改

import { CDP_JSON_NEW_TIMEOUT_MS, CDP_JSON_NEW_REMOTE_TIMEOUT_MS } from "./cdp-timeouts.js";const openTab = async (url: string): Promise<BrowserTab> => {  console.log(`[openTab] Opening URL: ${url}`);  console.log(`[openTab] Profile: ${profile.name}, cdpUrl: ${profile.cdpUrl}, isLoopback: ${profile.cdpIsLoopback}`);  // 提前计算超时时间(Playwright 和 CDP 分支都会用到)  const cdpNewTimeout = profile.cdpIsLoopback    ? CDP_JSON_NEW_TIMEOUT_MS    : CDP_JSON_NEW_REMOTE_TIMEOUT_MS;  console.log(`[openTab] CDP timeout: ${cdpNewTimeout}ms (isLoopback: ${profile.cdpIsLoopback})`);  const ssrfPolicyOpts = withBrowserNavigationPolicy(state().resolved.ssrfPolicy);  console.log(`[openTab] Checking capabilities: usesChromeMcp=${capabilities.usesChromeMcp}, usesPersistentPlaywright=${capabilities.usesPersistentPlaywright}`);  if (capabilities.usesChromeMcp) {    await assertBrowserNavigationAllowed({ url, ...ssrfPolicyOpts });    const page = await openChromeMcpTab(profile.name, url);    const profileState = getProfileState();    profileState.lastTargetId = page.targetId;    await assertBrowserNavigationResultAllowed({ url: page.url, ...ssrfPolicyOpts });    return page;  }  if (capabilities.usesPersistentPlaywright) {    console.log(`[openTab] Checking Playwright module...`);    const mod = await getPwAiModule({ mode: "strict" });    console.log(`[openTab] Playwright module obtained:`, !!mod);    const createPageViaPlaywright = (mod as Partial<PwAiModule> | null)?.createPageViaPlaywright;    console.log(`[openTab] createPageViaPlaywright function available:`, !!createPageViaPlaywright);    if (typeof createPageViaPlaywright === "function") {      console.log(`[openTab] Using Playwright to create page`);      const page = await createPageViaPlaywright({        cdpUrl: profile.cdpUrl,        url,        ...ssrfPolicyOpts,        timeoutMs: cdpNewTimeout,  // 传递超时参数      });      console.log(`[openTab] ✅ Playwright page created: targetId=${page.targetId}, url=${page.url}`);      const profileState = getProfileState();      profileState.lastTargetId = page.targetId;      triggerManagedTabLimit(page.targetId);      return {        targetId: page.targetId,        title: page.title,        url: page.url,        type: page.type,      };    }    console.log(`[openTab] Playwright function not available, falling through to CDP`);  }  console.log(`[openTab] Using standard CDP path`);  if (requiresInspectableBrowserNavigationRedirects(state().resolved.ssrfPolicy)) {    throw new InvalidBrowserNavigationUrlError(      "Navigation blocked: strict browser SSRF policy requires Playwright-backed redirect-hop inspection",    );  }  const createdViaCdp = await createTargetViaCdp({    cdpUrl: profile.cdpUrl,    url,    ...ssrfPolicyOpts,    timeoutMs: cdpNewTimeout,  // 传递超时参数  })    .then((r) => r.targetId)    .catch(() => null);  if (createdViaCdp) {    const profileState = getProfileState();    profileState.lastTargetId = createdViaCdp;    const deadline = Date.now() + OPEN_TAB_DISCOVERY_WINDOW_MS;    while (Date.now() < deadline) {      const tabs = await listTabs().catch(() => [] as BrowserTab[]);      const found = tabs.find((t) => t.targetId === createdViaCdp);      if (found) {        await assertBrowserNavigationResultAllowed({ url: found.url, ...ssrfPolicyOpts });        triggerManagedTabLimit(found.targetId);        return found;      }      await new Promise((r) => setTimeout(r, OPEN_TAB_DISCOVERY_POLL_MS));    }    triggerManagedTabLimit(createdViaCdp);    return { targetId: createdViaCdp, title: "", url, type: "page" };  }  const encoded = encodeURIComponent(url);  const endpointUrl = new URL(appendCdpPath(cdpHttpBase, "/json/new"));  await assertBrowserNavigationAllowed({ url, ...ssrfPolicyOpts });  const endpoint = endpointUrl.search    ? (() => {        endpointUrl.searchParams.set("url", url);        return endpointUrl.toString();      })()    : `${endpointUrl.toString()}?${encoded}`;  console.log(`[openTab] Fetching /json/new: ${endpoint}, timeout: ${cdpNewTimeout}ms`);  const created = await fetchJson<CdpTarget>(endpoint, cdpNewTimeout, {    method: "PUT",  }).catch(async (err) => {    console.error(`[openTab] PUT failed:`, err);    if (String(err).includes("HTTP 405")) {      console.log(`[openTab] Retrying with GET`);      return await fetchJson<CdpTarget>(endpoint, cdpNewTimeout);    }    throw err;  });  if (!created.id) {    console.error(`[openTab] Missing id in response:`, created);    throw new Error("Failed to open tab (missing id)");  }  console.log(`[openTab] ✅ Tab created: id=${created.id}`);  const profileState = getProfileState();  profileState.lastTargetId = created.id;  triggerManagedTabLimit(created.id);  return { targetId: created.id, title: created.title ?? "", url: created.url ?? url, type: created.type ?? "page" };};

「文档版本」:v1.0「最后更新」:2026-03-24「适用版本」:OpenClaw Gateway (Docker 环境)

参考PR: https://github.com/openclaw/openclaw/pull/49392

看到这里,点个关注再走呗!

回首页,搜索你想要的文档

在首页搜索框输入关键词,快速定位资料

文库首页搜索指引 立即搜索

相关学习资料