Преглед изворни кода

fix(browser-use): close lifecycle gaps and isolate replay fixtures

Tianyi Cui пре 1 месец
родитељ
комит
d1e32e481a
52 измењених фајлова са 1409 додато и 276 уклоњено
  1. 2 2
      .agents/notes/implemented/architecture/2026-09-12-browser-use-provider-registration.i18n.yaml
  2. 2 2
      .agents/notes/implemented/architecture/2026-09-12-browser-use-provider-registration.md
  3. 2 2
      .agents/notes/implemented/architecture/2026-09-12-browser-use-provider-registration.zh.md
  4. 1 0
      THIRD_PARTY_NOTICES.md
  5. 1 1
      packages/browser-use/browser-use/src/brand.ts
  6. 1 0
      packages/core/scope/tests/invariant.spec.ts
  7. 1 0
      packages/core/tools/tests/gen-tool-catalog.spec.ts
  8. 2 2
      packages/experimental/browser-use-chrome-devtools-mcp/README.i18n.yaml
  9. 1 1
      packages/experimental/browser-use-chrome-devtools-mcp/README.md
  10. 1 1
      packages/experimental/browser-use-chrome-devtools-mcp/README.zh.md
  11. 2 3
      packages/experimental/browser-use-chrome-devtools-mcp/src/index.ts
  12. 2 2
      packages/experimental/browser-use-playwright-mcp/README.i18n.yaml
  13. 1 1
      packages/experimental/browser-use-playwright-mcp/README.md
  14. 1 1
      packages/experimental/browser-use-playwright-mcp/README.zh.md
  15. 2 3
      packages/experimental/browser-use-playwright-mcp/src/index.ts
  16. 2 2
      packages/experimental/browser-use-runtime/README.i18n.yaml
  17. 3 3
      packages/experimental/browser-use-runtime/README.md
  18. 3 3
      packages/experimental/browser-use-runtime/README.zh.md
  19. 28 4
      packages/experimental/browser-use-runtime/src/index.ts
  20. 18 0
      packages/experimental/browser-use-runtime/src/mcp.ts
  21. 91 0
      packages/experimental/browser-use-runtime/tests/agent-disposal.spec.ts
  22. 79 11
      packages/experimental/browser-use-runtime/tests/mcp.spec.ts
  23. 104 12
      packages/experimental/browser-use-runtime/tests/resources.spec.ts
  24. 2 2
      packages/experimental/browser-use-stagehand-native/README.i18n.yaml
  25. 8 8
      packages/experimental/browser-use-stagehand-native/README.md
  26. 7 7
      packages/experimental/browser-use-stagehand-native/README.zh.md
  27. 3 1
      packages/experimental/browser-use-stagehand-native/package.json
  28. 53 16
      packages/experimental/browser-use-stagehand-native/src/index.ts
  29. 64 0
      packages/experimental/browser-use-stagehand-native/src/launch.ts
  30. 63 79
      packages/experimental/browser-use-stagehand-native/src/native.ts
  31. 48 30
      packages/experimental/browser-use-stagehand-native/src/worker-client.ts
  32. 14 6
      packages/experimental/browser-use-stagehand-native/src/worker.ts
  33. 24 0
      packages/experimental/browser-use-stagehand-native/tests/fixtures/chromium.ts
  34. 13 3
      packages/experimental/browser-use-stagehand-native/tests/fixtures/stagehand.ts
  35. 14 0
      packages/experimental/browser-use-stagehand-native/tests/launch-process.spec.ts
  36. 115 0
      packages/experimental/browser-use-stagehand-native/tests/launch.spec.ts
  37. 18 1
      packages/experimental/browser-use-stagehand-native/tests/loader-composition.spec.ts
  38. 125 2
      packages/experimental/browser-use-stagehand-native/tests/native.e2e.ts
  39. 12 3
      packages/experimental/browser-use-stagehand-native/tests/native.spec.ts
  40. 99 5
      packages/experimental/browser-use-stagehand-native/tests/provider.spec.ts
  41. 68 8
      packages/experimental/browser-use-stagehand-native/tests/worker-client.spec.ts
  42. 35 0
      packages/experimental/browser-use-stagehand-native/tests/worker-protocol.spec.ts
  43. 1 0
      packages/experimental/browser-use-stagehand-native/tsconfig.json
  44. 13 8
      packages/mcp/mcp-client/src/connection.ts
  45. 31 0
      packages/mcp/mcp-client/tests/reconnect.spec.ts
  46. 101 0
      pnpm-lock.yaml
  47. 12 3
      snapshots/session/browser-use-chrome-devtools-mcp/provider-fixture.mjs
  48. 18 5
      snapshots/session/browser-use-chrome-devtools-mcp/workspace/cli.js
  49. 12 3
      snapshots/session/browser-use-playwright-mcp/provider-fixture.mjs
  50. 18 5
      snapshots/session/browser-use-playwright-mcp/workspace/cli.js
  51. 66 24
      snapshots/session/browser-use-stagehand-native/native-fixture.mjs
  52. 2 1
      snapshots/session/browser-use-stagehand-native/tool-schemas.expected.json

+ 2 - 2
.agents/notes/implemented/architecture/2026-09-12-browser-use-provider-registration.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-12-browser-use-provider-registration.md
-2026-09-12-browser-use-provider-registration.md: b56982fa9a86800f8cb3c17a36c300a4956cb747
-2026-09-12-browser-use-provider-registration.zh.md: f857a1c754d7c9b8b70b62f952ad50224110ebab
+2026-09-12-browser-use-provider-registration.md: 216c02d7342f1b6fd1371a271dfa52903e961b74
+2026-09-12-browser-use-provider-registration.zh.md: 5b0b87ed016944eaaf6a9bee1c9e37769d4fd643

+ 2 - 2
.agents/notes/implemented/architecture/2026-09-12-browser-use-provider-registration.md

@@ -16,9 +16,9 @@ Browser-control backends expose different operations and observation formats. A
 
 Browser resources belong to the exact live Agent and Session, not merely a reusable Session id. Calls retain state across turns. Runtime disposal closes launched resources, and reload or fork does not inherit a launched profile. Attachment preserves existing browser state and reserves the external browser exclusively for one Session within that provider instance. Cleanup disconnects without closing the external browser.
 
-The [experimental runtime helper](../../../../packages/experimental/browser-use-runtime/README.md) owns shared resource lifetime and attachment reservation without introducing those methods into the browser-use service. Provider teardown stops tool admission, waits for owned work, and closes resources before releasing its provider registration. The service remains independent of all experimental packages.
+The [experimental runtime helper](../../../../packages/experimental/browser-use-runtime/README.md) owns shared resource lifetime and attachment reservation without introducing those methods into the browser-use service. Canceling a caller's acquisition wait leaves initialization and its reservation owned by the Session. An active operation's Agent-disposal cancellation starts resource shutdown before the Agent waits for idle, because browser calls may settle only after their connections close. Provider teardown stops tool admission and retains its registration until resource cleanup, active calls, and auxiliary inference logging settle. The service remains independent of all experimental packages.
 
-Stagehand's browser close operation shuts down Chromium even after attachment. Each attached runtime therefore owns a Worker: cleanup attempts to release Stagehand state, then terminates the Worker to close its connections without invoking browser close on the externally owned browser. Launched browsers retain normal owned-browser cleanup.
+Stagehand's launcher inherits its process environment, and SDK initialization can time out before its cleanup settles. The provider host uses `@puppeteer/browsers` to own launched Chromium and its temporary profile before CDP or SDK readiness, with a scrubbed child environment. For both launch and attachment, an isolated Worker runs the SDK and only connects over CDP. Ordinary operation cancellation closes that Worker while retaining the browser and Session reservation; the next operation reconnects lazily. Session or provider teardown attempts to release Stagehand state, then terminates the Worker to close its sockets. The host kills only its own Chromium process and waits for child closure before removing the profile; an externally owned browser remains running.
 
 Stagehand uses its supported custom-model callback to request structured results through the Session's selected DSH model. A provider-local adapter requests one result tool call and validates its arguments against Stagehand's requested JSON Schema; it does not execute additional model tool calls. Auxiliary requests are logged and flushed before model dispatch, and settled responses are logged and flushed before automation continues. These events remain separate from the main conversation, preserving the browser operation's model input without altering the agent loop.
 

+ 2 - 2
.agents/notes/implemented/architecture/2026-09-12-browser-use-provider-registration.zh.md

@@ -16,9 +16,9 @@ Status: implemented
 
 浏览器资源属于确切的实时 Agent 与 Session,而非仅凭可复用的 Session id。调用跨轮次保留状态。运行时释放会关闭启动的资源,重新加载或 fork 不会继承启动的 profile。附加保留现有浏览器状态,并在该提供方实例内将外部浏览器独占保留给一个 Session。清理断开连接而不关闭外部浏览器。
 
-[实验性运行时辅助库](../../../../packages/experimental/browser-use-runtime/README.zh.md)拥有共享资源生命周期与附加保留机制,而不向浏览器操作服务引入这些方法。提供方清理先停止接收工具调用、等待自有工作并关闭资源,再释放提供方注册。服务保持独立于所有实验包。
+[实验性运行时辅助库](../../../../packages/experimental/browser-use-runtime/README.zh.md)拥有共享资源生命周期与附加保留机制,而不向浏览器操作服务引入这些方法。取消调用方对资源获取的等待后,初始化及其保留仍归 Session 所有。活动操作收到 Agent 释放的取消信号时,在 Agent 等待空闲之前启动资源关闭,因为浏览器调用可能只有在连接关闭后才能结束。提供方清理停止接收工具调用,并保留注册,直到资源清理、活动调用及辅助推理日志记录完成。服务保持独立于所有实验包。
 
-Stagehand 的浏览器关闭操作即使在附加后也会关闭 Chromium。因此,每个附加运行时拥有一个 Worker:清理尝试释放 Stagehand 状态,然后终止 Worker 以关闭其连接,不对外部拥有的浏览器调用浏览器关闭操作。自行启动的浏览器仍按常规自有资源方式清理。
+Stagehand 的启动器继承其进程环境,SDK 初始化可能在清理完成前超时。提供方 Host 使用 `@puppeteer/browsers` 在 CDP 或 SDK 就绪前取得所启动 Chromium 及其临时 profile 的所有权,并清理子进程环境。启动和附加模式都由隔离的 Worker 运行 SDK,且只通过 CDP 连接。普通操作取消会关闭该 Worker,但保留浏览器和 Session 保留状态;下一次操作按需重新连接。Session 或提供方清理尝试释放 Stagehand 状态,然后终止 Worker 以关闭其套接字。Host 仅终止自己拥有的 Chromium 进程,等待子进程关闭后才删除 profile;外部拥有的浏览器保持运行。
 
 Stagehand 使用受支持的自定义模型回调,通过 Session 选定的 DSH 模型请求结构化结果。提供方内的适配器请求一个结果工具调用,并依据 Stagehand 请求的 JSON Schema 验证其参数;它不执行额外的模型工具调用。辅助请求在模型分派前记录并刷新,已结算响应在自动化继续前记录并刷新。这些事件保持独立于主对话,在不改变 agent loop(智能体循环)的情况下保留浏览器操作的模型输入。
 

+ 1 - 0
THIRD_PARTY_NOTICES.md

