From eb1cc57937932dfd09b6c1ebea71e26bf28db230 Mon Sep 17 00:00:00 2001 From: Shrey Pandya Date: Thu, 6 Aug 2026 11:46:59 -0700 Subject: [PATCH 1/9] feat(crewai): add Stagehand code-mode MCP example --- .../workflows/codemode-framework-examples.yml | 28 ++++++ packages/integrations/README.md | 1 + .../integrations/examples/crewai/README.md | 57 ++++++++++++ .../integrations/examples/crewai/agent.py | 64 ++++++++++++++ .../examples/crewai/requirements.txt | 2 + .../integrations/examples/crewai/smoke.py | 86 +++++++++++++++++++ 6 files changed, 238 insertions(+) create mode 100644 packages/integrations/examples/crewai/README.md create mode 100644 packages/integrations/examples/crewai/agent.py create mode 100644 packages/integrations/examples/crewai/requirements.txt create mode 100644 packages/integrations/examples/crewai/smoke.py diff --git a/.github/workflows/codemode-framework-examples.yml b/.github/workflows/codemode-framework-examples.yml index 6af411392f..6648c62646 100644 --- a/.github/workflows/codemode-framework-examples.yml +++ b/.github/workflows/codemode-framework-examples.yml @@ -72,3 +72,31 @@ jobs: env: CHROME_PATH: ${{ steps.setup-chrome.outputs.chrome-path }} STAGEHAND_BROWSER: local + + crewai: + name: CrewAI + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + + - uses: ./.github/actions/setup-node-pnpm + with: + use-prebuilt-artifacts: "false" + + - uses: ./.github/actions/setup-chrome-verified + id: setup-chrome + + - name: Set up Python + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + with: + python-version: "3.12" + cache: pip + cache-dependency-path: packages/integrations/examples/crewai/requirements.txt + + - run: pnpm exec turbo run build --filter @browserbasehq/stagehand-integrations + - run: python -m pip install -r packages/integrations/examples/crewai/requirements.txt + - run: python packages/integrations/examples/crewai/smoke.py + env: + CHROME_PATH: ${{ steps.setup-chrome.outputs.chrome-path }} + STAGEHAND_BROWSER: local diff --git a/packages/integrations/README.md b/packages/integrations/README.md index 8447be54e5..63292c383c 100644 --- a/packages/integrations/README.md +++ b/packages/integrations/README.md @@ -48,6 +48,7 @@ The process stays alive across calls and closes when its input stream ends. `SIG - [Vercel AI SDK](./examples/vercel) launches the stdio server through the AI SDK MCP client and keeps one process alive for the complete agent run. - [Mastra](./examples/mastra) discovers the canonical MCP toolset once and reuses one client and browser for the complete agent run. +- [CrewAI](./examples/crewai) keeps its context-managed MCP adapter open across every tool call in one crew execution. ### Configuration diff --git a/packages/integrations/examples/crewai/README.md b/packages/integrations/examples/crewai/README.md new file mode 100644 index 0000000000..3e537c8f76 --- /dev/null +++ b/packages/integrations/examples/crewai/README.md @@ -0,0 +1,57 @@ +# CrewAI + Stagehand code mode + +This example gives a CrewAI agent the canonical Stagehand `code_execute` tool over stdio. It starts +one `packages/integrations/dist/codemode/stdio-server.mjs` child for the complete agent run, so later +tool calls reuse the browser state created by earlier calls. Leaving the adapter context closes the +MCP connection and its child process. + +The server inherits the current environment. Select the browser without changing the example: + +```bash +STAGEHAND_BROWSER=local +STAGEHAND_BROWSER=browserbase +``` + +Browserbase mode also requires `BROWSERBASE_API_KEY`; `BROWSERBASE_PROJECT_ID` is optional. If the +agent calls Stagehand AI methods, configure a supported model provider key or set +`STAGEHAND_MODEL_NAME` and `STAGEHAND_MODEL_API_KEY` explicitly. + +## Setup + +From the Stagehand repository root, build the exact MCP under review and install the Python example +in an isolated environment: + +```bash +pnpm turbo run build --filter @browserbasehq/stagehand-integrations +python3.12 -m venv .venv +. .venv/bin/activate +python -m pip install -r packages/integrations/examples/crewai/requirements.txt +``` + +Run the deterministic smoke. It discovers exactly `code_execute`, invokes that CrewAI tool twice +against a real local browser, and verifies that the second call sees state left by the first: + +```bash +python packages/integrations/examples/crewai/smoke.py +``` + +To run a model-driven agent, provide the outer CrewAI model credential and call the helper from the +example directory while the Stagehand server configuration remains in the environment: + +```bash +cd packages/integrations/examples/crewai +``` + +```python +from agent import run_stagehand_agent + +result = run_stagehand_agent( + "Open https://example.com and return its title and URL.", + llm="openai/gpt-5-mini", +) +print(result) +``` + +`run_stagehand_agent` keeps CrewAI's context-managed `MCPServerAdapter` open through `kickoff`. +This is important for stateful browser work: each `code_execute` call must reach the same stdio MCP +process rather than launching a new browser. diff --git a/packages/integrations/examples/crewai/agent.py b/packages/integrations/examples/crewai/agent.py new file mode 100644 index 0000000000..1603752db1 --- /dev/null +++ b/packages/integrations/examples/crewai/agent.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +import os +from collections.abc import Iterator, Sequence +from contextlib import contextmanager +from pathlib import Path +from typing import Any + +from crewai import Agent +from crewai.tools import BaseTool +from crewai_tools import MCPServerAdapter +from mcp import StdioServerParameters + +REPOSITORY_ROOT = Path(__file__).resolve().parents[4] +STDIO_SERVER_PATH = ( + REPOSITORY_ROOT / "packages/integrations/dist/codemode/stdio-server.mjs" +) +SKILL_PATH = REPOSITORY_ROOT / "packages/integrations/codemode/SKILL.md" +STAGEHAND_CODEMODE_SKILL = SKILL_PATH.read_text().strip() + + +@contextmanager +def stagehand_code_tools() -> Iterator[list[BaseTool]]: + """Keep one Stagehand MCP process connected for a complete CrewAI run.""" + if not STDIO_SERVER_PATH.is_file(): + raise FileNotFoundError( + "Build @browserbasehq/stagehand-integrations before starting CrewAI: " + "pnpm turbo run build --filter @browserbasehq/stagehand-integrations" + ) + + parameters = StdioServerParameters( + command="node", + args=[str(STDIO_SERVER_PATH)], + cwd=REPOSITORY_ROOT, + env=dict(os.environ), + ) + with MCPServerAdapter(parameters) as discovered_tools: + tools = list(discovered_tools) + names = [tool.name for tool in tools] + if names != ["code_execute"]: + raise RuntimeError( + f"Expected only code_execute from Stagehand MCP, got {names!r}." + ) + yield tools + + +def build_stagehand_agent( + tools: Sequence[BaseTool], + llm: str | Any = "openai/gpt-5-mini", +) -> Agent: + return Agent( + role="Stagehand browser agent", + goal="Complete browser tasks by writing compact, correct Stagehand V4 JavaScript.", + backstory=STAGEHAND_CODEMODE_SKILL, + llm=llm, + tools=list(tools), + max_iter=8, + verbose=False, + ) + + +def run_stagehand_agent(prompt: str, llm: str | Any = "openai/gpt-5-mini") -> str: + with stagehand_code_tools() as tools: + return str(build_stagehand_agent(tools, llm).kickoff(prompt)) diff --git a/packages/integrations/examples/crewai/requirements.txt b/packages/integrations/examples/crewai/requirements.txt new file mode 100644 index 0000000000..e7bc384645 --- /dev/null +++ b/packages/integrations/examples/crewai/requirements.txt @@ -0,0 +1,2 @@ +crewai>=1.15.9,<2 +crewai-tools[mcp]>=1.15.9,<2 diff --git a/packages/integrations/examples/crewai/smoke.py b/packages/integrations/examples/crewai/smoke.py new file mode 100644 index 0000000000..b20967d848 --- /dev/null +++ b/packages/integrations/examples/crewai/smoke.py @@ -0,0 +1,86 @@ +from __future__ import annotations + +import json +import os +from typing import Any + +from agent import ( + STAGEHAND_CODEMODE_SKILL, + build_stagehand_agent, + stagehand_code_tools, +) + + +def successful_result(raw_result: Any) -> dict[str, Any]: + result = json.loads(str(raw_result)) + assert result["ok"] is True, result + return result + + +def main() -> None: + os.environ.setdefault("STAGEHAND_BROWSER", "local") + os.environ.setdefault("OPENAI_API_KEY", "smoke-only-placeholder") + + with stagehand_code_tools() as tools: + assert [tool.name for tool in tools] == ["code_execute"] + assert "Stagehand V4 code-mode syntax" in tools[0].description + assert "stagehand.extract" in STAGEHAND_CODEMODE_SKILL + + agent = build_stagehand_agent(tools, llm="openai/gpt-4o-mini") + code_execute = agent.tools[0] + + first = successful_result( + code_execute.run( + code=""" +await page.goto("https://example.com", { waitUntil: "domcontentloaded" }); +await page.evaluate(() => { + document.documentElement.dataset.crewaiStagehandSession = "persisted"; +}); +return { + pageId: page.pageId, + title: await page.title(), + marker: await page.evaluate( + () => document.documentElement.dataset.crewaiStagehandSession, + ), +}; +""" + ) + ) + second = successful_result( + code_execute.run( + code=""" +return { + pageId: page.pageId, + title: await page.title(), + marker: await page.evaluate( + () => document.documentElement.dataset.crewaiStagehandSession, + ), +}; +""" + ) + ) + + first_value = first["value"] + second_value = second["value"] + assert first_value["title"] == "Example Domain" + assert second_value["title"] == "Example Domain" + assert second_value["marker"] == "persisted" + assert second_value["pageId"] == first_value["pageId"] + + print( + "CrewAI Stagehand MCP persistence PASS:", + json.dumps( + { + "browser": os.environ["STAGEHAND_BROWSER"], + "tool": "code_execute", + "pageId": second_value["pageId"], + "title": second_value["title"], + "marker": second_value["marker"], + }, + sort_keys=True, + ), + ) + + +if __name__ == "__main__": + main() From 1353b8387d21fec13b7c83cb1043d5361265f229 Mon Sep 17 00:00:00 2001 From: Shrey Pandya Date: Fri, 7 Aug 2026 17:04:41 -0700 Subject: [PATCH 2/9] docs: sandbox the CrewAI code-mode MCP --- .../workflows/codemode-framework-examples.yml | 5 + .../integrations/examples/crewai/README.md | 91 +++++--- .../integrations/examples/crewai/agent.py | 79 +++++-- .../examples/crewai/requirements.txt | 1 + .../integrations/examples/crewai/smoke.py | 48 +++- .../examples/shared/modal_stdio_bridge.py | 218 ++++++++++++++++++ 6 files changed, 386 insertions(+), 56 deletions(-) create mode 100644 packages/integrations/examples/shared/modal_stdio_bridge.py diff --git a/.github/workflows/codemode-framework-examples.yml b/.github/workflows/codemode-framework-examples.yml index 6648c62646..bc89217f1e 100644 --- a/.github/workflows/codemode-framework-examples.yml +++ b/.github/workflows/codemode-framework-examples.yml @@ -96,6 +96,11 @@ jobs: - run: pnpm exec turbo run build --filter @browserbasehq/stagehand-integrations - run: python -m pip install -r packages/integrations/examples/crewai/requirements.txt + - run: >- + python -m py_compile + packages/integrations/examples/shared/modal_stdio_bridge.py + packages/integrations/examples/crewai/agent.py + packages/integrations/examples/crewai/smoke.py - run: python packages/integrations/examples/crewai/smoke.py env: CHROME_PATH: ${{ steps.setup-chrome.outputs.chrome-path }} diff --git a/packages/integrations/examples/crewai/README.md b/packages/integrations/examples/crewai/README.md index 3e537c8f76..17a7f84fc3 100644 --- a/packages/integrations/examples/crewai/README.md +++ b/packages/integrations/examples/crewai/README.md @@ -1,57 +1,90 @@ -# CrewAI + Stagehand code mode +# CrewAI + sandboxed Stagehand code mode -This example gives a CrewAI agent the canonical Stagehand `code_execute` tool over stdio. It starts -one `packages/integrations/dist/codemode/stdio-server.mjs` child for the complete agent run, so later -tool calls reuse the browser state created by earlier calls. Leaving the adapter context closes the -MCP connection and its child process. +Use Stagehand code mode as one CrewAI tool without running generated JavaScript on the agent host. +CrewAI still connects to a local stdio child, but that child is a small trusted bridge. The bridge +starts `stdio-server.mjs` as the primary process in a [Modal Sandbox](https://modal.com/docs/guide/sandbox) +and forwards MCP bytes unchanged: -The server inherits the current environment. Select the browser without changing the example: - -```bash -STAGEHAND_BROWSER=local -STAGEHAND_BROWSER=browserbase +```text +CrewAI -> local stdio -> trusted bridge -> Modal Sandbox -> Stagehand MCP + `-> generated JavaScript ``` -Browserbase mode also requires `BROWSERBASE_API_KEY`; `BROWSERBASE_PROJECT_ID` is optional. If the -agent calls Stagehand AI methods, configure a supported model provider key or set -`STAGEHAND_MODEL_NAME` and `STAGEHAND_MODEL_API_KEY` explicitly. +The agent's model credential remains in the host process. The bridge gives the sandbox only +`STAGEHAND_BROWSER=browserbase`, `BROWSERBASE_API_KEY`, optional `BROWSERBASE_PROJECT_ID`, and the +optional Stagehand-specific `STAGEHAND_MODEL_NAME` and `STAGEHAND_MODEL_API_KEY` pair. Set that pair +only when generated code needs AI-backed Stagehand methods such as `act` or `extract`. + +> The proposed `ghcr.io/browserbase/stagehand-codemode` image is not published yet. Until it is, +> maintainers can set `STAGEHAND_CODEMODE_MODAL_IMAGE_ID` to an image built from the same Stagehand +> commit. Once published, pin `STAGEHAND_CODEMODE_IMAGE` to a version or digest instead of a mutable +> `latest` tag. ## Setup -From the Stagehand repository root, build the exact MCP under review and install the Python example -in an isolated environment: +Create a Python 3.12 environment and install the example: ```bash -pnpm turbo run build --filter @browserbasehq/stagehand-integrations python3.12 -m venv .venv . .venv/bin/activate python -m pip install -r packages/integrations/examples/crewai/requirements.txt ``` -Run the deterministic smoke. It discovers exactly `code_execute`, invokes that CrewAI tool twice -against a real local browser, and verifies that the second call sees state left by the first: +Configure Modal, Browserbase, and an immutable code-mode image. Configure the model provider key +required by `DEFAULT_STAGEHAND_LLM` (`openai/gpt-5-mini`) separately in the host environment: ```bash -python packages/integrations/examples/crewai/smoke.py +export MODAL_TOKEN_ID="..." +export MODAL_TOKEN_SECRET="..." +export BROWSERBASE_API_KEY="..." +export STAGEHAND_CODEMODE_IMAGE="ghcr.io/browserbase/stagehand-codemode:" +export OPENAI_API_KEY="..." ``` -To run a model-driven agent, provide the outer CrewAI model credential and call the helper from the -example directory while the Stagehand server configuration remains in the environment: +`BROWSERBASE_PROJECT_ID` is optional. Modal can also use its normal local profile instead of token +environment variables. -```bash -cd packages/integrations/examples/crewai -``` +## Run an agent + +Run from `packages/integrations/examples/crewai`: ```python from agent import run_stagehand_agent result = run_stagehand_agent( - "Open https://example.com and return its title and URL.", - llm="openai/gpt-5-mini", + "Open https://example.com and return its title and URL." ) print(result) ``` -`run_stagehand_agent` keeps CrewAI's context-managed `MCPServerAdapter` open through `kickoff`. -This is important for stateful browser work: each `code_execute` call must reach the same stdio MCP -process rather than launching a new browser. +`run_stagehand_agent` keeps one context-managed `MCPServerAdapter` open for the complete `kickoff`. +That lifecycle matters: every `code_execute` call reaches the same MCP process, sandbox, and browser. +Leaving the context sends EOF to the MCP, gives Stagehand a chance to close the browser, and then +terminates the sandbox if it is still running. + +## Sandbox policy + +The bridge defaults to a 10-minute hard timeout, a 5-minute idle timeout, and outbound access only +to `*.browserbase.com`. Adjust them only when the task requires it: + +```bash +export STAGEHAND_CODEMODE_TIMEOUT_SECONDS=900 +export STAGEHAND_CODEMODE_IDLE_TIMEOUT_SECONDS=300 +export STAGEHAND_CODEMODE_OUTBOUND_DOMAINS="*.browserbase.com,api.example.com" +``` + +Each extra domain is reachable by arbitrary generated JavaScript, so keep the list task-specific. +The Browserbase credential is intentionally present inside the sandbox and should be scoped and +rotated accordingly. The hard timeout is the final cleanup backstop if generated synchronous code +cannot be interrupted cooperatively. + +## Local CI smoke + +The deterministic smoke starts the MCP directly on the CI runner with a local browser. It is useful +for secret-free protocol and persistence coverage, but it is not the recommended boundary for +production or untrusted prompts: + +```bash +pnpm turbo run build --filter @browserbasehq/stagehand-integrations +python packages/integrations/examples/crewai/smoke.py +``` diff --git a/packages/integrations/examples/crewai/agent.py b/packages/integrations/examples/crewai/agent.py index 1603752db1..d8f9ef32cf 100644 --- a/packages/integrations/examples/crewai/agent.py +++ b/packages/integrations/examples/crewai/agent.py @@ -1,57 +1,98 @@ from __future__ import annotations import os +import sys from collections.abc import Iterator, Sequence from contextlib import contextmanager +from functools import lru_cache from pathlib import Path from typing import Any -from crewai import Agent from crewai.tools import BaseTool from crewai_tools import MCPServerAdapter + +from crewai import Agent from mcp import StdioServerParameters REPOSITORY_ROOT = Path(__file__).resolve().parents[4] -STDIO_SERVER_PATH = ( - REPOSITORY_ROOT / "packages/integrations/dist/codemode/stdio-server.mjs" +MODAL_STDIO_BRIDGE_PATH = ( + REPOSITORY_ROOT / "packages/integrations/examples/shared/modal_stdio_bridge.py" ) SKILL_PATH = REPOSITORY_ROOT / "packages/integrations/codemode/SKILL.md" -STAGEHAND_CODEMODE_SKILL = SKILL_PATH.read_text().strip() +DEFAULT_STAGEHAND_LLM = "openai/gpt-5-mini" + +BRIDGE_ENV_KEYS = ( + "PATH", + "HOME", + "TMPDIR", + "LANG", + "LC_ALL", + "SSL_CERT_FILE", + "REQUESTS_CA_BUNDLE", + "MODAL_TOKEN_ID", + "MODAL_TOKEN_SECRET", + "MODAL_PROFILE", + "BROWSERBASE_API_KEY", + "BROWSERBASE_PROJECT_ID", + "STAGEHAND_MODEL_NAME", + "STAGEHAND_MODEL_API_KEY", + "STAGEHAND_CODEMODE_IMAGE", + "STAGEHAND_CODEMODE_MODAL_IMAGE_ID", + "STAGEHAND_CODEMODE_MODAL_APP", + "STAGEHAND_CODEMODE_ENTRYPOINT", + "STAGEHAND_CODEMODE_TIMEOUT_SECONDS", + "STAGEHAND_CODEMODE_IDLE_TIMEOUT_SECONDS", + "STAGEHAND_CODEMODE_OUTBOUND_DOMAINS", +) + + +@lru_cache(maxsize=1) +def load_stagehand_codemode_skill() -> str: + return SKILL_PATH.read_text(encoding="utf-8").strip() + + +def modal_bridge_env(overrides: dict[str, str] | None = None) -> dict[str, str]: + """Build the trusted proxy environment without forwarding agent model keys.""" + child_env = { + key: value for key in BRIDGE_ENV_KEYS if (value := os.environ.get(key)) is not None + } + if overrides: + child_env.update(overrides) + return child_env @contextmanager -def stagehand_code_tools() -> Iterator[list[BaseTool]]: - """Keep one Stagehand MCP process connected for a complete CrewAI run.""" - if not STDIO_SERVER_PATH.is_file(): +def stagehand_code_tools( + env: dict[str, str] | None = None, +) -> Iterator[list[BaseTool]]: + """Keep one sandboxed Stagehand MCP connected for a complete CrewAI run.""" + if not MODAL_STDIO_BRIDGE_PATH.is_file(): raise FileNotFoundError( - "Build @browserbasehq/stagehand-integrations before starting CrewAI: " - "pnpm turbo run build --filter @browserbasehq/stagehand-integrations" + f"Stagehand Modal stdio bridge not found: {MODAL_STDIO_BRIDGE_PATH}" ) parameters = StdioServerParameters( - command="node", - args=[str(STDIO_SERVER_PATH)], + command=sys.executable, + args=[str(MODAL_STDIO_BRIDGE_PATH)], cwd=REPOSITORY_ROOT, - env=dict(os.environ), + env=modal_bridge_env(env), ) - with MCPServerAdapter(parameters) as discovered_tools: + with MCPServerAdapter(parameters, connect_timeout=600) as discovered_tools: tools = list(discovered_tools) names = [tool.name for tool in tools] if names != ["code_execute"]: - raise RuntimeError( - f"Expected only code_execute from Stagehand MCP, got {names!r}." - ) + raise RuntimeError(f"Expected only code_execute from Stagehand MCP, got {names!r}.") yield tools def build_stagehand_agent( tools: Sequence[BaseTool], - llm: str | Any = "openai/gpt-5-mini", + llm: str | Any = DEFAULT_STAGEHAND_LLM, ) -> Agent: return Agent( role="Stagehand browser agent", goal="Complete browser tasks by writing compact, correct Stagehand V4 JavaScript.", - backstory=STAGEHAND_CODEMODE_SKILL, + backstory=load_stagehand_codemode_skill(), llm=llm, tools=list(tools), max_iter=8, @@ -59,6 +100,6 @@ def build_stagehand_agent( ) -def run_stagehand_agent(prompt: str, llm: str | Any = "openai/gpt-5-mini") -> str: +def run_stagehand_agent(prompt: str, llm: str | Any = DEFAULT_STAGEHAND_LLM) -> str: with stagehand_code_tools() as tools: return str(build_stagehand_agent(tools, llm).kickoff(prompt)) diff --git a/packages/integrations/examples/crewai/requirements.txt b/packages/integrations/examples/crewai/requirements.txt index e7bc384645..9a13265591 100644 --- a/packages/integrations/examples/crewai/requirements.txt +++ b/packages/integrations/examples/crewai/requirements.txt @@ -1,2 +1,3 @@ crewai>=1.15.9,<2 crewai-tools[mcp]>=1.15.9,<2 +modal>=1.5.3,<2 diff --git a/packages/integrations/examples/crewai/smoke.py b/packages/integrations/examples/crewai/smoke.py index b20967d848..a3b6cae8ef 100644 --- a/packages/integrations/examples/crewai/smoke.py +++ b/packages/integrations/examples/crewai/smoke.py @@ -2,13 +2,46 @@ import json import os +from collections.abc import Iterator +from contextlib import contextmanager +from pathlib import Path from typing import Any +from crewai.tools import BaseTool +from crewai_tools import MCPServerAdapter + from agent import ( - STAGEHAND_CODEMODE_SKILL, + REPOSITORY_ROOT, build_stagehand_agent, - stagehand_code_tools, + load_stagehand_codemode_skill, ) +from mcp import StdioServerParameters + +LOCAL_STDIO_SERVER_PATH = REPOSITORY_ROOT / "packages/integrations/dist/codemode/stdio-server.mjs" + + +@contextmanager +def local_stagehand_code_tools_for_ci() -> Iterator[list[BaseTool]]: + """Launch the MCP directly only for the credential-free local CI smoke.""" + if not LOCAL_STDIO_SERVER_PATH.is_file(): + raise FileNotFoundError( + "Build @browserbasehq/stagehand-integrations before running the smoke" + ) + child_env = { + "PATH": os.environ.get("PATH", ""), + "STAGEHAND_BROWSER": "local", + } + for name in ("HOME", "TMPDIR", "CHROME_PATH"): + if value := os.environ.get(name): + child_env[name] = value + parameters = StdioServerParameters( + command="node", + args=[str(LOCAL_STDIO_SERVER_PATH)], + cwd=Path(REPOSITORY_ROOT), + env=child_env, + ) + with MCPServerAdapter(parameters) as discovered_tools: + yield list(discovered_tools) def successful_result(raw_result: Any) -> dict[str, Any]: @@ -18,15 +51,14 @@ def successful_result(raw_result: Any) -> dict[str, Any]: def main() -> None: - os.environ.setdefault("STAGEHAND_BROWSER", "local") os.environ.setdefault("OPENAI_API_KEY", "smoke-only-placeholder") - with stagehand_code_tools() as tools: + with local_stagehand_code_tools_for_ci() as tools: assert [tool.name for tool in tools] == ["code_execute"] assert "Stagehand V4 code-mode syntax" in tools[0].description - assert "stagehand.extract" in STAGEHAND_CODEMODE_SKILL + assert "stagehand.extract" in load_stagehand_codemode_skill() - agent = build_stagehand_agent(tools, llm="openai/gpt-4o-mini") + agent = build_stagehand_agent(tools) code_execute = agent.tools[0] first = successful_result( @@ -68,10 +100,10 @@ def main() -> None: assert second_value["pageId"] == first_value["pageId"] print( - "CrewAI Stagehand MCP persistence PASS:", + "CrewAI local CI-only Stagehand MCP persistence PASS:", json.dumps( { - "browser": os.environ["STAGEHAND_BROWSER"], + "browser": "local", "tool": "code_execute", "pageId": second_value["pageId"], "title": second_value["title"], diff --git a/packages/integrations/examples/shared/modal_stdio_bridge.py b/packages/integrations/examples/shared/modal_stdio_bridge.py new file mode 100644 index 0000000000..4c03ba063a --- /dev/null +++ b/packages/integrations/examples/shared/modal_stdio_bridge.py @@ -0,0 +1,218 @@ +#!/usr/bin/env python3 +"""Forward local stdio to a Stagehand code-mode MCP in a Modal Sandbox. + +This process is trusted host-side glue. The MCP server and every generated +JavaScript body run inside the sandbox; this process only copies stdio and owns +the sandbox lifecycle. +""" + +from __future__ import annotations + +import atexit +import os +import signal +import sys +import threading +import time +from collections.abc import Iterator + +import modal + +DEFAULT_ENTRYPOINT = "/opt/stagehand-codemode/dist/codemode/stdio-server.mjs" +DEFAULT_OUTBOUND_DOMAINS = "*.browserbase.com" +DEFAULT_TIMEOUT_SECONDS = 10 * 60 +DEFAULT_IDLE_TIMEOUT_SECONDS = 5 * 60 + + +def _required_env(name: str) -> str: + value = os.environ.get(name, "").strip() + if not value: + raise RuntimeError(f"{name} is required") + return value + + +def _integer_env(name: str, default: int, minimum: int, maximum: int) -> int: + raw = os.environ.get(name) + try: + value = default if raw is None else int(raw) + except ValueError as error: + raise RuntimeError(f"{name} must be an integer") from error + if not minimum <= value <= maximum: + raise RuntimeError(f"{name} must be between {minimum} and {maximum}") + return value + + +def _resolve_image() -> modal.Image: + # Maintainers can point at an already-built Modal image while the proposed + # public GHCR image is being published. End users should set a versioned or + # digest-pinned STAGEHAND_CODEMODE_IMAGE reference. + modal_image_id = os.environ.get("STAGEHAND_CODEMODE_MODAL_IMAGE_ID", "").strip() + if modal_image_id: + return modal.Image.from_id(modal_image_id) + return modal.Image.from_registry(_required_env("STAGEHAND_CODEMODE_IMAGE")) + + +def _browserbase_secret() -> modal.Secret: + # This is the complete guest credential allowlist. Outer-agent provider + # credentials are never injected into the guest. Stagehand-specific model + # settings are opt-in for Stagehand AI methods such as act and extract. + values = { + "STAGEHAND_BROWSER": "browserbase", + "BROWSERBASE_API_KEY": _required_env("BROWSERBASE_API_KEY"), + } + project_id = os.environ.get("BROWSERBASE_PROJECT_ID", "").strip() + if project_id: + values["BROWSERBASE_PROJECT_ID"] = project_id + for name in ("STAGEHAND_MODEL_NAME", "STAGEHAND_MODEL_API_KEY"): + value = os.environ.get(name, "").strip() + if value: + values[name] = value + return modal.Secret.from_dict(values) + + +def _outbound_domains() -> list[str]: + raw = os.environ.get("STAGEHAND_CODEMODE_OUTBOUND_DOMAINS", DEFAULT_OUTBOUND_DOMAINS) + domains = [domain.strip() for domain in raw.split(",") if domain.strip()] + if not domains: + raise RuntimeError( + "STAGEHAND_CODEMODE_OUTBOUND_DOMAINS must allow required Browserbase hosts" + ) + return domains + + +class SandboxProcess: + def __init__(self) -> None: + self.sandbox: modal.Sandbox | None = None + self.process: modal.Sandbox | None = None + self._shutdown_requested = False + self._closed = False + + def start(self) -> None: + app = modal.App.lookup( + os.environ.get("STAGEHAND_CODEMODE_MODAL_APP", "stagehand-codemode"), + create_if_missing=True, + ) + timeout = _integer_env( + "STAGEHAND_CODEMODE_TIMEOUT_SECONDS", + DEFAULT_TIMEOUT_SECONDS, + 30, + 24 * 60 * 60, + ) + idle_timeout = _integer_env( + "STAGEHAND_CODEMODE_IDLE_TIMEOUT_SECONDS", + DEFAULT_IDLE_TIMEOUT_SECONDS, + 30, + timeout, + ) + entrypoint = os.environ.get("STAGEHAND_CODEMODE_ENTRYPOINT", DEFAULT_ENTRYPOINT) + self.sandbox = modal.Sandbox.create( + "node", + entrypoint, + app=app, + image=_resolve_image(), + secrets=[_browserbase_secret()], + timeout=timeout, + idle_timeout=idle_timeout, + outbound_domain_allowlist=_outbound_domains(), + ) + # The MCP is the primary process. EOF therefore lets its own shutdown + # handler close Stagehand and the Browserbase browser before the sandbox + # finishes. + self.process = self.sandbox + print("Stagehand code-mode MCP started in a Modal Sandbox", file=sys.stderr) + + def request_shutdown(self) -> None: + if self._shutdown_requested: + return + self._shutdown_requested = True + if self.process is not None: + try: + self.process.stdin.write_eof() + self.process.stdin.drain() + except Exception: + pass + + def close(self) -> None: + if self._closed: + return + self._closed = True + self.request_shutdown() + if self.sandbox is not None: + try: + deadline = time.monotonic() + 5 + while self.sandbox.poll() is None and time.monotonic() < deadline: + time.sleep(0.1) + if self.sandbox.poll() is None: + self.sandbox.terminate() + except Exception: + pass + try: + self.sandbox.detach() + except Exception: + pass + + +def _stdin_chunks() -> Iterator[bytes]: + while True: + chunk = os.read(sys.stdin.fileno(), 64 * 1024) + if not chunk: + return + yield chunk + + +def _forward_stdin(owner: SandboxProcess) -> None: + assert owner.process is not None + try: + for chunk in _stdin_chunks(): + owner.process.stdin.write(chunk) + owner.process.stdin.drain() + finally: + owner.request_shutdown() + + +def _forward_stdout(owner: SandboxProcess) -> None: + assert owner.process is not None + for line in owner.process.stdout: + data = line.encode() if isinstance(line, str) else line + sys.stdout.buffer.write(data) + sys.stdout.buffer.flush() + + +def _forward_stderr(owner: SandboxProcess) -> None: + assert owner.process is not None + for line in owner.process.stderr: + data = line.encode() if isinstance(line, str) else line + sys.stderr.buffer.write(b"sandbox: " + data) + sys.stderr.buffer.flush() + + +def main() -> int: + owner = SandboxProcess() + atexit.register(owner.close) + + def stop(_signum: int, _frame: object) -> None: + owner.request_shutdown() + + signal.signal(signal.SIGTERM, stop) + signal.signal(signal.SIGINT, stop) + + try: + owner.start() + threads = [ + threading.Thread(target=_forward_stdin, args=(owner,), daemon=True), + threading.Thread(target=_forward_stdout, args=(owner,), daemon=True), + threading.Thread(target=_forward_stderr, args=(owner,), daemon=True), + ] + for thread in threads: + thread.start() + assert owner.process is not None + owner.process.wait() + for thread in threads[1:]: + thread.join(timeout=5) + return owner.process.returncode or 0 + finally: + owner.close() + + +if __name__ == "__main__": + raise SystemExit(main()) From 1143cd5976294684a9fcd7086d30b8cd5039123e Mon Sep 17 00:00:00 2001 From: Shrey Pandya Date: Fri, 7 Aug 2026 17:12:59 -0700 Subject: [PATCH 3/9] fix: preserve CI Chrome launch settings --- packages/integrations/examples/crewai/README.md | 2 +- packages/integrations/examples/crewai/smoke.py | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/integrations/examples/crewai/README.md b/packages/integrations/examples/crewai/README.md index 17a7f84fc3..91357f8ac1 100644 --- a/packages/integrations/examples/crewai/README.md +++ b/packages/integrations/examples/crewai/README.md @@ -2,7 +2,7 @@ Use Stagehand code mode as one CrewAI tool without running generated JavaScript on the agent host. CrewAI still connects to a local stdio child, but that child is a small trusted bridge. The bridge -starts `stdio-server.mjs` as the primary process in a [Modal Sandbox](https://modal.com/docs/guide/sandbox) +starts `stdio-server.mjs` as the primary process in a [Modal Sandbox](https://modal.com/docs/guide/sandboxes) and forwards MCP bytes unchanged: ```text diff --git a/packages/integrations/examples/crewai/smoke.py b/packages/integrations/examples/crewai/smoke.py index a3b6cae8ef..2a8a932857 100644 --- a/packages/integrations/examples/crewai/smoke.py +++ b/packages/integrations/examples/crewai/smoke.py @@ -31,7 +31,8 @@ def local_stagehand_code_tools_for_ci() -> Iterator[list[BaseTool]]: "PATH": os.environ.get("PATH", ""), "STAGEHAND_BROWSER": "local", } - for name in ("HOME", "TMPDIR", "CHROME_PATH"): + # Stagehand's local launcher uses CI to add Chrome's --no-sandbox flag. + for name in ("HOME", "TMPDIR", "CHROME_PATH", "CI"): if value := os.environ.get(name): child_env[name] = value parameters = StdioServerParameters( From 40f8b450373ef0e038120fbf797751e6f46f95f8 Mon Sep 17 00:00:00 2001 From: Shrey Pandya Date: Fri, 7 Aug 2026 17:28:32 -0700 Subject: [PATCH 4/9] fix: harden sandbox bridge lifecycle --- .../workflows/codemode-framework-examples.yml | 2 + .../integrations/examples/crewai/agent.py | 2 +- .../examples/shared/modal_stdio_bridge.py | 37 +++--- .../shared/test_modal_stdio_bridge.py | 124 ++++++++++++++++++ 4 files changed, 146 insertions(+), 19 deletions(-) create mode 100644 packages/integrations/examples/shared/test_modal_stdio_bridge.py diff --git a/.github/workflows/codemode-framework-examples.yml b/.github/workflows/codemode-framework-examples.yml index bc89217f1e..9cd0697af6 100644 --- a/.github/workflows/codemode-framework-examples.yml +++ b/.github/workflows/codemode-framework-examples.yml @@ -99,8 +99,10 @@ jobs: - run: >- python -m py_compile packages/integrations/examples/shared/modal_stdio_bridge.py + packages/integrations/examples/shared/test_modal_stdio_bridge.py packages/integrations/examples/crewai/agent.py packages/integrations/examples/crewai/smoke.py + - run: python packages/integrations/examples/shared/test_modal_stdio_bridge.py - run: python packages/integrations/examples/crewai/smoke.py env: CHROME_PATH: ${{ steps.setup-chrome.outputs.chrome-path }} diff --git a/packages/integrations/examples/crewai/agent.py b/packages/integrations/examples/crewai/agent.py index d8f9ef32cf..d52bb963a3 100644 --- a/packages/integrations/examples/crewai/agent.py +++ b/packages/integrations/examples/crewai/agent.py @@ -57,7 +57,7 @@ def modal_bridge_env(overrides: dict[str, str] | None = None) -> dict[str, str]: key: value for key in BRIDGE_ENV_KEYS if (value := os.environ.get(key)) is not None } if overrides: - child_env.update(overrides) + child_env.update({key: value for key, value in overrides.items() if key in BRIDGE_ENV_KEYS}) return child_env diff --git a/packages/integrations/examples/shared/modal_stdio_bridge.py b/packages/integrations/examples/shared/modal_stdio_bridge.py index 4c03ba063a..98d3e7db29 100644 --- a/packages/integrations/examples/shared/modal_stdio_bridge.py +++ b/packages/integrations/examples/shared/modal_stdio_bridge.py @@ -83,7 +83,6 @@ def _outbound_domains() -> list[str]: class SandboxProcess: def __init__(self) -> None: self.sandbox: modal.Sandbox | None = None - self.process: modal.Sandbox | None = None self._shutdown_requested = False self._closed = False @@ -115,20 +114,19 @@ def start(self) -> None: idle_timeout=idle_timeout, outbound_domain_allowlist=_outbound_domains(), ) - # The MCP is the primary process. EOF therefore lets its own shutdown - # handler close Stagehand and the Browserbase browser before the sandbox - # finishes. - self.process = self.sandbox + # The MCP is the sandbox's primary process. EOF therefore lets its own + # shutdown handler close Stagehand and the Browserbase browser before + # the sandbox finishes. print("Stagehand code-mode MCP started in a Modal Sandbox", file=sys.stderr) def request_shutdown(self) -> None: if self._shutdown_requested: return self._shutdown_requested = True - if self.process is not None: + if self.sandbox is not None: try: - self.process.stdin.write_eof() - self.process.stdin.drain() + self.sandbox.stdin.write_eof() + self.sandbox.stdin.drain() except Exception: pass @@ -161,26 +159,26 @@ def _stdin_chunks() -> Iterator[bytes]: def _forward_stdin(owner: SandboxProcess) -> None: - assert owner.process is not None + assert owner.sandbox is not None try: for chunk in _stdin_chunks(): - owner.process.stdin.write(chunk) - owner.process.stdin.drain() + owner.sandbox.stdin.write(chunk) + owner.sandbox.stdin.drain() finally: owner.request_shutdown() def _forward_stdout(owner: SandboxProcess) -> None: - assert owner.process is not None - for line in owner.process.stdout: + assert owner.sandbox is not None + for line in owner.sandbox.stdout: data = line.encode() if isinstance(line, str) else line sys.stdout.buffer.write(data) sys.stdout.buffer.flush() def _forward_stderr(owner: SandboxProcess) -> None: - assert owner.process is not None - for line in owner.process.stderr: + assert owner.sandbox is not None + for line in owner.sandbox.stderr: data = line.encode() if isinstance(line, str) else line sys.stderr.buffer.write(b"sandbox: " + data) sys.stderr.buffer.flush() @@ -188,9 +186,12 @@ def _forward_stderr(owner: SandboxProcess) -> None: def main() -> int: owner = SandboxProcess() + signal_exit_code: int | None = None atexit.register(owner.close) def stop(_signum: int, _frame: object) -> None: + nonlocal signal_exit_code + signal_exit_code = 128 + _signum owner.request_shutdown() signal.signal(signal.SIGTERM, stop) @@ -205,11 +206,11 @@ def stop(_signum: int, _frame: object) -> None: ] for thread in threads: thread.start() - assert owner.process is not None - owner.process.wait() + assert owner.sandbox is not None + owner.sandbox.wait() for thread in threads[1:]: thread.join(timeout=5) - return owner.process.returncode or 0 + return signal_exit_code or owner.sandbox.returncode or 0 finally: owner.close() diff --git a/packages/integrations/examples/shared/test_modal_stdio_bridge.py b/packages/integrations/examples/shared/test_modal_stdio_bridge.py new file mode 100644 index 0000000000..58844e88d8 --- /dev/null +++ b/packages/integrations/examples/shared/test_modal_stdio_bridge.py @@ -0,0 +1,124 @@ +from __future__ import annotations + +import io +import signal +import threading +import types +import unittest +from collections.abc import Callable, Iterator +from unittest.mock import patch + +import modal_stdio_bridge as bridge + + +class FakeInput: + def __init__(self) -> None: + self.data = bytearray() + self.eof_calls = 0 + self.eof = threading.Event() + + def write(self, data: bytes) -> None: + self.data.extend(data) + + def write_eof(self) -> None: + self.eof_calls += 1 + self.eof.set() + + def drain(self) -> None: + pass + + +class FakeSandbox: + def __init__( + self, + returncode: int, + on_wait: Callable[[], None] | None = None, + ) -> None: + self.stdin = FakeInput() + self.stdout = ["response\n"] + self.stderr = ["warning\n"] + self.returncode: int | None = None + self._final_returncode = returncode + self._on_wait = on_wait + self.terminated = False + self.detached = False + + def wait(self) -> None: + if not self.stdin.eof.wait(timeout=2): + raise TimeoutError("stdin EOF was not forwarded") + if self._on_wait: + self._on_wait() + self.returncode = self._final_returncode + + def poll(self) -> int | None: + return self.returncode + + def terminate(self) -> None: + self.terminated = True + self.returncode = 143 + + def detach(self) -> None: + self.detached = True + + +class ModalStdioBridgeTest(unittest.TestCase): + def run_bridge( + self, + fake: FakeSandbox, + stdin_chunks: Iterator[bytes], + signal_handlers: dict[int, Callable[[int, object], None]] | None = None, + ) -> tuple[int, bytes, bytes]: + stdout = types.SimpleNamespace(buffer=io.BytesIO()) + stderr = types.SimpleNamespace(buffer=io.BytesIO()) + + def start(owner: bridge.SandboxProcess) -> None: + owner.sandbox = fake # type: ignore[assignment] + + def register_signal( + signum: int, + handler: Callable[[int, object], None], + ) -> None: + if signal_handlers is not None: + signal_handlers[signum] = handler + + with ( + patch.object(bridge.SandboxProcess, "start", start), + patch.object(bridge, "_stdin_chunks", return_value=stdin_chunks), + patch.object(bridge.atexit, "register"), + patch.object(bridge.signal, "signal", side_effect=register_signal), + patch.object(bridge.sys, "stdout", stdout), + patch.object(bridge.sys, "stderr", stderr), + ): + exit_code = bridge.main() + + return exit_code, stdout.buffer.getvalue(), stderr.buffer.getvalue() + + def test_forwards_stdio_eof_and_process_returncode(self) -> None: + fake = FakeSandbox(returncode=7) + + exit_code, stdout, stderr = self.run_bridge(fake, iter((b"request\n",))) + + self.assertEqual(exit_code, 7) + self.assertEqual(fake.stdin.data, b"request\n") + self.assertEqual(fake.stdin.eof_calls, 1) + self.assertEqual(stdout, b"response\n") + self.assertEqual(stderr, b"sandbox: warning\n") + self.assertFalse(fake.terminated) + self.assertTrue(fake.detached) + + def test_preserves_signal_exit_code(self) -> None: + handlers: dict[int, Callable[[int, object], None]] = {} + fake = FakeSandbox( + returncode=0, + on_wait=lambda: handlers[signal.SIGTERM](signal.SIGTERM, object()), + ) + + exit_code, _, _ = self.run_bridge(fake, iter(()), handlers) + + self.assertEqual(exit_code, 143) + self.assertEqual(fake.stdin.eof_calls, 1) + self.assertFalse(fake.terminated) + + +if __name__ == "__main__": + unittest.main() From 0d7cfd7537c89a51af6f69df28535bc29eaa1d70 Mon Sep 17 00:00:00 2001 From: Shrey Pandya Date: Sat, 8 Aug 2026 03:41:21 +0000 Subject: [PATCH 5/9] refactor(crewai): use sandboxed code-mode MCP --- .../workflows/codemode-framework-examples.yml | 31 ++- .../integrations/examples/crewai/README.md | 109 ++++----- .../integrations/examples/crewai/agent.py | 111 ++++----- packages/integrations/examples/crewai/e2e.py | 135 +++++++++++ .../examples/crewai/requirements.txt | 5 +- .../integrations/examples/crewai/sandbox.py | 21 ++ .../integrations/examples/crewai/smoke.py | 119 ---------- .../examples/shared/modal_stdio_bridge.py | 219 ------------------ .../shared/test_modal_stdio_bridge.py | 124 ---------- .../shared/test_vercel_sandbox_lease.py | 90 +++++++ .../examples/shared/vercel_sandbox_lease.py | 210 +++++++++++++++++ 11 files changed, 567 insertions(+), 607 deletions(-) create mode 100644 packages/integrations/examples/crewai/e2e.py create mode 100644 packages/integrations/examples/crewai/sandbox.py delete mode 100644 packages/integrations/examples/crewai/smoke.py delete mode 100644 packages/integrations/examples/shared/modal_stdio_bridge.py delete mode 100644 packages/integrations/examples/shared/test_modal_stdio_bridge.py create mode 100644 packages/integrations/examples/shared/test_vercel_sandbox_lease.py create mode 100644 packages/integrations/examples/shared/vercel_sandbox_lease.py diff --git a/.github/workflows/codemode-framework-examples.yml b/.github/workflows/codemode-framework-examples.yml index cd8af783a7..195183ab82 100644 --- a/.github/workflows/codemode-framework-examples.yml +++ b/.github/workflows/codemode-framework-examples.yml @@ -109,6 +109,10 @@ jobs: crewai: name: CrewAI + if: >- + github.event_name == 'push' || + github.event.pull_request.head.repo.full_name == github.repository || + contains(github.event.pull_request.labels.*.name, 'safe-to-test') runs-on: ubuntu-latest timeout-minutes: 20 steps: @@ -118,9 +122,6 @@ jobs: with: use-prebuilt-artifacts: "false" - - uses: ./.github/actions/setup-chrome-verified - id: setup-chrome - - name: Set up Python uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: @@ -128,16 +129,24 @@ jobs: cache: pip cache-dependency-path: packages/integrations/examples/crewai/requirements.txt - - run: pnpm exec turbo run build --filter @browserbasehq/stagehand-integrations - run: python -m pip install -r packages/integrations/examples/crewai/requirements.txt - run: >- python -m py_compile - packages/integrations/examples/shared/modal_stdio_bridge.py - packages/integrations/examples/shared/test_modal_stdio_bridge.py + packages/integrations/examples/shared/vercel_sandbox_lease.py + packages/integrations/examples/shared/test_vercel_sandbox_lease.py packages/integrations/examples/crewai/agent.py - packages/integrations/examples/crewai/smoke.py - - run: python packages/integrations/examples/shared/test_modal_stdio_bridge.py - - run: python packages/integrations/examples/crewai/smoke.py + packages/integrations/examples/crewai/sandbox.py + packages/integrations/examples/crewai/e2e.py + - run: python packages/integrations/examples/shared/test_vercel_sandbox_lease.py + - run: pnpm exec turbo run build --filter @browserbasehq/stagehand-codemode + - run: pnpm --filter @browserbasehq/stagehand-integrations-example-vercel-sandbox pack:artifacts + - run: python packages/integrations/examples/crewai/e2e.py env: - CHROME_PATH: ${{ steps.setup-chrome.outputs.chrome-path }} - STAGEHAND_BROWSER: local + STAGEHAND_SANDBOX_ARTIFACTS: ${{ github.workspace }}/packages/integrations/examples/vercel-sandbox/.artifacts + BROWSERBASE_API_KEY: ${{ secrets.BROWSERBASE_API_KEY }} + BROWSERBASE_PROJECT_ID: ${{ secrets.BROWSERBASE_PROJECT_ID }} + VERCEL_OIDC_TOKEN: ${{ secrets.VERCEL_OIDC_TOKEN }} + VERCEL_TEAM_ID: ${{ secrets.VERCEL_TEAM_ID }} + VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} + VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} diff --git a/packages/integrations/examples/crewai/README.md b/packages/integrations/examples/crewai/README.md index 91357f8ac1..61646f213a 100644 --- a/packages/integrations/examples/crewai/README.md +++ b/packages/integrations/examples/crewai/README.md @@ -1,28 +1,21 @@ -# CrewAI + sandboxed Stagehand code mode +# CrewAI with Stagehand code mode -Use Stagehand code mode as one CrewAI tool without running generated JavaScript on the agent host. -CrewAI still connects to a local stdio child, but that child is a small trusted bridge. The bridge -starts `stdio-server.mjs` as the primary process in a [Modal Sandbox](https://modal.com/docs/guide/sandboxes) -and forwards MCP bytes unchanged: +This example gives a CrewAI agent one browser tool, `code_execute`, without running generated +JavaScript in the Python agent process. A small trusted Python lease owner starts the +package-installed [Vercel Sandbox example](../vercel-sandbox) and receives its `{ url, token }` +connection. CrewAI then connects directly through authenticated Streamable HTTP: ```text -CrewAI -> local stdio -> trusted bridge -> Modal Sandbox -> Stagehand MCP - `-> generated JavaScript +CrewAI MCPServerAdapter -> authenticated HTTPS -> Vercel Sandbox -> Stagehand code-mode MCP + `-> generated JavaScript ``` -The agent's model credential remains in the host process. The bridge gives the sandbox only -`STAGEHAND_BROWSER=browserbase`, `BROWSERBASE_API_KEY`, optional `BROWSERBASE_PROJECT_ID`, and the -optional Stagehand-specific `STAGEHAND_MODEL_NAME` and `STAGEHAND_MODEL_API_KEY` pair. Set that pair -only when generated code needs AI-backed Stagehand methods such as `act` or `extract`. - -> The proposed `ghcr.io/browserbase/stagehand-codemode` image is not published yet. Until it is, -> maintainers can set `STAGEHAND_CODEMODE_MODAL_IMAGE_ID` to an image built from the same Stagehand -> commit. Once published, pin `STAGEHAND_CODEMODE_IMAGE` to a version or digest instead of a mutable -> `latest` tag. +The adapter discovers the canonical tool description and uses it as the agent's backstory. It does +not copy the executor, schema, or code-mode skill. ## Setup -Create a Python 3.12 environment and install the example: +Create a Python 3.12 environment and install the pinned CrewAI example dependencies: ```bash python3.12 -m venv .venv @@ -30,61 +23,51 @@ python3.12 -m venv .venv python -m pip install -r packages/integrations/examples/crewai/requirements.txt ``` -Configure Modal, Browserbase, and an immutable code-mode image. Configure the model provider key -required by `DEFAULT_STAGEHAND_LLM` (`openai/gpt-5-mini`) separately in the host environment: +Build and pack the exact Stagehand packages under review: ```bash -export MODAL_TOKEN_ID="..." -export MODAL_TOKEN_SECRET="..." -export BROWSERBASE_API_KEY="..." -export STAGEHAND_CODEMODE_IMAGE="ghcr.io/browserbase/stagehand-codemode:" -export OPENAI_API_KEY="..." -``` - -`BROWSERBASE_PROJECT_ID` is optional. Modal can also use its normal local profile instead of token -environment variables. - -## Run an agent - -Run from `packages/integrations/examples/crewai`: - -```python -from agent import run_stagehand_agent - -result = run_stagehand_agent( - "Open https://example.com and return its title and URL." -) -print(result) +pnpm install +pnpm exec turbo run build --filter @browserbasehq/stagehand-codemode +pnpm --filter @browserbasehq/stagehand-integrations-example-vercel-sandbox pack:artifacts ``` -`run_stagehand_agent` keeps one context-managed `MCPServerAdapter` open for the complete `kickoff`. -That lifecycle matters: every `code_execute` call reaches the same MCP process, sandbox, and browser. -Leaving the context sends EOF to the MCP, gives Stagehand a chance to close the browser, and then -terminates the sandbox if it is still running. - -## Sandbox policy - -The bridge defaults to a 10-minute hard timeout, a 5-minute idle timeout, and outbound access only -to `*.browserbase.com`. Adjust them only when the task requires it: +## Run the end-to-end proof ```bash -export STAGEHAND_CODEMODE_TIMEOUT_SECONDS=900 -export STAGEHAND_CODEMODE_IDLE_TIMEOUT_SECONDS=300 -export STAGEHAND_CODEMODE_OUTBOUND_DOMAINS="*.browserbase.com,api.example.com" +STAGEHAND_SANDBOX_ARTIFACTS="$PWD/packages/integrations/examples/vercel-sandbox/.artifacts" \ +BROWSERBASE_API_KEY= \ +BROWSERBASE_PROJECT_ID= \ +VERCEL_OIDC_TOKEN= \ +OPENAI_API_KEY= \ +python packages/integrations/examples/crewai/e2e.py ``` -Each extra domain is reachable by arbitrary generated JavaScript, so keep the list task-specific. -The Browserbase credential is intentionally present inside the sandbox and should be scoped and -rotated accordingly. The hard timeout is the final cleanup backstop if generated synchronous code -cannot be interrupted cooperatively. +For external CI, replace `VERCEL_OIDC_TOKEN` with `VERCEL_TEAM_ID`, `VERCEL_PROJECT_ID`, and +`VERCEL_TOKEN`. Set `CREWAI_MODEL` in your own wrapper if you want to pass a model other than the +example's `openai/gpt-5-mini` default. -## Local CI smoke +The proof uses one live package-installed sandbox and one context-managed CrewAI MCP adapter. It: -The deterministic smoke starts the MCP directly on the CI runner with a local browser. It is useful -for secret-free protocol and persistence coverage, but it is not the recommended boundary for -production or untrusted prompts: +1. invokes `code_execute` twice directly and requires the same page ID and DOM marker; +2. records CrewAI's tool-usage event from a real model-selected `code_execute` call; +3. invokes the tool again to independently verify the model's browser-side change; +4. proves the model key and a host-only marker are absent inside generated code; and +5. closes the CrewAI MCP adapter before ending the sandbox lease, emitting `PASS` only afterward. -```bash -pnpm turbo run build --filter @browserbasehq/stagehand-integrations -python packages/integrations/examples/crewai/smoke.py +## Use the agent + +```python +from agent import run_stagehand_agent +from sandbox import StagehandSandboxLease + +with StagehandSandboxLease() as connection: + result = run_stagehand_agent( + connection, + "Open https://example.com and return its title and URL.", + ) + print(result) ``` + +The lease subprocess receives only Browserbase, Vercel, artifact, and runtime variables from its +allowlist. Outer model-provider credentials are intentionally excluded, and the sandbox foundation +brokers the Browserbase credential at its egress boundary. diff --git a/packages/integrations/examples/crewai/agent.py b/packages/integrations/examples/crewai/agent.py index d52bb963a3..8f0f34702c 100644 --- a/packages/integrations/examples/crewai/agent.py +++ b/packages/integrations/examples/crewai/agent.py @@ -1,98 +1,69 @@ from __future__ import annotations import os -import sys +from builtins import BaseExceptionGroup from collections.abc import Iterator, Sequence from contextlib import contextmanager -from functools import lru_cache -from pathlib import Path -from typing import Any +from typing import Any, Protocol +from crewai import Agent from crewai.tools import BaseTool from crewai_tools import MCPServerAdapter -from crewai import Agent -from mcp import StdioServerParameters - -REPOSITORY_ROOT = Path(__file__).resolve().parents[4] -MODAL_STDIO_BRIDGE_PATH = ( - REPOSITORY_ROOT / "packages/integrations/examples/shared/modal_stdio_bridge.py" -) -SKILL_PATH = REPOSITORY_ROOT / "packages/integrations/codemode/SKILL.md" -DEFAULT_STAGEHAND_LLM = "openai/gpt-5-mini" - -BRIDGE_ENV_KEYS = ( - "PATH", - "HOME", - "TMPDIR", - "LANG", - "LC_ALL", - "SSL_CERT_FILE", - "REQUESTS_CA_BUNDLE", - "MODAL_TOKEN_ID", - "MODAL_TOKEN_SECRET", - "MODAL_PROFILE", - "BROWSERBASE_API_KEY", - "BROWSERBASE_PROJECT_ID", - "STAGEHAND_MODEL_NAME", - "STAGEHAND_MODEL_API_KEY", - "STAGEHAND_CODEMODE_IMAGE", - "STAGEHAND_CODEMODE_MODAL_IMAGE_ID", - "STAGEHAND_CODEMODE_MODAL_APP", - "STAGEHAND_CODEMODE_ENTRYPOINT", - "STAGEHAND_CODEMODE_TIMEOUT_SECONDS", - "STAGEHAND_CODEMODE_IDLE_TIMEOUT_SECONDS", - "STAGEHAND_CODEMODE_OUTBOUND_DOMAINS", -) +DEFAULT_STAGEHAND_LLM = os.environ.get("CREWAI_MODEL", "openai/gpt-5-mini") -@lru_cache(maxsize=1) -def load_stagehand_codemode_skill() -> str: - return SKILL_PATH.read_text(encoding="utf-8").strip() - - -def modal_bridge_env(overrides: dict[str, str] | None = None) -> dict[str, str]: - """Build the trusted proxy environment without forwarding agent model keys.""" - child_env = { - key: value for key in BRIDGE_ENV_KEYS if (value := os.environ.get(key)) is not None - } - if overrides: - child_env.update({key: value for key, value in overrides.items() if key in BRIDGE_ENV_KEYS}) - return child_env +class StagehandSandboxConnection(Protocol): + url: str + token: str @contextmanager def stagehand_code_tools( - env: dict[str, str] | None = None, + connection: StagehandSandboxConnection, ) -> Iterator[list[BaseTool]]: - """Keep one sandboxed Stagehand MCP connected for a complete CrewAI run.""" - if not MODAL_STDIO_BRIDGE_PATH.is_file(): - raise FileNotFoundError( - f"Stagehand Modal stdio bridge not found: {MODAL_STDIO_BRIDGE_PATH}" - ) - - parameters = StdioServerParameters( - command=sys.executable, - args=[str(MODAL_STDIO_BRIDGE_PATH)], - cwd=REPOSITORY_ROOT, - env=modal_bridge_env(env), + """Keep one authenticated remote MCP client open for a complete CrewAI run.""" + adapter = MCPServerAdapter( + { + "url": connection.url, + "transport": "streamable-http", + "headers": {"Authorization": f"Bearer {connection.token}"}, + }, + connect_timeout=60, ) - with MCPServerAdapter(parameters, connect_timeout=600) as discovered_tools: - tools = list(discovered_tools) + try: + tools = list(adapter.tools) names = [tool.name for tool in tools] if names != ["code_execute"]: - raise RuntimeError(f"Expected only code_execute from Stagehand MCP, got {names!r}.") + raise RuntimeError( + f"Expected only code_execute from Stagehand MCP, got {names!r}." + ) + if "# Stagehand V4 code-mode syntax" not in tools[0].description: + raise RuntimeError("code_execute did not include the canonical guidance") yield tools + except BaseException as primary_error: + try: + adapter.stop() + except BaseException as cleanup_error: + raise BaseExceptionGroup( + "CrewAI run and MCP cleanup both failed", + [primary_error, cleanup_error], + ) + raise + else: + adapter.stop() def build_stagehand_agent( tools: Sequence[BaseTool], llm: str | Any = DEFAULT_STAGEHAND_LLM, ) -> Agent: + if len(tools) != 1 or tools[0].name != "code_execute": + raise ValueError("CrewAI Stagehand agent requires exactly code_execute") return Agent( role="Stagehand browser agent", goal="Complete browser tasks by writing compact, correct Stagehand V4 JavaScript.", - backstory=load_stagehand_codemode_skill(), + backstory=tools[0].description, llm=llm, tools=list(tools), max_iter=8, @@ -100,6 +71,10 @@ def build_stagehand_agent( ) -def run_stagehand_agent(prompt: str, llm: str | Any = DEFAULT_STAGEHAND_LLM) -> str: - with stagehand_code_tools() as tools: +def run_stagehand_agent( + connection: StagehandSandboxConnection, + prompt: str, + llm: str | Any = DEFAULT_STAGEHAND_LLM, +) -> str: + with stagehand_code_tools(connection) as tools: return str(build_stagehand_agent(tools, llm).kickoff(prompt)) diff --git a/packages/integrations/examples/crewai/e2e.py b/packages/integrations/examples/crewai/e2e.py new file mode 100644 index 0000000000..28c6f05f84 --- /dev/null +++ b/packages/integrations/examples/crewai/e2e.py @@ -0,0 +1,135 @@ +from __future__ import annotations + +import json +import os +from typing import Any +from uuid import uuid4 + +from crewai.events import ToolUsageFinishedEvent, crewai_event_bus + +from agent import build_stagehand_agent, stagehand_code_tools +from sandbox import StagehandSandboxLease + + +def successful_result(raw_result: Any) -> dict[str, Any]: + result = json.loads(str(raw_result)) + assert result["ok"] is True, result + value = result.get("value") + assert isinstance(value, dict), result + return value + + +def main() -> None: + direct_marker = f"crewai-direct-{uuid4()}" + model_marker = f"crewai-model-{uuid4()}" + os.environ["CREWAI_HOST_ONLY_MARKER"] = f"host-{uuid4()}" + model_tool_calls: list[str] = [] + final_state: dict[str, Any] + + with StagehandSandboxLease() as connection: + with stagehand_code_tools(connection) as tools: + code_execute = tools[0] + first = successful_result( + code_execute.run( + code=f""" +await page.goto("https://example.com", {{ waitUntil: "domcontentloaded" }}); +await page.evaluate((marker) => {{ + document.documentElement.dataset.crewaiDirectMarker = marker; +}}, {json.dumps(direct_marker)}); +return {{ + pageId: page.pageId, + title: await page.title(), + directMarker: await page.evaluate( + () => document.documentElement.dataset.crewaiDirectMarker, + ), + modelKeyVisible: process.env.OPENAI_API_KEY ?? null, + hostMarkerVisible: process.env.CREWAI_HOST_ONLY_MARKER ?? null, +}}; +""" + ) + ) + assert first["title"] == "Example Domain" + assert first["directMarker"] == direct_marker + assert first["modelKeyVisible"] is None + assert first["hostMarkerVisible"] is None + + second = successful_result( + code_execute.run( + code=""" +return { + pageId: page.pageId, + title: await page.title(), + directMarker: await page.evaluate( + () => document.documentElement.dataset.crewaiDirectMarker, + ), +}; +""" + ) + ) + assert second["title"] == "Example Domain" + assert second["pageId"] == first["pageId"] + assert second["directMarker"] == direct_marker + + @crewai_event_bus.on(ToolUsageFinishedEvent) + def record_model_tool(_source: Any, event: ToolUsageFinishedEvent) -> None: + if event.tool_name == "code_execute": + model_tool_calls.append(event.tool_name) + + try: + agent = build_stagehand_agent(tools) + agent.kickoff( + " ".join( + ( + "Use code_execute to modify the already-open page.", + "Set document.documentElement.dataset.crewaiModelMarker to", + f"{json.dumps(model_marker)}.", + "Then read that dataset value and the current pageId and report them.", + "You must call code_execute; do not merely describe JavaScript.", + ) + ) + ) + assert crewai_event_bus.flush(), "CrewAI tool events did not finish" + finally: + crewai_event_bus.off(ToolUsageFinishedEvent, record_model_tool) + assert model_tool_calls, "the real CrewAI model must select code_execute" + + final_state = successful_result( + code_execute.run( + code=""" +return { + pageId: page.pageId, + title: await page.title(), + directMarker: await page.evaluate( + () => document.documentElement.dataset.crewaiDirectMarker, + ), + modelMarker: await page.evaluate( + () => document.documentElement.dataset.crewaiModelMarker, + ), +}; +""" + ) + ) + assert final_state["title"] == "Example Domain" + assert final_state["pageId"] == first["pageId"] + assert final_state["directMarker"] == direct_marker + assert final_state["modelMarker"] == model_marker + + print( + json.dumps( + { + "status": "PASS", + "framework": "crewai", + "directToolCalls": 3, + "modelToolCalls": len(model_tool_calls), + "sessionPersisted": True, + "modelCredentialIsolated": True, + "finalState": final_state, + "cleanup": ["crewai-mcp", "vercel-sandbox"], + }, + sort_keys=True, + ) + ) + + +if __name__ == "__main__": + main() diff --git a/packages/integrations/examples/crewai/requirements.txt b/packages/integrations/examples/crewai/requirements.txt index 9a13265591..d0f49d9f56 100644 --- a/packages/integrations/examples/crewai/requirements.txt +++ b/packages/integrations/examples/crewai/requirements.txt @@ -1,3 +1,2 @@ -crewai>=1.15.9,<2 -crewai-tools[mcp]>=1.15.9,<2 -modal>=1.5.3,<2 +crewai==1.15.13 +crewai-tools[mcp]==1.15.13 diff --git a/packages/integrations/examples/crewai/sandbox.py b/packages/integrations/examples/crewai/sandbox.py new file mode 100644 index 0000000000..3c61cd3fd0 --- /dev/null +++ b/packages/integrations/examples/crewai/sandbox.py @@ -0,0 +1,21 @@ +"""Expose the shared Vercel Sandbox lease from the CrewAI example directory.""" + +from __future__ import annotations + +import sys +from pathlib import Path + +SHARED_EXAMPLES_DIRECTORY = Path(__file__).resolve().parents[1] / "shared" +sys.path.insert(0, str(SHARED_EXAMPLES_DIRECTORY)) + +from vercel_sandbox_lease import ( # noqa: E402 + StagehandSandboxConnection, + StagehandSandboxLease, + StagehandSandboxLeaseError, +) + +__all__ = [ + "StagehandSandboxConnection", + "StagehandSandboxLease", + "StagehandSandboxLeaseError", +] diff --git a/packages/integrations/examples/crewai/smoke.py b/packages/integrations/examples/crewai/smoke.py deleted file mode 100644 index 2a8a932857..0000000000 --- a/packages/integrations/examples/crewai/smoke.py +++ /dev/null @@ -1,119 +0,0 @@ -from __future__ import annotations - -import json -import os -from collections.abc import Iterator -from contextlib import contextmanager -from pathlib import Path -from typing import Any - -from crewai.tools import BaseTool -from crewai_tools import MCPServerAdapter - -from agent import ( - REPOSITORY_ROOT, - build_stagehand_agent, - load_stagehand_codemode_skill, -) -from mcp import StdioServerParameters - -LOCAL_STDIO_SERVER_PATH = REPOSITORY_ROOT / "packages/integrations/dist/codemode/stdio-server.mjs" - - -@contextmanager -def local_stagehand_code_tools_for_ci() -> Iterator[list[BaseTool]]: - """Launch the MCP directly only for the credential-free local CI smoke.""" - if not LOCAL_STDIO_SERVER_PATH.is_file(): - raise FileNotFoundError( - "Build @browserbasehq/stagehand-integrations before running the smoke" - ) - child_env = { - "PATH": os.environ.get("PATH", ""), - "STAGEHAND_BROWSER": "local", - } - # Stagehand's local launcher uses CI to add Chrome's --no-sandbox flag. - for name in ("HOME", "TMPDIR", "CHROME_PATH", "CI"): - if value := os.environ.get(name): - child_env[name] = value - parameters = StdioServerParameters( - command="node", - args=[str(LOCAL_STDIO_SERVER_PATH)], - cwd=Path(REPOSITORY_ROOT), - env=child_env, - ) - with MCPServerAdapter(parameters) as discovered_tools: - yield list(discovered_tools) - - -def successful_result(raw_result: Any) -> dict[str, Any]: - result = json.loads(str(raw_result)) - assert result["ok"] is True, result - return result - - -def main() -> None: - os.environ.setdefault("OPENAI_API_KEY", "smoke-only-placeholder") - - with local_stagehand_code_tools_for_ci() as tools: - assert [tool.name for tool in tools] == ["code_execute"] - assert "Stagehand V4 code-mode syntax" in tools[0].description - assert "stagehand.extract" in load_stagehand_codemode_skill() - - agent = build_stagehand_agent(tools) - code_execute = agent.tools[0] - - first = successful_result( - code_execute.run( - code=""" -await page.goto("https://example.com", { waitUntil: "domcontentloaded" }); -await page.evaluate(() => { - document.documentElement.dataset.crewaiStagehandSession = "persisted"; -}); -return { - pageId: page.pageId, - title: await page.title(), - marker: await page.evaluate( - () => document.documentElement.dataset.crewaiStagehandSession, - ), -}; -""" - ) - ) - second = successful_result( - code_execute.run( - code=""" -return { - pageId: page.pageId, - title: await page.title(), - marker: await page.evaluate( - () => document.documentElement.dataset.crewaiStagehandSession, - ), -}; -""" - ) - ) - - first_value = first["value"] - second_value = second["value"] - assert first_value["title"] == "Example Domain" - assert second_value["title"] == "Example Domain" - assert second_value["marker"] == "persisted" - assert second_value["pageId"] == first_value["pageId"] - - print( - "CrewAI local CI-only Stagehand MCP persistence PASS:", - json.dumps( - { - "browser": "local", - "tool": "code_execute", - "pageId": second_value["pageId"], - "title": second_value["title"], - "marker": second_value["marker"], - }, - sort_keys=True, - ), - ) - - -if __name__ == "__main__": - main() diff --git a/packages/integrations/examples/shared/modal_stdio_bridge.py b/packages/integrations/examples/shared/modal_stdio_bridge.py deleted file mode 100644 index 98d3e7db29..0000000000 --- a/packages/integrations/examples/shared/modal_stdio_bridge.py +++ /dev/null @@ -1,219 +0,0 @@ -#!/usr/bin/env python3 -"""Forward local stdio to a Stagehand code-mode MCP in a Modal Sandbox. - -This process is trusted host-side glue. The MCP server and every generated -JavaScript body run inside the sandbox; this process only copies stdio and owns -the sandbox lifecycle. -""" - -from __future__ import annotations - -import atexit -import os -import signal -import sys -import threading -import time -from collections.abc import Iterator - -import modal - -DEFAULT_ENTRYPOINT = "/opt/stagehand-codemode/dist/codemode/stdio-server.mjs" -DEFAULT_OUTBOUND_DOMAINS = "*.browserbase.com" -DEFAULT_TIMEOUT_SECONDS = 10 * 60 -DEFAULT_IDLE_TIMEOUT_SECONDS = 5 * 60 - - -def _required_env(name: str) -> str: - value = os.environ.get(name, "").strip() - if not value: - raise RuntimeError(f"{name} is required") - return value - - -def _integer_env(name: str, default: int, minimum: int, maximum: int) -> int: - raw = os.environ.get(name) - try: - value = default if raw is None else int(raw) - except ValueError as error: - raise RuntimeError(f"{name} must be an integer") from error - if not minimum <= value <= maximum: - raise RuntimeError(f"{name} must be between {minimum} and {maximum}") - return value - - -def _resolve_image() -> modal.Image: - # Maintainers can point at an already-built Modal image while the proposed - # public GHCR image is being published. End users should set a versioned or - # digest-pinned STAGEHAND_CODEMODE_IMAGE reference. - modal_image_id = os.environ.get("STAGEHAND_CODEMODE_MODAL_IMAGE_ID", "").strip() - if modal_image_id: - return modal.Image.from_id(modal_image_id) - return modal.Image.from_registry(_required_env("STAGEHAND_CODEMODE_IMAGE")) - - -def _browserbase_secret() -> modal.Secret: - # This is the complete guest credential allowlist. Outer-agent provider - # credentials are never injected into the guest. Stagehand-specific model - # settings are opt-in for Stagehand AI methods such as act and extract. - values = { - "STAGEHAND_BROWSER": "browserbase", - "BROWSERBASE_API_KEY": _required_env("BROWSERBASE_API_KEY"), - } - project_id = os.environ.get("BROWSERBASE_PROJECT_ID", "").strip() - if project_id: - values["BROWSERBASE_PROJECT_ID"] = project_id - for name in ("STAGEHAND_MODEL_NAME", "STAGEHAND_MODEL_API_KEY"): - value = os.environ.get(name, "").strip() - if value: - values[name] = value - return modal.Secret.from_dict(values) - - -def _outbound_domains() -> list[str]: - raw = os.environ.get("STAGEHAND_CODEMODE_OUTBOUND_DOMAINS", DEFAULT_OUTBOUND_DOMAINS) - domains = [domain.strip() for domain in raw.split(",") if domain.strip()] - if not domains: - raise RuntimeError( - "STAGEHAND_CODEMODE_OUTBOUND_DOMAINS must allow required Browserbase hosts" - ) - return domains - - -class SandboxProcess: - def __init__(self) -> None: - self.sandbox: modal.Sandbox | None = None - self._shutdown_requested = False - self._closed = False - - def start(self) -> None: - app = modal.App.lookup( - os.environ.get("STAGEHAND_CODEMODE_MODAL_APP", "stagehand-codemode"), - create_if_missing=True, - ) - timeout = _integer_env( - "STAGEHAND_CODEMODE_TIMEOUT_SECONDS", - DEFAULT_TIMEOUT_SECONDS, - 30, - 24 * 60 * 60, - ) - idle_timeout = _integer_env( - "STAGEHAND_CODEMODE_IDLE_TIMEOUT_SECONDS", - DEFAULT_IDLE_TIMEOUT_SECONDS, - 30, - timeout, - ) - entrypoint = os.environ.get("STAGEHAND_CODEMODE_ENTRYPOINT", DEFAULT_ENTRYPOINT) - self.sandbox = modal.Sandbox.create( - "node", - entrypoint, - app=app, - image=_resolve_image(), - secrets=[_browserbase_secret()], - timeout=timeout, - idle_timeout=idle_timeout, - outbound_domain_allowlist=_outbound_domains(), - ) - # The MCP is the sandbox's primary process. EOF therefore lets its own - # shutdown handler close Stagehand and the Browserbase browser before - # the sandbox finishes. - print("Stagehand code-mode MCP started in a Modal Sandbox", file=sys.stderr) - - def request_shutdown(self) -> None: - if self._shutdown_requested: - return - self._shutdown_requested = True - if self.sandbox is not None: - try: - self.sandbox.stdin.write_eof() - self.sandbox.stdin.drain() - except Exception: - pass - - def close(self) -> None: - if self._closed: - return - self._closed = True - self.request_shutdown() - if self.sandbox is not None: - try: - deadline = time.monotonic() + 5 - while self.sandbox.poll() is None and time.monotonic() < deadline: - time.sleep(0.1) - if self.sandbox.poll() is None: - self.sandbox.terminate() - except Exception: - pass - try: - self.sandbox.detach() - except Exception: - pass - - -def _stdin_chunks() -> Iterator[bytes]: - while True: - chunk = os.read(sys.stdin.fileno(), 64 * 1024) - if not chunk: - return - yield chunk - - -def _forward_stdin(owner: SandboxProcess) -> None: - assert owner.sandbox is not None - try: - for chunk in _stdin_chunks(): - owner.sandbox.stdin.write(chunk) - owner.sandbox.stdin.drain() - finally: - owner.request_shutdown() - - -def _forward_stdout(owner: SandboxProcess) -> None: - assert owner.sandbox is not None - for line in owner.sandbox.stdout: - data = line.encode() if isinstance(line, str) else line - sys.stdout.buffer.write(data) - sys.stdout.buffer.flush() - - -def _forward_stderr(owner: SandboxProcess) -> None: - assert owner.sandbox is not None - for line in owner.sandbox.stderr: - data = line.encode() if isinstance(line, str) else line - sys.stderr.buffer.write(b"sandbox: " + data) - sys.stderr.buffer.flush() - - -def main() -> int: - owner = SandboxProcess() - signal_exit_code: int | None = None - atexit.register(owner.close) - - def stop(_signum: int, _frame: object) -> None: - nonlocal signal_exit_code - signal_exit_code = 128 + _signum - owner.request_shutdown() - - signal.signal(signal.SIGTERM, stop) - signal.signal(signal.SIGINT, stop) - - try: - owner.start() - threads = [ - threading.Thread(target=_forward_stdin, args=(owner,), daemon=True), - threading.Thread(target=_forward_stdout, args=(owner,), daemon=True), - threading.Thread(target=_forward_stderr, args=(owner,), daemon=True), - ] - for thread in threads: - thread.start() - assert owner.sandbox is not None - owner.sandbox.wait() - for thread in threads[1:]: - thread.join(timeout=5) - return signal_exit_code or owner.sandbox.returncode or 0 - finally: - owner.close() - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/packages/integrations/examples/shared/test_modal_stdio_bridge.py b/packages/integrations/examples/shared/test_modal_stdio_bridge.py deleted file mode 100644 index 58844e88d8..0000000000 --- a/packages/integrations/examples/shared/test_modal_stdio_bridge.py +++ /dev/null @@ -1,124 +0,0 @@ -from __future__ import annotations - -import io -import signal -import threading -import types -import unittest -from collections.abc import Callable, Iterator -from unittest.mock import patch - -import modal_stdio_bridge as bridge - - -class FakeInput: - def __init__(self) -> None: - self.data = bytearray() - self.eof_calls = 0 - self.eof = threading.Event() - - def write(self, data: bytes) -> None: - self.data.extend(data) - - def write_eof(self) -> None: - self.eof_calls += 1 - self.eof.set() - - def drain(self) -> None: - pass - - -class FakeSandbox: - def __init__( - self, - returncode: int, - on_wait: Callable[[], None] | None = None, - ) -> None: - self.stdin = FakeInput() - self.stdout = ["response\n"] - self.stderr = ["warning\n"] - self.returncode: int | None = None - self._final_returncode = returncode - self._on_wait = on_wait - self.terminated = False - self.detached = False - - def wait(self) -> None: - if not self.stdin.eof.wait(timeout=2): - raise TimeoutError("stdin EOF was not forwarded") - if self._on_wait: - self._on_wait() - self.returncode = self._final_returncode - - def poll(self) -> int | None: - return self.returncode - - def terminate(self) -> None: - self.terminated = True - self.returncode = 143 - - def detach(self) -> None: - self.detached = True - - -class ModalStdioBridgeTest(unittest.TestCase): - def run_bridge( - self, - fake: FakeSandbox, - stdin_chunks: Iterator[bytes], - signal_handlers: dict[int, Callable[[int, object], None]] | None = None, - ) -> tuple[int, bytes, bytes]: - stdout = types.SimpleNamespace(buffer=io.BytesIO()) - stderr = types.SimpleNamespace(buffer=io.BytesIO()) - - def start(owner: bridge.SandboxProcess) -> None: - owner.sandbox = fake # type: ignore[assignment] - - def register_signal( - signum: int, - handler: Callable[[int, object], None], - ) -> None: - if signal_handlers is not None: - signal_handlers[signum] = handler - - with ( - patch.object(bridge.SandboxProcess, "start", start), - patch.object(bridge, "_stdin_chunks", return_value=stdin_chunks), - patch.object(bridge.atexit, "register"), - patch.object(bridge.signal, "signal", side_effect=register_signal), - patch.object(bridge.sys, "stdout", stdout), - patch.object(bridge.sys, "stderr", stderr), - ): - exit_code = bridge.main() - - return exit_code, stdout.buffer.getvalue(), stderr.buffer.getvalue() - - def test_forwards_stdio_eof_and_process_returncode(self) -> None: - fake = FakeSandbox(returncode=7) - - exit_code, stdout, stderr = self.run_bridge(fake, iter((b"request\n",))) - - self.assertEqual(exit_code, 7) - self.assertEqual(fake.stdin.data, b"request\n") - self.assertEqual(fake.stdin.eof_calls, 1) - self.assertEqual(stdout, b"response\n") - self.assertEqual(stderr, b"sandbox: warning\n") - self.assertFalse(fake.terminated) - self.assertTrue(fake.detached) - - def test_preserves_signal_exit_code(self) -> None: - handlers: dict[int, Callable[[int, object], None]] = {} - fake = FakeSandbox( - returncode=0, - on_wait=lambda: handlers[signal.SIGTERM](signal.SIGTERM, object()), - ) - - exit_code, _, _ = self.run_bridge(fake, iter(()), handlers) - - self.assertEqual(exit_code, 143) - self.assertEqual(fake.stdin.eof_calls, 1) - self.assertFalse(fake.terminated) - - -if __name__ == "__main__": - unittest.main() diff --git a/packages/integrations/examples/shared/test_vercel_sandbox_lease.py b/packages/integrations/examples/shared/test_vercel_sandbox_lease.py new file mode 100644 index 0000000000..18a74ede53 --- /dev/null +++ b/packages/integrations/examples/shared/test_vercel_sandbox_lease.py @@ -0,0 +1,90 @@ +from __future__ import annotations + +import io +import os +import subprocess +import unittest +from builtins import BaseExceptionGroup +from pathlib import Path +from unittest.mock import patch + +import vercel_sandbox_lease as lease + + +class FakeProcess: + def __init__(self, *, exit_code: int = 0, stderr: str = "") -> None: + self.stdin = io.StringIO() + self.stdout = io.StringIO( + '{"url":"https://sandbox.example.vercel.run/mcp","token":"token"}\n' + ) + self.stderr = io.StringIO(stderr) + self.returncode: int | None = None + self._exit_code = exit_code + self.waited_after_stdin_closed = False + + def poll(self) -> int | None: + return self.returncode + + def wait(self, timeout: float) -> int: + del timeout + self.waited_after_stdin_closed = self.stdin.closed + self.returncode = self._exit_code + return self._exit_code + + def terminate(self) -> None: + self.returncode = -15 + + def kill(self) -> None: + self.returncode = -9 + + +class StagehandSandboxLeaseTest(unittest.TestCase): + def test_model_credentials_are_excluded_from_lease_environment(self) -> None: + environment = { + "PATH": "/usr/bin", + "BROWSERBASE_API_KEY": "browserbase-secret", + "VERCEL_OIDC_TOKEN": "vercel-token", + "OPENAI_API_KEY": "model-secret", + "ANTHROPIC_API_KEY": "other-model-secret", + } + with patch.dict(os.environ, environment, clear=True): + child_environment = lease.lease_environment() + + self.assertEqual(child_environment["BROWSERBASE_API_KEY"], "browserbase-secret") + self.assertEqual(child_environment["VERCEL_OIDC_TOKEN"], "vercel-token") + self.assertNotIn("OPENAI_API_KEY", child_environment) + self.assertNotIn("ANTHROPIC_API_KEY", child_environment) + + def test_context_returns_connection_then_closes_stdin_before_waiting(self) -> None: + process = FakeProcess() + with ( + patch.object(lease, "TSX_PATH", Path(__file__)), + patch.object(lease, "LEASE_PATH", Path(__file__)), + patch.object(subprocess, "Popen", return_value=process), + lease.StagehandSandboxLease() as connection, + ): + self.assertEqual(connection.url, "https://sandbox.example.vercel.run/mcp") + self.assertEqual(connection.token, "token") + + self.assertTrue(process.waited_after_stdin_closed) + + def test_cleanup_failure_preserves_primary_failure(self) -> None: + process = FakeProcess(exit_code=1, stderr="cleanup failed\n") + with self.assertRaises(BaseExceptionGroup) as raised: + with ( + patch.object(lease, "TSX_PATH", Path(__file__)), + patch.object(lease, "LEASE_PATH", Path(__file__)), + patch.object(subprocess, "Popen", return_value=process), + lease.StagehandSandboxLease(), + ): + raise ValueError("primary failed") + + self.assertEqual(len(raised.exception.exceptions), 2) + self.assertIsInstance(raised.exception.exceptions[0], ValueError) + self.assertIsInstance( + raised.exception.exceptions[1], lease.StagehandSandboxLeaseError + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/packages/integrations/examples/shared/vercel_sandbox_lease.py b/packages/integrations/examples/shared/vercel_sandbox_lease.py new file mode 100644 index 0000000000..3d177825a5 --- /dev/null +++ b/packages/integrations/examples/shared/vercel_sandbox_lease.py @@ -0,0 +1,210 @@ +"""Own the TypeScript Vercel Sandbox lease from a Python framework example.""" + +from __future__ import annotations + +import json +import os +import queue +import subprocess +import threading +from builtins import BaseExceptionGroup +from dataclasses import dataclass +from pathlib import Path +from types import TracebackType +from typing import TextIO +from urllib.parse import urlsplit + +REPOSITORY_ROOT = Path(__file__).resolve().parents[4] +LEASE_PATH = ( + REPOSITORY_ROOT / "packages/integrations/examples/vercel-sandbox/src/lease.ts" +) +TSX_PATH = REPOSITORY_ROOT / "node_modules/.bin/tsx" +LEASE_SETUP_TIMEOUT_SECONDS = 3 * 60 +LEASE_CLEANUP_TIMEOUT_SECONDS = 60 +LEASE_ENVIRONMENT_KEYS = ( + "PATH", + "HOME", + "TMPDIR", + "LANG", + "LC_ALL", + "SSL_CERT_FILE", + "NODE_EXTRA_CA_CERTS", + "NODE_OPTIONS", + "CI", + "STAGEHAND_SANDBOX_ARTIFACTS", + "BROWSERBASE_API_KEY", + "BROWSERBASE_PROJECT_ID", + "VERCEL_OIDC_TOKEN", + "VERCEL_TEAM_ID", + "VERCEL_PROJECT_ID", + "VERCEL_TOKEN", +) + + +class StagehandSandboxLeaseError(RuntimeError): + """The trusted sandbox owner could not start or clean up its lease.""" + + +@dataclass(frozen=True) +class StagehandSandboxConnection: + url: str + token: str + + +class StagehandSandboxLease: + """Start the reviewed Node lease and close it after the Python MCP client.""" + + def __init__(self) -> None: + self._process: subprocess.Popen[str] | None = None + self._stderr: list[str] = [] + self._stderr_thread: threading.Thread | None = None + self._closed = False + + def __enter__(self) -> StagehandSandboxConnection: + return self.start() + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + traceback: TracebackType | None, + ) -> bool: + del exc_type, traceback + try: + self.close() + except BaseException as cleanup_error: + if exc_value is not None: + raise BaseExceptionGroup( + "Python framework run and Vercel Sandbox cleanup both failed", + [exc_value, cleanup_error], + ) + raise + return False + + def start(self) -> StagehandSandboxConnection: + if self._process is not None: + raise StagehandSandboxLeaseError( + "Stagehand sandbox lease was already started" + ) + if not TSX_PATH.is_file() or not LEASE_PATH.is_file(): + raise StagehandSandboxLeaseError( + "Install the workspace before starting the Stagehand sandbox lease" + ) + + process = subprocess.Popen( + [str(TSX_PATH), str(LEASE_PATH)], + cwd=REPOSITORY_ROOT, + env=lease_environment(), + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + encoding="utf-8", + bufsize=1, + ) + self._process = process + assert process.stdout is not None + assert process.stderr is not None + self._stderr_thread = threading.Thread( + target=self._drain_stderr, + args=(process.stderr,), + name="stagehand-sandbox-lease-stderr", + daemon=True, + ) + self._stderr_thread.start() + + line_queue: queue.Queue[str] = queue.Queue(maxsize=1) + line_thread = threading.Thread( + target=lambda: line_queue.put(process.stdout.readline()), + name="stagehand-sandbox-lease-connection", + daemon=True, + ) + line_thread.start() + + try: + line = line_queue.get(timeout=LEASE_SETUP_TIMEOUT_SECONDS) + if not line: + raise self._lease_error("exited before returning a connection") + connection = parse_connection(line) + if process.poll() is not None: + raise self._lease_error( + "exited immediately after returning a connection" + ) + return connection + except BaseException as primary_error: + try: + self.close() + except BaseException as cleanup_error: + raise BaseExceptionGroup( + "Vercel Sandbox lease setup and cleanup both failed", + [primary_error, cleanup_error], + ) + raise + + def close(self) -> None: + if self._closed: + return + self._closed = True + process = self._process + if process is None: + return + + assert process.stdin is not None + try: + process.stdin.close() + except BrokenPipeError: + pass + + try: + return_code = process.wait(timeout=LEASE_CLEANUP_TIMEOUT_SECONDS) + except subprocess.TimeoutExpired: + process.terminate() + try: + return_code = process.wait(timeout=5) + except subprocess.TimeoutExpired: + process.kill() + return_code = process.wait(timeout=5) + + if self._stderr_thread is not None: + self._stderr_thread.join(timeout=1) + if return_code != 0: + raise self._lease_error(f"exited with status {return_code}") + + def _drain_stderr(self, stderr: TextIO) -> None: + for line in stderr: + self._stderr.append(line) + + def _lease_error(self, message: str) -> StagehandSandboxLeaseError: + detail = "".join(self._stderr).strip() + suffix = f": {detail}" if detail else "" + return StagehandSandboxLeaseError(f"Stagehand sandbox lease {message}{suffix}") + + +def lease_environment() -> dict[str, str]: + """Return only the trusted lease inputs; outer model credentials stay outside.""" + return { + key: value + for key in LEASE_ENVIRONMENT_KEYS + if (value := os.environ.get(key)) is not None + } + + +def parse_connection(line: str) -> StagehandSandboxConnection: + try: + value = json.loads(line) + url = value["url"] + token = value["token"] + except (json.JSONDecodeError, KeyError, TypeError) as error: + raise StagehandSandboxLeaseError( + "Stagehand sandbox lease returned an invalid connection" + ) from error + if not isinstance(url, str) or not isinstance(token, str) or not token: + raise StagehandSandboxLeaseError( + "Stagehand sandbox lease returned an invalid connection" + ) + parsed = urlsplit(url) + if parsed.scheme != "https" or not parsed.hostname or parsed.path != "/mcp": + raise StagehandSandboxLeaseError( + "Stagehand sandbox lease returned an invalid connection" + ) + return StagehandSandboxConnection(url=url, token=token) From b9172e499390b35aa2b380845bf647046bd2df46 Mon Sep 17 00:00:00 2001 From: Shrey Pandya Date: Sat, 8 Aug 2026 04:04:59 +0000 Subject: [PATCH 6/9] fix(crewai): gate live proof on credentials --- .../workflows/codemode-framework-examples.yml | 21 ++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/.github/workflows/codemode-framework-examples.yml b/.github/workflows/codemode-framework-examples.yml index 56f3c43d16..aa3b4497d4 100644 --- a/.github/workflows/codemode-framework-examples.yml +++ b/.github/workflows/codemode-framework-examples.yml @@ -177,7 +177,26 @@ jobs: - run: python packages/integrations/examples/shared/test_vercel_sandbox_lease.py - run: pnpm exec turbo run build --filter @browserbasehq/stagehand-codemode - run: pnpm --filter @browserbasehq/stagehand-integrations-example-vercel-sandbox pack:artifacts - - run: python packages/integrations/examples/crewai/e2e.py + - name: Detect CrewAI live test credentials + id: crewai-live-credentials + env: + BROWSERBASE_API_KEY: ${{ secrets.BROWSERBASE_API_KEY }} + BROWSERBASE_PROJECT_ID: ${{ secrets.BROWSERBASE_PROJECT_ID }} + VERCEL_OIDC_TOKEN: ${{ secrets.VERCEL_OIDC_TOKEN }} + VERCEL_TEAM_ID: ${{ secrets.VERCEL_TEAM_ID }} + VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} + VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + run: | + if [[ -n "$BROWSERBASE_API_KEY" && -n "$BROWSERBASE_PROJECT_ID" && -n "$OPENAI_API_KEY" ]] && \ + [[ -n "$VERCEL_OIDC_TOKEN" || ( -n "$VERCEL_TEAM_ID" && -n "$VERCEL_PROJECT_ID" && -n "$VERCEL_TOKEN" ) ]]; then + echo "available=true" >> "$GITHUB_OUTPUT" + else + echo "available=false" >> "$GITHUB_OUTPUT" + fi + - name: Run CrewAI live sandbox proof + if: steps.crewai-live-credentials.outputs.available == 'true' + run: python packages/integrations/examples/crewai/e2e.py env: STAGEHAND_SANDBOX_ARTIFACTS: ${{ github.workspace }}/packages/integrations/examples/vercel-sandbox/.artifacts BROWSERBASE_API_KEY: ${{ secrets.BROWSERBASE_API_KEY }} From 60e1c4513eebce07cdf85a80d8f74f6e94b7adc0 Mon Sep 17 00:00:00 2001 From: Shrey Pandya Date: Sat, 8 Aug 2026 04:19:06 +0000 Subject: [PATCH 7/9] fix(crewai): harden sandbox lease and adapter contracts --- .../action.yml | 40 ++++++ .../workflows/codemode-framework-examples.yml | 35 ++--- .../integrations/examples/crewai/README.md | 8 +- .../integrations/examples/crewai/agent.py | 120 ++++++++++++---- packages/integrations/examples/crewai/e2e.py | 133 +++++++++++------- .../integrations/examples/crewai/sandbox.py | 2 +- .../examples/crewai/test_agent.py | 80 +++++++++++ .../integrations/examples/crewai/test_e2e.py | 34 +++++ .../shared/test_vercel_sandbox_lease.py | 96 +++++++++++-- .../examples/shared/vercel_sandbox_lease.py | 105 +++++++++----- .../examples/vercel-sandbox/src/lease.ts | 7 +- .../examples/vercel-sandbox/src/sandbox.ts | 105 +++++++++----- 12 files changed, 576 insertions(+), 189 deletions(-) create mode 100644 .github/actions/detect-codemode-live-credentials/action.yml create mode 100644 packages/integrations/examples/crewai/test_agent.py create mode 100644 packages/integrations/examples/crewai/test_e2e.py diff --git a/.github/actions/detect-codemode-live-credentials/action.yml b/.github/actions/detect-codemode-live-credentials/action.yml new file mode 100644 index 0000000000..a24f0fae97 --- /dev/null +++ b/.github/actions/detect-codemode-live-credentials/action.yml @@ -0,0 +1,40 @@ +name: Detect code-mode live credentials +description: Decide whether Browserbase, Vercel, and optional OpenAI live proofs can run. + +inputs: + require-openai: + description: Require OPENAI_API_KEY in addition to Browserbase and Vercel credentials. + default: "false" + +outputs: + available: + description: Whether every credential required by the live proof is available. + value: ${{ steps.detect.outputs.available }} + +runs: + using: composite + steps: + - id: detect + shell: bash + env: + REQUIRE_OPENAI: ${{ inputs.require-openai }} + run: | + browserbase_ready=false + vercel_ready=false + openai_ready=false + + if [[ -n "$BROWSERBASE_API_KEY" && -n "$BROWSERBASE_PROJECT_ID" ]]; then + browserbase_ready=true + fi + if [[ -n "$VERCEL_OIDC_TOKEN" || ( -n "$VERCEL_TEAM_ID" && -n "$VERCEL_PROJECT_ID" && -n "$VERCEL_TOKEN" ) ]]; then + vercel_ready=true + fi + if [[ "$REQUIRE_OPENAI" != "true" || -n "$OPENAI_API_KEY" ]]; then + openai_ready=true + fi + + if [[ "$browserbase_ready" == "true" && "$vercel_ready" == "true" && "$openai_ready" == "true" ]]; then + echo "available=true" >> "$GITHUB_OUTPUT" + else + echo "available=false" >> "$GITHUB_OUTPUT" + fi diff --git a/.github/workflows/codemode-framework-examples.yml b/.github/workflows/codemode-framework-examples.yml index bfc12d2233..b512da03b6 100644 --- a/.github/workflows/codemode-framework-examples.yml +++ b/.github/workflows/codemode-framework-examples.yml @@ -60,7 +60,6 @@ jobs: - uses: ./.github/actions/setup-chrome-verified id: setup-chrome - - run: pnpm exec turbo run build --filter @browserbasehq/stagehand-codemode - run: pnpm --filter ${{ matrix.package }} typecheck - run: pnpm --filter ${{ matrix.package }} test:contract - run: pnpm --filter ${{ matrix.package }} pack:artifacts @@ -70,6 +69,7 @@ jobs: STAGEHAND_BROWSER: local - name: Detect live test credentials id: live-credentials + uses: ./.github/actions/detect-codemode-live-credentials env: BROWSERBASE_API_KEY: ${{ secrets.BROWSERBASE_API_KEY }} BROWSERBASE_PROJECT_ID: ${{ secrets.BROWSERBASE_PROJECT_ID }} @@ -77,13 +77,6 @@ jobs: VERCEL_TEAM_ID: ${{ secrets.VERCEL_TEAM_ID }} VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} - run: | - if [[ -n "$BROWSERBASE_API_KEY" && -n "$BROWSERBASE_PROJECT_ID" ]] && \ - [[ -n "$VERCEL_OIDC_TOKEN" || ( -n "$VERCEL_TEAM_ID" && -n "$VERCEL_PROJECT_ID" && -n "$VERCEL_TOKEN" ) ]]; then - echo "available=true" >> "$GITHUB_OUTPUT" - else - echo "available=false" >> "$GITHUB_OUTPUT" - fi - name: Run live package-installed sandbox proof if: steps.live-credentials.outputs.available == 'true' run: pnpm --filter ${{ matrix.package }} e2e @@ -111,12 +104,14 @@ jobs: with: use-prebuilt-artifacts: "false" - - run: pnpm exec turbo run build --filter @browserbasehq/stagehand-codemode - run: pnpm --filter @browserbasehq/stagehand-integrations-example-mastra typecheck - run: pnpm --filter @browserbasehq/stagehand-integrations-example-mastra test:contract - run: pnpm --filter @browserbasehq/stagehand-integrations-example-vercel-sandbox pack:artifacts - name: Detect Mastra live test credentials id: mastra-live-credentials + uses: ./.github/actions/detect-codemode-live-credentials + with: + require-openai: "true" env: BROWSERBASE_API_KEY: ${{ secrets.BROWSERBASE_API_KEY }} BROWSERBASE_PROJECT_ID: ${{ secrets.BROWSERBASE_PROJECT_ID }} @@ -125,13 +120,6 @@ jobs: VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} - run: | - if [[ -n "$BROWSERBASE_API_KEY" && -n "$BROWSERBASE_PROJECT_ID" && -n "$OPENAI_API_KEY" ]] && \ - [[ -n "$VERCEL_OIDC_TOKEN" || ( -n "$VERCEL_TEAM_ID" && -n "$VERCEL_PROJECT_ID" && -n "$VERCEL_TOKEN" ) ]]; then - echo "available=true" >> "$GITHUB_OUTPUT" - else - echo "available=false" >> "$GITHUB_OUTPUT" - fi - name: Run Mastra live sandbox proof if: steps.mastra-live-credentials.outputs.available == 'true' run: pnpm --filter @browserbasehq/stagehand-integrations-example-mastra e2e @@ -174,12 +162,18 @@ jobs: packages/integrations/examples/shared/test_vercel_sandbox_lease.py packages/integrations/examples/crewai/agent.py packages/integrations/examples/crewai/sandbox.py + packages/integrations/examples/crewai/test_agent.py + packages/integrations/examples/crewai/test_e2e.py packages/integrations/examples/crewai/e2e.py - run: python packages/integrations/examples/shared/test_vercel_sandbox_lease.py - - run: pnpm exec turbo run build --filter @browserbasehq/stagehand-codemode + - run: python packages/integrations/examples/crewai/test_agent.py + - run: python packages/integrations/examples/crewai/test_e2e.py - run: pnpm --filter @browserbasehq/stagehand-integrations-example-vercel-sandbox pack:artifacts - name: Detect CrewAI live test credentials id: crewai-live-credentials + uses: ./.github/actions/detect-codemode-live-credentials + with: + require-openai: "true" env: BROWSERBASE_API_KEY: ${{ secrets.BROWSERBASE_API_KEY }} BROWSERBASE_PROJECT_ID: ${{ secrets.BROWSERBASE_PROJECT_ID }} @@ -188,13 +182,6 @@ jobs: VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} - run: | - if [[ -n "$BROWSERBASE_API_KEY" && -n "$BROWSERBASE_PROJECT_ID" && -n "$OPENAI_API_KEY" ]] && \ - [[ -n "$VERCEL_OIDC_TOKEN" || ( -n "$VERCEL_TEAM_ID" && -n "$VERCEL_PROJECT_ID" && -n "$VERCEL_TOKEN" ) ]]; then - echo "available=true" >> "$GITHUB_OUTPUT" - else - echo "available=false" >> "$GITHUB_OUTPUT" - fi - name: Run CrewAI live sandbox proof if: steps.crewai-live-credentials.outputs.available == 'true' run: python packages/integrations/examples/crewai/e2e.py diff --git a/packages/integrations/examples/crewai/README.md b/packages/integrations/examples/crewai/README.md index 61646f213a..e96d0ad61a 100644 --- a/packages/integrations/examples/crewai/README.md +++ b/packages/integrations/examples/crewai/README.md @@ -27,7 +27,6 @@ Build and pack the exact Stagehand packages under review: ```bash pnpm install -pnpm exec turbo run build --filter @browserbasehq/stagehand-codemode pnpm --filter @browserbasehq/stagehand-integrations-example-vercel-sandbox pack:artifacts ``` @@ -56,6 +55,13 @@ The proof uses one live package-installed sandbox and one context-managed CrewAI ## Use the agent +Run the snippet with the example directory as the working directory so its local `agent.py` and +`sandbox.py` modules resolve: + +```bash +cd packages/integrations/examples/crewai +``` + ```python from agent import run_stagehand_agent from sandbox import StagehandSandboxLease diff --git a/packages/integrations/examples/crewai/agent.py b/packages/integrations/examples/crewai/agent.py index 8f0f34702c..9ca2de4d48 100644 --- a/packages/integrations/examples/crewai/agent.py +++ b/packages/integrations/examples/crewai/agent.py @@ -13,6 +13,26 @@ DEFAULT_STAGEHAND_LLM = os.environ.get("CREWAI_MODEL", "openai/gpt-5-mini") +class StagehandCrewAIConnectionError(RuntimeError): + """CrewAI could not establish the authenticated Stagehand MCP connection.""" + + +class StagehandCrewAIToolContractError(RuntimeError): + """The remote Stagehand MCP tool contract was not the expected code-mode API.""" + + +class StagehandCrewAICleanupError(RuntimeError): + """CrewAI could not close its Stagehand MCP adapter.""" + + +class StagehandCrewAISetupError(RuntimeError): + """CrewAI could not construct the Stagehand browser agent.""" + + +class StagehandCrewAIRunError(RuntimeError): + """The CrewAI Stagehand browser run failed.""" + + class StagehandSandboxConnection(Protocol): url: str token: str @@ -23,35 +43,62 @@ def stagehand_code_tools( connection: StagehandSandboxConnection, ) -> Iterator[list[BaseTool]]: """Keep one authenticated remote MCP client open for a complete CrewAI run.""" - adapter = MCPServerAdapter( - { - "url": connection.url, - "transport": "streamable-http", - "headers": {"Authorization": f"Bearer {connection.token}"}, - }, - connect_timeout=60, - ) + adapter: MCPServerAdapter | None = None try: + adapter = MCPServerAdapter( + { + "url": connection.url, + "transport": "streamable-http", + "headers": {"Authorization": f"Bearer {connection.token}"}, + }, + connect_timeout=60, + ) tools = list(adapter.tools) names = [tool.name for tool in tools] if names != ["code_execute"]: - raise RuntimeError( - f"Expected only code_execute from Stagehand MCP, got {names!r}." + raise StagehandCrewAIToolContractError( + "The Stagehand MCP server returned an invalid tool contract." ) if "# Stagehand V4 code-mode syntax" not in tools[0].description: - raise RuntimeError("code_execute did not include the canonical guidance") + raise StagehandCrewAIToolContractError( + "The Stagehand MCP server returned an invalid tool contract." + ) + except Exception as error: # noqa: BLE001 -- third-party discovery is untyped. + primary_error = ( + error + if isinstance(error, StagehandCrewAIToolContractError) + else StagehandCrewAIConnectionError( + "Could not connect CrewAI to the Stagehand MCP server." + ) + ) + if adapter is None: + raise primary_error from None + try: + stop_adapter(adapter) + except StagehandCrewAICleanupError as cleanup_error: + raise BaseExceptionGroup( + "CrewAI MCP setup and cleanup both failed", + [primary_error, cleanup_error], + ) + raise primary_error from None + + if adapter is None: + raise StagehandCrewAIConnectionError( + "Could not connect CrewAI to the Stagehand MCP server." + ) + try: yield tools except BaseException as primary_error: try: - adapter.stop() - except BaseException as cleanup_error: + stop_adapter(adapter) + except StagehandCrewAICleanupError as cleanup_error: raise BaseExceptionGroup( "CrewAI run and MCP cleanup both failed", [primary_error, cleanup_error], ) raise else: - adapter.stop() + stop_adapter(adapter) def build_stagehand_agent( @@ -59,16 +106,23 @@ def build_stagehand_agent( llm: str | Any = DEFAULT_STAGEHAND_LLM, ) -> Agent: if len(tools) != 1 or tools[0].name != "code_execute": - raise ValueError("CrewAI Stagehand agent requires exactly code_execute") - return Agent( - role="Stagehand browser agent", - goal="Complete browser tasks by writing compact, correct Stagehand V4 JavaScript.", - backstory=tools[0].description, - llm=llm, - tools=list(tools), - max_iter=8, - verbose=False, - ) + raise StagehandCrewAIToolContractError( + "The Stagehand MCP server returned an invalid tool contract." + ) + try: + return Agent( + role="Stagehand browser agent", + goal="Complete browser tasks by writing compact, correct Stagehand V4 JavaScript.", + backstory=tools[0].description, + llm=llm, + tools=list(tools), + max_iter=8, + verbose=False, + ) + except Exception: # noqa: BLE001 -- CrewAI construction is an untyped boundary. + raise StagehandCrewAISetupError( + "Could not configure the CrewAI Stagehand agent." + ) from None def run_stagehand_agent( @@ -77,4 +131,20 @@ def run_stagehand_agent( llm: str | Any = DEFAULT_STAGEHAND_LLM, ) -> str: with stagehand_code_tools(connection) as tools: - return str(build_stagehand_agent(tools, llm).kickoff(prompt)) + try: + return str(build_stagehand_agent(tools, llm).kickoff(prompt)) + except (StagehandCrewAISetupError, StagehandCrewAIToolContractError): + raise + except Exception: # noqa: BLE001 -- CrewAI kickoff is an untyped boundary. + raise StagehandCrewAIRunError( + "The CrewAI Stagehand agent run failed." + ) from None + + +def stop_adapter(adapter: MCPServerAdapter) -> None: + try: + adapter.stop() + except Exception: # noqa: BLE001 -- adapter shutdown is an untyped boundary. + raise StagehandCrewAICleanupError( + "Could not close the CrewAI Stagehand MCP adapter." + ) from None diff --git a/packages/integrations/examples/crewai/e2e.py b/packages/integrations/examples/crewai/e2e.py index 28c6f05f84..f1ca2a654c 100644 --- a/packages/integrations/examples/crewai/e2e.py +++ b/packages/integrations/examples/crewai/e2e.py @@ -5,17 +5,35 @@ from typing import Any from uuid import uuid4 -from crewai.events import ToolUsageFinishedEvent, crewai_event_bus - from agent import build_stagehand_agent, stagehand_code_tools +from crewai.events import ToolUsageFinishedEvent, crewai_event_bus from sandbox import StagehandSandboxLease +class StagehandCrewAIResultError(RuntimeError): + """code_execute returned a malformed or unsuccessful result.""" + + +class StagehandCrewAIIsolationError(RuntimeError): + """A host-only value crossed the Vercel Sandbox boundary.""" + + def successful_result(raw_result: Any) -> dict[str, Any]: - result = json.loads(str(raw_result)) - assert result["ok"] is True, result + try: + result = json.loads(str(raw_result)) + except (json.JSONDecodeError, TypeError, ValueError): + raise StagehandCrewAIResultError( + "Stagehand code_execute returned an invalid result." + ) from None + if not isinstance(result, dict) or result.get("ok") is not True: + raise StagehandCrewAIResultError( + "Stagehand code_execute returned an invalid result." + ) value = result.get("value") - assert isinstance(value, dict), result + if not isinstance(value, dict): + raise StagehandCrewAIResultError( + "Stagehand code_execute returned an invalid result." + ) return value @@ -26,12 +44,14 @@ def main() -> None: model_tool_calls: list[str] = [] final_state: dict[str, Any] - with StagehandSandboxLease() as connection: - with stagehand_code_tools(connection) as tools: - code_execute = tools[0] - first = successful_result( - code_execute.run( - code=f""" + with ( + StagehandSandboxLease() as connection, + stagehand_code_tools(connection) as tools, + ): + code_execute = tools[0] + first = successful_result( + code_execute.run( + code=f""" await page.goto("https://example.com", {{ waitUntil: "domcontentloaded" }}); await page.evaluate((marker) => {{ document.documentElement.dataset.crewaiDirectMarker = marker; @@ -46,16 +66,21 @@ def main() -> None: hostMarkerVisible: process.env.CREWAI_HOST_ONLY_MARKER ?? null, }}; """ - ) ) - assert first["title"] == "Example Domain" - assert first["directMarker"] == direct_marker - assert first["modelKeyVisible"] is None - assert first["hostMarkerVisible"] is None - - second = successful_result( - code_execute.run( - code=""" + ) + assert first["title"] == "Example Domain" + assert first["directMarker"] == direct_marker + if ( + first["modelKeyVisible"] is not None + or first["hostMarkerVisible"] is not None + ): + raise StagehandCrewAIIsolationError( + "A host-only value crossed the CrewAI sandbox boundary." + ) + + second = successful_result( + code_execute.run( + code=""" return { pageId: page.pageId, title: await page.title(), @@ -64,38 +89,38 @@ def main() -> None: ), }; """ - ) ) - assert second["title"] == "Example Domain" - assert second["pageId"] == first["pageId"] - assert second["directMarker"] == direct_marker - - @crewai_event_bus.on(ToolUsageFinishedEvent) - def record_model_tool(_source: Any, event: ToolUsageFinishedEvent) -> None: - if event.tool_name == "code_execute": - model_tool_calls.append(event.tool_name) - - try: - agent = build_stagehand_agent(tools) - agent.kickoff( - " ".join( - ( - "Use code_execute to modify the already-open page.", - "Set document.documentElement.dataset.crewaiModelMarker to", - f"{json.dumps(model_marker)}.", - "Then read that dataset value and the current pageId and report them.", - "You must call code_execute; do not merely describe JavaScript.", - ) + ) + assert second["title"] == "Example Domain" + assert second["pageId"] == first["pageId"] + assert second["directMarker"] == direct_marker + + @crewai_event_bus.on(ToolUsageFinishedEvent) + def record_model_tool(_source: Any, event: ToolUsageFinishedEvent) -> None: + if event.tool_name == "code_execute": + model_tool_calls.append(event.tool_name) + + try: + agent = build_stagehand_agent(tools) + agent.kickoff( + " ".join( + ( + "Use code_execute to modify the already-open page.", + "Set document.documentElement.dataset.crewaiModelMarker to", + f"{json.dumps(model_marker)}.", + "Then read that dataset value and the current pageId and report them.", + "You must call code_execute; do not merely describe JavaScript.", ) ) - assert crewai_event_bus.flush(), "CrewAI tool events did not finish" - finally: - crewai_event_bus.off(ToolUsageFinishedEvent, record_model_tool) - assert model_tool_calls, "the real CrewAI model must select code_execute" - - final_state = successful_result( - code_execute.run( - code=""" + ) + assert crewai_event_bus.flush(), "CrewAI tool events did not finish" + finally: + crewai_event_bus.off(ToolUsageFinishedEvent, record_model_tool) + assert model_tool_calls, "the real CrewAI model must select code_execute" + + final_state = successful_result( + code_execute.run( + code=""" return { pageId: page.pageId, title: await page.title(), @@ -107,12 +132,12 @@ def record_model_tool(_source: Any, event: ToolUsageFinishedEvent) -> None: ), }; """ - ) ) - assert final_state["title"] == "Example Domain" - assert final_state["pageId"] == first["pageId"] - assert final_state["directMarker"] == direct_marker - assert final_state["modelMarker"] == model_marker + ) + assert final_state["title"] == "Example Domain" + assert final_state["pageId"] == first["pageId"] + assert final_state["directMarker"] == direct_marker + assert final_state["modelMarker"] == model_marker print( json.dumps( diff --git a/packages/integrations/examples/crewai/sandbox.py b/packages/integrations/examples/crewai/sandbox.py index 3c61cd3fd0..b70b33511b 100644 --- a/packages/integrations/examples/crewai/sandbox.py +++ b/packages/integrations/examples/crewai/sandbox.py @@ -8,7 +8,7 @@ SHARED_EXAMPLES_DIRECTORY = Path(__file__).resolve().parents[1] / "shared" sys.path.insert(0, str(SHARED_EXAMPLES_DIRECTORY)) -from vercel_sandbox_lease import ( # noqa: E402 +from vercel_sandbox_lease import ( StagehandSandboxConnection, StagehandSandboxLease, StagehandSandboxLeaseError, diff --git a/packages/integrations/examples/crewai/test_agent.py b/packages/integrations/examples/crewai/test_agent.py new file mode 100644 index 0000000000..785a590e05 --- /dev/null +++ b/packages/integrations/examples/crewai/test_agent.py @@ -0,0 +1,80 @@ +from __future__ import annotations + +import unittest +from types import SimpleNamespace +from unittest.mock import patch + +import agent + + +class FakeAdapter: + def __init__( + self, tools: object, *, discovery_error: Exception | None = None + ) -> None: + self._tools = tools + self._discovery_error = discovery_error + self.stop_error: Exception | None = None + + @property + def tools(self) -> object: + if self._discovery_error is not None: + raise self._discovery_error + return self._tools + + def stop(self) -> None: + if self.stop_error is not None: + raise self.stop_error + + +class StagehandCodeToolsTest(unittest.TestCase): + connection = SimpleNamespace( + url="https://sandbox.example.test/mcp", token="secret-token" + ) + + def test_sanitizes_adapter_discovery_failure(self) -> None: + secret = "https://sandbox.example.test/mcp?token=do-not-reflect" + adapter = FakeAdapter([], discovery_error=RuntimeError(secret)) + with ( + patch.object(agent, "MCPServerAdapter", return_value=adapter), + self.assertRaises(agent.StagehandCrewAIConnectionError) as raised, + agent.stagehand_code_tools(self.connection), + ): + pass + self.assertNotIn(secret, str(raised.exception)) + + def test_rejects_unexpected_remote_tool_without_reflecting_name(self) -> None: + secret = "unexpected-secret-tool" + adapter = FakeAdapter( + [SimpleNamespace(name=secret, description="untrusted description")] + ) + with ( + patch.object(agent, "MCPServerAdapter", return_value=adapter), + self.assertRaises(agent.StagehandCrewAIToolContractError) as raised, + agent.stagehand_code_tools(self.connection), + ): + pass + self.assertNotIn(secret, str(raised.exception)) + + def test_sanitizes_adapter_cleanup_failure(self) -> None: + adapter = FakeAdapter( + [ + SimpleNamespace( + name="code_execute", + description="# Stagehand V4 code-mode syntax", + ) + ] + ) + adapter.stop_error = RuntimeError("cleanup-secret-do-not-reflect") + with ( + patch.object(agent, "MCPServerAdapter", return_value=adapter), + self.assertRaises(agent.StagehandCrewAICleanupError) as raised, + agent.stagehand_code_tools(self.connection), + ): + pass + self.assertEqual( + str(raised.exception), "Could not close the CrewAI Stagehand MCP adapter." + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/packages/integrations/examples/crewai/test_e2e.py b/packages/integrations/examples/crewai/test_e2e.py new file mode 100644 index 0000000000..644c5d0c0b --- /dev/null +++ b/packages/integrations/examples/crewai/test_e2e.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +import json +import unittest + +from e2e import StagehandCrewAIResultError, successful_result + + +class SuccessfulResultTest(unittest.TestCase): + def test_accepts_successful_object_value(self) -> None: + value = {"title": "Example Domain"} + self.assertEqual( + successful_result(json.dumps({"ok": True, "value": value})), value + ) + + def test_rejects_invalid_json_without_reflecting_it(self) -> None: + secret = "not-json-do-not-reflect" + with self.assertRaises(StagehandCrewAIResultError) as raised: + successful_result(secret) + self.assertNotIn(secret, str(raised.exception)) + + def test_rejects_failed_result_without_reflecting_it(self) -> None: + secret = "remote-error-do-not-reflect" + with self.assertRaises(StagehandCrewAIResultError) as raised: + successful_result(json.dumps({"ok": False, "error": secret})) + self.assertNotIn(secret, str(raised.exception)) + + def test_rejects_non_object_value(self) -> None: + with self.assertRaises(StagehandCrewAIResultError): + successful_result(json.dumps({"ok": True, "value": ["unexpected"]})) + + +if __name__ == "__main__": + unittest.main() diff --git a/packages/integrations/examples/shared/test_vercel_sandbox_lease.py b/packages/integrations/examples/shared/test_vercel_sandbox_lease.py index 18a74ede53..9427dbdbb8 100644 --- a/packages/integrations/examples/shared/test_vercel_sandbox_lease.py +++ b/packages/integrations/examples/shared/test_vercel_sandbox_lease.py @@ -10,12 +10,15 @@ import vercel_sandbox_lease as lease +VALID_TOKEN = "A" * 43 + class FakeProcess: def __init__(self, *, exit_code: int = 0, stderr: str = "") -> None: self.stdin = io.StringIO() self.stdout = io.StringIO( - '{"url":"https://sandbox.example.vercel.run/mcp","token":"token"}\n' + '{"url":"https://sandbox.example.vercel.run/mcp",' + f'"token":"{VALID_TOKEN}"}}\n' ) self.stderr = io.StringIO(stderr) self.returncode: int | None = None @@ -38,6 +41,28 @@ def kill(self) -> None: self.returncode = -9 +class EscalatingFakeProcess(FakeProcess): + def __init__(self) -> None: + super().__init__() + self.wait_calls = 0 + self.terminated = False + self.killed = False + + def wait(self, timeout: float) -> int: + self.wait_calls += 1 + self.waited_after_stdin_closed = self.stdin.closed + if self.wait_calls < 3: + raise subprocess.TimeoutExpired("stagehand-lease", timeout) + self.returncode = -9 + return -9 + + def terminate(self) -> None: + self.terminated = True + + def kill(self) -> None: + self.killed = True + + class StagehandSandboxLeaseTest(unittest.TestCase): def test_model_credentials_are_excluded_from_lease_environment(self) -> None: environment = { @@ -64,26 +89,75 @@ def test_context_returns_connection_then_closes_stdin_before_waiting(self) -> No lease.StagehandSandboxLease() as connection, ): self.assertEqual(connection.url, "https://sandbox.example.vercel.run/mcp") - self.assertEqual(connection.token, "token") + self.assertEqual(connection.token, VALID_TOKEN) self.assertTrue(process.waited_after_stdin_closed) def test_cleanup_failure_preserves_primary_failure(self) -> None: - process = FakeProcess(exit_code=1, stderr="cleanup failed\n") - with self.assertRaises(BaseExceptionGroup) as raised: - with ( - patch.object(lease, "TSX_PATH", Path(__file__)), - patch.object(lease, "LEASE_PATH", Path(__file__)), - patch.object(subprocess, "Popen", return_value=process), - lease.StagehandSandboxLease(), - ): - raise ValueError("primary failed") + secret = "cleanup-secret-do-not-reflect" + process = FakeProcess(exit_code=1, stderr=f"cleanup failed: {secret}\n") + with ( + self.assertRaises(BaseExceptionGroup) as raised, + patch.object(lease, "TSX_PATH", Path(__file__)), + patch.object(lease, "LEASE_PATH", Path(__file__)), + patch.object(subprocess, "Popen", return_value=process), + lease.StagehandSandboxLease(), + ): + raise ValueError("primary failed") self.assertEqual(len(raised.exception.exceptions), 2) self.assertIsInstance(raised.exception.exceptions[0], ValueError) self.assertIsInstance( raised.exception.exceptions[1], lease.StagehandSandboxLeaseError ) + self.assertNotIn(secret, str(raised.exception.exceptions[1])) + + def test_setup_timeout_is_typed_and_closes_the_owner(self) -> None: + process = FakeProcess() + with ( + patch.object(lease, "TSX_PATH", Path(__file__)), + patch.object(lease, "LEASE_PATH", Path(__file__)), + patch.object(subprocess, "Popen", return_value=process), + patch.object( + lease.queue.Queue, + "get", + side_effect=lease.queue.Empty, + ), + self.assertRaises(lease.StagehandSandboxLeaseError) as raised, + ): + lease.StagehandSandboxLease().start() + + self.assertEqual( + str(raised.exception), "Stagehand sandbox lease timed out while starting" + ) + self.assertTrue(process.waited_after_stdin_closed) + + def test_connection_parser_rejects_untrusted_shapes(self) -> None: + invalid_connections = ( + "not-json", + f'{{"url":"http://sandbox.example.test/mcp","token":"{VALID_TOKEN}"}}', + f'{{"url":"https://sandbox.example.test/not-mcp","token":"{VALID_TOKEN}"}}', + '{"url":"https://sandbox.example.test/mcp","token":""}', + ) + for connection in invalid_connections: + with ( + self.subTest(connection=connection), + self.assertRaises(lease.StagehandSandboxLeaseError), + ): + lease.parse_connection(connection) + + def test_close_escalates_from_terminate_to_kill(self) -> None: + process = EscalatingFakeProcess() + owner = lease.StagehandSandboxLease() + owner._process = process + + with self.assertRaises(lease.StagehandSandboxLeaseError): + owner.close() + + self.assertTrue(process.waited_after_stdin_closed) + self.assertTrue(process.terminated) + self.assertTrue(process.killed) + self.assertEqual(process.wait_calls, 3) if __name__ == "__main__": diff --git a/packages/integrations/examples/shared/vercel_sandbox_lease.py b/packages/integrations/examples/shared/vercel_sandbox_lease.py index 3d177825a5..e6f689025b 100644 --- a/packages/integrations/examples/shared/vercel_sandbox_lease.py +++ b/packages/integrations/examples/shared/vercel_sandbox_lease.py @@ -5,6 +5,7 @@ import json import os import queue +import re import subprocess import threading from builtins import BaseExceptionGroup @@ -21,6 +22,8 @@ TSX_PATH = REPOSITORY_ROOT / "node_modules/.bin/tsx" LEASE_SETUP_TIMEOUT_SECONDS = 3 * 60 LEASE_CLEANUP_TIMEOUT_SECONDS = 60 +LEASE_TERMINATION_TIMEOUT_SECONDS = 60 +LEASE_KILL_TIMEOUT_SECONDS = 5 LEASE_ENVIRONMENT_KEYS = ( "PATH", "HOME", @@ -56,7 +59,6 @@ class StagehandSandboxLease: def __init__(self) -> None: self._process: subprocess.Popen[str] | None = None - self._stderr: list[str] = [] self._stderr_thread: threading.Thread | None = None self._closed = False @@ -91,17 +93,20 @@ def start(self) -> StagehandSandboxConnection: "Install the workspace before starting the Stagehand sandbox lease" ) - process = subprocess.Popen( - [str(TSX_PATH), str(LEASE_PATH)], - cwd=REPOSITORY_ROOT, - env=lease_environment(), - stdin=subprocess.PIPE, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - encoding="utf-8", - bufsize=1, - ) + try: + process = subprocess.Popen( + [str(TSX_PATH), str(LEASE_PATH)], + cwd=REPOSITORY_ROOT, + env=lease_environment(), + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + encoding="utf-8", + bufsize=1, + ) + except (OSError, subprocess.SubprocessError): + raise self._lease_error("could not start the trusted owner") from None self._process = process assert process.stdout is not None assert process.stderr is not None @@ -122,7 +127,10 @@ def start(self) -> StagehandSandboxConnection: line_thread.start() try: - line = line_queue.get(timeout=LEASE_SETUP_TIMEOUT_SECONDS) + try: + line = line_queue.get(timeout=LEASE_SETUP_TIMEOUT_SECONDS) + except queue.Empty: + raise self._lease_error("timed out while starting") from None if not line: raise self._lease_error("exited before returning a connection") connection = parse_connection(line) @@ -131,15 +139,20 @@ def start(self) -> StagehandSandboxConnection: "exited immediately after returning a connection" ) return connection - except BaseException as primary_error: + except BaseException as error: # noqa: BLE001 -- cleanup must preserve any primary failure. + primary_error = ( + error + if isinstance(error, StagehandSandboxLeaseError) + else self._lease_error("failed during setup") + ) try: self.close() - except BaseException as cleanup_error: + except BaseException as cleanup_error: # noqa: BLE001 -- preserve both failures. raise BaseExceptionGroup( "Vercel Sandbox lease setup and cleanup both failed", [primary_error, cleanup_error], ) - raise + raise primary_error from None def close(self) -> None: if self._closed: @@ -149,21 +162,31 @@ def close(self) -> None: if process is None: return - assert process.stdin is not None try: - process.stdin.close() - except BrokenPipeError: - pass + assert process.stdin is not None + try: + process.stdin.close() + except BrokenPipeError: + pass - try: - return_code = process.wait(timeout=LEASE_CLEANUP_TIMEOUT_SECONDS) - except subprocess.TimeoutExpired: - process.terminate() try: - return_code = process.wait(timeout=5) + return_code = process.wait(timeout=LEASE_CLEANUP_TIMEOUT_SECONDS) except subprocess.TimeoutExpired: - process.kill() - return_code = process.wait(timeout=5) + process.terminate() + try: + return_code = process.wait( + timeout=LEASE_TERMINATION_TIMEOUT_SECONDS + ) + except subprocess.TimeoutExpired: + process.kill() + try: + return_code = process.wait(timeout=LEASE_KILL_TIMEOUT_SECONDS) + except subprocess.TimeoutExpired: + raise self._lease_error( + "could not stop the trusted owner" + ) from None + except (OSError, subprocess.SubprocessError): + raise self._lease_error("could not stop the trusted owner") from None if self._stderr_thread is not None: self._stderr_thread.join(timeout=1) @@ -171,13 +194,11 @@ def close(self) -> None: raise self._lease_error(f"exited with status {return_code}") def _drain_stderr(self, stderr: TextIO) -> None: - for line in stderr: - self._stderr.append(line) + for _line in stderr: + pass def _lease_error(self, message: str) -> StagehandSandboxLeaseError: - detail = "".join(self._stderr).strip() - suffix = f": {detail}" if detail else "" - return StagehandSandboxLeaseError(f"Stagehand sandbox lease {message}{suffix}") + return StagehandSandboxLeaseError(f"Stagehand sandbox lease {message}") def lease_environment() -> dict[str, str]: @@ -194,16 +215,28 @@ def parse_connection(line: str) -> StagehandSandboxConnection: value = json.loads(line) url = value["url"] token = value["token"] - except (json.JSONDecodeError, KeyError, TypeError) as error: + except (json.JSONDecodeError, KeyError, TypeError): raise StagehandSandboxLeaseError( "Stagehand sandbox lease returned an invalid connection" - ) from error - if not isinstance(url, str) or not isinstance(token, str) or not token: + ) from None + if ( + not isinstance(url, str) + or not isinstance(token, str) + or re.fullmatch(r"[A-Za-z0-9_-]{43}", token) is None + ): raise StagehandSandboxLeaseError( "Stagehand sandbox lease returned an invalid connection" ) parsed = urlsplit(url) - if parsed.scheme != "https" or not parsed.hostname or parsed.path != "/mcp": + if ( + parsed.scheme != "https" + or not parsed.hostname + or parsed.path != "/mcp" + or parsed.username is not None + or parsed.password is not None + or parsed.query + or parsed.fragment + ): raise StagehandSandboxLeaseError( "Stagehand sandbox lease returned an invalid connection" ) diff --git a/packages/integrations/examples/vercel-sandbox/src/lease.ts b/packages/integrations/examples/vercel-sandbox/src/lease.ts index 96fb9d7956..73dcf5bf78 100644 --- a/packages/integrations/examples/vercel-sandbox/src/lease.ts +++ b/packages/integrations/examples/vercel-sandbox/src/lease.ts @@ -11,12 +11,14 @@ try { const browserbaseApiKey = requiredEnvironment("BROWSERBASE_API_KEY"); const browserbaseProjectId = requiredEnvironment("BROWSERBASE_PROJECT_ID"); const vercelCredentials = vercelCredentialsFromEnvironment(); - const leaseEnd = waitForLeaseEnd(); + const setupAbort = new AbortController(); + const leaseEnd = waitForLeaseEnd(setupAbort); const connection = await createStagehandSandbox({ packageArtifactsPath, browserbaseApiKey, browserbaseProjectId, vercelCredentials, + signal: setupAbort.signal, }); process.stdout.write( @@ -43,12 +45,13 @@ try { process.exitCode = 1; } -function waitForLeaseEnd(): Promise { +function waitForLeaseEnd(setupAbort: AbortController): Promise { return new Promise((resolve) => { let finished = false; const finish = (end: LeaseEnd = {}) => { if (finished) return; finished = true; + setupAbort.abort(); resolve(end); }; diff --git a/packages/integrations/examples/vercel-sandbox/src/sandbox.ts b/packages/integrations/examples/vercel-sandbox/src/sandbox.ts index 4a2fb82f5a..efed11742f 100644 --- a/packages/integrations/examples/vercel-sandbox/src/sandbox.ts +++ b/packages/integrations/examples/vercel-sandbox/src/sandbox.ts @@ -87,6 +87,7 @@ export type StagehandSandboxOptions = { readinessTimeoutMs?: number; sandboxTimeoutMs?: number; cleanupTimeoutMs?: number; + signal?: AbortSignal; }; export type StagehandSandboxConnection = { @@ -105,9 +106,11 @@ export async function createStagehandSandbox( ): Promise { assertNonEmpty(options.browserbaseApiKey, "browserbaseApiKey"); assertNonEmpty(options.browserbaseProjectId, "browserbaseProjectId"); + assertNotAborted(options.signal); const artifacts = await loadPackageArtifacts(options); const cdpHost = await discoverBrowserbaseCdpHost(options); + assertNotAborted(options.signal); let sandbox: Sandbox; try { const vercelCredentials = options.vercelCredentials; @@ -133,51 +136,64 @@ export async function createStagehandSandbox( const close = sandboxCloser(sandbox, options.cleanupTimeoutMs ?? 30_000); try { - await installStagehandPackages(sandbox, artifacts); - await sandbox.writeFiles([ - { - path: AUTH_PROXY_PATH, - content: await readFile(new URL("./guest/auth-proxy.mjs", import.meta.url)), - mode: 0o555, - }, - { - path: STDIO_WRAPPER_PATH, - content: await readFile(new URL("./guest/stdio-wrapper.mjs", import.meta.url)), - mode: 0o555, - }, - ]); + assertNotAborted(options.signal); + await abortable(installStagehandPackages(sandbox, artifacts), options.signal); + await abortable( + sandbox.writeFiles([ + { + path: AUTH_PROXY_PATH, + content: await readFile(new URL("./guest/auth-proxy.mjs", import.meta.url)), + mode: 0o555, + }, + { + path: STDIO_WRAPPER_PATH, + content: await readFile(new URL("./guest/stdio-wrapper.mjs", import.meta.url)), + mode: 0o555, + }, + ]), + options.signal, + ); - const mcpUser = await sandbox.createUser(MCP_USER); - const proxyUser = await sandbox.createUser(PROXY_USER); - await assertUnprivileged(mcpUser, MCP_USER); - await assertUnprivileged(proxyUser, PROXY_USER); - await protectRuntimeFiles(sandbox); + const mcpUser = await abortable(sandbox.createUser(MCP_USER), options.signal); + const proxyUser = await abortable(sandbox.createUser(PROXY_USER), options.signal); + await abortable(assertUnprivileged(mcpUser, MCP_USER), options.signal); + await abortable(assertUnprivileged(proxyUser, PROXY_USER), options.signal); + await abortable(protectRuntimeFiles(sandbox), options.signal); // This update is the trust transition: everything above is trusted setup; // everything below may eventually execute model-generated JavaScript. - await sandbox.update({ - networkPolicy: { - allow: { - [BROWSERBASE_API_HOST]: [ - { - transform: [{ headers: { "X-BB-API-Key": options.browserbaseApiKey } }], - }, - ], - [cdpHost]: [], + await abortable( + sandbox.update({ + networkPolicy: { + allow: { + [BROWSERBASE_API_HOST]: [ + { + transform: [{ headers: { "X-BB-API-Key": options.browserbaseApiKey } }], + }, + ], + [cdpHost]: [], + }, }, - }, - }); + }), + options.signal, + ); const token = randomBytes(32).toString("base64url"); const tokenDigest = createHash("sha256").update(token).digest("hex"); - await startGateway(mcpUser, options.browserbaseProjectId); - await startAuthProxy(proxyUser, tokenDigest); + await abortable(startGateway(mcpUser, options.browserbaseProjectId), options.signal); + await abortable(startAuthProxy(proxyUser, tokenDigest), options.signal); const origin = new URL(sandbox.domain(BRIDGE_PORT)); - await waitForHealth(origin, token, options.readinessTimeoutMs ?? 2 * 60_000); - const unauthorized = await fetch(new URL("/healthz", origin), { - signal: AbortSignal.timeout(HEALTH_REQUEST_TIMEOUT_MS), - }).catch(() => undefined); + await abortable( + waitForHealth(origin, token, options.readinessTimeoutMs ?? 2 * 60_000), + options.signal, + ); + const unauthorized = await abortable( + fetch(new URL("/healthz", origin), { + signal: AbortSignal.timeout(HEALTH_REQUEST_TIMEOUT_MS), + }).catch(() => undefined), + options.signal, + ); if (unauthorized?.status !== 401) { throw new StagehandSandboxHealthError(); } @@ -442,6 +458,25 @@ function delay(milliseconds: number): Promise { return new Promise((resolve) => setTimeout(resolve, milliseconds)); } +function assertNotAborted(signal: AbortSignal | undefined): void { + if (signal?.aborted) throw new StagehandSandboxSetupError(); +} + +async function abortable(promise: Promise, signal: AbortSignal | undefined): Promise { + if (!signal) return promise; + assertNotAborted(signal); + let onAbort: (() => void) | undefined; + const aborted = new Promise((_resolve, reject) => { + onAbort = () => reject(new StagehandSandboxSetupError()); + signal.addEventListener("abort", onAbort, { once: true }); + }); + try { + return await Promise.race([promise, aborted]); + } finally { + if (onAbort) signal.removeEventListener("abort", onAbort); + } +} + type PackageArtifact = { content: Buffer; sha256: string }; type PackageArtifacts = { stagehand: PackageArtifact; From e1b860ee7e0d4f65733974107d0830cfcc725ed8 Mon Sep 17 00:00:00 2001 From: Shrey Pandya Date: Sat, 8 Aug 2026 04:44:37 +0000 Subject: [PATCH 8/9] fix(crewai): harden cancellation and teardown edges --- .../action.yml | 6 +- .../integrations/examples/crewai/agent.py | 27 ++++- .../examples/crewai/test_agent.py | 57 +++++++++ .../shared/test_vercel_sandbox_lease.py | 48 ++++++++ .../examples/shared/vercel_sandbox_lease.py | 3 +- .../vercel-sandbox/src/lease.test.mjs | 99 ++++++++++++++- .../examples/vercel-sandbox/src/lease.ts | 21 +++- .../vercel-sandbox/src/sandbox.test.ts | 113 ++++++++++++++++++ .../examples/vercel-sandbox/src/sandbox.ts | 42 +++++-- 9 files changed, 393 insertions(+), 23 deletions(-) diff --git a/.github/actions/detect-codemode-live-credentials/action.yml b/.github/actions/detect-codemode-live-credentials/action.yml index a24f0fae97..6010aa4f3f 100644 --- a/.github/actions/detect-codemode-live-credentials/action.yml +++ b/.github/actions/detect-codemode-live-credentials/action.yml @@ -26,7 +26,11 @@ runs: if [[ -n "$BROWSERBASE_API_KEY" && -n "$BROWSERBASE_PROJECT_ID" ]]; then browserbase_ready=true fi - if [[ -n "$VERCEL_OIDC_TOKEN" || ( -n "$VERCEL_TEAM_ID" && -n "$VERCEL_PROJECT_ID" && -n "$VERCEL_TOKEN" ) ]]; then + if [[ -n "$VERCEL_TOKEN" ]]; then + if [[ -n "$VERCEL_TEAM_ID" && -n "$VERCEL_PROJECT_ID" ]]; then + vercel_ready=true + fi + elif [[ -n "$VERCEL_OIDC_TOKEN" ]]; then vercel_ready=true fi if [[ "$REQUIRE_OPENAI" != "true" || -n "$OPENAI_API_KEY" ]]; then diff --git a/packages/integrations/examples/crewai/agent.py b/packages/integrations/examples/crewai/agent.py index 9ca2de4d48..e78d3a9b9f 100644 --- a/packages/integrations/examples/crewai/agent.py +++ b/packages/integrations/examples/crewai/agent.py @@ -1,6 +1,8 @@ from __future__ import annotations import os +import queue +import threading from builtins import BaseExceptionGroup from collections.abc import Iterator, Sequence from contextlib import contextmanager @@ -11,6 +13,7 @@ from crewai_tools import MCPServerAdapter DEFAULT_STAGEHAND_LLM = os.environ.get("CREWAI_MODEL", "openai/gpt-5-mini") +ADAPTER_STOP_TIMEOUT_SECONDS = 30 class StagehandCrewAIConnectionError(RuntimeError): @@ -82,10 +85,6 @@ def stagehand_code_tools( ) raise primary_error from None - if adapter is None: - raise StagehandCrewAIConnectionError( - "Could not connect CrewAI to the Stagehand MCP server." - ) try: yield tools except BaseException as primary_error: @@ -142,9 +141,25 @@ def run_stagehand_agent( def stop_adapter(adapter: MCPServerAdapter) -> None: + result: queue.Queue[BaseException | None] = queue.Queue(maxsize=1) + + def stop() -> None: + try: + adapter.stop() + result.put(None) + except BaseException as error: # noqa: BLE001 -- adapter shutdown is an untyped boundary. + result.put(error) + + threading.Thread( + target=stop, + name="stagehand-crewai-mcp-cleanup", + daemon=True, + ).start() try: - adapter.stop() - except Exception: # noqa: BLE001 -- adapter shutdown is an untyped boundary. + error = result.get(timeout=ADAPTER_STOP_TIMEOUT_SECONDS) + except queue.Empty: + error = TimeoutError() + if error is not None: raise StagehandCrewAICleanupError( "Could not close the CrewAI Stagehand MCP adapter." ) from None diff --git a/packages/integrations/examples/crewai/test_agent.py b/packages/integrations/examples/crewai/test_agent.py index 785a590e05..068acb4b4c 100644 --- a/packages/integrations/examples/crewai/test_agent.py +++ b/packages/integrations/examples/crewai/test_agent.py @@ -1,6 +1,8 @@ from __future__ import annotations import unittest +from builtins import BaseExceptionGroup +from threading import Event from types import SimpleNamespace from unittest.mock import patch @@ -75,6 +77,61 @@ def test_sanitizes_adapter_cleanup_failure(self) -> None: str(raised.exception), "Could not close the CrewAI Stagehand MCP adapter." ) + def test_adapter_cleanup_has_a_deadline(self) -> None: + release = Event() + adapter = FakeAdapter([]) + adapter.stop = release.wait + try: + with ( + patch.object(agent, "ADAPTER_STOP_TIMEOUT_SECONDS", 0.001), + self.assertRaises(agent.StagehandCrewAICleanupError), + ): + agent.stop_adapter(adapter) + finally: + release.set() + + def test_setup_and_cleanup_failures_preserve_order_and_types(self) -> None: + adapter = FakeAdapter([], discovery_error=RuntimeError("discovery-secret")) + adapter.stop_error = RuntimeError("cleanup-secret") + with ( + patch.object(agent, "MCPServerAdapter", return_value=adapter), + self.assertRaises(BaseExceptionGroup) as raised, + agent.stagehand_code_tools(self.connection), + ): + pass + + self.assertEqual(len(raised.exception.exceptions), 2) + self.assertIsInstance( + raised.exception.exceptions[0], agent.StagehandCrewAIConnectionError + ) + self.assertIsInstance( + raised.exception.exceptions[1], agent.StagehandCrewAICleanupError + ) + + def test_run_and_cleanup_failures_preserve_order_and_types(self) -> None: + adapter = FakeAdapter( + [ + SimpleNamespace( + name="code_execute", + description="# Stagehand V4 code-mode syntax", + ) + ] + ) + adapter.stop_error = RuntimeError("cleanup-secret") + primary = ValueError("run failed") + with ( + patch.object(agent, "MCPServerAdapter", return_value=adapter), + self.assertRaises(BaseExceptionGroup) as raised, + agent.stagehand_code_tools(self.connection), + ): + raise primary + + self.assertEqual(len(raised.exception.exceptions), 2) + self.assertIs(raised.exception.exceptions[0], primary) + self.assertIsInstance( + raised.exception.exceptions[1], agent.StagehandCrewAICleanupError + ) + if __name__ == "__main__": unittest.main() diff --git a/packages/integrations/examples/shared/test_vercel_sandbox_lease.py b/packages/integrations/examples/shared/test_vercel_sandbox_lease.py index 9427dbdbb8..8a6c3983b4 100644 --- a/packages/integrations/examples/shared/test_vercel_sandbox_lease.py +++ b/packages/integrations/examples/shared/test_vercel_sandbox_lease.py @@ -11,6 +11,8 @@ import vercel_sandbox_lease as lease VALID_TOKEN = "A" * 43 +SHORT_TOKEN = "A" * 42 +LONG_TOKEN = "A" * 44 class FakeProcess: @@ -63,6 +65,13 @@ def kill(self) -> None: self.killed = True +class UnstoppableFakeProcess(EscalatingFakeProcess): + def wait(self, timeout: float) -> int: + self.wait_calls += 1 + self.waited_after_stdin_closed = self.stdin.closed + raise subprocess.TimeoutExpired("stagehand-lease", timeout) + + class StagehandSandboxLeaseTest(unittest.TestCase): def test_model_credentials_are_excluded_from_lease_environment(self) -> None: environment = { @@ -132,11 +141,33 @@ def test_setup_timeout_is_typed_and_closes_the_owner(self) -> None: ) self.assertTrue(process.waited_after_stdin_closed) + def test_keyboard_interrupt_during_setup_is_preserved(self) -> None: + process = FakeProcess() + with ( + patch.object(lease, "TSX_PATH", Path(__file__)), + patch.object(lease, "LEASE_PATH", Path(__file__)), + patch.object(subprocess, "Popen", return_value=process), + patch.object( + lease.queue.Queue, + "get", + side_effect=KeyboardInterrupt, + ), + self.assertRaises(KeyboardInterrupt), + ): + lease.StagehandSandboxLease().start() + + self.assertTrue(process.waited_after_stdin_closed) + def test_connection_parser_rejects_untrusted_shapes(self) -> None: invalid_connections = ( "not-json", f'{{"url":"http://sandbox.example.test/mcp","token":"{VALID_TOKEN}"}}', f'{{"url":"https://sandbox.example.test/not-mcp","token":"{VALID_TOKEN}"}}', + f'{{"url":"https://user:pass@sandbox.example.test/mcp","token":"{VALID_TOKEN}"}}', + f'{{"url":"https://sandbox.example.test/mcp?x=1","token":"{VALID_TOKEN}"}}', + f'{{"url":"https://sandbox.example.test/mcp#fragment","token":"{VALID_TOKEN}"}}', + f'{{"url":"https://sandbox.example.test/mcp","token":"{SHORT_TOKEN}"}}', + f'{{"url":"https://sandbox.example.test/mcp","token":"{LONG_TOKEN}"}}', '{"url":"https://sandbox.example.test/mcp","token":""}', ) for connection in invalid_connections: @@ -159,6 +190,23 @@ def test_close_escalates_from_terminate_to_kill(self) -> None: self.assertTrue(process.killed) self.assertEqual(process.wait_calls, 3) + def test_close_reports_owner_that_survives_kill(self) -> None: + process = UnstoppableFakeProcess() + owner = lease.StagehandSandboxLease() + owner._process = process + + with self.assertRaises(lease.StagehandSandboxLeaseError) as raised: + owner.close() + + self.assertEqual( + str(raised.exception), + "Stagehand sandbox lease could not stop the trusted owner", + ) + self.assertTrue(process.waited_after_stdin_closed) + self.assertTrue(process.terminated) + self.assertTrue(process.killed) + self.assertEqual(process.wait_calls, 3) + if __name__ == "__main__": unittest.main() diff --git a/packages/integrations/examples/shared/vercel_sandbox_lease.py b/packages/integrations/examples/shared/vercel_sandbox_lease.py index e6f689025b..1a58a1b59c 100644 --- a/packages/integrations/examples/shared/vercel_sandbox_lease.py +++ b/packages/integrations/examples/shared/vercel_sandbox_lease.py @@ -142,7 +142,8 @@ def start(self) -> StagehandSandboxConnection: except BaseException as error: # noqa: BLE001 -- cleanup must preserve any primary failure. primary_error = ( error - if isinstance(error, StagehandSandboxLeaseError) + if not isinstance(error, Exception) + or isinstance(error, StagehandSandboxLeaseError) else self._lease_error("failed during setup") ) try: diff --git a/packages/integrations/examples/vercel-sandbox/src/lease.test.mjs b/packages/integrations/examples/vercel-sandbox/src/lease.test.mjs index 5780eb200b..f770efdc71 100644 --- a/packages/integrations/examples/vercel-sandbox/src/lease.test.mjs +++ b/packages/integrations/examples/vercel-sandbox/src/lease.test.mjs @@ -1,7 +1,10 @@ import assert from "node:assert/strict"; import { spawn } from "node:child_process"; +import { access, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; import test from "node:test"; -import { fileURLToPath } from "node:url"; +import { fileURLToPath, pathToFileURL } from "node:url"; const EXIT_TIMEOUT_MS = 2_000; @@ -45,3 +48,97 @@ test("lease setup failure exits while the parent keeps stdin open", async () => assert.equal(Buffer.concat(stdout).length, 0); assert.match(Buffer.concat(stderr).toString(), /^Stagehand sandbox lease failed: Missing /); }); + +test("lease preserves signal exit semantics during setup", async () => { + const temporaryRoot = await mkdtemp(path.join(os.tmpdir(), "stagehand-lease-signal-")); + const artifactRoot = path.join(temporaryRoot, "artifacts"); + const packageRoot = path.join(artifactRoot, "packages"); + const runtimeRoot = path.join(artifactRoot, "runtime"); + const readyPath = path.join(temporaryRoot, "fetch-ready"); + const preloadPath = path.join(temporaryRoot, "block-fetch.mjs"); + const dependencies = { + "@browserbasehq/stagehand": "file:../packages/stagehand.tgz", + "@browserbasehq/stagehand-codemode": "file:../packages/stagehand-codemode.tgz", + supergateway: "3.4.3", + }; + await Promise.all([ + mkdir(packageRoot, { recursive: true }), + mkdir(runtimeRoot, { recursive: true }), + ]); + await Promise.all([ + writeFile(path.join(packageRoot, "stagehand.tgz"), Buffer.from([0x1f, 0x8b])), + writeFile(path.join(packageRoot, "stagehand-codemode.tgz"), Buffer.from([0x1f, 0x8b])), + writeFile(path.join(runtimeRoot, "package.json"), JSON.stringify({ dependencies })), + writeFile( + path.join(runtimeRoot, "package-lock.json"), + JSON.stringify({ + lockfileVersion: 3, + packages: { "": { dependencies } }, + }), + ), + writeFile( + preloadPath, + [ + 'import { writeFileSync } from "node:fs";', + "globalThis.fetch = (_input, init = {}) => {", + ' writeFileSync(process.env.STAGEHAND_SIGNAL_READY_FILE, "ready");', + " return new Promise((_resolve, reject) => {", + ' init.signal?.addEventListener("abort", () => reject(new Error("aborted")), { once: true });', + " });", + "};", + ].join("\n"), + ), + ]); + + const child = spawn( + process.execPath, + [ + fileURLToPath(import.meta.resolve("tsx/cli")), + fileURLToPath(new URL("./lease.ts", import.meta.url)), + ], + { + env: { + ...process.env, + NODE_OPTIONS: `--import=${pathToFileURL(preloadPath).href}`, + STAGEHAND_SIGNAL_READY_FILE: readyPath, + STAGEHAND_SANDBOX_ARTIFACTS: artifactRoot, + BROWSERBASE_API_KEY: "unused-test-key", + BROWSERBASE_PROJECT_ID: "unused-test-project", + }, + stdio: ["pipe", "pipe", "pipe"], + }, + ); + + try { + await waitForFile(readyPath); + child.kill("SIGTERM"); + const exit = await waitForExit(child, "Lease did not preserve the setup signal"); + assert.deepEqual(exit, { code: 143, signal: null }); + } finally { + if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL"); + await rm(temporaryRoot, { force: true, recursive: true }); + } +}); + +async function waitForFile(filePath) { + const deadline = Date.now() + EXIT_TIMEOUT_MS; + while (Date.now() < deadline) { + try { + await access(filePath); + return; + } catch { + await new Promise((resolve) => setTimeout(resolve, 10)); + } + } + throw new Error("Lease did not reach the blocked setup request"); +} + +async function waitForExit(child, message) { + let timeout; + return Promise.race([ + new Promise((resolve) => child.once("close", (code, signal) => resolve({ code, signal }))), + new Promise((_, reject) => { + timeout = setTimeout(() => reject(new Error(message)), EXIT_TIMEOUT_MS); + }), + ]).finally(() => clearTimeout(timeout)); +} diff --git a/packages/integrations/examples/vercel-sandbox/src/lease.ts b/packages/integrations/examples/vercel-sandbox/src/lease.ts index 73dcf5bf78..6259691877 100644 --- a/packages/integrations/examples/vercel-sandbox/src/lease.ts +++ b/packages/integrations/examples/vercel-sandbox/src/lease.ts @@ -6,13 +6,16 @@ const SHUTDOWN_FALLBACK_MS = 35_000; type LeaseEnd = { signal?: NodeJS.Signals }; +let setupSignal: NodeJS.Signals | undefined; try { const packageArtifactsPath = requiredEnvironment("STAGEHAND_SANDBOX_ARTIFACTS"); const browserbaseApiKey = requiredEnvironment("BROWSERBASE_API_KEY"); const browserbaseProjectId = requiredEnvironment("BROWSERBASE_PROJECT_ID"); const vercelCredentials = vercelCredentialsFromEnvironment(); const setupAbort = new AbortController(); - const leaseEnd = waitForLeaseEnd(setupAbort); + const leaseEnd = waitForLeaseEnd(setupAbort, (signal) => { + setupSignal = signal; + }); const connection = await createStagehandSandbox({ packageArtifactsPath, browserbaseApiKey, @@ -41,11 +44,15 @@ try { if (signal) forwardSignal(signal); } catch (error) { process.stdin.pause(); + if (setupSignal) forwardSignal(setupSignal); process.stderr.write(`Stagehand sandbox lease failed: ${safeMessage(error)}\n`); process.exitCode = 1; } -function waitForLeaseEnd(setupAbort: AbortController): Promise { +function waitForLeaseEnd( + setupAbort: AbortController, + recordSignal: (signal: NodeJS.Signals) => void, +): Promise { return new Promise((resolve) => { let finished = false; const finish = (end: LeaseEnd = {}) => { @@ -55,8 +62,14 @@ function waitForLeaseEnd(setupAbort: AbortController): Promise { resolve(end); }; - process.once("SIGINT", () => finish({ signal: "SIGINT" })); - process.once("SIGTERM", () => finish({ signal: "SIGTERM" })); + process.once("SIGINT", () => { + recordSignal("SIGINT"); + finish({ signal: "SIGINT" }); + }); + process.once("SIGTERM", () => { + recordSignal("SIGTERM"); + finish({ signal: "SIGTERM" }); + }); process.stdin.once("end", () => finish()); process.stdin.once("close", () => finish()); process.stdin.resume(); diff --git a/packages/integrations/examples/vercel-sandbox/src/sandbox.test.ts b/packages/integrations/examples/vercel-sandbox/src/sandbox.test.ts index 0e19789276..9600abe078 100644 --- a/packages/integrations/examples/vercel-sandbox/src/sandbox.test.ts +++ b/packages/integrations/examples/vercel-sandbox/src/sandbox.test.ts @@ -1,4 +1,6 @@ import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { getEventListeners } from "node:events"; import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; @@ -67,6 +69,76 @@ void test("runtime lock rejects non-string resolved values", async () => { } }); +void test("an already-aborted setup never creates a sandbox", async () => { + const controller = new AbortController(); + controller.abort(); + const createMock = mock.method(Sandbox, "create", async () => { + throw new Error("Sandbox.create must not run"); + }); + try { + await assert.rejects( + createStagehandSandbox({ + packageArtifactsPath: path.join(os.tmpdir(), "unused-stagehand-artifacts"), + browserbaseApiKey: "unused-test-key", + browserbaseProjectId: "unused-test-project", + signal: controller.signal, + }), + { name: "StagehandSandboxSetupError" }, + ); + assert.equal(createMock.mock.callCount(), 0); + assert.equal(getEventListeners(controller.signal, "abort").length, 0); + } finally { + createMock.mock.restore(); + } +}); + +void test("abort during readiness stops polling, clears listeners, and disposes", async () => { + const resolved = "https://registry.npmjs.org/supergateway.tgz"; + const artifactRoot = await writeArtifacts(resolved); + const controller = new AbortController(); + const fake = readySandboxFake(resolved); + let healthRequests = 0; + const fetchMock = mock.method(globalThis, "fetch", async (input) => { + const url = new URL(input.toString()); + if (url.hostname === "api.browserbase.com" && url.pathname === "/v1/sessions") { + return { + ok: true, + json: async () => ({ + id: "discovery-session", + connectUrl: "wss://connect.browserbase.com/devtools/browser/test", + }), + } as Response; + } + if (url.hostname === "api.browserbase.com") return { ok: true } as Response; + healthRequests += 1; + if (healthRequests === 1) setTimeout(() => controller.abort(), 10); + return { ok: false, status: 503 } as Response; + }); + const createMock = mock.method(Sandbox, "create", async () => fake.sandbox); + + try { + await assert.rejects( + createStagehandSandbox({ + packageArtifactsPath: artifactRoot, + browserbaseApiKey: "unused-test-key", + browserbaseProjectId: "unused-test-project", + readinessTimeoutMs: 5_000, + signal: controller.signal, + }), + { name: "StagehandSandboxSetupError" }, + ); + await new Promise((resolve) => setTimeout(resolve, 300)); + assert.equal(healthRequests, 1); + assert.equal(fake.stop.mock.callCount(), 1); + assert.equal(fake.deleteSandbox.mock.callCount(), 1); + assert.equal(getEventListeners(controller.signal, "abort").length, 0); + } finally { + createMock.mock.restore(); + fetchMock.mock.restore(); + await rm(artifactRoot, { force: true, recursive: true }); + } +}); + void test("Sandbox.create receives only allowlisted Vercel credentials", async () => { const createOptions = await captureSandboxCreateOptions({ teamId: "expected-team", @@ -157,3 +229,44 @@ async function writeArtifacts(resolved: unknown): Promise { ]); return artifactRoot; } + +function readySandboxFake(resolved: unknown) { + const gzip = Buffer.from([0x1f, 0x8b]); + const manifest = Buffer.from(JSON.stringify({ dependencies })); + const lock = Buffer.from( + JSON.stringify({ + lockfileVersion: 3, + packages: { + "": { dependencies }, + "node_modules/supergateway": { resolved }, + }, + }), + ); + const sha256 = (content: Buffer) => createHash("sha256").update(content).digest("hex"); + const artifactDigests = new Map([ + ["/vercel/sandbox/packages/stagehand.tgz", sha256(gzip)], + ["/vercel/sandbox/packages/stagehand-codemode.tgz", sha256(gzip)], + ["/vercel/sandbox/stagehand-runtime/package.json", sha256(manifest)], + ["/vercel/sandbox/stagehand-runtime/package-lock.json", sha256(lock)], + ]); + const runCommand = mock.fn(async ({ cmd, args }: { cmd: string; args?: string[] }) => ({ + exitCode: cmd === "sudo" ? 1 : 0, + stdout: async () => + cmd === "sha256sum" ? `${artifactDigests.get(args?.[0] ?? "")} ${args?.[0]}\n` : "", + stderr: async () => "", + })); + const user = { runCommand }; + const stop = mock.fn(async () => undefined); + const deleteSandbox = mock.fn(async () => undefined); + const sandbox = { + runCommand, + writeFiles: async () => undefined, + createUser: async () => user, + asUser: () => user, + update: async () => undefined, + domain: () => "https://sandbox.example.vercel.run", + stop, + delete: deleteSandbox, + } as unknown as Sandbox; + return { sandbox, stop, deleteSandbox }; +} diff --git a/packages/integrations/examples/vercel-sandbox/src/sandbox.ts b/packages/integrations/examples/vercel-sandbox/src/sandbox.ts index efed11742f..dde0f08fdb 100644 --- a/packages/integrations/examples/vercel-sandbox/src/sandbox.ts +++ b/packages/integrations/examples/vercel-sandbox/src/sandbox.ts @@ -184,10 +184,7 @@ export async function createStagehandSandbox( await abortable(startAuthProxy(proxyUser, tokenDigest), options.signal); const origin = new URL(sandbox.domain(BRIDGE_PORT)); - await abortable( - waitForHealth(origin, token, options.readinessTimeoutMs ?? 2 * 60_000), - options.signal, - ); + await waitForHealth(origin, token, options.readinessTimeoutMs ?? 2 * 60_000, options.signal); const unauthorized = await abortable( fetch(new URL("/healthz", origin), { signal: AbortSignal.timeout(HEALTH_REQUEST_TIMEOUT_MS), @@ -318,7 +315,13 @@ async function assertUnprivileged(user: SandboxUser, name: string): Promise { +async function waitForHealth( + origin: URL, + token: string, + timeoutMs: number, + signal?: AbortSignal, +): Promise { + assertNotAborted(signal); const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { const requestTimeoutMs = Math.max( @@ -327,10 +330,13 @@ async function waitForHealth(origin: URL, token: string, timeoutMs: number): Pro ); const response = await fetch(new URL("/healthz", origin), { headers: { Authorization: `Bearer ${token}` }, - signal: AbortSignal.timeout(requestTimeoutMs), + signal: signal + ? AbortSignal.any([signal, AbortSignal.timeout(requestTimeoutMs)]) + : AbortSignal.timeout(requestTimeoutMs), }).catch(() => undefined); + assertNotAborted(signal); if (response?.ok) return; - await delay(Math.min(250, Math.max(0, deadline - Date.now()))); + await delay(Math.min(250, Math.max(0, deadline - Date.now())), signal); } throw new StagehandSandboxHealthError(); } @@ -348,8 +354,11 @@ async function discoverBrowserbaseCdpHost(options: StagehandSandboxOptions): Pro "X-BB-API-Key": options.browserbaseApiKey, }, body: JSON.stringify({ projectId: options.browserbaseProjectId }), - signal: AbortSignal.timeout(30_000), + signal: options.signal + ? AbortSignal.any([options.signal, AbortSignal.timeout(30_000)]) + : AbortSignal.timeout(30_000), }); + assertNotAborted(options.signal); if (!response.ok) { throw new Error(`Browserbase CDP host discovery returned ${response.status}`); } @@ -454,8 +463,21 @@ function assertBrowserbaseCdpHost(hostname: string): string { return hostname; } -function delay(milliseconds: number): Promise { - return new Promise((resolve) => setTimeout(resolve, milliseconds)); +function delay(milliseconds: number, signal?: AbortSignal): Promise { + if (!signal) return new Promise((resolve) => setTimeout(resolve, milliseconds)); + assertNotAborted(signal); + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + signal.removeEventListener("abort", onAbort); + resolve(); + }, milliseconds); + const onAbort = () => { + clearTimeout(timeout); + signal.removeEventListener("abort", onAbort); + reject(new StagehandSandboxSetupError()); + }; + signal.addEventListener("abort", onAbort, { once: true }); + }); } function assertNotAborted(signal: AbortSignal | undefined): void { From 73911f329536c079ff436c0e654b94d04fdc9da5 Mon Sep 17 00:00:00 2001 From: Shrey Pandya Date: Sat, 8 Aug 2026 04:56:24 +0000 Subject: [PATCH 9/9] fix(crewai): close lifecycle review gaps --- .../integrations/examples/crewai/agent.py | 19 +++- .../examples/crewai/test_agent.py | 21 +++++ .../shared/test_vercel_sandbox_lease.py | 20 ++++ .../vercel-sandbox/src/lease.test.mjs | 4 +- .../vercel-sandbox/src/sandbox.test.ts | 92 +++++++++++++------ .../examples/vercel-sandbox/src/sandbox.ts | 3 +- 6 files changed, 124 insertions(+), 35 deletions(-) diff --git a/packages/integrations/examples/crewai/agent.py b/packages/integrations/examples/crewai/agent.py index e78d3a9b9f..a00e1b7498 100644 --- a/packages/integrations/examples/crewai/agent.py +++ b/packages/integrations/examples/crewai/agent.py @@ -150,11 +150,20 @@ def stop() -> None: except BaseException as error: # noqa: BLE001 -- adapter shutdown is an untyped boundary. result.put(error) - threading.Thread( - target=stop, - name="stagehand-crewai-mcp-cleanup", - daemon=True, - ).start() + # MCPServerAdapter exposes only a synchronous stop with no cancellation hook or + # documented thread affinity. A daemon supervisor bounds that call; after a + # timeout the sandbox lease may unwind while the orphaned stop finishes, and + # its result is intentionally ignored because cleanup failure was reported. + try: + threading.Thread( + target=stop, + name="stagehand-crewai-mcp-cleanup", + daemon=True, + ).start() + except BaseException: # noqa: BLE001 -- cleanup startup is an untyped boundary. + raise StagehandCrewAICleanupError( + "Could not close the CrewAI Stagehand MCP adapter." + ) from None try: error = result.get(timeout=ADAPTER_STOP_TIMEOUT_SECONDS) except queue.Empty: diff --git a/packages/integrations/examples/crewai/test_agent.py b/packages/integrations/examples/crewai/test_agent.py index 068acb4b4c..5bd3a5f57f 100644 --- a/packages/integrations/examples/crewai/test_agent.py +++ b/packages/integrations/examples/crewai/test_agent.py @@ -108,6 +108,27 @@ def test_setup_and_cleanup_failures_preserve_order_and_types(self) -> None: raised.exception.exceptions[1], agent.StagehandCrewAICleanupError ) + def test_setup_and_cleanup_thread_start_failures_preserve_types(self) -> None: + adapter = FakeAdapter([], discovery_error=RuntimeError("discovery-secret")) + with ( + patch.object(agent, "MCPServerAdapter", return_value=adapter), + patch.object( + agent.threading.Thread, + "start", + side_effect=RuntimeError("thread-start-secret"), + ), + self.assertRaises(BaseExceptionGroup) as raised, + agent.stagehand_code_tools(self.connection), + ): + pass + + self.assertIsInstance( + raised.exception.exceptions[0], agent.StagehandCrewAIConnectionError + ) + self.assertIsInstance( + raised.exception.exceptions[1], agent.StagehandCrewAICleanupError + ) + def test_run_and_cleanup_failures_preserve_order_and_types(self) -> None: adapter = FakeAdapter( [ diff --git a/packages/integrations/examples/shared/test_vercel_sandbox_lease.py b/packages/integrations/examples/shared/test_vercel_sandbox_lease.py index 8a6c3983b4..a9ff0b465f 100644 --- a/packages/integrations/examples/shared/test_vercel_sandbox_lease.py +++ b/packages/integrations/examples/shared/test_vercel_sandbox_lease.py @@ -158,6 +158,26 @@ def test_keyboard_interrupt_during_setup_is_preserved(self) -> None: self.assertTrue(process.waited_after_stdin_closed) + def test_unexpected_setup_exception_is_sanitized(self) -> None: + process = FakeProcess() + with ( + patch.object(lease, "TSX_PATH", Path(__file__)), + patch.object(lease, "LEASE_PATH", Path(__file__)), + patch.object(subprocess, "Popen", return_value=process), + patch.object( + lease.queue.Queue, + "get", + side_effect=ValueError("setup-secret-do-not-reflect"), + ), + self.assertRaises(lease.StagehandSandboxLeaseError) as raised, + ): + lease.StagehandSandboxLease().start() + + self.assertEqual( + str(raised.exception), "Stagehand sandbox lease failed during setup" + ) + self.assertTrue(process.waited_after_stdin_closed) + def test_connection_parser_rejects_untrusted_shapes(self) -> None: invalid_connections = ( "not-json", diff --git a/packages/integrations/examples/vercel-sandbox/src/lease.test.mjs b/packages/integrations/examples/vercel-sandbox/src/lease.test.mjs index f770efdc71..bea55cecd2 100644 --- a/packages/integrations/examples/vercel-sandbox/src/lease.test.mjs +++ b/packages/integrations/examples/vercel-sandbox/src/lease.test.mjs @@ -98,7 +98,7 @@ test("lease preserves signal exit semantics during setup", async () => { ], { env: { - ...process.env, + PATH: process.env.PATH ?? "", NODE_OPTIONS: `--import=${pathToFileURL(preloadPath).href}`, STAGEHAND_SIGNAL_READY_FILE: readyPath, STAGEHAND_SANDBOX_ARTIFACTS: artifactRoot, @@ -113,6 +113,8 @@ test("lease preserves signal exit semantics during setup", async () => { await waitForFile(readyPath); child.kill("SIGTERM"); const exit = await waitForExit(child, "Lease did not preserve the setup signal"); + // The production lease is launched through the tsx executable, whose signal + // proxy reports the conventional 128 + SIGTERM status rather than signalCode. assert.deepEqual(exit, { code: 143, signal: null }); } finally { if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL"); diff --git a/packages/integrations/examples/vercel-sandbox/src/sandbox.test.ts b/packages/integrations/examples/vercel-sandbox/src/sandbox.test.ts index 9600abe078..15d28fbb0b 100644 --- a/packages/integrations/examples/vercel-sandbox/src/sandbox.test.ts +++ b/packages/integrations/examples/vercel-sandbox/src/sandbox.test.ts @@ -92,6 +92,44 @@ void test("an already-aborted setup never creates a sandbox", async () => { } }); +void test("abort during CDP discovery is reported as setup cancellation", async () => { + const artifactRoot = await writeArtifacts("https://registry.npmjs.org/supergateway.tgz"); + const controller = new AbortController(); + let discoveryStarted!: () => void; + const started = new Promise((resolve) => { + discoveryStarted = resolve; + }); + const fetchMock = mock.method(globalThis, "fetch", async (_input, init) => { + discoveryStarted(); + return new Promise((_resolve, reject) => { + init?.signal?.addEventListener("abort", () => reject(new Error("aborted")), { + once: true, + }); + }); + }); + const createMock = mock.method(Sandbox, "create", async () => { + throw new Error("Sandbox.create must not run"); + }); + + try { + const setup = createStagehandSandbox({ + packageArtifactsPath: artifactRoot, + browserbaseApiKey: "unused-test-key", + browserbaseProjectId: "unused-test-project", + signal: controller.signal, + }); + await started; + controller.abort(); + await assert.rejects(setup, { name: "StagehandSandboxSetupError" }); + assert.equal(createMock.mock.callCount(), 0); + assert.equal(getEventListeners(controller.signal, "abort").length, 0); + } finally { + createMock.mock.restore(); + fetchMock.mock.restore(); + await rm(artifactRoot, { force: true, recursive: true }); + } +}); + void test("abort during readiness stops polling, clears listeners, and disposes", async () => { const resolved = "https://registry.npmjs.org/supergateway.tgz"; const artifactRoot = await writeArtifacts(resolved); @@ -211,43 +249,25 @@ async function writeArtifacts(resolved: unknown): Promise { const artifactRoot = await mkdtemp(path.join(os.tmpdir(), "stagehand-artifacts-")); const packageRoot = path.join(artifactRoot, "packages"); const runtimeRoot = path.join(artifactRoot, "runtime"); + const contents = artifactContents(resolved); await Promise.all([mkdir(packageRoot), mkdir(runtimeRoot)]); await Promise.all([ - writeFile(path.join(packageRoot, "stagehand.tgz"), Buffer.from([0x1f, 0x8b])), - writeFile(path.join(packageRoot, "stagehand-codemode.tgz"), Buffer.from([0x1f, 0x8b])), - writeFile(path.join(runtimeRoot, "package.json"), JSON.stringify({ dependencies })), - writeFile( - path.join(runtimeRoot, "package-lock.json"), - JSON.stringify({ - lockfileVersion: 3, - packages: { - "": { dependencies }, - "node_modules/supergateway": { resolved }, - }, - }), - ), + writeFile(path.join(packageRoot, "stagehand.tgz"), contents.gzip), + writeFile(path.join(packageRoot, "stagehand-codemode.tgz"), contents.gzip), + writeFile(path.join(runtimeRoot, "package.json"), contents.manifest), + writeFile(path.join(runtimeRoot, "package-lock.json"), contents.lock), ]); return artifactRoot; } function readySandboxFake(resolved: unknown) { - const gzip = Buffer.from([0x1f, 0x8b]); - const manifest = Buffer.from(JSON.stringify({ dependencies })); - const lock = Buffer.from( - JSON.stringify({ - lockfileVersion: 3, - packages: { - "": { dependencies }, - "node_modules/supergateway": { resolved }, - }, - }), - ); + const contents = artifactContents(resolved); const sha256 = (content: Buffer) => createHash("sha256").update(content).digest("hex"); const artifactDigests = new Map([ - ["/vercel/sandbox/packages/stagehand.tgz", sha256(gzip)], - ["/vercel/sandbox/packages/stagehand-codemode.tgz", sha256(gzip)], - ["/vercel/sandbox/stagehand-runtime/package.json", sha256(manifest)], - ["/vercel/sandbox/stagehand-runtime/package-lock.json", sha256(lock)], + ["/vercel/sandbox/packages/stagehand.tgz", sha256(contents.gzip)], + ["/vercel/sandbox/packages/stagehand-codemode.tgz", sha256(contents.gzip)], + ["/vercel/sandbox/stagehand-runtime/package.json", sha256(contents.manifest)], + ["/vercel/sandbox/stagehand-runtime/package-lock.json", sha256(contents.lock)], ]); const runCommand = mock.fn(async ({ cmd, args }: { cmd: string; args?: string[] }) => ({ exitCode: cmd === "sudo" ? 1 : 0, @@ -270,3 +290,19 @@ function readySandboxFake(resolved: unknown) { } as unknown as Sandbox; return { sandbox, stop, deleteSandbox }; } + +function artifactContents(resolved: unknown) { + return { + gzip: Buffer.from([0x1f, 0x8b]), + manifest: Buffer.from(JSON.stringify({ dependencies })), + lock: Buffer.from( + JSON.stringify({ + lockfileVersion: 3, + packages: { + "": { dependencies }, + "node_modules/supergateway": { resolved }, + }, + }), + ), + }; +} diff --git a/packages/integrations/examples/vercel-sandbox/src/sandbox.ts b/packages/integrations/examples/vercel-sandbox/src/sandbox.ts index dde0f08fdb..24a73c5473 100644 --- a/packages/integrations/examples/vercel-sandbox/src/sandbox.ts +++ b/packages/integrations/examples/vercel-sandbox/src/sandbox.ts @@ -369,7 +369,7 @@ async function discoverBrowserbaseCdpHost(options: StagehandSandboxOptions): Pro } discoveredHost = assertBrowserbaseCdpHost(new URL(session.connectUrl).hostname); } catch (error) { - primaryError = error; + primaryError = options.signal?.aborted ? new StagehandSandboxSetupError() : error; } let cleanupError: unknown = NO_ERROR; @@ -395,6 +395,7 @@ async function discoverBrowserbaseCdpHost(options: StagehandSandboxOptions): Pro } if (primaryError !== NO_ERROR || cleanupError !== NO_ERROR || !discoveredHost) { + if (primaryError instanceof StagehandSandboxSetupError) throw primaryError; throw new StagehandCdpDiscoveryError(); } return discoveredHost;