@@ -55,6 +55,7 @@ External packages installed for runtime use or distributed inside the prebuilt b
 | [`@opentelemetry/resources`](https://github.com/open-telemetry/opentelemetry-js) | Apache-2.0 |
 | [`@opentelemetry/sdk-logs`](https://github.com/open-telemetry/opentelemetry-js) | Apache-2.0 |
 | [`@playwright/mcp`](https://github.com/microsoft/playwright-mcp) | Apache-2.0 |
+| [`@puppeteer/browsers`](https://github.com/puppeteer/puppeteer/tree/main/packages/browsers) | Apache-2.0 |
 | [`@shikijs/langs`](https://github.com/shikijs/shiki) | MIT |
 | [`@standard-schema/spec`](https://github.com/standard-schema/standard-schema) | MIT |
 | [`@tanstack/react-virtual`](https://github.com/TanStack/virtual) | MIT |

+ 1 - 1
packages/browser-use/browser-use/src/brand.ts

@@ -1,4 +1,4 @@
-/** Computer-use provider identities. @module @deepseek-ai/dsh-browser-use/brand */
+/** Browser-use provider identities. @module @deepseek-ai/dsh-browser-use/brand */
 
 import type { Branded } from '@deepseek-ai/dsh-brand'
 

+ 1 - 0
packages/core/scope/tests/invariant.spec.ts

@@ -80,6 +80,7 @@ describe('scoped-dispatch invariants', () => {
       ['approval/request', [{ agent, toolName: 'echo' }, () => Promise.resolve('unavailable')]],
       ['goal/changed', [{ agent, change: { operation: 'create', ref: { id: 'goal-a', revision: 1 } } }]],
       ['system-prompt/assemble', [[], { scope: agent }]],
+      ['system-prompt/prepare', [{ scope: agent, signal }]],
       ['tools/ptc-dispatch-log', [{ exec: { callId: 'c', name: 't', arguments: {} }, agent, subCallId: 'c:code:1', name: 't', isError: false, content: [] }, () => Promise.resolve([])]],
       ['tools/execute', [{ callId: 'c', name: 't', arguments: {}, agent }, () => Promise.resolve({ content: [], isError: false })]],
       ['tools/post-execute', [{ callId: 'c', name: 't', arguments: {}, agent }, { content: [], isError: false }, () => Promise.resolve({ kind: 'accept' })]],

+ 1 - 0
packages/core/tools/tests/gen-tool-catalog.spec.ts

@@ -35,6 +35,7 @@ describe('gen-tool-catalog collectToolCatalog', () => {
       'read', 'read_image', 'read_mcp_resource', 'run_code', 'schedule_create', 'schedule_delete',
       'schedule_list', 'send_message', 'send_message', 'session_event_read', 'session_event_search',
       'session_event_trace', 'session_search', 'session_trace', 'skill', 'spawn_teammate',
+      'stagehand_act', 'stagehand_extract', 'stagehand_navigate', 'stagehand_observe', 'stagehand_screenshot', 'stagehand_tabs',
       'str_replace_editor', 'subagent', 'team_task_create',
       'team_task_get', 'team_task_list', 'team_task_update', 'terminal_close', 'terminal_list',
       'terminal_open', 'terminal_read', 'terminal_send', 'terminal_signal', 'todo_write',

+ 2 - 2
packages/experimental/browser-use-chrome-devtools-mcp/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/experimental/browser-use-chrome-devtools-mcp/README.md
-README.md: 3ac8984c736715bfa53f66c5023b9a3b8a058780
-README.zh.md: 72116000ba9624d91e8420aa5fb1939e12bddc0e
+README.md: 4c24180e1a8e8bd3d39aaec5e5b5a940a370cafd
+README.zh.md: 6bac5a0de976b80aa42dc7c444a73e6b368c280b

+ 1 - 1
packages/experimental/browser-use-chrome-devtools-mcp/README.md

@@ -35,7 +35,7 @@ Mount both entries in a profile composition that supplies Agents, tools, and sys
     headless: true
 ```
 
-Use `mode: attach` and set `endpoint` to an HTTP(S) debugging URL or WS(S) browser endpoint to operate an existing browser. The first live Session claims it until that Session unloads; another Session fails acquisition. Cleanup disconnects and leaves the external browser and its pages running.
+Use `mode: attach` and set `endpoint` to an HTTP(S) debugging URL or WS(S) browser endpoint to operate an existing browser. A Session claims the attachment while loading its browser tools and retains it until unloading. Other Sessions and subagents continue their turns without these tools; a later turn can acquire the attachment after cleanup. Direct calls from another Session fail. Cleanup disconnects and leaves the external browser and its pages running.
 
 | Field | Default | Meaning |
 |---|---|---|

+ 1 - 1
packages/experimental/browser-use-chrome-devtools-mcp/README.zh.md

@@ -35,7 +35,7 @@ kind: "package-reference"
     headless: true
 ```
 
-使用 `mode: attach`,并在 `endpoint` 中设置 HTTP(S) 调试 URL 或 WS(S) 浏览器端点,即可操作已有浏览器。第一个活动 Session 会持续占用连接直到卸载;其他 Session 无法获取该连接。清理只断开连接,保留外部浏览器及其页面。
+使用 `mode: attach`,并在 `endpoint` 中设置 HTTP(S) 调试 URL 或 WS(S) 浏览器端点,即可操作已有浏览器。Session 在加载浏览器工具时占用附加连接,并持续保留直到卸载。其他 Session 和子 Agent 可继续各自的轮次,但不获得这些工具;清理完成后,后续轮次可以获取附加连接。其他 Session 的直接调用会失败。清理只断开连接,保留外部浏览器及其页面。
 
 | 字段 | 默认值 | 含义 |
 |---|---|---|

+ 2 - 3
packages/experimental/browser-use-chrome-devtools-mcp/src/index.ts

@@ -1,6 +1,6 @@
 /** Chromium inspection and automation through the pinned Chrome DevTools MCP server. @module */
 
-import { createRequire } from 'node:module'
+import { fileURLToPath } from 'node:url'
 import type { Context } from '@deepseek-ai/cordis'
 import { BrowserMcpConfig, mountSessionMcp, validateBrowserMcpConfig } from '@deepseek-ai/dsh-experimental-browser-use-runtime/mcp'
 
@@ -24,8 +24,7 @@ export const Config: typeof BrowserMcpConfig = BrowserMcpConfig
  */
 export function apply(ctx: Context, config: Config): void {
   validateBrowserMcpConfig(config)
-  const require = createRequire(import.meta.url)
-  const cli = require.resolve('chrome-devtools-mcp/build/src/bin/chrome-devtools-mcp.js')
+  const cli = fileURLToPath(import.meta.resolve('chrome-devtools-mcp/build/src/bin/chrome-devtools-mcp.js'))
   const args = [cli, '--no-usage-statistics']
   if (config.mode === 'attach') {
     args.push(/^wss?:/u.test(config.endpoint) ? '--ws-endpoint' : '--browser-url', config.endpoint)

+ 2 - 2
packages/experimental/browser-use-playwright-mcp/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/experimental/browser-use-playwright-mcp/README.md
-README.md: 71eee20a5b3b3707b3b169c898c1ef4ad92abfe2
-README.zh.md: 98805e6e69602d0a80ca17fedc2c26ccf98bf753
+README.md: 1132dd937855bdc01b4b7181003d530a7fdab70e
+README.zh.md: eaccc65c99c073ee5a991bec5d87d084d1614d5c

+ 1 - 1
packages/experimental/browser-use-playwright-mcp/README.md

@@ -35,7 +35,7 @@ Mount both entries in a profile composition that supplies Agents, tools, and sys
     headless: true
 ```
 
-Use `mode: attach` and set `endpoint` to an HTTP(S) debugging URL or WS(S) browser endpoint to operate an existing browser. The first live Session claims it until that Session unloads; another Session fails acquisition. Cleanup disconnects and leaves the external browser and its pages running.
+Use `mode: attach` and set `endpoint` to an HTTP(S) debugging URL or WS(S) browser endpoint to operate an existing browser. A Session claims the attachment while loading its browser tools and retains it until unloading. Other Sessions and subagents continue their turns without these tools; a later turn can acquire the attachment after cleanup. Direct calls from another Session fail. Cleanup disconnects and leaves the external browser and its pages running.
 
 | Field | Default | Meaning |
 |---|---|---|

+ 1 - 1
packages/experimental/browser-use-playwright-mcp/README.zh.md

@@ -35,7 +35,7 @@ kind: "package-reference"
     headless: true
 ```
 
-使用 `mode: attach`,并在 `endpoint` 中设置 HTTP(S) 调试 URL 或 WS(S) 浏览器端点,即可操作已有浏览器。第一个活动 Session 会持续占用连接直到卸载;其他 Session 无法获取该连接。清理只断开连接,保留外部浏览器及其页面。
+使用 `mode: attach`,并在 `endpoint` 中设置 HTTP(S) 调试 URL 或 WS(S) 浏览器端点,即可操作已有浏览器。Session 在加载浏览器工具时占用附加连接,并持续保留直到卸载。其他 Session 和子 Agent 可继续各自的轮次,但不获得这些工具;清理完成后,后续轮次可以获取附加连接。其他 Session 的直接调用会失败。清理只断开连接,保留外部浏览器及其页面。
 
 | 字段 | 默认值 | 含义 |
 |---|---|---|

+ 2 - 3
packages/experimental/browser-use-playwright-mcp/src/index.ts

@@ -1,7 +1,7 @@
 /** Chromium browser tools from the pinned Playwright MCP server. @module */
 
-import { createRequire } from 'node:module'
 import { dirname, join } from 'node:path'
+import { fileURLToPath } from 'node:url'
 import type { Context } from '@deepseek-ai/cordis'
 import { BrowserMcpConfig, mountSessionMcp, validateBrowserMcpConfig } from '@deepseek-ai/dsh-experimental-browser-use-runtime/mcp'
 
@@ -25,8 +25,7 @@ export const Config: typeof BrowserMcpConfig = BrowserMcpConfig
  */
 export function apply(ctx: Context, config: Config): void {
   validateBrowserMcpConfig(config)
-  const require = createRequire(import.meta.url)
-  const cli = join(dirname(require.resolve('@playwright/mcp/package.json')), 'cli.js')
+  const cli = join(dirname(fileURLToPath(import.meta.resolve('@playwright/mcp/package.json'))), 'cli.js')
   // Upstream environment options can otherwise replace the configured browser
   // mode or import an unrelated profile. Empty values mean absent to its parser.
   const env = Object.fromEntries(Object.keys(process.env)

+ 2 - 2
packages/experimental/browser-use-runtime/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/experimental/browser-use-runtime/README.md
-README.md: cc79a34e58c13368dbbf170ec85843b34087f2f4
-README.zh.md: d5adb83bb2cd0641030bb7248dd13a6146d655e0
+README.md: 0fdfd2ff29e897736866620dec30d0be8b098325
+README.zh.md: 1670308233375b4cde572620a5a98973267114f0

+ 3 - 3
packages/experimental/browser-use-runtime/README.md

@@ -27,9 +27,9 @@ Browser providers use this library to reuse a browser across one Session's turns
 
 This public experimental library is a dependency of the browser providers. It has no plugin entry or mount configuration. The [browser-use service](../../browser-use/browser-use/README.md) remains independent of the library.
 
-Native providers construct `SessionResources` from the package root, supplying resource acquisition and cleanup callbacks. Calls pass the exact live Agent to `run()`; stale owners and a second owner of an exclusive attachment fail before acquisition. Providers keep their registration until `dispose()` finishes.
+Native providers construct `SessionResources` from the package root, supplying resource acquisition and cleanup callbacks. Calls pass the exact live Agent to `run()`; stale owners and a second owner of an exclusive attachment fail before acquisition. Canceling an acquisition wait leaves initialization available to other callers in the same Session; Session disposal aborts and awaits that initialization. Providers keep their registration until `dispose()` finishes.
 
-MCP providers use `mountSessionMcp` from `@deepseek-ai/dsh-experimental-browser-use-runtime/mcp`. They supply their fixed server name, executable, arguments, and ownership policy. The helper mounts one scoped MCP client per live Session and discovers tools during `system-prompt/prepare`, before ordinary tool-schema collection for its first model request. Startup or discovery failure rejects that step after cleanup. Reconnection is disabled; a closed client does not silently replace the Session's browser state.
+MCP providers use `mountSessionMcp` from `@deepseek-ai/dsh-experimental-browser-use-runtime/mcp`. They supply their fixed server name, executable, arguments, and ownership policy. The helper mounts one scoped MCP client per live Session and discovers tools during `system-prompt/prepare`, before ordinary tool-schema collection for its first model request. A busy attachment leaves other Sessions without this provider's tools while their turns continue; after cleanup, a later request can acquire it. Startup or discovery failure rejects that step after cleanup. Reconnection is disabled; a closed client does not silently replace the Session's browser state.
 
 -----
 
@@ -41,7 +41,7 @@ MCP providers use `mountSessionMcp` from `@deepseek-ai/dsh-experimental-browser-
 
 The [resource manager](src/index.ts) keys ownership by live Agent identity and joins operation cancellation with owner disposal. Each resource has one acquisition promise and one operation queue. Failed acquisition releases its reservation only after the provider callback rolls back acquired resources.
 
-Disposal closes resources before waiting for running operations, allowing connection teardown to interrupt upstream APIs without abort support. A failed close rejects disposal and retains ownership. Agent-scoped cleanup prevents a resumed Session with the same durable id from inheriting a previous browser.
+Disposed-cause cancellation starts resource cleanup before AgentHandle waits for idle. Cleanup closes resources before waiting for running operations, allowing connection teardown to interrupt upstream APIs without abort support. A failed close rejects disposal and retains ownership. Agent-scoped cleanup prevents a resumed Session with the same durable id from inheriting a previous browser.
 
 The [MCP helper](src/mcp.ts) places discovery and execution in each Agent's scope. It retains the provider registration through resource cleanup and uses the [MCP client](../../mcp/mcp-client/README.md) for transport, schema discovery, result conversion, and durable image admission.
 

+ 3 - 3
packages/experimental/browser-use-runtime/README.zh.md

@@ -27,9 +27,9 @@ kind: "package-library"
 
 此公共实验性库是浏览器提供方的依赖。它没有插件入口或挂载配置。[浏览器操作服务](../../browser-use/browser-use/README.zh.md)保持独立于此库。
 
-原生提供方从包根入口构造 `SessionResources`,提供资源获取与清理回调。调用向 `run()` 传递确切的实时 Agent;失效的所有者与独占附加的第二个所有者在获取资源前失败。提供方将注册保留到 `dispose()` 完成。
+原生提供方从包根入口构造 `SessionResources`,提供资源获取与清理回调。调用向 `run()` 传递确切的实时 Agent;失效的所有者与独占附加的第二个所有者在获取资源前失败。取消资源获取等待不会终止初始化,同一 Session 的其他调用方仍可继续等待;释放 Session 会中止并等待该初始化结束。提供方将注册保留到 `dispose()` 完成。
 
-MCP 提供方使用 `@deepseek-ai/dsh-experimental-browser-use-runtime/mcp` 中的 `mountSessionMcp`。它们提供固定服务器名称、可执行文件、参数与所有权策略。辅助库为每个实时 Session 挂载一个有作用域的 MCP 客户端,并在 `system-prompt/prepare` 期间发现工具,先于其首次模型请求的常规工具 schema 收集。启动或发现失败会在清理后拒绝该步骤。重连已禁用;已关闭的客户端不会静默替换 Session 的浏览器状态。
+MCP 提供方使用 `@deepseek-ai/dsh-experimental-browser-use-runtime/mcp` 中的 `mountSessionMcp`。它们提供固定服务器名称、可执行文件、参数与所有权策略。辅助库为每个实时 Session 挂载一个有作用域的 MCP 客户端,并在 `system-prompt/prepare` 期间发现工具,先于其首次模型请求的常规工具 schema 收集。附加连接被占用时,其他 Session 不获得此提供方的工具,但可继续各自的轮次;清理完成后,后续请求可以获取该连接。启动或发现失败会在清理后拒绝该步骤。重连已禁用;已关闭的客户端不会静默替换 Session 的浏览器状态。
 
 -----
 
@@ -41,7 +41,7 @@ MCP 提供方使用 `@deepseek-ai/dsh-experimental-browser-use-runtime/mcp` 中
 
 [资源管理器](src/index.ts)以实时 Agent 身份为所有权键,并将操作取消与所有者释放关联起来。每个资源只有一个获取 promise 和一个操作队列。获取失败时,仅在提供方回调回滚已获取资源后释放保留。
 
-释放先关闭资源再等待运行中的操作,使连接清理能够中断不支持 abort 的上游 API。关闭失败会拒绝释放并保留所有权。Agent 作用域清理防止使用同一持久 id 恢复的 Session 继承之前的浏览器。
+因释放而取消时,在 AgentHandle 等待空闲前开始资源清理。清理先关闭资源再等待运行中的操作,使连接清理能够中断不支持 abort 的上游 API。关闭失败会拒绝释放并保留所有权。Agent 作用域清理防止使用同一持久 id 恢复的 Session 继承之前的浏览器。
 
 [MCP 辅助库](src/mcp.ts)将发现与执行放入各 Agent 的作用域。它在资源清理期间保留提供方注册,并通过 [MCP 客户端](../../mcp/mcp-client/README.zh.md)处理传输、schema 发现、结果转换与持久图像接纳。
 

+ 28 - 4
packages/experimental/browser-use-runtime/src/index.ts

@@ -69,6 +69,17 @@ export class SessionResources<T> {
    */
   constructor(private readonly ctx: Context, private readonly options: SessionResourceOptions<T>) {}
 
+  /**
+   * Check admission without reserving or acquiring a browser.
+   * @param agent - exact live Agent that would own the resource.
+   * @returns whether this owner can use or acquire the configured browser.
+   */
+  available(agent: Agent): boolean {
+    return this.disposing === undefined && !this.disposedOwners.has(agent)
+      && this.ctx.get('agents')?.get(agent.id) === agent
+      && (this.entries.has(agent) || !this.options.exclusive || this.entries.size === 0)
+  }
+
   /**
    * Obtain the current activation's resource, acquiring it once when absent.
    * @param agent - exact live owner, never merely a durable Session id.
@@ -79,13 +90,15 @@ export class SessionResources<T> {
     signal?.throwIfAborted()
     const entry = this.entry(agent)
     const resource = await (signal === undefined ? entry.ready : awaitOperation(entry.ready, signal))
+    signal?.throwIfAborted()
     entry.controller.signal.throwIfAborted()
     return resource.value
   }
 
   /**
    * Run after earlier operations on this Session settle; other Sessions proceed independently.
-   * Cancellation reaches the provider operation and prevents queued work from starting.
+   * Cancellation stops this caller's acquisition wait without canceling Session-owned initialization.
+   * It reaches an active provider operation and prevents queued work from starting.
    * @param agent - exact live resource owner.
    * @param signal - cancellation for this operation.
    * @param operation - provider call, which must retain ownership until its work settles.
@@ -95,14 +108,24 @@ export class SessionResources<T> {
     signal.throwIfAborted()
     const entry = this.entry(agent)
     const combined = AbortSignal.any([signal, entry.controller.signal])
+    const releaseDisposed = () => {
+      const reason = signal.reason as { kind?: unknown } | undefined
+      if (reason?.kind !== 'disposed') return
+      this.disposedOwners.add(agent)
+      // AgentHandle waits for idle before disposing its scope; close interrupts the owned operation first.
+      void this.closeEntry(agent, entry).catch((error: unknown) => {
+        this.ctx.logger.warn(`${this.options.label}: browser cleanup during Session cancellation failed: ${String(error)}`)
+      })
+    }
+    signal.addEventListener('abort', releaseDisposed, { once: true })
     const task = entry.tail.then(async () => {
       combined.throwIfAborted()
-      const resource = await entry.ready
+      const resource = await awaitOperation(entry.ready, combined)
       combined.throwIfAborted()
       const result = await operation(resource.value, combined)
       combined.throwIfAborted()
       return result
-    })
+    }).finally(() => { signal.removeEventListener('abort', releaseDisposed) })
     // The queue tracks settlement independently of a caller observing its error.
     entry.tail = task.then(() => {}, () => {})
     return task
@@ -153,7 +176,8 @@ export class SessionResources<T> {
       }),
       tail: Promise.resolve(),
     }
-    entry.tail = entry.ready.then(() => {}, () => {})
+    // Acquisition can outlive every canceled caller; later consumers still receive its failure.
+    void entry.ready.catch(() => {})
     this.entries.set(agent, entry)
     return entry
   }

+ 18 - 0
packages/experimental/browser-use-runtime/src/mcp.ts

@@ -1,6 +1,7 @@
 /** Session-owned MCP browser processes and provider catalog activation. @module */
 
 import type { Context } from '@deepseek-ai/cordis'
+import type { Agent } from '@deepseek-ai/dsh-agent'
 import Schema from '@deepseek-ai/schemastery'
 import { BrowserUseProviderName } from '@deepseek-ai/dsh-browser-use/brand'
 import * as McpClient from '@deepseek-ai/dsh-mcp-client'
@@ -86,12 +87,14 @@ export interface SessionMcpOptions {
 
 /**
  * Reserve browser use and discover one MCP catalog before each Session's first request.
+ * A busy attachment contributes no browser tools to other Sessions, which can continue their turns.
  * Calls are serialized per Session; unload closes every server before releasing registration.
  * @param ctx - provider context supplying browser use, Agents, and tools.
  * @param options - provider identity, attachment exclusivity, and executable configuration.
  */
 export function mountSessionMcp(ctx: Context, options: SessionMcpOptions): void {
   let resources!: SessionResources<Scope>
+  const inheritedMasks = new WeakMap<Agent, Scope>()
   ctx.effect(function* () {
     yield ctx.browserUse.register(BrowserUseProviderName(options.name))
     resources = new SessionResources(ctx, {
@@ -146,6 +149,21 @@ export function mountSessionMcp(ctx: Context, options: SessionMcpOptions): void
   ctx.on('system-prompt/prepare', async ({ agent, signal }) => {
     if (agent === undefined) return
     signal?.throwIfAborted()
+    if (!resources.available(agent)) {
+      const inherited = ctx.tools.schemas(agent).filter(tool => tool.name.startsWith(`mcp__${options.name}__`))
+      if (inherited.length > 0) {
+        let scope = inheritedMasks.get(agent)
+        if (scope === undefined) {
+          scope = createScope(ctx, agent)
+          const owned = scope
+          agent.ctx.effect(() => () => owned.dispose(), `${options.name}.inherited-tools`)
+          inheritedMasks.set(agent, scope)
+        }
+        // Restrictions hide inherited tools; this Agent's later own registrations remain visible.
+        scope.ctx.tools.restrict({ deny: inherited.map(tool => tool.name) })
+      }
+      return
+    }
     await resources.get(agent, signal)
     signal?.throwIfAborted()
   })

+ 91 - 0
packages/experimental/browser-use-runtime/tests/agent-disposal.spec.ts

@@ -0,0 +1,91 @@
+/** AgentHandle disposal must release blocking browser work before waiting for idle. */
+
+import { Context } from '@deepseek-ai/cordis'
+import { mountAgentLoopTestDependencies, mountAgentLoopTestHarness } from '@deepseek-ai/dsh-agent-loop-testkit'
+import { SessionId } from '@deepseek-ai/dsh-session'
+import { expect, it, vi } from 'vitest'
+import { SessionResources } from '../src/index.ts'
+
+it('closes an owned resource during disposed cancellation before the real Agent reaches idle', async () => {
+  const ctx = new Context()
+  const entered = Promise.withResolvers<undefined>()
+  const closing = Promise.withResolvers<undefined>()
+  const stopped = Promise.withResolvers<undefined>()
+  const releaseClose = Promise.withResolvers<undefined>()
+  await mountAgentLoopTestDependencies(ctx)
+  await mountAgentLoopTestHarness(ctx)
+  const owner = await ctx.agents.create({ sessionId: SessionId('browser-disposal') })
+  const resources = new SessionResources(ctx, {
+    label: 'browser-fixture', exclusive: true,
+    open: async () => ({
+      value: {},
+      async close() { closing.resolve(undefined); stopped.resolve(undefined); await releaseClose.promise },
+    }),
+  })
+  let disposed: Promise<void> | undefined
+  try {
+    const operation = owner.agent.runMaintenance(signal => resources.run(owner.agent, signal, async () => {
+      entered.resolve(undefined)
+      await stopped.promise
+    }))
+    const canceled = expect(operation).rejects.toMatchObject({ kind: 'disposed' })
+    await entered.promise
+    disposed = owner.dispose()
+    await closing.promise
+    await canceled
+    expect(resources.available(owner.agent)).toBe(false)
+    expect(ctx.agents.get(owner.agent.id)).toBe(owner.agent)
+    releaseClose.resolve(undefined)
+    await disposed
+    expect(ctx.agents.get(owner.agent.id)).toBeUndefined()
+  } finally {
+    stopped.resolve(undefined)
+    releaseClose.resolve(undefined)
+    await disposed
+    await resources.dispose()
+    await ctx.fiber.dispose()
+  }
+})
+
+it('disposes an initializing browser after its first caller was already user-canceled', async () => {
+  const ctx = new Context()
+  const entered = Promise.withResolvers<undefined>()
+  const rollback = Promise.withResolvers<undefined>()
+  let acquisitionAborted = false
+  await mountAgentLoopTestDependencies(ctx)
+  await mountAgentLoopTestHarness(ctx)
+  const owner = await ctx.agents.create({ sessionId: SessionId('browser-initialization-disposal') })
+  const execute = vi.fn(async () => true)
+  const resources = new SessionResources(ctx, {
+    label: 'initializing-browser', exclusive: true,
+    async open(_agent, signal) {
+      signal.addEventListener('abort', () => { acquisitionAborted = true }, { once: true })
+      entered.resolve(undefined)
+      await rollback.promise
+      signal.throwIfAborted()
+      return { value: {}, close: async () => {} }
+    },
+  })
+  let disposing: Promise<void> | undefined
+  try {
+    const operation = owner.agent.runMaintenance(signal => resources.run(owner.agent, signal, execute))
+    const canceled = operation.catch((error: unknown) => error)
+    await entered.promise
+    owner.agent.cancel({ kind: 'user' })
+    let disposed = false
+    disposing = owner.dispose().then(() => { disposed = true })
+    await vi.waitFor(() => { expect(acquisitionAborted).toBe(true) })
+    expect(await canceled).toMatchObject({ cause: { kind: 'user' } })
+    expect(disposed).toBe(false)
+    expect(ctx.agents.get(owner.agent.id)).toBe(owner.agent)
+    rollback.resolve(undefined)
+    await disposing
+    expect(execute).not.toHaveBeenCalled()
+    expect(ctx.agents.get(owner.agent.id)).toBeUndefined()
+  } finally {
+    rollback.resolve(undefined)
+    await resources.dispose()
+    await disposing
+    await ctx.fiber.dispose()
+  }
+})

+ 79 - 11
packages/experimental/browser-use-runtime/tests/mcp.spec.ts

@@ -36,7 +36,7 @@ class FixtureModel extends LlmAdapter {
   }
   async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
     this.requests.push(options)
-    if (options.messages.at(-1)?.content[0]?.type === 'text') {
+    if (options.tools?.some(tool => tool.name === TOOL) && options.messages.at(-1)?.content[0]?.type === 'text') {
       const call = { type: 'tool-call' as const, id: ToolCallId('visit'), name: TOOL, arguments: '{"label":"fixture"}' }
       yield { type: 'block-start', index: 0, blockType: 'tool-call' }
       yield { type: 'tool-call-delta', index: 0, id: call.id, name: call.name, argumentsDelta: call.arguments }
@@ -91,6 +91,14 @@ function execute(ctx: Context, agent: Agent, name = TOOL) {
   return ctx.tools.execute({ agent, name, arguments: name === TOOL ? { label: 'direct' } : {}, callId: ToolCallId('direct'), signal: new AbortController().signal })
 }
 
+function registerIndependentTool(ctx: Context) {
+  ctx.tools.register({
+    name: 'unrelated', description: 'An independent capability.', parameters: { type: 'object' },
+    output: { schema: { type: 'boolean' }, render: () => [{ type: 'text', text: 'Independent.' }] },
+    execute: async () => true,
+  })
+}
+
 async function events(root: string) {
   return (await readFile(join(root, 'events.ndjson'), 'utf8')).trim().split('\n').map(line => JSON.parse(line) as { event: string; pid: number; name?: string })
 }
@@ -107,6 +115,26 @@ it('requires a browser mode and validates launch and attachment settings', () =>
   }
 })
 
+it('waits for SystemPrompt through the required ToolRuntime before reserving the provider', async () => {
+  const ctx = new Context()
+  contexts.push(ctx)
+  await ctx.plugin(BrowserUse)
+  await ctx.plugin(Agents)
+  const tools = ctx.plugin(Tools)
+  const browser = ctx.plugin({
+    inject: ['browserUse', 'agents', 'tools'],
+    apply(provider: Context) {
+      mountSessionMcp(provider, { name: 'browser-fixture', exclusive: true, command: process.execPath, args: [fixture] })
+    },
+  })
+  await Promise.all([tools, browser])
+  expect(ctx.get('tools')).toBeUndefined()
+  expect(ctx.browserUse.providerName).toBeUndefined()
+  await ctx.plugin(SystemPrompt)
+  await Promise.all([tools, browser])
+  expect(ctx.browserUse.providerName).toBe('browser-fixture')
+})
+
 describe('Session MCP Loader composition', () => {
   it('discovers schemas before the first model request and retains distinct state across turns', async () => {
     const { ctx, root, model, browser } = await load(false, undefined, 5000)
@@ -140,18 +168,62 @@ describe('Session MCP Loader composition', () => {
     expect(closed.filter(event => event.event === 'exit').map(event => event.pid).sort()).toEqual(closed.filter(event => event.event === 'start').map(event => event.pid).sort())
   })
 
-  it('rejects a second attached Session until the first owner finishes cleanup', async () => {
-    const { ctx, root } = await load(true)
+  it('keeps unrelated and child Sessions running without a busy attachment and admits a later owner', async () => {
+    const { ctx, root, model } = await load(true)
+    registerIndependentTool(ctx)
     const first = await ctx.agents.create({ sessionId: SessionId('first') })
-    const second = await ctx.agents.create({ sessionId: SessionId('second') })
+    const second = await ctx.agents.create({ sessionId: SessionId('second'), agentOptions: { provider: 'fixture', model: 'fixture' } })
+    const child = await ctx.agents.create({ sessionId: SessionId('child'), agentOptions: { provider: 'fixture', model: 'fixture' } })
+    bindScopeParent(child.agent, first.agent)
     await warm(ctx, first.agent)
-    await expect(warm(ctx, second.agent)).rejects.toThrow('already reserved')
+    expect(ctx.tools.schemas(child.agent).some(tool => tool.name === TOOL)).toBe(true)
+    for (const { agent } of [second, child]) {
+      agent.followup(createUserMessage({ content: [{ type: 'text', text: 'Answer without using the browser.' }], source: { kind: 'user' } }))
+    }
+    await Promise.all([second.agent.whenIdle(), child.agent.whenIdle()])
+    expect(model.requests).toHaveLength(2)
+    expect(model.requests.map(request => request.tools?.map(tool => tool.name))).toEqual([['unrelated'], ['unrelated']])
+    for (const { agent } of [second, child]) {
+      expect(ctx.tools.schemas(agent).map(tool => tool.name)).toEqual(['unrelated'])
+      expect((await execute(ctx, agent)).isError).toBe(true)
+      expect((await execute(ctx, agent, 'unrelated')).isError).toBe(false)
+      expect((await warm(ctx, agent)).tools.map(tool => tool.name)).toEqual(['unrelated'])
+    }
+    first.agent.ctx.tools.register({
+      name: 'mcp__browser-fixture__late', description: 'A newly discovered browser operation.', parameters: { type: 'object' },
+      output: { schema: { type: 'boolean' }, render: () => [{ type: 'text', text: 'Late browser result.' }] },
+      execute: async () => true,
+    })
+    expect(ctx.tools.schemas(child.agent).filter(tool => tool.name.startsWith('mcp__browser-fixture__')).map(tool => tool.name)).toEqual(['mcp__browser-fixture__late'])
+    expect((await warm(ctx, child.agent)).tools.map(tool => tool.name)).toEqual(['unrelated'])
+    expect((await execute(ctx, child.agent, 'mcp__browser-fixture__late')).isError).toBe(true)
     expect((await events(root)).filter(event => event.event === 'start')).toHaveLength(1)
+    expect((await events(root)).filter(event => event.event === 'call')).toEqual([])
+    expect((await execute(ctx, first.agent)).isError).toBe(false)
     await first.dispose()
-    await warm(ctx, second.agent)
+    expect((await warm(ctx, child.agent)).tools.some(tool => tool.name === TOOL)).toBe(true)
+    expect((await execute(ctx, child.agent)).isError).toBe(false)
+    expect((await warm(ctx, second.agent)).tools.map(tool => tool.name)).toEqual(['unrelated'])
+    await child.dispose()
+    expect((await warm(ctx, second.agent)).tools.some(tool => tool.name === TOOL)).toBe(true)
     expect((await execute(ctx, second.agent)).isError).toBe(false)
   })
 
+  it('allows another Session to answer while attached-browser discovery is pending', async () => {
+    const { ctx, root, model, browser } = await load(true, 'hold')
+    const first = await ctx.agents.create({ sessionId: SessionId('first') })
+    const second = await ctx.agents.create({ sessionId: SessionId('second'), agentOptions: { provider: 'fixture', model: 'fixture' } })
+    const preparing = warm(ctx, first.agent).catch((error: unknown) => error)
+    await vi.waitFor(async () => { expect((await events(root))[0]?.event).toBe('start') })
+    second.agent.followup(createUserMessage({ content: [{ type: 'text', text: 'Answer without using the browser.' }], source: { kind: 'user' } }))
+    await second.agent.whenIdle()
+    expect(model.requests).toHaveLength(1)
+    expect(model.requests[0]?.tools ?? []).toEqual([])
+    expect((await events(root)).filter(event => event.event === 'start')).toHaveLength(1)
+    await browser.dispose()
+    expect(await preparing).toBeInstanceOf(Error)
+  })
+
   it('rolls back failed discovery and stops a child when unload interrupts discovery', async () => {
     const failed = await load(false, 'fail')
     const owner = await failed.ctx.agents.create({ sessionId: SessionId('failure') })
@@ -185,11 +257,7 @@ describe('Session MCP Loader composition', () => {
     const child = await ctx.agents.create({ sessionId: SessionId('child') })
     bindScopeParent(child.agent, owner.agent)
     await warm(ctx, owner.agent)
-    ctx.tools.register({
-      name: 'unrelated', description: 'An independent capability.', parameters: { type: 'object' },
-      output: { schema: { type: 'boolean' }, render: () => [{ type: 'text', text: 'Independent.' }] },
-      execute: async () => true,
-    })
+    registerIndependentTool(ctx)
     expect((await execute(ctx, owner.agent, 'unrelated')).content).toEqual([{ type: 'text', text: 'Independent.' }])
     expect((await execute(ctx, child.agent)).content).toEqual([{ type: 'text', text: 'Error: browser-fixture: browser tool belongs to another Session' }])
     expect((await events(root)).filter(event => event.event === 'call')).toEqual([])

+ 104 - 12
packages/experimental/browser-use-runtime/tests/resources.spec.ts

@@ -51,6 +51,7 @@ describe('Session browser resource ownership', () => {
     expect(await resources.get(resumed.agent)).not.toBe(first)
     await resources.dispose()
     expect(close).toHaveBeenCalledTimes(3)
+    expect(resources.available(b.agent)).toBe(false)
     await expect(resources.get(b.agent)).rejects.toThrow('not a live browser owner')
   })
 
@@ -68,15 +69,22 @@ describe('Session browser resource ownership', () => {
         return { value: {}, async close() { closing.resolve(undefined); await released.promise } }
       },
     })
+    expect(resources.available(a.agent)).toBe(true)
+    expect(resources.available(b.agent)).toBe(true)
     const first = resources.get(a.agent)
+    expect(resources.available(a.agent)).toBe(true)
+    expect(resources.available(b.agent)).toBe(false)
     await expect(resources.get(b.agent)).rejects.toThrow('already reserved')
     opened.resolve(undefined)
     await first
     const disposing = a.dispose()
     await closing.promise
+    expect(resources.available(a.agent)).toBe(false)
+    expect(resources.available(b.agent)).toBe(false)
     await expect(resources.get(b.agent)).rejects.toThrow('already reserved')
     released.resolve(undefined)
     await disposing
+    expect(resources.available(b.agent)).toBe(true)
     await resources.get(b.agent)
     await resources.dispose()
   })
@@ -152,27 +160,88 @@ describe('Session browser resource ownership', () => {
     await resources.dispose()
   })
 
-  it('cancels a waiting turn without transferring or abandoning its pending acquisition', async () => {
+  it.each(['get', 'run'] as const)('cancels one %s caller while another waiter retains the same acquisition', async (kind) => {
     const { ctx, owner } = await fixture()
     const a = owner('a')
+    const b = owner('b')
     const entered = Promise.withResolvers<undefined>()
     const release = Promise.withResolvers<undefined>()
     const close = vi.fn(async () => {})
-    const open = vi.fn(async () => { entered.resolve(undefined); await release.promise; return { value: 1, close } })
-    const resources = new SessionResources(ctx, { label: 'test', exclusive: false, open })
+    let acquisitionSignal: AbortSignal | undefined
+    const open = vi.fn(async (_agent: Agent, signal: AbortSignal) => {
+      acquisitionSignal = signal
+      entered.resolve(undefined)
+      await release.promise
+      return { value: 1, close }
+    })
+    const resources = new SessionResources(ctx, { label: 'test', exclusive: true, open })
     const controller = new AbortController()
-    const acquiring = resources.get(a.agent, controller.signal)
-    const rejected = expect(acquiring).rejects.toThrow('cancel turn')
-    await entered.promise
-    controller.abort(new Error('cancel turn'))
-    await rejected
-    release.resolve(undefined)
-    expect(await resources.get(a.agent, new AbortController().signal)).toBe(1)
-    expect(open).toHaveBeenCalledOnce()
-    await resources.dispose()
+    const execute = vi.fn(async (value: number) => value)
+    const acquiring = kind === 'get'
+      ? resources.get(a.agent, controller.signal)
+      : resources.run(a.agent, controller.signal, execute)
+    const canceled = acquiring.catch((error: unknown) => error)
+    const retained = resources.get(a.agent, new AbortController().signal).catch((error: unknown) => error)
+    try {
+      await entered.promise
+      controller.abort(new Error('cancel turn'))
+      expect(await canceled).toMatchObject({ message: 'cancel turn' })
+      expect(acquisitionSignal?.aborted).toBe(false)
+      expect(resources.available(b.agent)).toBe(false)
+      expect(execute).not.toHaveBeenCalled()
+      release.resolve(undefined)
+      expect(await retained).toBe(1)
+      expect(await resources.get(a.agent)).toBe(1)
+      expect(open).toHaveBeenCalledOnce()
+    } finally {
+      release.resolve(undefined)
+      await resources.dispose()
+    }
     expect(close).toHaveBeenCalledOnce()
   })
 
+  it('honors caller cancellation between shared readiness and the waiting continuation', async () => {
+    const { ctx, owner } = await fixture()
+    const a = owner('a')
+    const ready = Promise.withResolvers<undefined>()
+    const resources = new SessionResources(ctx, {
+      label: 'test', exclusive: false,
+      async open() { await ready.promise; return { value: 1, close: async () => {} } },
+    })
+    const controller = new AbortController()
+    const error = new Error('cancel ready waiter')
+    const retained = resources.get(a.agent)
+    const canceled = resources.get(a.agent, controller.signal).catch((failure: unknown) => failure)
+    const abort = retained.then(() => { controller.abort(error) })
+    try {
+      ready.resolve(undefined)
+      await abort
+      expect(await canceled).toBe(error)
+      expect(await retained).toBe(1)
+      expect(await resources.get(a.agent)).toBe(1)
+    } finally {
+      ready.resolve(undefined)
+      await resources.dispose()
+    }
+  })
+
+  it('retains a late initialization failure after its only caller canceled before waiting', async () => {
+    const { ctx, owner } = await fixture()
+    const a = owner('a')
+    const b = owner('b')
+    const ready = Promise.withResolvers<never>()
+    const open = vi.fn().mockImplementationOnce(() => ready.promise).mockResolvedValue({ value: 1, close: async () => {} })
+    const resources = new SessionResources<number>(ctx, { label: 'test', exclusive: true, open })
+    const controller = new AbortController()
+    const running = resources.run(a.agent, controller.signal, async value => value)
+    controller.abort(new Error('cancel before waiting'))
+    await expect(running).rejects.toThrow('cancel before waiting')
+    ready.reject(new Error('late browser startup failed'))
+    await vi.waitFor(() => { expect(resources.available(b.agent)).toBe(true) })
+    expect(await resources.get(b.agent)).toBe(1)
+    await resources.dispose()
+  })
+
   it('reports non-Error cancellation and acquisition failures through cancellable waits', async () => {
     const { ctx, owner } = await fixture()
     const a = owner('a')
@@ -262,3 +331,26 @@ describe('Session browser resource ownership', () => {
     await expect(resources.get(b.agent)).rejects.toThrow('not a live browser owner')
   })
 })
+
+
+it('reports early disposal cleanup failure while retaining the owned resource', async () => {
+  const { ctx, owner } = await fixture()
+  const a = owner('early-close-failure')
+  const entered = Promise.withResolvers<undefined>()
+  const stopped = Promise.withResolvers<undefined>()
+  const warning = vi.spyOn(ctx.logger, 'warn')
+  const resources = new SessionResources(ctx, {
+    label: 'early-close', exclusive: true,
+    open: async () => ({ value: {}, async close() { stopped.resolve(undefined); throw new Error('Close failed') } }),
+  })
+  const controller = new AbortController()
+  const running = resources.run(a.agent, controller.signal, async () => { entered.resolve(undefined); await stopped.promise })
+  const canceled = expect(running).rejects.toMatchObject({ kind: 'disposed' })
+  await entered.promise
+  controller.abort({ kind: 'disposed' })
+  await canceled
+  await a.dispose()
+  expect(warning).toHaveBeenCalledWith(expect.stringContaining('cleanup during Session cancellation failed'))
+  await expect(resources.dispose()).rejects.toThrow('browser cleanup failed')
+  warning.mockRestore()
+})

+ 2 - 2
packages/experimental/browser-use-stagehand-native/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/experimental/browser-use-stagehand-native/README.md
-README.md: 8b53474fd5f2abb7b19c6e251a43d9f77b4451f9
-README.zh.md: b85a2fa34fe371c3c4b4d6c1fd6cf155f098789b
+README.md: c79e0735c5ee5e8fa55263c3d71986e3d9163efb
+README.zh.md: 0a553da27af296eaa0ac0654a61c245f12fbe324

+ 8 - 8
packages/experimental/browser-use-stagehand-native/README.md

@@ -37,18 +37,18 @@ Mount this provider in a profile that supplies Agents, Sessions, an LLM route, t
     headless: true
 ```
 
-Install a Chrome or Chromium executable that supports the pinned Stagehand SDK. Native startup happens on the first browser tool call. Stagehand manages its runtime extension; an incompatible or unavailable runtime rejects startup.
+Install a Chrome or Chromium executable that supports the pinned Stagehand SDK. Native startup happens on the first browser tool call. Default discovery uses the standard stable Chrome installation path; select other Chrome or Chromium installations with `executablePath`. Stagehand manages its runtime extension; an incompatible or unavailable runtime rejects startup.
 
 | Field | Default | Meaning |
 |---|---|---|
 | `mode` | `launch` | Start a fresh browser or `attach` to an existing browser. |
 | `cdpEndpoint` | Required for `attach` | HTTP or WebSocket debugging endpoint selected by the profile. |
 | `extensionId` | Load bundled extension | Installed Stagehand extension to use in an existing browser. |
-| `executablePath` | Upstream discovery | Installed browser executable, for `launch` only. |
+| `executablePath` | Installed stable Chrome | Chrome or Chromium executable, for `launch` only. |
 | `headless` | `true` | Hide a launched browser's window. |
-| `operationTimeoutMs` | `30000` | Timeout passed to navigation and natural-language actions. |
+| `operationTimeoutMs` | `30000` | Deadline for Chromium startup, navigation, and natural-language actions. |
 | `maxOutputTokens` | `4096` | Output-token cap for each auxiliary model request. |
-| `shutdownGraceMs` | `5000` | Grace for attachment SDK cleanup before terminating its Worker. |
+| `shutdownGraceMs` | `5000` | Grace for SDK cleanup before terminating the connection Worker. |
 
 An existing Chromium browser must expose CDP and permit the Stagehand extension to connect to it. The verified local setup uses `--remote-debugging-port=0`, `--remote-allow-origins=*`, `--enable-unsafe-extension-debugging`, and a dedicated `--user-data-dir`. Set `cdpEndpoint` to Chrome's reported endpoint.
 
@@ -79,9 +79,9 @@ env -u NODE_USE_ENV_PROXY DSH_STAGEHAND_E2E=1 pnpm exec vitest run --config vite
 
 [SessionResources](../browser-use-runtime/README.md) owns lazy acquisition, serialization, and teardown for each exact live Agent. The provider retains the browser-use registration until cleanup settles. The [native provider](src/index.ts) registers its tools through the existing MCP result adapter, which saves screenshots as durable attachments.
 
-Launch mode embeds the SDK directly. Attachment runs the SDK in a dedicated Worker because the pinned SDK's browser close operation also closes an attached browser. Cleanup releases Stagehand state and terminates that Worker, which closes its sockets while preserving the external browser. The host continues to own model selection, credentials, and inference logging. Tool completion and disposal cancel auxiliary inference and wait for its logging and cleanup to settle.
+The host owns each launched Chromium process and its temporary profile before waiting for CDP readiness. Chromium receives the standard scrubbed child environment, preserving paths, locale, and proxy settings while excluding credential-shaped variables and DSH identity. Both modes connect the SDK inside a dedicated Worker. The Worker receives no ambient environment except the explicit source TypeScript configuration path, so its CDP connection does not inherit host proxy settings. Cleanup terminates the connection Worker after the configured SDK grace; launch also kills and awaits its owned Chromium process before removing the profile. Attached external browsers remain open. The host owns model selection, credentials, and inference logging, and disposal waits for outstanding inference to settle.
 
-The [model adapter](src/model.ts) implements Stagehand's public custom-generation callback. It prepares the selected DSH model route and asks it to call one result tool whose argument schema contains Stagehand's requested JSON schema. It logs and flushes the complete auxiliary request before dispatch and the assembled response before returning validated data to Stagehand. Plain-text answers, other tools, multiple calls, truncation, and schema mismatches fail the operation. Auxiliary records do not enter the main conversation's model history.
+The [model adapter](src/model.ts) implements Stagehand's public custom-generation callback. It prepares the selected DSH model route and asks it to call one result tool whose argument schema contains Stagehand's requested JSON schema. It logs and flushes the complete auxiliary request before dispatch and the assembled response before returning validated data to Stagehand. Plain-text answers, other tools, multiple calls, truncation, and schema mismatches fail the operation. Auxiliary records do not enter the main conversation's model history. An unmatched request means no settled output was persisted; it may still be running or may have been interrupted, and the request alone does not prove model dispatch.
 
 No invariant companion is published: every browser operation uses the resource owner's single acquired handle, with no separately maintained browser relationship to compare.
 
@@ -166,9 +166,9 @@ The provider inherits the pinned Stagehand SDK's browser and extension requireme
 - **Chromium only** — Firefox and WebKit are outside this provider's scope.
 - **Live browser state** — Session replay restores recorded conversation data, not a browser process, cookies, or tab handles.
 - **Structured inference** — the selected model must produce a schema-valid result tool call. This provider supports text inputs for Stagehand's three AI primitives; it does not expose Stagehand's autonomous agent or per-call model overrides.
-- **Cancellation** — DSH cancels auxiliary inference and waits for active work to settle. Stagehand browser methods do not share an end-to-end abort API, and delivered input is not rolled back.
+- **Cancellation** — DSH closes the SDK connection Worker and drains auxiliary inference. The live Session retains launched Chromium and reconnects on the next tool call. Input already delivered is not rolled back.
 - **Existing browser access** — an attached browser can also be changed by its user; the reservation coordinates DSH Sessions only.
-- **Cleanup failure** — failed native cleanup retains the provider reservation; restart the host before selecting another provider.
+- **Cleanup failure** — SDK cleanup that exceeds its grace logs a warning and releases the Worker. Failed owned-process or profile cleanup retains the provider reservation; restart the host before selecting another provider.
 
 <a id="dev-note"></a>
 ### Dev Note

+ 7 - 7
packages/experimental/browser-use-stagehand-native/README.zh.md

@@ -37,16 +37,16 @@ kind: "package-reference"
     headless: true
 ```
 
-安装与固定版本 Stagehand SDK 兼容的 Chrome 或 Chromium 可执行程序。首次浏览器工具调用时才启动原生运行时。Stagehand 管理其运行时扩展;运行时不兼容或不可用时,启动会失败。
+安装与固定版本 Stagehand SDK 兼容的 Chrome 或 Chromium 可执行程序。首次浏览器工具调用时才启动原生运行时。默认查找稳定版 Chrome 的标准安装路径;其他 Chrome 或 Chromium 安装位置通过 `executablePath` 指定。Stagehand 管理其运行时扩展;运行时不兼容或不可用时,启动会失败。
 
 | 字段 | 默认值 | 含义 |
 |---|---|---|
 | `mode` | `launch` | 启动独立浏览器,或以 `attach` 连接现有浏览器。 |
 | `cdpEndpoint` | `attach` 必填 | 由 profile 选择的 HTTP 或 WebSocket 调试端点。 |
 | `extensionId` | 加载内置扩展 | 在现有浏览器中使用的已安装 Stagehand 扩展。 |
-| `executablePath` | 上游发现 | 已安装的浏览器可执行程序,仅适用于 `launch`。 |
+| `executablePath` | 已安装的稳定版 Chrome | Chrome 或 Chromium 可执行程序,仅适用于 `launch`。 |
 | `headless` | `true` | 隐藏启动的浏览器窗口。 |
-| `operationTimeoutMs` | `30000` | 传给导航和自然语言操作的超时时间。 |
+| `operationTimeoutMs` | `30000` | Chromium 启动、导航和自然语言操作的超时时间。 |
 | `maxOutputTokens` | `4096` | 每次辅助模型请求的输出 token 上限。 |
 | `shutdownGraceMs` | `5000` | 终止连接 Worker 前,允许 SDK 完成清理的宽限时间。 |
 
@@ -79,9 +79,9 @@ env -u NODE_USE_ENV_PROXY DSH_STAGEHAND_E2E=1 pnpm exec vitest run --config vite
 
 [SessionResources](../browser-use-runtime/README.zh.md) 为每个确切的活动 Agent 管理延迟获取、串行执行和资源释放。Provider 保留浏览器使用注册,直到清理完成。[原生 Provider](src/index.ts) 通过现有 MCP 结果适配器注册工具,由该适配器把截图保存为持久附件。
 
-启动模式直接嵌入 SDK。连接模式在独立 Worker 中运行 SDK,因为固定版本 SDK 的浏览器关闭操作也会关闭已连接的浏览器。清理释放 Stagehand 状态并终止该 Worker,从而关闭其套接字而保留外部浏览器。宿主继续负责模型选择、凭据和推理日志。工具完成与资源释放会取消辅助推理,并等待其日志写入和清理结束。
+宿主在等待 CDP 就绪前即拥有启动的 Chromium 进程及其临时配置目录。Chromium 接收标准清理后的子进程环境,保留路径、区域设置和代理配置,排除凭据形式的变量及 DSH 身份信息。两种模式都在独立 Worker 中连接 SDK。Worker 除显式的源码 TypeScript 配置路径外不接收宿主环境,因此其 CDP 连接不继承宿主代理设置。清理在配置的 SDK 宽限时间后终止连接 Worker;启动模式还会终止并等待自有 Chromium 进程退出,再移除配置目录。外部连接的浏览器保持运行。宿主负责模型选择、凭据和推理日志,释放时等待未完成推理结束。
 
-[模型适配器](src/model.ts) 实现 Stagehand 的公开自定义生成回调。它准备所选 DSH 模型路由,并要求模型调用一个结果工具,其参数 schema 包含 Stagehand 要求的 JSON schema。适配器在分派前记录并刷盘完整辅助请求,在向 Stagehand 返回验证后的数据之前记录并刷盘已组装的响应。纯文本回答、其他工具、多次调用、截断和 schema 不匹配都会使操作失败。辅助记录不进入主对话的模型历史。
+[模型适配器](src/model.ts) 实现 Stagehand 的公开自定义生成回调。它准备所选 DSH 模型路由,并要求模型调用一个结果工具,其参数 schema 包含 Stagehand 要求的 JSON schema。适配器在分派前记录并刷盘完整辅助请求,在向 Stagehand 返回验证后的数据之前记录并刷盘已组装的响应。纯文本回答、其他工具、多次调用、截断和 schema 不匹配都会使操作失败。辅助记录不进入主对话的模型历史。没有对应结果的请求只表示未持久化已结束的输出;它可能仍在运行,也可能已被中断,单凭请求记录不能证明模型请求已发出。
 
 此包不发布不变量配套模块:每次浏览器操作都使用资源所有者获取的唯一句柄,没有独立维护、需要比较的浏览器关系。
 
@@ -166,9 +166,9 @@ Return the requested structured result by calling stagehand_result exactly once.
 - **仅 Chromium**——Firefox 和 WebKit 不在此 Provider 的支持范围内。
 - **活动浏览器状态**——Session 回放恢复记录的对话数据,不恢复浏览器进程、Cookie 或标签页句柄。
 - **结构化推理**——所选模型必须生成符合 schema 的结果工具调用。此 Provider 为 Stagehand 的三个 AI 原语支持文本输入;不开放 Stagehand 自主 Agent 或单次调用的模型覆盖。
-- **取消**——DSH 取消辅助推理并等待活动工作结束。Stagehand 浏览器方法没有端到端统一取消 API,已传递的输入不会回滚。
+- **取消**——DSH 关闭 SDK 连接 Worker 并等待辅助推理结束。活动 Session 保留已启动的 Chromium,在下次工具调用时重新连接。已传递的输入不会回滚。
 - **现有浏览器访问**——用户也可修改已连接的浏览器;占用机制只协调 DSH Session。
-- **清理失败**——原生清理失败时保留 Provider 占用;选择其他 Provider 前应重启宿主。
+- **清理失败**——SDK 清理超过宽限时间时记录警告并释放 Worker。自有进程或配置目录清理失败时保留 Provider 占用;选择其他 Provider 前应重启宿主。
 
 <a id="dev-note"></a>
 ### 开发备注

+ 3 - 1
packages/experimental/browser-use-stagehand-native/package.json

@@ -46,7 +46,9 @@
     "@deepseek-ai/dsh-mcp-client": "workspace:^",
     "@deepseek-ai/dsh-util-values": "workspace:^",
     "@deepseek-ai/schemastery": "workspace:^",
-    "zod": "^4.4.3"
+    "zod": "^4.4.3",
+    "@deepseek-ai/dsh-subprocess": "workspace:^",
+    "@puppeteer/browsers": "3.2.2"
   },
   "devDependencies": {
     "@deepseek-ai/cordis": "workspace:^",

+ 53 - 16
packages/experimental/browser-use-stagehand-native/src/index.ts

@@ -11,9 +11,10 @@ import { SessionResources } from '@deepseek-ai/dsh-experimental-browser-use-runt
 import { createMcpToolDefinition } from '@deepseek-ai/dsh-mcp-client'
 import { z } from 'zod'
 import { generateWithSessionModel } from './model.ts'
-import { browserInputs, openNativeBrowser } from './native.ts'
+import { browserInputs } from './native.ts'
 import type { BrowserMethod, NativeBrowserRuntime } from './native.ts'
-import { openAttachedBrowser } from './worker-client.ts'
+import { openBrowserWorker } from './worker-client.ts'
+import { launchChromium } from './launch.ts'
 import type {} from '@deepseek-ai/dsh-agent'
 import type {} from '@deepseek-ai/dsh-browser-use'
 import type {} from '@deepseek-ai/dsh-system-prompt'
@@ -37,11 +38,11 @@ export interface Config {
   executablePath?: string
   /** Hide an owned browser's window. */
   headless?: boolean
-  /** Deadline passed to Stagehand navigation and action operations. */
+  /** Deadline for Chromium startup and Stagehand navigation/action operations. */
   operationTimeoutMs?: number
   /** Maximum output tokens for each auxiliary Session-model generation. */
   maxOutputTokens?: number
-  /** Grace for native attachment cleanup before its Worker is terminated. */
+  /** Grace for native SDK cleanup before its connection Worker is terminated. */
   shutdownGraceMs?: number
 }
 
@@ -61,9 +62,12 @@ export const Config: Schema<Config, ResolvedConfig> = Schema.object({
 })
 
 interface BrowserResource {
-  native: NativeBrowserRuntime
+  native: {
+    execute(method: BrowserMethod, args: unknown, signal: AbortSignal): Promise<unknown>
+    close(): Promise<void>
+  }
   inferences: Set<ReturnType<ClientLLM['generate']>>
-  inferenceSignal?: AbortSignal
+  inferenceSignal: AbortSignal
 }
 
 const GUIDANCE = `Stagehand browser tools control a browser owned by this Session or an explicitly configured existing browser. Use the tab ids returned by stagehand_tabs. Inspect current pages before acting after reconnecting, cancellation, or a resumed Session; browser state is not restored from the Session log. A completed action does not prove the requested outcome, so verify it from fresh page state.
@@ -108,25 +112,58 @@ export function apply(ctx: Context, input: Config): void {
           void inference.then(() => { inferences.delete(inference) }, () => { inferences.delete(inference) })
           return inference
         }
-        const native = config.mode === 'attach'
-          ? await openAttachedBrowser(config, generate, signal)
-          : await openNativeBrowser(config, generate)
+        const chromium = config.mode === 'launch' ? await launchChromium(config, signal) : undefined
+        const connect = (connectionSignal: AbortSignal) => openBrowserWorker({
+          mode: 'attach', headless: config.headless,
+          operationTimeoutMs: config.operationTimeoutMs, shutdownGraceMs: config.shutdownGraceMs,
+          ...config.extensionId === undefined ? {} : { extensionId: config.extensionId },
+          ...config.cdpEndpoint === undefined ? {} : { cdpEndpoint: config.cdpEndpoint },
+          ...chromium === undefined ? {} : { cdpEndpoint: chromium.endpoint },
+        }, generate, connectionSignal, (message) => { ctx.logger.warn(message) })
+        let connection: NativeBrowserRuntime | undefined
+        try {
+          connection = await connect(signal)
+        } catch (error) {
+          await chromium?.close()
+          throw error
+        }
+        const native: BrowserResource['native'] = {
+          async execute(method, args, operationSignal) {
+            const current = connection ??= await connect(operationSignal)
+            try {
+              return await current.execute(method, args, operationSignal)
+            } finally {
+              if (operationSignal.aborted) {
+                await current.close()
+                connection = undefined
+              }
+            }
+          },
+          async close() { await connection?.close() },
+        }
+        const close = async () => {
+          const results = await Promise.allSettled([native.close(), chromium?.close()])
+          const errors = results.flatMap(result => result.status === 'rejected' ? [result.reason as unknown] : [])
+          if (errors.length > 0) throw new AggregateError(errors, 'Stagehand browser cleanup failed')
+        }
         try {
           signal.throwIfAborted()
-          const acquired: BrowserResource = resource = { native, inferences }
+          const acquired: BrowserResource = resource = {
+            native, inferences, inferenceSignal: AbortSignal.abort(new Error('Stagehand inference requires an active browser tool call')),
+          }
           return {
             value: acquired,
             async close() {
-              delete acquired.inferenceSignal
+              acquired.inferenceSignal = AbortSignal.abort(new Error('Stagehand browser is closing'))
               try {
-                await native.close()
+                await close()
               } finally {
                 await Promise.allSettled(inferences)
               }
             },
           }
         } catch (error) {
-          await native.close()
+          await close()
           throw error
         }
       },
@@ -158,10 +195,11 @@ function mountTools(ctx: Context, resources: SessionResources<BrowserResource>):
       name: toolName,
       rawName: method,
       description: descriptions[method],
-      inputSchema: z.record(z.string(), z.json()).parse(z.toJSONSchema(browserInputs[method])),
+      inputSchema: { ...z.record(z.string(), z.json()).parse(z.toJSONSchema(browserInputs[method])), type: 'object' },
       async call(args) {
         const agent = ctx.agents.requireInitiator()
-        return (await resources.get(agent)).native.execute(method, args)
+        const resource = await resources.get(agent)
+        return resource.native.execute(method, args, resource.inferenceSignal)
       },
     }))
   }
@@ -180,7 +218,6 @@ function mountTools(ctx: Context, resources: SessionResources<BrowserResource>):
       try {
         return await ctx.agents.withInitiator(agent, next)
       } finally {
-        delete resource.inferenceSignal
         inference.abort(new Error('Stagehand browser operation has settled'))
         await Promise.allSettled(resource.inferences)
         exec.signal = upstreamSignal

+ 64 - 0
packages/experimental/browser-use-stagehand-native/src/launch.ts

@@ -0,0 +1,64 @@
+/** Own Chromium before CDP or Stagehand initialization can wait or fail. */
+
+import { mkdtemp, rm } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { Browser, ChromeReleaseChannel, CDP_WEBSOCKET_ENDPOINT_REGEX, computeSystemExecutablePath, launch } from '@puppeteer/browsers'
+import { scrubbedParentEnv } from '@deepseek-ai/dsh-subprocess'
+import type { NativeBrowserConfig } from './native.ts'
+
+/** Chromium process and profile owned independently of the Stagehand Worker. */
+export interface OwnedChromium {
+  /** CDP endpoint reported by this exact process. */
+  endpoint: string
+  /** Kill the owned process tree, await child close, and remove its profile. */
+  close(): Promise<void>
+}
+
+/**
+ * Launch Chromium with scrubbed environment and OS-allocated debugging port.
+ * @param config - executable, window visibility, and startup deadline.
+ * @param signal - cancellation before the caller receives browser ownership.
+ * @returns the owned browser after its debugging endpoint is ready.
+ */
+export async function launchChromium(config: NativeBrowserConfig, signal: AbortSignal): Promise<OwnedChromium> {
+  signal.throwIfAborted()
+  const executablePath = config.executablePath
+    ?? computeSystemExecutablePath({ browser: Browser.CHROME, channel: ChromeReleaseChannel.STABLE })
+  const profile = await mkdtemp(join(tmpdir(), 'dsh-stagehand-chrome-'))
+  let browser: ReturnType<typeof launch> | undefined
+  let closed: Promise<void> | undefined
+  let closing: Promise<void> | undefined
+  const close = () => closing ??= (async () => {
+    browser?.kill()
+    await closed
+    await rm(profile, { recursive: true, force: true })
+  })()
+  const abort = () => { void close().catch(() => {}) }
+  try {
+    signal.throwIfAborted()
+    browser = launch({
+      executablePath,
+      args: [
+        '--remote-debugging-port=0', '--enable-unsafe-extension-debugging', '--remote-allow-origins=*',
+        '--no-first-run', '--no-default-browser-check', `--user-data-dir=${profile}`,
+        ...config.headless ? ['--headless=new'] : [],
+        'about:blank',
+      ],
+      env: scrubbedParentEnv(), handleSIGINT: false, handleSIGTERM: false, handleSIGHUP: false,
+    })
+    // Failed spawn emits error + close without exit; Process.hasClosed waits only for exit.
+    const child = browser.nodeProcess
+    closed = new Promise<void>((resolve) => { child.once('close', () => { resolve() }) })
+    signal.addEventListener('abort', abort, { once: true })
+    const endpoint = await browser.waitForLineOutput(CDP_WEBSOCKET_ENDPOINT_REGEX, config.operationTimeoutMs)
+    signal.throwIfAborted()
+    return { endpoint, close }
+  } catch (error) {
+    await close()
+    signal.throwIfAborted()
+    throw error
+  } finally {
+    signal.removeEventListener('abort', abort)
+  }
+}

+ 63 - 79
packages/experimental/browser-use-stagehand-native/src/native.ts

@@ -1,4 +1,4 @@
-/** Native Stagehand operations shared by embedded launch and isolated attachment. */
+/** Native Stagehand operations run inside one isolated browser Worker. */
 
 import type { ClientLLM, Page, StagehandBrowser } from '@browserbasehq/stagehand'
 import { assertNever } from '@deepseek-ai/dsh-util-values'
@@ -16,7 +16,7 @@ export interface NativeBrowserConfig {
   executablePath?: string
   /** Whether to hide an owned browser's window. */
   headless: boolean
-  /** Deadline passed to navigation and natural-language actions. */
+  /** Deadline for Chromium startup, navigation, and natural-language actions. */
   operationTimeoutMs: number
   /** Time allowed for attachment-worker shutdown before terminating it. */
   shutdownGraceMs: number
@@ -47,98 +47,82 @@ export interface NativeBrowserRuntime {
    * Execute one operation after validating its tool or worker arguments.
    * @param method - supported browser operation.
    * @param args - untrusted JSON arguments.
+   * @param signal - host-side cancellation; closes the Worker connection when supplied.
    * @returns a canonicalizable MCP result with text or screenshot content.
    */
-  execute(method: BrowserMethod, args: unknown): Promise<unknown>
-  /** Close an owned browser or release Stagehand state before worker termination. */
+  execute(method: BrowserMethod, args: unknown, signal?: AbortSignal): Promise<unknown>
+  /** Release Stagehand state before Worker termination disconnects CDP. */
   close(): Promise<void>
 }
 
 /**
  * Open the pinned SDK using its public initialization and custom model APIs.
- * Attach mode must run in a Worker: its owner terminates the Worker after
- * close or failed initialization to release sockets without closing Chromium.
+ * The host owns launched Chromium separately; this Worker owns only its CDP connection.
  * @param config - resolved profile-owned browser options.
  * @param generate - host-owned, durably logged structured inference.
  * @returns the native operation runtime after initialization completes.
  */
 export async function openNativeBrowser(config: NativeBrowserConfig, generate: ClientLLM['generate']): Promise<NativeBrowserRuntime> {
   const { Stagehand, localBrowser } = await import('@browserbasehq/stagehand')
-  const browser = config.mode === 'attach'
-    ? await localBrowser.connect({
-      cdpUrl: z.string().parse(config.cdpEndpoint),
-      ...config.extensionId === undefined ? {} : { extensionId: config.extensionId },
-    })
-    : await localBrowser.launch({
-      headless: config.headless,
-      ...config.executablePath === undefined ? {} : { executablePath: config.executablePath },
-    })
-  try {
-    const stagehand = await Stagehand.create({ browser, model: { generate }, logging: { level: 'off' } })
-    return {
-      async close() {
-        try {
-          await stagehand.close()
-        } finally {
-          if (config.mode === 'launch') await browser.close()
+  const browser = await localBrowser.connect({
+    cdpUrl: z.string().parse(config.cdpEndpoint),
+    ...config.extensionId === undefined ? {} : { extensionId: config.extensionId },
+  })
+  const stagehand = await Stagehand.create({ browser, model: { generate }, logging: { level: 'off' } })
+  return {
+    close: () => stagehand.close(),
+    async execute(method, rawArgs) {
+      switch (method) {
+        case 'navigate': {
+          const args = browserInputs.navigate.parse(rawArgs)
+          const page = await selectPage(browser, args.pageId)
+          await page.goto(args.url, { timeout: config.operationTimeoutMs })
+          return textResult({ pageId: page.pageId, url: await page.url(), title: await page.title() })
         }
-      },
-      async execute(method, rawArgs) {
-        switch (method) {
-          case 'navigate': {
-            const args = browserInputs.navigate.parse(rawArgs)
-            const page = await selectPage(browser, args.pageId)
-            await page.goto(args.url, { timeout: config.operationTimeoutMs })
-            return textResult({ pageId: page.pageId, url: await page.url(), title: await page.title() })
-          }
-          case 'tabs': {
-            const args = browserInputs.tabs.parse(rawArgs)
-            const context = browser.context
-            if (args.action === 'new') await context.newPage(args.url)
-            if (args.action === 'select') await context.setActivePage(await selectPage(browser, args.pageId))
-            if (args.action === 'close') await (await selectPage(browser, args.pageId)).close()
-            const active = await context.activePage()
-            return textResult({ tabs: await Promise.all((await context.pages()).map(async page => ({
-              pageId: page.pageId, url: await page.url(), title: await page.title(), active: page.pageId === active?.pageId,
-            }))) })
-          }
-          case 'screenshot': {
-            const args = browserInputs.screenshot.parse(rawArgs)
-            const page = await selectPage(browser, args.pageId)
-            const bytes = await page.screenshot({ type: 'png', fullPage: args.fullPage })
-            return { content: [
-              { type: 'text', text: `Screenshot of tab ${page.pageId}.` },
-              { type: 'image', data: Buffer.from(bytes).toString('base64'), mimeType: 'image/png' },
-            ] }
-          }
-          case 'act': {
-            const args = browserInputs.act.parse(rawArgs)
-            const result = await stagehand.act(args.instruction, {
-              page: await selectPage(browser, args.pageId), timeout: config.operationTimeoutMs,
-            })
-            if (!result.data.success) throw new Error(result.data.message)
-            return textResult(result)
-          }
-          case 'observe': {
-            const args = browserInputs.observe.parse(rawArgs)
-            return textResult(await stagehand.observe(args.instruction, { page: await selectPage(browser, args.pageId) }))
-          }
-          case 'extract': {
-            const args = browserInputs.extract.parse(rawArgs)
-            const options = { page: await selectPage(browser, args.pageId) }
-            const result = args.schema === undefined
-              ? await stagehand.extract(args.instruction, options)
-              : await stagehand.extract(args.instruction, z.fromJSONSchema(args.schema), options)
-            return textResult(result)
-          }
-          /* v8 ignore next -- closed-union exhaustiveness guard; Worker methods are parsed before dispatch. */
-          default: return assertNever(method, 'Stagehand browser operation')
+        case 'tabs': {
+          const args = browserInputs.tabs.parse(rawArgs)
+          const context = browser.context
+          if (args.action === 'new') await context.newPage(args.url)
+          if (args.action === 'select') await context.setActivePage(await selectPage(browser, args.pageId))
+          if (args.action === 'close') await (await selectPage(browser, args.pageId)).close()
+          const active = await context.activePage()
+          return textResult({ tabs: await Promise.all((await context.pages()).map(async page => ({
+            pageId: page.pageId, url: await page.url(), title: await page.title(), active: page.pageId === active?.pageId,
+          }))) })
         }
-      },
-    }
-  } catch (error) {
-    if (config.mode === 'launch') await browser.close()
-    throw error
+        case 'screenshot': {
+          const args = browserInputs.screenshot.parse(rawArgs)
+          const page = await selectPage(browser, args.pageId)
+          const bytes = await page.screenshot({ type: 'png', fullPage: args.fullPage })
+          return { content: [
+            { type: 'text', text: `Screenshot of tab ${page.pageId}.` },
+            { type: 'image', data: Buffer.from(bytes).toString('base64'), mimeType: 'image/png' },
+          ] }
+        }
+        case 'act': {
+          const args = browserInputs.act.parse(rawArgs)
+          const result = await stagehand.act(args.instruction, {
+            page: await selectPage(browser, args.pageId), timeout: config.operationTimeoutMs,
+          })
+          if (!result.data.success) throw new Error(result.data.message)
+          return textResult(result)
+        }
+        case 'observe': {
+          const args = browserInputs.observe.parse(rawArgs)
+          return textResult(await stagehand.observe(args.instruction, { page: await selectPage(browser, args.pageId) }))
+        }
+        case 'extract': {
+          const args = browserInputs.extract.parse(rawArgs)
+          const options = { page: await selectPage(browser, args.pageId) }
+          const result = args.schema === undefined
+            ? await stagehand.extract(args.instruction, options)
+            : await stagehand.extract(args.instruction, z.fromJSONSchema(args.schema), options)
+          return textResult(result)
+        }
+        /* v8 ignore next -- closed-union exhaustiveness guard; Worker methods are parsed before dispatch. */
+        default: return assertNever(method, 'Stagehand browser operation')
+      }
+    },
   }
 }
 

+ 48 - 30
packages/experimental/browser-use-stagehand-native/src/worker-client.ts

@@ -1,4 +1,4 @@
-/** Attachment cleanup releases Stagehand before terminating its owned sockets. */
+/** Isolated Stagehand Workers own CDP connections; the host owns browser processes. */
 
 import { Worker } from 'node:worker_threads'
 import type { ClientLLM } from '@browserbasehq/stagehand'
@@ -6,18 +6,23 @@ import type { NativeBrowserConfig, NativeBrowserRuntime } from './native.ts'
 import { answer, request } from './worker-rpc.ts'
 
 /**
- * Attach through a dedicated Worker; disposal never sends Browser.close.
- * @param config - resolved browser options with an explicit attachment endpoint.
+ * Connect Stagehand through an isolated Worker that receives no ambient environment.
+ * @param config - resolved CDP connection and operation options.
  * @param generate - host-owned, logged inference callback.
  * @param signal - cancellation of lazy browser acquisition.
- * @returns an initialized runtime whose close awaits Worker termination.
+ * @param warn - report SDK cleanup failures after connection termination.
+ * @returns an initialized runtime whose close awaits connection release.
  */
-export async function openAttachedBrowser(config: NativeBrowserConfig, generate: ClientLLM['generate'], signal: AbortSignal): Promise<NativeBrowserRuntime> {
+export async function openBrowserWorker(
+  config: NativeBrowserConfig, generate: ClientLLM['generate'], signal: AbortSignal, warn: (message: string) => void,
+): Promise<NativeBrowserRuntime> {
   signal.throwIfAborted()
   const { ClientLLMSchema } = await import('@browserbasehq/stagehand')
   signal.throwIfAborted()
   const entry = new URL('./worker.js', import.meta.url)
-  const env = process.env.TSX_TSCONFIG_PATH === undefined ? {} : { TSX_TSCONFIG_PATH: process.env.TSX_TSCONFIG_PATH }
+  // The SDK Worker only connects over CDP; host paths, credentials, and proxy variables stay outside it.
+  const env: NodeJS.ProcessEnv = {}
+  if (process.env.TSX_TSCONFIG_PATH !== undefined) env.TSX_TSCONFIG_PATH = process.env.TSX_TSCONFIG_PATH
   let worker: Worker
   /* v8 ignore next 3 -- native.e2e.ts starts the bundled Worker through a plain-Node provider fixture. */
   if (!import.meta.url.endsWith('.ts')) {
@@ -31,13 +36,34 @@ export async function openAttachedBrowser(config: NativeBrowserConfig, generate:
   const terminate = () => termination ??= worker.terminate()
   const lifetime = new AbortController()
   worker.once('error', (error) => { lifetime.abort(error) })
-  worker.once('exit', (code) => { lifetime.abort(new Error(`Stagehand attachment Worker exited (${code})`)) })
+  worker.once('exit', (code) => { lifetime.abort(new Error(`Stagehand browser Worker exited (${code})`)) })
+  let closing: Promise<void> | undefined
+  const close = () => closing ??= (async () => {
+    let timeout: ReturnType<typeof setTimeout> | undefined
+    try {
+      await Promise.race([
+        request(worker, 'close', undefined, lifetime.signal),
+        new Promise<never>((_resolve, reject) => {
+          timeout = setTimeout(() => { reject(new Error('Stagehand connection cleanup timed out')) }, config.shutdownGraceMs)
+        }),
+      ])
+    } catch (error) {
+      warn(`Stagehand SDK cleanup did not finish: ${String(error)}`)
+    } finally {
+      clearTimeout(timeout)
+      await terminate()
+    }
+    worker.removeAllListeners()
+  })()
   const validatedGenerate = ClientLLMSchema.shape.generate.implementAsync(generate)
   worker.on('message', (raw: unknown) => {
     void answer(raw, async (method, args) => {
       if (method !== 'generate') throw new Error(`Unsupported Stagehand Worker request: ${method}`)
       return validatedGenerate(args as Parameters<ClientLLM['generate']>[0])
-    }).catch((error: unknown) => { lifetime.abort(error); void terminate() })
+    }).catch((error: unknown) => {
+      warn(`Stagehand Worker protocol failed: ${String(error)}`)
+      void close().catch((cleanupError: unknown) => { lifetime.abort(cleanupError) })
+    })
   })
   const abortOpening = () => { void terminate() }
   signal.addEventListener('abort', abortOpening, { once: true })
@@ -53,30 +79,22 @@ export async function openAttachedBrowser(config: NativeBrowserConfig, generate:
   } finally {
     signal.removeEventListener('abort', abortOpening)
   }
-  let closing: Promise<void> | undefined
   return {
-    async execute(method, args) {
+    async execute(method, args, signal) {
+      signal?.throwIfAborted()
       lifetime.signal.throwIfAborted()
-      if (closing !== undefined) throw new Error('Stagehand attachment is closed')
-      return request(worker, method, args, lifetime.signal)
-    },
-    close() {
-      closing ??= (async () => {
-        let timeout: ReturnType<typeof setTimeout> | undefined
-        try {
-          await Promise.race([
-            request(worker, 'close', undefined, lifetime.signal),
-            new Promise<never>((_resolve, reject) => {
-              timeout = setTimeout(() => { reject(new Error('Stagehand attachment cleanup timed out')) }, config.shutdownGraceMs)
-            }),
-          ])
-        } finally {
-          clearTimeout(timeout)
-          await terminate()
-          worker.removeAllListeners()
-        }
-      })()
-      return closing
+      if (closing !== undefined) throw new Error('Stagehand browser Worker is closed')
+      const canceled = () => { void close().catch((error: unknown) => { lifetime.abort(error) }) }
+      signal?.addEventListener('abort', canceled, { once: true })
+      try {
+        return await request(worker, method, args, lifetime.signal)
+      } catch (error) {
+        signal?.throwIfAborted()
+        throw error
+      } finally {
+        signal?.removeEventListener('abort', canceled)
+      }
     },
+    close,
   }
 }

+ 14 - 6
packages/experimental/browser-use-stagehand-native/src/worker.ts

@@ -1,4 +1,4 @@
-/** Isolated attachment owns SDK sockets while the host owns model inference. */
+/** Isolated browser runtime owns SDK resources while the host owns model inference. */
 
 import { parentPort, workerData } from 'node:worker_threads'
 import { ClientLLMSchema } from '@browserbasehq/stagehand'
@@ -9,9 +9,10 @@ import { answer, request } from './worker-rpc.ts'
 
 const port = parentPort
 if (port === null) throw new Error('Stagehand attachment requires a Worker parent')
-const { extensionId, ...config } = z.object({
-  mode: z.literal('attach'),
-  cdpEndpoint: z.string(),
+const { extensionId, cdpEndpoint, executablePath, ...config } = z.object({
+  mode: z.enum(['launch', 'attach']),
+  cdpEndpoint: z.string().optional(),
+  executablePath: z.string().optional(),
   extensionId: z.string().optional(),
   headless: z.boolean(),
   operationTimeoutMs: z.number().int().positive(),
@@ -20,7 +21,11 @@ const { extensionId, ...config } = z.object({
 const generate = ClientLLMSchema.shape.generate.implementAsync(params => request(port, 'generate', params) as ReturnType<ClientLLM['generate']>)
 const methodSchema = z.enum(Object.keys(browserInputs) as [keyof typeof browserInputs, ...Array<keyof typeof browserInputs>])
 const opening = openNativeBrowser(
-  { ...config, ...extensionId === undefined ? {} : { extensionId } },
+  {
+    ...config, ...extensionId === undefined ? {} : { extensionId },
+    ...cdpEndpoint === undefined ? {} : { cdpEndpoint },
+    ...executablePath === undefined ? {} : { executablePath },
+  },
   async params => generate(params),
 )
 // Initialization failure stays observable through the host's ready request.
@@ -31,5 +36,8 @@ port.on('message', (raw: unknown) => {
     if (method === 'ready') return
     if (method === 'close') return native.close()
     return native.execute(methodSchema.parse(method), args)
-  }).catch((error: unknown) => { throw error })
+  }).catch((error: unknown) => {
+    console.error('Stagehand Worker protocol failed:', error)
+    process.exit(1)
+  })
 })

+ 24 - 0
packages/experimental/browser-use-stagehand-native/tests/fixtures/chromium.ts

@@ -0,0 +1,24 @@
+/** External Chromium process fixture; the DSH launcher still owns profile cleanup. */
+
+import { EventEmitter } from 'node:events'
+import { FixtureBrowser, fixture } from './stagehand.ts'
+
+export const Browser = { CHROME: 'chrome' }
+export const ChromeReleaseChannel = { STABLE: 'stable' }
+export const CDP_WEBSOCKET_ENDPOINT_REGEX = /^DevTools listening on (ws:\/\/.*)$/
+export const computeSystemExecutablePath = () => '/fixture/chromium'
+
+export function launch(options: { executablePath: string; args: string[] }) {
+  const browser = new FixtureBrowser('launched', {
+    executablePath: options.executablePath, headless: options.args.includes('--headless=new'),
+  })
+  fixture.browsers.push(browser)
+  const endpoint = `http://fixture/${fixture.browsers.length}`
+  fixture.connections.set(endpoint, browser)
+  const nodeProcess = new EventEmitter()
+  return {
+    nodeProcess,
+    waitForLineOutput: () => Promise.resolve(endpoint),
+    kill() { void browser.close().then(() => { nodeProcess.emit('close', 0) }) },
+  }
+}

+ 13 - 3
packages/experimental/browser-use-stagehand-native/tests/fixtures/stagehand.ts

@@ -9,6 +9,7 @@ export const screenshotBase64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAA
 /** Controllable external operations and acquired browser handles. */
 export const fixture: {
   browsers: FixtureBrowser[]
+  connections: Map<string, FixtureBrowser>
   createError?: Error
   create?: (model: ClientLLM) => Promise<void>
   navigate?: (page: FixturePage) => Promise<void>
@@ -16,11 +17,13 @@ export const fixture: {
   actResult?: { success: boolean; message: string }
   interrupt?: Promise<void>
   stagehandClose?: () => void
-} = { browsers: [] }
+  screenshot?: () => Promise<void>
+} = { browsers: [], connections: new Map() }
 
 /** Reset fixture state after the previous test has closed its contexts. */
 export function resetFixture(): void {
   fixture.browsers = []
+  fixture.connections.clear()
   delete fixture.createError
   delete fixture.create
   delete fixture.navigate
@@ -28,6 +31,7 @@ export function resetFixture(): void {
   delete fixture.actResult
   delete fixture.interrupt
   delete fixture.stagehandClose
+  delete fixture.screenshot
 }
 
 /** A browser tab with only the operations exercised by the provider. */
@@ -37,7 +41,10 @@ export class FixturePage {
   async goto(url: string): Promise<void> { this.currentURL = url; await fixture.navigate?.(this) }
   async url(): Promise<string> { return this.currentURL }
   async title(): Promise<string> { return 'Fixture heading' }
-  async screenshot(): Promise<Buffer> { return Buffer.from(screenshotBase64, 'base64') }
+  async screenshot(): Promise<Buffer> {
+    await Promise.race([fixture.screenshot?.(), this.owner.connectionClosed.then(() => { throw new Error('Fixture connection closed') })])
+    return Buffer.from(screenshotBase64, 'base64')
+  }
   async close(): Promise<void> { this.owner.pages = this.owner.pages.filter(page => page !== this) }
 }
 
@@ -47,6 +54,7 @@ export class FixtureBrowser {
   active: FixturePage
   closed = false
   stagehandClosed = false
+  connectionClosed: Promise<void> = new Promise(() => {})
   readonly context = {
     pages: async (): Promise<FixturePage[]> => this.pages,
     activePage: async (): Promise<FixturePage | undefined> => this.pages.includes(this.active) ? this.active : this.pages[0],
@@ -74,6 +82,8 @@ export const localBrowser = {
     return browser
   },
   async connect(options: unknown): Promise<FixtureBrowser> {
+    const existing = fixture.connections.get((options as { cdpUrl: string }).cdpUrl)
+    if (existing !== undefined) return existing
     const browser = new FixtureBrowser('connected', options)
     fixture.browsers.push(browser)
     return browser
@@ -83,7 +93,7 @@ export const localBrowser = {
 /** Native wrapper that exercises the public custom-generation callback. */
 export class Stagehand {
   private readonly closed: PromiseWithResolvers<void> = Promise.withResolvers()
-  constructor(readonly browser: FixtureBrowser, readonly model: ClientLLM) {}
+  constructor(readonly browser: FixtureBrowser, readonly model: ClientLLM) { browser.connectionClosed = this.closed.promise }
   static async create(options: { browser: FixtureBrowser; model: ClientLLM }): Promise<Stagehand> {
     await fixture.create?.(options.model)
     if (fixture.createError) throw fixture.createError

+ 14 - 0
packages/experimental/browser-use-stagehand-native/tests/launch-process.spec.ts

@@ -0,0 +1,14 @@
+/** Failed executable startup emits close without exit and must not hang cleanup. */
+
+import { join } from 'node:path'
+import { tmpdir } from 'node:os'
+import { randomUUID } from 'node:crypto'
+import { expect, it } from 'vitest'
+import { launchChromium } from '../src/launch.ts'
+
+it('releases a real failed spawn without waiting for an exit event', async () => {
+  await expect(launchChromium({
+    mode: 'launch', executablePath: join(tmpdir(), `missing-browser-${randomUUID()}`),
+    headless: true, operationTimeoutMs: 5000, shutdownGraceMs: 50,
+  }, new AbortController().signal)).rejects.toThrow('Failed to launch the browser process')
+})

+ 115 - 0
packages/experimental/browser-use-stagehand-native/tests/launch.spec.ts

@@ -0,0 +1,115 @@
+/** Chromium ownership spans readiness failure, cancellation, and process close. */
+
+import { EventEmitter } from 'node:events'
+import { access, rm } from 'node:fs/promises'
+import { afterEach, beforeEach, expect, it, vi } from 'vitest'
+import type { NativeBrowserConfig } from '../src/native.ts'
+import { launchChromium } from '../src/launch.ts'
+
+const state = vi.hoisted(() => ({ launch: vi.fn(), path: vi.fn(() => '/fixture/chrome') }))
+vi.mock('@puppeteer/browsers', () => ({
+  Browser: { CHROME: 'chrome' }, ChromeReleaseChannel: { STABLE: 'stable' },
+  CDP_WEBSOCKET_ENDPOINT_REGEX: /^DevTools listening on (.+)$/,
+  computeSystemExecutablePath: state.path,
+  launch: state.launch,
+}))
+
+const config: NativeBrowserConfig = { mode: 'launch', headless: true, operationTimeoutMs: 5000, shutdownGraceMs: 50 }
+const profiles: string[] = []
+beforeEach(() => { state.launch.mockReset(); state.path.mockClear() })
+afterEach(async () => {
+  vi.unstubAllEnvs()
+  await Promise.all(profiles.splice(0).map(profile => rm(profile, { recursive: true, force: true })))
+})
+
+function processFixture() {
+  const child = new EventEmitter()
+  const ready = Promise.withResolvers<string>()
+  const started = Promise.withResolvers<undefined>()
+  const killed = Promise.withResolvers<undefined>()
+  const kill = vi.fn(() => { killed.resolve(undefined); child.emit('close', 0) })
+  state.launch.mockImplementation((options: { args: string[] }) => {
+    profiles.push(options.args.find(arg => arg.startsWith('--user-data-dir='))!.slice('--user-data-dir='.length))
+    started.resolve(undefined)
+    return { nodeProcess: child, kill, waitForLineOutput: () => ready.promise }
+  })
+  return { child, ready, started, killed, kill }
+}
+
+it.each([true, false])('launches with scrubbed environment and owns the profile until child close (headless %s)', async (headless) => {
+  vi.stubEnv('BROWSER_LAUNCH_API_TOKEN', 'fixture-secret')
+  vi.stubEnv('DSH_FIXTURE_ID', 'fixture-identity')
+  vi.stubEnv('BROWSER_LAUNCH_PUBLIC', 'visible')
+  const fake = processFixture()
+  const opening = launchChromium({ ...config, headless, ...headless ? {} : { executablePath: '/custom/chrome' } }, new AbortController().signal)
+  await fake.started.promise
+  fake.ready.resolve('ws://127.0.0.1:1234/devtools/browser/fixture')
+  const browser = await opening
+  const options = state.launch.mock.calls[0]![0] as { executablePath: string; args: string[]; env: Record<string, string> }
+  expect(options.executablePath).toBe(headless ? '/fixture/chrome' : '/custom/chrome')
+  expect(options.args.includes('--headless=new')).toBe(headless)
+  expect(options.args).toContain('--remote-debugging-port=0')
+  expect(options.env.BROWSER_LAUNCH_PUBLIC).toBe('visible')
+  expect(options.env.BROWSER_LAUNCH_API_TOKEN).toBeUndefined()
+  expect(options.env.DSH_FIXTURE_ID).toBeUndefined()
+  await expect(access(profiles[0]!)).resolves.toBeUndefined()
+  await browser.close()
+  expect(fake.kill).toHaveBeenCalledOnce()
+  await expect(access(profiles[0]!)).rejects.toThrow()
+})
+
+it('cancels after spawn and waits for close before removing the profile', async () => {
+  const fake = processFixture()
+  fake.kill.mockImplementation(() => { fake.killed.resolve(undefined) })
+  const controller = new AbortController()
+  const opening = launchChromium(config, controller.signal)
+  const canceled = expect(opening).rejects.toThrow('Stop acquisition')
+  await fake.started.promise
+  controller.abort(new Error('Stop acquisition'))
+  await fake.killed.promise
+  await expect(access(profiles[0]!)).resolves.toBeUndefined()
+  fake.child.emit('close', 0)
+  fake.ready.reject(new Error('Browser exited'))
+  await canceled
+  await expect(access(profiles[0]!)).rejects.toThrow()
+})
+
+it('cleans up before reporting a CDP readiness failure', async () => {
+  const fake = processFixture()
+  const opening = launchChromium(config, new AbortController().signal)
+  const failed = expect(opening).rejects.toThrow('No debugging endpoint')
+  await fake.started.promise
+  fake.ready.reject(new Error('No debugging endpoint'))
+  await failed
+  expect(fake.kill).toHaveBeenCalledOnce()
+  await expect(access(profiles[0]!)).rejects.toThrow()
+})
+
+it('removes the profile when construction fails before returning a process', async () => {
+  processFixture()
+  const capture = state.launch.getMockImplementation()!
+  state.launch.mockImplementation((options) => { capture(options); throw new Error('Cannot spawn browser') })
+  await expect(launchChromium(config, new AbortController().signal)).rejects.toThrow('Cannot spawn browser')
+  await expect(access(profiles[0]!)).rejects.toThrow()
+})
+
+it('does not spawn after cancellation during profile acquisition', async () => {
+  const controller = new AbortController()
+  const opening = launchChromium(config, controller.signal)
+  controller.abort(new Error('Canceled before spawn'))
+  await expect(opening).rejects.toThrow('Canceled before spawn')
+  expect(state.launch).not.toHaveBeenCalled()
+})
+
+it('preserves cleanup failure when a canceled process cannot be killed', async () => {
+  const fake = processFixture()
+  fake.kill.mockImplementation(() => { throw new Error('Kill denied') })
+  const controller = new AbortController()
+  const opening = launchChromium(config, controller.signal)
+  const failed = expect(opening).rejects.toThrow('Kill denied')
+  await fake.started.promise
+  controller.abort(new Error('Canceled'))
+  fake.ready.reject(new Error('Readiness deadline'))
+  await failed
+  await expect(access(profiles[0]!)).resolves.toBeUndefined()
+})

+ 18 - 1
packages/experimental/browser-use-stagehand-native/tests/loader-composition.spec.ts

@@ -21,7 +21,24 @@ import ToolRuntime from '@deepseek-ai/dsh-tools'
 import * as Provider from '../src/index.ts'
 import { resetFixture, screenshotBase64 } from './fixtures/stagehand.ts'
 
-vi.mock('@browserbasehq/stagehand', async () => import('./fixtures/stagehand.ts'))
+vi.mock('@browserbasehq/stagehand', async importActual => ({
+  ...await import('./fixtures/stagehand.ts'),
+  ClientLLMSchema: (await importActual<typeof import('@browserbasehq/stagehand')>()).ClientLLMSchema,
+}))
+vi.mock('@puppeteer/browsers', async () => import('./fixtures/chromium.ts'))
+vi.mock('node:worker_threads', async (importActual) => {
+  const actual = await importActual<typeof import('node:worker_threads')>()
+  return {
+    ...actual,
+    Worker: class extends actual.Worker {
+      constructor(entry: string | URL, options: import('node:worker_threads').WorkerOptions) {
+        const hooks = new URL('../../../../snapshots/session/browser-use-stagehand-native/native-fixture.mjs', import.meta.url)
+        const bootstrap = `import { installExternalBrowserHooks } from ${JSON.stringify(hooks.href)}; installExternalBrowserHooks(); await import(${JSON.stringify(String(entry))});`
+        super(new URL(`data:text/javascript,${encodeURIComponent(bootstrap)}`), options)
+      }
+    },
+  }
+})
 
 class BrowserModel extends LlmAdapter {
   mainRequests: GenerateOptions[] = []

+ 125 - 2
packages/experimental/browser-use-stagehand-native/tests/native.e2e.ts

@@ -3,12 +3,12 @@
 import { createServer } from 'node:http'
 import { execFile, spawn } from 'node:child_process'
 import { once } from 'node:events'
-import { mkdtemp, rm } from 'node:fs/promises'
+import { chmod, mkdtemp, readFile, rm, writeFile, access } from 'node:fs/promises'
 import { tmpdir } from 'node:os'
 import { join } from 'node:path'
 import { fileURLToPath } from 'node:url'
 import { promisify } from 'node:util'
-import { expect, it } from 'vitest'
+import { expect, it, vi } from 'vitest'
 import { Context } from '@deepseek-ai/cordis'
 import { mountAgentLoopTestDependencies, mountAgentLoopTestHarness } from '@deepseek-ai/dsh-agent-loop-testkit'
 import LocalAttachmentStore from '@deepseek-ai/dsh-attachment-local'
@@ -141,3 +141,126 @@ it.skipIf(process.env.DSH_STAGEHAND_E2E !== '1' || !process.env.DEEPSEEK_API_KEY
     await new Promise<void>((resolve, reject) => { server.close((error) => { if (error) reject(error); else resolve() }) })
   }
 })
+
+
+// The executable wrapper uses a POSIX shebang; the ordinary browser smoke runs on every platform.
+it.skipIf(process.env.DSH_STAGEHAND_E2E !== '1' || process.platform === 'win32')('scrubs real Chromium environment and closes it during canceled Stagehand initialization', { timeout: 120_000, retry: 0 }, async () => {
+  const executable = process.env.DSH_BROWSER_EXECUTABLE
+  if (executable === undefined) throw new Error('DSH_STAGEHAND_E2E requires DSH_BROWSER_EXECUTABLE')
+  const root = await mkdtemp(join(tmpdir(), 'dsh-stagehand-acquire-'))
+  const marker = join(root, 'browser.json')
+  const wrapper = join(root, 'chrome.mjs')
+  const sockets = new Set<import('node:stream').Duplex>()
+  const connected: PromiseWithResolvers<void> = Promise.withResolvers()
+  const stalled = createServer()
+  stalled.on('upgrade', (_request, socket) => {
+    sockets.add(socket)
+    socket.on('close', () => { sockets.delete(socket) })
+    connected.resolve()
+  })
+  const ctx = new Context()
+  vi.stubEnv('BROWSER_FIXTURE_API_TOKEN', 'do-not-forward')
+  vi.stubEnv('DSH_BROWSER_FIXTURE_ID', 'do-not-forward')
+  vi.stubEnv('BROWSER_FIXTURE_PUBLIC', 'visible')
+  try {
+    stalled.listen(0, '127.0.0.1')
+    await once(stalled, 'listening')
+    const address = stalled.address()
+    if (address === null || typeof address === 'string') throw new Error('No stalled CDP listener')
+    const endpoint = `ws://127.0.0.1:${address.port}/devtools/browser/stalled`
+    await writeFile(wrapper, [
+      `#!${process.execPath}`,
+      'import { spawn } from \'node:child_process\'',
+      'import { writeFileSync } from \'node:fs\'',
+      `const child = spawn(${JSON.stringify(executable)}, process.argv.slice(2), { stdio: ['ignore', 'ignore', 'pipe'], env: process.env })`,
+      `writeFileSync(${JSON.stringify(marker)}, JSON.stringify({ pid: child.pid, token: process.env.BROWSER_FIXTURE_API_TOKEN ?? null, identity: process.env.DSH_BROWSER_FIXTURE_ID ?? null, publicValue: process.env.BROWSER_FIXTURE_PUBLIC, profile: process.argv.find(value => value.startsWith('--user-data-dir=')).slice('--user-data-dir='.length) }))`,
+      'let output = \'\'',
+      `child.stderr.on('data', chunk => { output += chunk.toString(); if (output.includes('DevTools listening on ')) { process.stderr.write(${JSON.stringify(`DevTools listening on ${endpoint}\n`)}); output = '' } })`,
+      'child.on(\'error\', error => { console.error(error); process.exit(1) })',
+      'child.on(\'exit\', code => process.exit(code ?? 1))',
+      '',
+    ].join('\n'))
+    await chmod(wrapper, 0o700)
+    await mountAgentLoopTestDependencies(ctx)
+    await ctx.plugin(BrowserUseRegistry)
+    await mountAgentLoopTestHarness(ctx)
+    await ctx.plugin(Provider, { mode: 'launch', executablePath: wrapper })
+    const owner = await ctx.agents.create({ sessionId: SessionId('stagehand-stalled-init') })
+    const operation = owner.agent.runMaintenance(signal => ctx.tools.execute({
+      agent: owner.agent, name: 'stagehand_tabs', arguments: { action: 'list' }, callId: ToolCallId('stalled-init'), signal,
+    }))
+    await connected.promise
+    const browser = JSON.parse(await readFile(marker, 'utf8')) as { pid: number; token: unknown; identity: unknown; publicValue: string; profile: string }
+    expect(browser.token).toBeNull()
+    expect(browser.identity).toBeNull()
+    expect(browser.publicValue).toBe('visible')
+    expect(() => process.kill(browser.pid, 0)).not.toThrow()
+    await owner.dispose()
+    expect((await operation).isError).toBe(true)
+    await vi.waitFor(() => { expect(() => process.kill(browser.pid, 0)).toThrow() })
+    await expect(access(browser.profile)).rejects.toThrow()
+  } finally {
+    for (const socket of sockets) socket.destroy()
+    await ctx.fiber.dispose()
+    await new Promise<void>((resolve, reject) => { stalled.close((error) => { if (error) reject(error); else resolve() }) })
+    vi.unstubAllEnvs()
+    await rm(root, { recursive: true, force: true })
+  }
+})
+
+it.skipIf(process.env.DSH_STAGEHAND_E2E !== '1')('keeps Chromium state when a canceled model operation reconnects', { timeout: 120_000, retry: 0 }, async () => {
+  const executable = process.env.DSH_BROWSER_EXECUTABLE
+  if (executable === undefined) throw new Error('DSH_STAGEHAND_E2E requires DSH_BROWSER_EXECUTABLE')
+  const entered: PromiseWithResolvers<void> = Promise.withResolvers()
+  const aborted: PromiseWithResolvers<void> = Promise.withResolvers()
+  const release: PromiseWithResolvers<void> = Promise.withResolvers()
+  class CanceledModel extends LlmAdapter {
+    async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
+      expect(JSON.stringify(options.messages)).toContain('Keep this page')
+      options.signal?.addEventListener('abort', () => { aborted.resolve() }, { once: true })
+      entered.resolve()
+      await release.promise
+      yield { type: 'finish', reason: { kind: 'aborted', failure: { code: 'ABORTED', message: 'Canceled browser inference' } } }
+    }
+  }
+  const server = createServer((_request, response) => {
+    response.setHeader('content-type', 'text/html')
+    response.end('<!doctype html><title>Retained browser</title><h1>Keep this page</h1>')
+  })
+  const ctx = new Context()
+  try {
+    server.listen(0, '127.0.0.1')
+    await once(server, 'listening')
+    const address = server.address()
+    if (address === null || typeof address === 'string') throw new Error('No cancellation fixture listener')
+    const url = `http://127.0.0.1:${address.port}/retained`
+    await mountAgentLoopTestDependencies(ctx)
+    await ctx.plugin(BrowserUseRegistry)
+    ctx.llm.registerAdapter(['cancel-fixture'], new CanceledModel())
+    await mountAgentLoopTestHarness(ctx)
+    await ctx.plugin(Provider, { mode: 'launch', executablePath: executable })
+    const owner = await ctx.agents.create({
+      sessionId: SessionId('stagehand-cancel-reconnect'), agentOptions: { provider: 'cancel-fixture', model: 'fixture' },
+    })
+    const call = (method: string, args: unknown) => owner.agent.runMaintenance(signal => ctx.tools.execute({
+      agent: owner.agent, name: `stagehand_${method}`, arguments: args, callId: ToolCallId(`reconnect-${method}`), signal,
+    }))
+    expect((await call('navigate', { url })).isError).toBe(false)
+    const extraction = call('extract', { instruction: 'Read the heading' })
+    await entered.promise
+    owner.agent.cancel({ kind: 'user' })
+    await aborted.promise
+    release.resolve()
+    expect((await extraction).isError).toBe(true)
+    const tabs = await call('tabs', { action: 'list' })
+    expect(tabs.isError, JSON.stringify(tabs.content)).toBe(false)
+    expect(JSON.stringify(tabs.content)).toContain(url)
+    expect(JSON.stringify(tabs.content)).toContain('Retained browser')
+    await owner.dispose()
+  } finally {
+    release.resolve()
+    await ctx.fiber.dispose()
+    server.closeAllConnections()
+    await new Promise<void>((resolve, reject) => { server.close((error) => { if (error) reject(error); else resolve() }) })
+  }
+})

+ 12 - 3
packages/experimental/browser-use-stagehand-native/tests/native.spec.ts

@@ -9,7 +9,7 @@ vi.mock('@browserbasehq/stagehand', async () => import('./fixtures/stagehand.ts'
 
 const runtimes: NativeBrowserRuntime[] = []
 const config: NativeBrowserConfig = {
-  mode: 'launch', headless: true, operationTimeoutMs: 30000, shutdownGraceMs: 5000,
+  mode: 'attach', cdpEndpoint: 'http://fixture', headless: true, operationTimeoutMs: 30000, shutdownGraceMs: 5000,
 }
 const generate = async (): Promise<never> => { throw new Error('Unexpected model call in browser cleanup test') }
 
@@ -29,10 +29,19 @@ it('preserves the external browser when attachment initialization fails', async
   expect(fixture.browsers[0]?.pages.map(page => page.currentURL)).toEqual(['about:blank'])
 })
 
-it('closes an owned browser even when Stagehand cleanup fails', async () => {
+it('leaves Chromium owned by the host when Stagehand cleanup fails', async () => {
   const runtime = await openNativeBrowser(config, generate)
   runtimes.push(runtime)
   fixture.stagehandClose = () => { throw new Error('Stagehand cleanup failed') }
   await expect(runtime.close()).rejects.toThrow('Stagehand cleanup failed')
-  expect(fixture.browsers[0]?.closed).toBe(true)
+  expect(fixture.browsers[0]?.closed).toBe(false)
+})
+
+
+it('returns a native screenshot as canonical text and image content', async () => {
+  const runtime = await openNativeBrowser(config, generate)
+  runtimes.push(runtime)
+  expect(await runtime.execute('screenshot', { fullPage: true })).toMatchObject({
+    content: [{ type: 'text', text: 'Screenshot of tab tab-1.' }, { type: 'image', mimeType: 'image/png' }],
+  })
 })

+ 99 - 5
packages/experimental/browser-use-stagehand-native/tests/provider.spec.ts

@@ -8,21 +8,36 @@ import type { Agent } from '@deepseek-ai/dsh-agent'
 import { mountAgentLoopTestDependencies, mountAgentLoopTestHarness } from '@deepseek-ai/dsh-agent-loop-testkit'
 import BrowserUseRegistry from '@deepseek-ai/dsh-browser-use'
 import { BrowserUseProviderName } from '@deepseek-ai/dsh-browser-use/brand'
-import { LlmAdapter, ToolCallId } from '@deepseek-ai/dsh-llm'
+import { LlmAdapter, ToolCallId, createUserMessage } from '@deepseek-ai/dsh-llm'
 import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
 import { SessionId } from '@deepseek-ai/dsh-session'
 import * as Provider from '../src/index.ts'
 import { fixture, resetFixture } from './fixtures/stagehand.ts'
 
-const acquisition = vi.hoisted(() => ({ signal: undefined as AbortSignal | undefined }))
+const acquisition = vi.hoisted(() => ({
+  signal: undefined as AbortSignal | undefined, warning: undefined as string | undefined, closeError: undefined as Error | undefined,
+}))
 
 vi.mock('@browserbasehq/stagehand', async () => import('./fixtures/stagehand.ts'))
+vi.mock('@puppeteer/browsers', async () => import('./fixtures/chromium.ts'))
 vi.mock('../src/worker-client.ts', async () => {
   const { openNativeBrowser } = await import('../src/native.ts')
   return {
-    openAttachedBrowser(config: NativeBrowserConfig, generate: ClientLLM['generate'], signal: AbortSignal) {
+    async openBrowserWorker(config: NativeBrowserConfig, generate: ClientLLM['generate'], signal: AbortSignal, warn: (message: string) => void) {
       acquisition.signal = signal
-      return openNativeBrowser(config, generate)
+      const native = await openNativeBrowser(config, generate)
+      return {
+        async execute(method: import('../src/native.ts').BrowserMethod, args: unknown, signal?: AbortSignal) {
+          const cancel = () => { void native.close() }
+          signal?.addEventListener('abort', cancel, { once: true })
+          try { return await native.execute(method, args) } finally { signal?.removeEventListener('abort', cancel) }
+        },
+        async close() {
+          if (acquisition.warning !== undefined) warn(acquisition.warning)
+          if (acquisition.closeError !== undefined) throw acquisition.closeError
+          await native.close()
+        },
+      }
     },
   }
 })
@@ -45,6 +60,8 @@ class StructuredModel extends LlmAdapter {
 beforeEach(async () => {
   resetFixture()
   acquisition.signal = undefined
+  acquisition.warning = undefined
+  acquisition.closeError = undefined
   ctx = new Context()
   await mountAgentLoopTestDependencies(ctx)
   await ctx.plugin(BrowserUseRegistry)
@@ -65,7 +82,9 @@ it('lazily launches distinct browsers and preserves each Session tab state', asy
   const provider = ctx.plugin(Provider, { mode: 'launch', executablePath: '/fixture/chromium', headless: false })
   await provider
   expect(fixture.browsers).toEqual([])
-  expect(ctx.tools.schemas()).toHaveLength(6)
+  const schemas = ctx.tools.schemas()
+  expect(schemas).toHaveLength(6)
+  for (const tool of schemas) expect(tool.parameters).toMatchObject({ type: 'object' })
   expect((await execute(first, 'navigate', { url: 'https://first.example' })).isError).toBe(false)
   expect((await execute(second, 'navigate', { url: 'https://second.example' })).isError).toBe(false)
   expect(fixture.browsers).toHaveLength(2)
@@ -259,3 +278,78 @@ it('releases an attachment whose initialization completes after provider disposa
     await closing
   }
 })
+
+
+it.each([false, true])('disposes a real AgentHandle while its screenshot waits, with prior user cancel %s', async (cancelFirst) => {
+  const entered: PromiseWithResolvers<void> = Promise.withResolvers()
+  const stopped: PromiseWithResolvers<void> = Promise.withResolvers()
+  fixture.screenshot = async () => { entered.resolve(); await stopped.promise }
+  fixture.browserClose = async () => { stopped.resolve() }
+  class ScreenshotModel extends LlmAdapter {
+    async * stream(): AsyncIterable<StreamChunk> {
+      yield { type: 'block-end', index: 0, block: { type: 'tool-call', id: ToolCallId('blocked-screenshot'), name: 'stagehand_screenshot', arguments: '{}' } }
+      yield { type: 'finish', reason: { kind: 'tool-calls' } }
+    }
+  }
+  ctx.llm.registerAdapter(['screenshot'], new ScreenshotModel())
+  await ctx.plugin(Provider, { mode: 'launch' })
+  const owner = await ctx.agents.create({ sessionId: SessionId('screenshot-disposal'), agentOptions: { provider: 'screenshot', model: 'fixture' } })
+  try {
+    owner.agent.followup(createUserMessage({ content: [{ type: 'text', text: 'Take a screenshot.' }], source: { kind: 'user' } }))
+    await entered.promise
+    if (cancelFirst) owner.agent.cancel({ kind: 'user' })
+    await owner.dispose()
+    expect(fixture.browsers[0]?.closed).toBe(true)
+    expect(ctx.agents.get(owner.agent.id)).toBeUndefined()
+  } finally {
+    stopped.resolve()
+    await owner.dispose()
+  }
+})
+
+
+it('releases the provider after a terminated connection reports an SDK cleanup warning', async () => {
+  const warning = vi.spyOn(ctx.logger, 'warn')
+  acquisition.warning = 'Stagehand SDK cleanup did not finish: deadline'
+  const provider = ctx.plugin(Provider, { mode: 'launch' })
+  await provider
+  await execute(first, 'tabs', { action: 'list' })
+  await provider.dispose()
+  expect(warning).toHaveBeenCalledWith(acquisition.warning)
+  expect(ctx.browserUse.providerName).toBeUndefined()
+  expect(fixture.browsers[0]?.closed).toBe(true)
+})
+
+it('closes owned Chromium but retains the reservation if the connection Worker fails to terminate', async () => {
+  acquisition.closeError = new Error('Worker failed to terminate')
+  const provider = ctx.plugin(Provider, { mode: 'launch' })
+  await provider
+  await execute(first, 'tabs', { action: 'list' })
+  await provider.dispose()
+  expect(fixture.browsers[0]?.closed).toBe(true)
+  expect(ctx.browserUse.providerName).toBe('stagehand-native')
+})
+
+
+it('reconnects after cancellation while preserving the owned browser and its tabs', async () => {
+  const entered: PromiseWithResolvers<void> = Promise.withResolvers()
+  const stopped: PromiseWithResolvers<void> = Promise.withResolvers()
+  fixture.screenshot = async () => { entered.resolve(); await stopped.promise }
+  await ctx.plugin(Provider, { mode: 'launch' })
+  await execute(first, 'navigate', { url: 'https://kept.example/' })
+  const controller = new AbortController()
+  const screenshot = execute(first, 'screenshot', {}, controller.signal)
+  await entered.promise
+  controller.abort({ kind: 'user' })
+  try {
+    expect((await screenshot).isError).toBe(true)
+    expect(fixture.browsers).toHaveLength(1)
+    expect(fixture.browsers[0]?.closed).toBe(false)
+    const tabs = await execute(first, 'tabs', { action: 'list' })
+    expect(tabs.isError).toBe(false)
+    expect(JSON.stringify(tabs.content)).toContain('https://kept.example/')
+    expect(fixture.browsers).toHaveLength(1)
+  } finally {
+    stopped.resolve()
+  }
+})

+ 68 - 8
packages/experimental/browser-use-stagehand-native/tests/worker-client.spec.ts

@@ -5,7 +5,7 @@ import { connect } from 'node:net'
 import type { Worker } from 'node:worker_threads'
 import type { ClientLLM } from '@browserbasehq/stagehand'
 import { afterEach, expect, it, vi } from 'vitest'
-import { openAttachedBrowser } from '../src/worker-client.ts'
+import { openBrowserWorker } from '../src/worker-client.ts'
 import type { NativeBrowserConfig, NativeBrowserRuntime } from '../src/native.ts'
 
 const state = vi.hoisted(() => ({ workers: [] as Worker[], entries: [] as string[] }))
@@ -32,11 +32,13 @@ const generation: Parameters<ClientLLM['generate']>[0] = {
   responseFormat: { type: 'json_schema', name: 'heading', schema: { type: 'object' } },
 }
 const runtimes: NativeBrowserRuntime[] = []
+const warn = vi.fn()
 afterEach(async () => {
   await Promise.allSettled(runtimes.splice(0).map(runtime => runtime.close()))
   await Promise.all(state.workers.splice(0).map(worker => worker.terminate()))
   state.entries = []
   vi.unstubAllEnvs()
+  warn.mockClear()
 })
 
 function config(scenario = 'ready'): NativeBrowserConfig {
@@ -44,7 +46,7 @@ function config(scenario = 'ready'): NativeBrowserConfig {
 }
 
 async function open(scenario = 'ready', generate: ClientLLM['generate'] = async () => result) {
-  const runtime = await openAttachedBrowser(config(scenario), generate, new AbortController().signal)
+  const runtime = await openBrowserWorker(config(scenario), generate, new AbortController().signal, warn)
   runtimes.push(runtime)
   return runtime
 }
@@ -78,7 +80,7 @@ it('preserves initialization failures while terminating the failed Worker', asyn
 
 it('terminates a Worker whose initialization overlaps acquisition cancellation', async () => {
   const controller = new AbortController()
-  const opening = openAttachedBrowser(config('opening'), async () => result, controller.signal)
+  const opening = openBrowserWorker(config('opening'), async () => result, controller.signal, warn)
   const observed = expect(opening).rejects.toThrow('Acquisition canceled')
   await vi.waitFor(() => { expect(state.workers).toHaveLength(1) })
 
@@ -95,12 +97,13 @@ it('fails active and later operations when the Worker exits', async () => {
   await expect(runtime.execute('tabs', { action: 'list' })).rejects.toThrow('Worker exited (23)')
 })
 
-it('terminates stalled SDK cleanup and reports the failed cleanup', async () => {
-  const runtime = await openAttachedBrowser({ ...config('closing'), shutdownGraceMs: 20 }, async () => result, new AbortController().signal)
+it('releases a connection after stalled SDK cleanup and reports a warning', async () => {
+  const runtime = await openBrowserWorker({ ...config('closing'), shutdownGraceMs: 20 }, async () => result, new AbortController().signal, warn)
   runtimes.push(runtime)
   const closing = runtime.close()
-  await expect(runtime.execute('tabs', { action: 'list' })).rejects.toThrow('attachment is closed')
-  await expect(closing).rejects.toThrow('attachment cleanup timed out')
+  await expect(runtime.execute('tabs', { action: 'list' })).rejects.toThrow('Worker is closed')
+  await closing
+  expect(warn).toHaveBeenCalledWith(expect.stringContaining('connection cleanup timed out'))
   expect(state.workers[0]?.threadId).toBe(-1)
 })
 
@@ -108,7 +111,7 @@ it('terminates stalled SDK cleanup and reports the failed cleanup', async () =>
 it.each(['error', 'malformed'] as const)('terminates pending operations after a Worker %s', async (scenario) => {
   const runtime = await open(scenario)
   await expect(runtime.execute('tabs', { action: 'list' })).rejects.toThrow()
-  await expect(runtime.close()).rejects.toThrow()
+  await runtime.close()
   expect(state.workers[0]?.threadId).toBe(-1)
 })
 
@@ -124,3 +127,60 @@ it('preserves the explicit source tsconfig for its Worker', async () => {
   const runtime = await open()
   expect(await runtime.execute('tabs', { action: 'list' })).toHaveProperty('port')
 })
+
+
+it('reports a failed Worker termination while handling malformed peer messages', async () => {
+  const runtime = await open('malformed')
+  const worker = state.workers[0]!
+  const terminate = worker.terminate.bind(worker)
+  const refused = vi.spyOn(worker, 'terminate').mockRejectedValueOnce(new Error('Worker shutdown refused'))
+  try {
+    await expect(runtime.execute('tabs', { action: 'list' })).rejects.toThrow('Worker shutdown refused')
+    await expect(runtime.close()).rejects.toThrow('Worker shutdown refused')
+  } finally {
+    refused.mockRestore()
+    await terminate()
+  }
+})
+
+
+it('closes the Worker connection when an active browser operation is canceled', async () => {
+  const started: PromiseWithResolvers<void> = Promise.withResolvers()
+  const release: PromiseWithResolvers<void> = Promise.withResolvers()
+  const runtime = await open('ready', async () => { started.resolve(); await release.promise; return result })
+  const controller = new AbortController()
+  const operation = runtime.execute('act', generation, controller.signal)
+  const canceled = expect(operation).rejects.toThrow('Cancel browser call')
+  await started.promise
+  controller.abort(new Error('Cancel browser call'))
+  try {
+    await canceled
+    await runtime.close()
+    expect(state.workers[0]?.threadId).toBe(-1)
+  } finally {
+    release.resolve()
+  }
+})
+
+
+it('reports failed termination when cancellation closes an active Worker', async () => {
+  const started: PromiseWithResolvers<void> = Promise.withResolvers()
+  const release: PromiseWithResolvers<void> = Promise.withResolvers()
+  const runtime = await open('ready', async () => { started.resolve(); await release.promise; return result })
+  const worker = state.workers[0]!
+  const terminate = worker.terminate.bind(worker)
+  const refused = vi.spyOn(worker, 'terminate').mockRejectedValueOnce(new Error('Worker shutdown refused'))
+  const controller = new AbortController()
+  const operation = runtime.execute('act', generation, controller.signal)
+  const canceled = expect(operation).rejects.toThrow('Cancel active call')
+  try {
+    await started.promise
+    controller.abort(new Error('Cancel active call'))
+    await canceled
+    await expect(runtime.close()).rejects.toThrow('Worker shutdown refused')
+  } finally {
+    release.resolve()
+    refused.mockRestore()
+    await terminate()
+  }
+})

+ 35 - 0
packages/experimental/browser-use-stagehand-native/tests/worker-protocol.spec.ts

@@ -0,0 +1,35 @@
+/** Invalid host envelopes terminate only the owned Worker with a diagnostic. */
+
+import { once } from 'node:events'
+import { Worker } from 'node:worker_threads'
+import { finished } from 'node:stream/promises'
+import { expect, it } from 'vitest'
+import { request } from '../src/worker-rpc.ts'
+
+it('reports malformed requests before exiting its real Worker', async () => {
+  const hooks = new URL('../../../../snapshots/session/browser-use-stagehand-native/native-fixture.mjs', import.meta.url)
+  const entry = new URL('../src/worker.ts', import.meta.url)
+  const bootstrap = [
+    `import { register } from ${JSON.stringify(import.meta.resolve('tsx/esm/api'))}; register();`,
+    `import { installExternalBrowserHooks } from ${JSON.stringify(hooks.href)}; installExternalBrowserHooks();`,
+    `await import(${JSON.stringify(entry.href)});`,
+  ].join('\n')
+  const worker = new Worker(new URL(`data:text/javascript,${encodeURIComponent(bootstrap)}`), {
+    workerData: { mode: 'attach', cdpEndpoint: 'http://fixture', headless: true, operationTimeoutMs: 30000, shutdownGraceMs: 1000 },
+    env: {}, execArgv: [], stderr: true,
+  })
+  let diagnostics = ''
+  worker.stderr.setEncoding('utf8')
+  worker.stderr.on('data', (chunk: string) => { diagnostics += chunk })
+  try {
+    await request(worker, 'ready')
+    const exited = once(worker, 'exit')
+    worker.postMessage({ method: 42, reply: false })
+    expect(await exited).toEqual([1])
+    await finished(worker.stderr)
+    expect(diagnostics).toContain('Stagehand Worker protocol failed:')
+    expect(worker.threadId).toBe(-1)
+  } finally {
+    await worker.terminate()
+  }
+})

+ 1 - 0
packages/experimental/browser-use-stagehand-native/tsconfig.json

@@ -12,6 +12,7 @@
     { "path": "../../core/system-prompt" },
     { "path": "../../llm/llm" },
     { "path": "../../mcp/mcp-client" },
+    { "path": "../../subprocess/subprocess" },
     { "path": "../../util/values" },
     { "path": "../browser-use-runtime" }
   ]

+ 13 - 8
packages/mcp/mcp-client/src/connection.ts

@@ -184,6 +184,18 @@ export function startConnection(ctx: Context, config: Config, policy: ResolvedRe
     scheduleReconnect()
   }
 
+  /** Decide retry ownership after a failed connection's close barrier settles. */
+  function settleFailedGeneration(generation: Client, quiesced: boolean): void {
+    if (!isCurrent(generation)) return
+    if (!quiesced) {
+      client = undefined
+      closeClient = undefined
+      ctx.logger.error(`${label}: failed generation could not confirm transport closure — reconnect stopped to avoid overlapping server processes; reload the plugin or restart the Host to retry`)
+      return
+    }
+    generationDown(generation)
+  }
+
   /** Wait for the transport-owned close signal without letting a broken transport wedge teardown forever. */
   function waitForClose(closed: Promise<void>): Promise<boolean> {
     return new Promise((resolve) => {
@@ -316,14 +328,7 @@ export function startConnection(ctx: Context, config: Config, policy: ResolvedRe
       if (isCurrent(generation)) ctx.logger.warn(`${label}: connection attempt failed: ${String(error)}`)
       const quiesced = await closeGeneration()
       attemptSettled = true
-      if (!isCurrent(generation)) return
-      if (!quiesced) {
-        client = undefined
-        closeClient = undefined
-        ctx.logger.error(`${label}: failed generation could not confirm transport closure — reconnect stopped to avoid overlapping server processes; reload the plugin or restart the Host to retry`)
-        return
-      }
-      generationDown(generation)
+      settleFailedGeneration(generation, quiesced)
       return
     }
     attemptSettled = true

+ 31 - 0
packages/mcp/mcp-client/tests/reconnect.spec.ts

@@ -313,6 +313,37 @@ describe('reconnect supervisor', () => {
     expect(instances).toHaveLength(1)
   })
 
+  it('stops reconnecting when disposal overlaps failed-generation cleanup', async () => {
+    vi.useFakeTimers()
+    const entered: PromiseWithResolvers<void> = Promise.withResolvers()
+    const closed: PromiseWithResolvers<void> = Promise.withResolvers()
+    const { warns } = captureLogs(ctx)
+    mockConnect.mockRejectedValue(new Error('initialize failed'))
+    mockClose.mockImplementation(async function (this: { onclose?: () => void }) {
+      entered.resolve()
+      await closed.promise
+      this.onclose?.()
+    })
+    const handle = startConnection(ctx, stdioConfig(), resolveReconnectPolicy(undefined, 'reconnect'))
+    try {
+      await entered.promise
+      expect(warns.some(line => line.includes('connection attempt failed'))).toBe(true)
+      const disposing = handle.dispose()
+      closed.resolve()
+      await disposing
+      await handle.ready
+      await vi.runAllTimersAsync()
+      expect(instances).toHaveLength(1)
+      expect(mockListTools).not.toHaveBeenCalled()
+      expect(warns.some(line => line.includes('retrying'))).toBe(false)
+    } finally {
+      closed.resolve()
+      await handle.dispose()
+      await ctx.fiber.dispose()
+      vi.useRealTimers()
+    }
+  })
+
   it.each(['connect', 'discovery'] as const)('bounds disposal during %s when the transport never reports closure', async (phase) => {
     vi.useFakeTimers()
     try {

+ 101 - 0
pnpm-lock.yaml

@@ -5728,12 +5728,18 @@ importers:
       '@deepseek-ai/dsh-mcp-client':
         specifier: workspace:^
         version: link:../../mcp/mcp-client
+      '@deepseek-ai/dsh-subprocess':
+        specifier: workspace:^
+        version: link:../../subprocess/subprocess
       '@deepseek-ai/dsh-util-values':
         specifier: workspace:^
         version: link:../../util/values
       '@deepseek-ai/schemastery':
         specifier: link:../../../vendor/schemastery
         version: link:../../../vendor/schemastery
+      '@puppeteer/browsers':
+        specifier: 3.2.2
+        version: 3.2.2(yauzl@2.10.0)
       zod:
         specifier: ^4.4.3
         version: 4.4.3
@@ -14240,6 +14246,19 @@ packages:
     resolution: {integrity: sha512-HDVTWq3H0uTXiU0eeSQntcVUTPP3GamzeXI41+x7uU9J65JgWQh3qWZHblR1i0npXfFtF+mxBiU2nJH8znxWnQ==}
     engines: {node: '>=18'}
 
+  '@puppeteer/browsers@3.2.2':
+    resolution: {integrity: sha512-q2BU4YfO9h/Wt7IcWPcggpOOqLk2Tbs1hDwolvKZrweRjy751OJBKMN9zO5bfD0pzU7X/tvKw/exQds4pM/LOg==}
+    engines: {node: '>=22.12.0'}
+    hasBin: true
+    peerDependencies:
+      proxy-agent: '>=8.0.1'
+      yauzl: ^2.10.0 || ^3.4.0
+    peerDependenciesMeta:
+      proxy-agent:
+        optional: true
+      yauzl:
+        optional: true
+
   '@quansync/fs@1.0.0':
     resolution: {integrity: sha512-4TJ3DFtlf1L5LDMaM6CanJ/0lckGNtJcMjQ1NAV6zDmA0tEHKZtxNKin8EgPaVX1YzljbxckyT2tJrpQKAtngQ==}
 
@@ -15715,6 +15734,10 @@ packages:
     resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==}
     engines: {node: '>=12'}
 
+  cliui@9.0.1:
+    resolution: {integrity: sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==}
+    engines: {node: '>=20'}
+
   clone-response@1.0.3:
     resolution: {integrity: sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==}
 
@@ -16169,6 +16192,9 @@ packages:
   emoji-regex-xs@1.0.0:
     resolution: {integrity: sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg==}
 
+  emoji-regex@10.6.0:
+    resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==}
+
   emoji-regex@8.0.0:
     resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
 
@@ -16555,6 +16581,10 @@ packages:
     resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==}
     engines: {node: 6.* || 8.* || >= 10.*}
 
+  get-east-asian-width@1.6.0:
+    resolution: {integrity: sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==}
+    engines: {node: '>=18'}
+
   get-intrinsic@1.3.0:
     resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==}
     engines: {node: '>= 0.4'}
@@ -17468,6 +17498,10 @@ packages:
     resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==}
     hasBin: true
 
+  modern-tar@0.8.5:
+    resolution: {integrity: sha512-snEhs+6G5Tjd4I7tLCDOaoln2RgE0bD19RzEKgvgK2hZ5VKy3MpLhLTZ2fWpXSTg4K2cyPwp+VHATFJhxfnOeA==}
+    engines: {node: '>=18.0.0'}
+
   mri@1.2.0:
     resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==}
     engines: {node: '>=4'}
@@ -18320,6 +18354,14 @@ packages:
     resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==}
     engines: {node: '>=12'}
 
+  string-width@7.2.0:
+    resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==}
+    engines: {node: '>=18'}
+
+  string-width@8.2.2:
+    resolution: {integrity: sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==}
+    engines: {node: '>=20'}
+
   string_decoder@1.1.1:
     resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==}
 
@@ -18952,6 +18994,10 @@ packages:
     resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==}
     engines: {node: '>=12'}
 
+  wrap-ansi@9.0.2:
+    resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==}
+    engines: {node: '>=18'}
+
   wrappy@1.0.2:
     resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
 
@@ -19013,6 +19059,10 @@ packages:
     resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}
     engines: {node: '>=12'}
 
+  yargs-parser@22.0.0:
+    resolution: {integrity: sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==}
+    engines: {node: ^20.19.0 || ^22.12.0 || >=23}
+
   yargs@16.2.2:
     resolution: {integrity: sha512-Nt9ZJjXTv5R8MHbqby/wXQ6Gi0Bb3TcYZkR1bzuL4yB2OxWPkXknz513gEF0GoA6tn00UpbPvERW8rzCuWCA6w==}
     engines: {node: '>=10'}
@@ -19021,6 +19071,10 @@ packages:
     resolution: {integrity: sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==}
     engines: {node: '>=12'}
 
+  yargs@18.1.0:
+    resolution: {integrity: sha512-2rAgRKu54VsHkqI0/tYkmluGXHD4KW7yZoycuqDQ15QOTnc2VVfy0nN/1eMhnQLO00A+dwtK20xuCnc1YGeUyg==}
+    engines: {node: ^20.19.0 || ^22.12.0 || >=23}
+
   yauzl@2.10.0:
     resolution: {integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==}
 
@@ -21008,6 +21062,13 @@ snapshots:
 
   '@publint/pack@0.1.4': {}
 
+  '@puppeteer/browsers@3.2.2(yauzl@2.10.0)':
+    dependencies:
+      modern-tar: 0.8.5
+      yargs: 18.1.0
+    optionalDependencies:
+      yauzl: 2.10.0
+
   '@quansync/fs@1.0.0':
     dependencies:
       quansync: 1.0.0
@@ -22484,6 +22545,12 @@ snapshots:
       strip-ansi: 6.0.1
       wrap-ansi: 7.0.0
 
+  cliui@9.0.1:
+    dependencies:
+      string-width: 7.2.0
+      strip-ansi: 7.2.0
+      wrap-ansi: 9.0.2
+
   clone-response@1.0.3:
     dependencies:
       mimic-response: 1.0.1
@@ -22981,6 +23048,8 @@ snapshots:
 
   emoji-regex-xs@1.0.0: {}
 
+  emoji-regex@10.6.0: {}
+
   emoji-regex@8.0.0: {}
 
   emoji-regex@9.2.2: {}
@@ -23489,6 +23558,8 @@ snapshots:
 
   get-caller-file@2.0.5: {}
 
+  get-east-asian-width@1.6.0: {}
+
   get-intrinsic@1.3.0:
     dependencies:
       call-bind-apply-helpers: 1.0.2
@@ -24591,6 +24662,8 @@ snapshots:
     dependencies:
       minimist: 1.2.8
 
+  modern-tar@0.8.5: {}
+
   mri@1.2.0: {}
 
   ms@2.0.0: {}
@@ -25557,6 +25630,17 @@ snapshots:
       emoji-regex: 9.2.2
       strip-ansi: 7.2.0
 
+  string-width@7.2.0:
+    dependencies:
+      emoji-regex: 10.6.0
+      get-east-asian-width: 1.6.0
+      strip-ansi: 7.2.0
+
+  string-width@8.2.2:
+    dependencies:
+      get-east-asian-width: 1.6.0
+      strip-ansi: 7.2.0
+
   string_decoder@1.1.1:
     dependencies:
       safe-buffer: 5.1.2
@@ -26219,6 +26303,12 @@ snapshots:
       string-width: 5.1.2
       strip-ansi: 7.2.0
 
+  wrap-ansi@9.0.2:
+    dependencies:
+      ansi-styles: 6.2.3
+      string-width: 7.2.0
+      strip-ansi: 7.2.0
+
   wrappy@1.0.2: {}
 
   ws@8.21.0: {}
@@ -26250,6 +26340,8 @@ snapshots:
 
   yargs-parser@21.1.1: {}
 
+  yargs-parser@22.0.0: {}
+
   yargs@16.2.2:
     dependencies:
       cliui: 7.0.4
@@ -26270,6 +26362,15 @@ snapshots:
       y18n: 5.0.8
       yargs-parser: 21.1.1
 
+  yargs@18.1.0:
+    dependencies:
+      cliui: 9.0.1
+      escalade: 3.2.0
+      get-caller-file: 2.0.5
+      string-width: 8.2.2
+      y18n: 5.0.8
+      yargs-parser: 22.0.0
+
   yauzl@2.10.0:
     dependencies:
       buffer-crc32: 0.2.13

+ 12 - 3
snapshots/session/browser-use-chrome-devtools-mcp/provider-fixture.mjs

@@ -1,5 +1,6 @@
 /** Replace only the external MCP executable; retain the shipped provider and browser runtime. */
 import { registerHooks } from 'node:module'
+import { readFile } from 'node:fs/promises'
 import { pathToFileURL } from 'node:url'
 import { resolve } from 'node:path'
 
@@ -7,16 +8,24 @@ export const name = 'browser-provider-fixture'
 export const inject = ['browserUse', 'agents', 'tools']
 
 export async function apply(ctx) {
+  let replaced = false
   ctx.effect(() => {
     const hooks = registerHooks({
       resolve(specifier, context, nextResolve) {
-        return specifier === 'chrome-devtools-mcp/build/src/bin/chrome-devtools-mcp.js'
-          ? { url: pathToFileURL(resolve('cli.js')).href, shortCircuit: true }
-          : nextResolve(specifier, context)
+        if (specifier !== 'chrome-devtools-mcp/build/src/bin/chrome-devtools-mcp.js') return nextResolve(specifier, context)
+        replaced = true
+        return { url: pathToFileURL(resolve('cli.js')).href, shortCircuit: true }
       },
     })
     return () => hooks.deregister()
   }, 'browser-fixture.executable')
   const provider = await import('@deepseek-ai/dsh-experimental-browser-use-chrome-devtools-mcp')
   await ctx.plugin(provider, { mode: 'launch' })
+  if (!replaced) throw new Error('Chrome DevTools snapshot did not replace the upstream executable')
+  ctx.on('system-prompt/prepare', async ({ agent }) => {
+    if (agent === undefined) return
+    if (await readFile(resolve('.dsh/browser-fixture.started'), 'utf8') !== 'chrome-devtools-mcp\n') {
+      throw new Error('Chrome DevTools snapshot did not start its fixture process')
+    }
+  })
 }

+ 18 - 5
snapshots/session/browser-use-chrome-devtools-mcp/workspace/cli.js

@@ -1,6 +1,8 @@
 /** External browser fixture; screenshots contain no host or network content. */
-import { readFileSync } from 'node:fs'
+import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
 import { createInterface } from 'node:readline'
+mkdirSync('.dsh', { recursive: true })
+writeFileSync('.dsh/browser-fixture.started', 'chrome-devtools-mcp\n', { flag: 'wx' })
 const catalog = JSON.parse(readFileSync(new URL('./catalog.json', import.meta.url), 'utf8'))
 const png = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAACXBIWXMAAAPoAAAD6AG1e1JrAAAADElEQVQImWNgZGIGAAAOAAeCcsnOAAAAAElFTkSuQmCC'
 const lines = createInterface({ input: process.stdin })
@@ -8,9 +10,20 @@ lines.once('close', () => process.exit(0))
 lines.on('line', line => {
   const request = JSON.parse(line)
   if (request.id === undefined) return
-  const result = request.method === 'initialize'
-    ? { protocolVersion: request.params.protocolVersion, capabilities: { tools: {} }, serverInfo: { name: 'browser-fixture', version: '1' } }
-    : request.method === 'tools/list' ? catalog
-      : { content: [{ type: 'text', text: 'Chromium page' }, { type: 'image', mimeType: 'image/png', data: png }] }
+  let result
+  switch (request.method) {
+    case 'initialize':
+      result = { protocolVersion: request.params.protocolVersion, capabilities: { tools: {} }, serverInfo: { name: 'browser-fixture', version: '1' } }
+      break
+    case 'tools/list':
+      result = catalog
+      break
+    case 'tools/call':
+      if (request.params.name !== 'take_screenshot') throw new Error(`Unexpected Chrome DevTools fixture tool: ${request.params.name}`)
+      result = { content: [{ type: 'text', text: 'Chromium page' }, { type: 'image', mimeType: 'image/png', data: png }] }
+      break
+    default:
+      throw new Error(`Unexpected Chrome DevTools fixture request: ${request.method}`)
+  }
   process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: request.id, result }) + '\n')
 })

+ 12 - 3
snapshots/session/browser-use-playwright-mcp/provider-fixture.mjs

@@ -1,5 +1,6 @@
 /** Replace only the external MCP executable; retain the shipped provider and browser runtime. */
 import { registerHooks } from 'node:module'
+import { readFile } from 'node:fs/promises'
 import { pathToFileURL } from 'node:url'
 import { resolve } from 'node:path'
 
@@ -7,16 +8,24 @@ export const name = 'browser-provider-fixture'
 export const inject = ['browserUse', 'agents', 'tools']
 
 export async function apply(ctx) {
+  let replaced = false
   ctx.effect(() => {
     const hooks = registerHooks({
       resolve(specifier, context, nextResolve) {
-        return specifier === '@playwright/mcp/package.json'
-          ? { url: pathToFileURL(resolve('package.json')).href, shortCircuit: true }
-          : nextResolve(specifier, context)
+        if (specifier !== '@playwright/mcp/package.json') return nextResolve(specifier, context)
+        replaced = true
+        return { url: pathToFileURL(resolve('package.json')).href, shortCircuit: true }
       },
     })
     return () => hooks.deregister()
   }, 'browser-fixture.executable')
   const provider = await import('@deepseek-ai/dsh-experimental-browser-use-playwright-mcp')
   await ctx.plugin(provider, { mode: 'launch' })
+  if (!replaced) throw new Error('Playwright snapshot did not replace the upstream executable')
+  ctx.on('system-prompt/prepare', async ({ agent }) => {
+    if (agent === undefined) return
+    if (await readFile(resolve('.dsh/browser-fixture.started'), 'utf8') !== 'playwright-mcp\n') {
+      throw new Error('Playwright snapshot did not start its fixture process')
+    }
+  })
 }

+ 18 - 5
snapshots/session/browser-use-playwright-mcp/workspace/cli.js

@@ -1,6 +1,8 @@
 /** External browser fixture; screenshots contain no host or network content. */
-import { readFileSync } from 'node:fs'
+import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
 import { createInterface } from 'node:readline'
+mkdirSync('.dsh', { recursive: true })
+writeFileSync('.dsh/browser-fixture.started', 'playwright-mcp\n', { flag: 'wx' })
 const catalog = JSON.parse(readFileSync(new URL('./catalog.json', import.meta.url), 'utf8'))
 const png = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAACXBIWXMAAAPoAAAD6AG1e1JrAAAADElEQVQImWNgZGIGAAAOAAeCcsnOAAAAAElFTkSuQmCC'
 const lines = createInterface({ input: process.stdin })
@@ -8,9 +10,20 @@ lines.once('close', () => process.exit(0))
 lines.on('line', line => {
   const request = JSON.parse(line)
   if (request.id === undefined) return
-  const result = request.method === 'initialize'
-    ? { protocolVersion: request.params.protocolVersion, capabilities: { tools: {} }, serverInfo: { name: 'browser-fixture', version: '1' } }
-    : request.method === 'tools/list' ? catalog
-      : { content: [{ type: 'text', text: 'Chromium page' }, { type: 'image', mimeType: 'image/png', data: png }] }
+  let result
+  switch (request.method) {
+    case 'initialize':
+      result = { protocolVersion: request.params.protocolVersion, capabilities: { tools: {} }, serverInfo: { name: 'browser-fixture', version: '1' } }
+      break
+    case 'tools/list':
+      result = catalog
+      break
+    case 'tools/call':
+      if (request.params.name !== 'browser_take_screenshot') throw new Error(`Unexpected Playwright fixture tool: ${request.params.name}`)
+      result = { content: [{ type: 'text', text: 'Chromium page' }, { type: 'image', mimeType: 'image/png', data: png }] }
+      break
+    default:
+      throw new Error(`Unexpected Playwright fixture request: ${request.method}`)
+  }
   process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: request.id, result }) + '\n')
 })

+ 66 - 24
snapshots/session/browser-use-stagehand-native/native-fixture.mjs

@@ -1,34 +1,76 @@
-/** Replace only external Stagehand; the profile runs the real DSH provider and model adapter. */
-import { registerHooks } from 'node:module'
+/** External browser fixtures around the real provider, Worker, RPC, and model adapter. */
+import { registerHooks, syncBuiltinESMExports } from 'node:module'
 import { readFileSync } from 'node:fs'
+import workerThreads from 'node:worker_threads'
 import ts from 'typescript'
 
 export const name = 'browser-use-stagehand-native-fixture'
 export const inject = ['browserUse', 'agents', 'sessions', 'llm', 'tools', 'systemPrompt']
 
+const packageRoot = new URL('../../../packages/experimental/browser-use-stagehand-native/', import.meta.url)
+const sdkFixture = new URL('tests/fixtures/stagehand.ts', packageRoot).href
+const chromiumFixture = new URL('tests/fixtures/chromium.ts', packageRoot).href
+
+/** Install external browser mocks in one real Node isolate. */
+export function installExternalBrowserHooks() {
+  const hooks = registerHooks({
+    resolve(specifier, context, nextResolve) {
+      if (specifier === '@puppeteer/browsers') return { url: chromiumFixture, shortCircuit: true }
+      if (specifier !== '@browserbasehq/stagehand') return nextResolve(specifier, context)
+      const actual = nextResolve(specifier, context).url
+      const proxy = `export * from ${JSON.stringify(sdkFixture)}; export { ClientLLMSchema } from ${JSON.stringify(actual)};`
+      return { url: `data:text/javascript,${encodeURIComponent(proxy)}`, shortCircuit: true }
+    },
+    // Built SDK snapshots use plain Node; only the external fixtures need transpilation.
+    load(url, context, nextLoad) {
+      if (url !== sdkFixture && url !== chromiumFixture) return nextLoad(url, context)
+      return {
+        format: 'module',
+        source: ts.transpileModule(readFileSync(new URL(url), 'utf8'), {
+          compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 },
+        }).outputText,
+        shortCircuit: true,
+      }
+    },
+  })
+  return () => hooks.deregister()
+}
+
+/**
+ * Prepend external SDK fixtures only to the production Stagehand Worker entry.
+ * @param {string | URL} filename - Original Worker entry from the real provider.
+ * @returns {string | URL} The original entry or a fixture-prefixed bootstrap.
+ */
+export function wrapStagehandWorkerEntry(filename) {
+  const sourceWorker = new URL('src/worker.ts', packageRoot).href
+  const builtWorker = new URL('lib/worker.js', packageRoot).href
+  const stagehand = filename instanceof URL && (filename.href === builtWorker
+    || filename.protocol === 'data:' && filename.href.includes(encodeURIComponent(sourceWorker)))
+  if (!stagehand) return filename
+  const bootstrap = `import { installExternalBrowserHooks } from ${JSON.stringify(import.meta.url)}; installExternalBrowserHooks(); await import(${JSON.stringify(filename.href)});`
+  return new URL(`data:text/javascript,${encodeURIComponent(bootstrap)}`)
+}
+
+/** Keep the real Stagehand Worker while installing its external SDK fixture before entry. */
+export function installBrowserFixtureHooks() {
+  const removeHooks = installExternalBrowserHooks()
+  const NativeWorker = workerThreads.Worker
+  class BrowserFixtureWorker extends NativeWorker {
+    constructor(filename, options) {
+      super(wrapStagehandWorkerEntry(filename), options)
+    }
+  }
+  workerThreads.Worker = BrowserFixtureWorker
+  syncBuiltinESMExports()
+  return () => {
+    workerThreads.Worker = NativeWorker
+    syncBuiltinESMExports()
+    removeHooks()
+  }
+}
+
 export async function apply(ctx) {
-  const fixture = new URL('../../../packages/experimental/browser-use-stagehand-native/tests/fixtures/stagehand.ts', import.meta.url).href
-  ctx.effect(() => {
-    const hooks = registerHooks({
-      resolve(specifier, context, nextResolve) {
-        return specifier === '@browserbasehq/stagehand'
-          ? { url: fixture, shortCircuit: true }
-          : nextResolve(specifier, context)
-      },
-      // SDK snapshots run built DSH under plain Node; only this external fixture needs transpilation.
-      load(url, context, nextLoad) {
-        if (url !== fixture) return nextLoad(url, context)
-        return {
-          format: 'module',
-          source: ts.transpileModule(readFileSync(new URL(url), 'utf8'), {
-            compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 },
-          }).outputText,
-          shortCircuit: true,
-        }
-      },
-    })
-    return () => hooks.deregister()
-  }, 'browser-use-stagehand-native-fixture.module')
+  ctx.effect(installBrowserFixtureHooks, 'browser-use-stagehand-native-fixture.module')
   const provider = await import('@deepseek-ai/dsh-experimental-browser-use-stagehand-native')
   await ctx.plugin(provider, { mode: 'launch' })
 }

+ 2 - 1
snapshots/session/browser-use-stagehand-native/tool-schemas.expected.json

@@ -566,7 +566,8 @@
             ],
             "additionalProperties": false
           }
-        ]
+        ],
+        "type": "object"
       }
     },
     {