Skip to content

Mockup: Stagehand Chrome extension that links live browser to cli and library#1828

Draft
pirate wants to merge 9 commits intomainfrom
claude/stagehand-browser-extension-OgEsp
Draft

Mockup: Stagehand Chrome extension that links live browser to cli and library#1828
pirate wants to merge 9 commits intomainfrom
claude/stagehand-browser-extension-OgEsp

Conversation

@pirate
Copy link
Member

@pirate pirate commented Mar 15, 2026

Summary

This PR introduces a new Chrome extension package that enables browser automation through Stagehand directly from a browser sidebar panel. The extension provides a chat-like UI for executing AI-powered actions (act, observe, extract, agent) on web pages using the Chrome DevTools Protocol (CDP) and Anthropic's Claude API.

Key Changes

  • New Extension Package (packages/extension/): Complete Chrome extension implementation with:

    • Background service worker (background.ts) that manages CDP attachment to tabs via chrome.debugger
    • Sidebar panel UI (sidepanel.ts) with chat interface for Stagehand operations
    • CDP adapter (cdp-adapter.ts) that proxies CDP commands through chrome.runtime.Port
    • Type definitions (types.ts) for inter-process messaging
  • Sidebar Panel Features:

    • Four action modes: Agent (multi-step), Act (single action), Observe (find elements), Extract (data extraction)
    • Real-time chat-like message display with action badges and results
    • API key management with persistent storage
    • Tab attachment/detachment controls with visual status indicator
    • Anthropic API integration using claude-sonnet-4-20250514
  • Background Service Worker:

    • Manages chrome.debugger attachment lifecycle for multiple tabs
    • Proxies CDP commands from sidebar to browser via chrome.debugger.sendCommand()
    • Forwards CDP events back to sidebar via chrome.runtime.Port
    • Handles OOPIF (out-of-process iframe) frames with synthetic session IDs
    • Tracks active tab and broadcasts state changes to sidebar
  • CDP Adapter:

    • Implements CDPSessionLike interface compatible with Stagehand's understudy
    • Multiplexes multiple child sessions over single port connection
    • Handles Target domain events for frame attachment/detachment
  • Build & Configuration:

    • Vite build configuration with manifest copying and icon generation
    • TypeScript configuration for Chrome extension APIs
    • Manifest v3 with required permissions (debugger, tabs, sidePanel, storage)
    • Icon generation script for placeholder extension icons
  • UI/UX:

    • Dark-themed sidebar with accent colors
    • Responsive layout with chat container, action buttons, and input area
    • Loading states and error handling
    • Keyboard shortcuts (Enter to send, Shift+Enter for newline)

Notable Implementation Details

  • Uses synthetic session IDs (sh-tab-{scope}-{id}) to multiplex CDP sessions across extension restarts
  • Handles restricted URLs (chrome://, about:, etc.) gracefully without attachment attempts
  • Implements Target.setAutoAttach for OOPIF frame support across all attached tabs
  • Direct Anthropic API calls from extension context with base64 screenshot encoding
  • Persistent API key storage in chrome.storage.local
  • Proper cleanup of child sessions and event handlers on tab detachment

Workspace Changes

  • Added packages/extension to pnpm-workspace.yaml for monorepo integration

https://claude.ai/code/session_01TCsbC7eC1Fm7w1VoVPWkpR


Summary by cubic

Adds a Chrome extension to run Stagehand in a side panel with a server-side CDP relay and v4 REST API. Also fixes MV3 port reconnects in the side panel and prevents debugger attachment on tabs without URLs.

  • New Features

    • New package @browserbasehq/stagehand-extension (MV3 side panel + background worker).
    • CDP relay via server WebSockets (/v4/extension/v4/cdp) with auto-attach, synthetic Target.attachedToTarget, and OOPIF; removed in-extension CDP adapter.
    • Side panel actions (Agent, Act, Observe, Extract), message stream, shortcuts; API key stored in chrome.storage.local.
    • Server v4: page and browsersession routes with zod schemas and OpenAPI; session store persists and lists actions.
    • Tests added for the relay, auto-attach, and v4 routes; extension README included.
  • Dependencies

    • Removed @anthropic-ai/sdk from @browserbasehq/stagehand-extension; the extension uses REST fetch to the v4 server.
    • Updated pnpm-lock.yaml; added ws/@types/ws to server-v4 and set @browserbasehq/sdk to ^2.4.0.

Written for commit 2824dee. Summary will update on new commits. Review in cubic

Chrome extension that connects the stagehand/understudy library to
running browser tabs via chrome.debugger CDP proxy. Inspired by
playwriter's CDP extension architecture.

- Background service worker with chrome.debugger attachment/detachment
- CDP command/event multiplexing with synthetic session IDs
- Native browser sidebar panel with AI chat interface
- act(), observe(), extract(), and agent execute buttons
- Tab activation tracking to keep sidebar in sync with foreground tab
- CDPSessionLike adapter bridging chrome.debugger ↔ understudy CDP
- Vite-based build producing a loadable Chrome extension in dist/

https://claude.ai/code/session_01TCsbC7eC1Fm7w1VoVPWkpR
@changeset-bot
Copy link

changeset-bot bot commented Mar 15, 2026

⚠️ No Changeset found

Latest commit: 2824dee

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@pirate pirate marked this pull request as draft March 15, 2026 04:49
Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

8 issues found across 13 files

Confidence score: 2/5

  • There are a few high-confidence, user-impacting risks in packages/extension/src/sidepanel.ts: selector interpolation in Runtime.evaluate does not escape backslashes (can break command execution), and sendCdpCommand lacks a timeout (can leave the UI stuck in a processing state).
  • packages/extension/src/cdp-adapter.ts is missing a port.onDisconnect path, so MV3 worker restarts can leave in-flight CDP promises hanging indefinitely; this makes connection liveness handling fragile.
  • Given multiple medium-to-high severity issues (6–8/10) with strong confidence and concrete runtime impact, merge risk is elevated until lifecycle/error-handling fixes are in place.
  • Pay close attention to packages/extension/src/sidepanel.ts, packages/extension/src/cdp-adapter.ts, and packages/extension/public/sidepanel.html - async hangs, escaping correctness, and input behavior mismatches are the main regression points.
Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="packages/extension/src/cdp-adapter.ts">

<violation number="1" location="packages/extension/src/cdp-adapter.ts:66">
P1: Missing `port.onDisconnect` handler — if the MV3 service worker goes idle or restarts, all in-flight CDP promises will hang forever and the connection won't know it's dead. Register a disconnect listener in the constructor that sets `_closed = true` and rejects all pending requests.</violation>

<violation number="2" location="packages/extension/src/cdp-adapter.ts:118">
P2: `close()` doesn't clear child sessions or event handlers, leaking the entire session graph. Clear `this.sessions` and `this.eventHandlers` alongside the inflight cleanup.</violation>
</file>

<file name="packages/extension/src/background.ts">

<violation number="1" location="packages/extension/src/background.ts:85">
P2: `isRestrictedUrl` returns `false` when `url` is `undefined`, allowing a debugger attach attempt on tabs whose URL is unknown. Return `true` for undefined/empty URLs to fail fast instead of triggering a debugger error.</violation>
</file>

<file name="packages/extension/src/sidepanel.ts">

<violation number="1" location="packages/extension/src/sidepanel.ts:321">
P1: `sendCdpCommand` has no timeout — if the background worker never responds, the promise hangs indefinitely, permanently locking the UI in the processing state. Add a timeout (e.g., 30s) that rejects the promise and removes the listener.</violation>

<violation number="2" location="packages/extension/src/sidepanel.ts:364">
P1: Custom agent: **Ensure we never check against hardcoded lists of allowed LLM model names**

Hardcoded model name `"claude-sonnet-4-20250514"` should be user-configurable. Make the model selectable (e.g., via an input field or dropdown in the settings bar, stored alongside the API key) so users can specify any model without requiring a code change. The rule requires newly added code to accept any model name and let the provider reject unsupported ones.</violation>

<violation number="3" location="packages/extension/src/sidepanel.ts:425">
P1: Incomplete escaping in `Runtime.evaluate` selector interpolation — backslashes are not escaped before single quotes. A selector containing a trailing backslash (e.g. from LLM output) breaks the string literal, causing syntax errors or arbitrary JS execution in the page context. Escape backslashes first: `.replace(/\\/g, '\\\\').replace(/'/g, "\\'")`.</violation>

<violation number="4" location="packages/extension/src/sidepanel.ts:585">
P2: The click/type/scroll/navigate execution logic in `executeAgent` (~lines 583-615) is nearly identical to `executeAct` (~lines 422-478). Extract a shared helper like `executeDomAction(parsed)` to avoid maintaining duplicate code — bugs (like the selector escaping issue) must currently be fixed in both places.</violation>
</file>

<file name="packages/extension/public/sidepanel.html">

<violation number="1" location="packages/extension/public/sidepanel.html:309">
P2: This `<input type="text">` cannot support the Shift+Enter newline behavior implemented in the JS (`sidepanel.ts:261-264` checks `!e.shiftKey` before sending). Replace with a `<textarea>` so multiline input actually works, or remove the dead Shift+Enter guard from the JS.</violation>
</file>
Architecture diagram
sequenceDiagram
    participant User
    participant Sidebar as Side Panel UI
    participant Storage as chrome.storage
    participant CDP as CDP Adapter
    participant SW as Background Service Worker
    participant Browser as chrome.debugger (Tab)
    participant AI as Anthropic API (Claude)

    Note over User,AI: NEW: Stagehand Browser Extension Runtime Flow

    User->>Sidebar: Select Action (Act/Observe/Extract/Agent)
    Sidebar->>Storage: NEW: Get ANTHROPIC_API_KEY
    Storage-->>Sidebar: API Key

    rect rgb(23, 37, 84)
    Note right of Sidebar: Attachment Phase
    User->>Sidebar: Click "Attach to Tab"
    Sidebar->>SW: NEW: Request attach (tabId)
    SW->>Browser: chrome.debugger.attach()
    SW->>Browser: NEW: Target.setAutoAttach (OOPIF Support)
    Browser-->>SW: Attached
    SW-->>Sidebar: NEW: Broadcast tab-state (attached)
    end

    rect rgb(5, 46, 22)
    Note right of Sidebar: Execution Phase (e.g., "Act")
    User->>Sidebar: Enter instruction + Submit
    Sidebar->>CDP: NEW: send("Page.captureScreenshot")
    CDP->>SW: NEW: Port.postMessage(CdpCommandRequest)
    SW->>Browser: chrome.debugger.sendCommand()
    Browser-->>SW: Base64 Screenshot
    SW-->>CDP: Port.onMessage(CdpCommandResponse)
    CDP-->>Sidebar: Image Data

    Sidebar->>AI: NEW: POST /messages (Image + Instruction)
    AI-->>Sidebar: Proposed Action (e.g., Click at X,Y)

    Sidebar->>CDP: NEW: send("Input.dispatchMouseEvent")
    CDP->>SW: Port.postMessage(CdpCommandRequest)
    
    alt Synthetic Session Multiplexing
        SW->>SW: NEW: Map sessionId to Tab/Frame
        SW->>Browser: chrome.debugger.sendCommand(target: {tabId, sessionId})
    end
    
    Browser-->>SW: Success
    SW-->>Sidebar: Action Result
    end

    rect rgb(69, 26, 3)
    Note right of SW: Event Loop
    Browser->>SW: chrome.debugger.onEvent (e.g., Page.navigated)
    SW->>SW: NEW: Update internal tab-state
    SW->>Sidebar: NEW: Broadcast CdpEventMessage
    Sidebar->>Sidebar: Update UI logs/state
    end
Loading

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

</div>

<div class="input-area">
<input
Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot Mar 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: This <input type="text"> cannot support the Shift+Enter newline behavior implemented in the JS (sidepanel.ts:261-264 checks !e.shiftKey before sending). Replace with a <textarea> so multiline input actually works, or remove the dead Shift+Enter guard from the JS.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/extension/public/sidepanel.html, line 309:

<comment>This `<input type="text">` cannot support the Shift+Enter newline behavior implemented in the JS (`sidepanel.ts:261-264` checks `!e.shiftKey` before sending). Replace with a `<textarea>` so multiline input actually works, or remove the dead Shift+Enter guard from the JS.</comment>

<file context>
@@ -0,0 +1,321 @@
+    </div>
+
+    <div class="input-area">
+      <input
+        type="text"
+        id="promptInput"
</file context>
Fix with Cubic

@greptile-apps
Copy link
Contributor

greptile-apps bot commented Mar 15, 2026

Greptile Summary

This PR introduces a new packages/extension Chrome extension package and a substantial packages/server-v4 expansion, adding a WebSocket relay (/v4/extension/v4/cdp) that bridges the extension's chrome.debugger CDP proxy to Stagehand's server-side automation pipeline. The extension opens as a sidebar panel with act/observe/extract/agent modes; the background service worker manages tab attachment and forwards CDP commands to/from the relay server.

Key issues found:

  • Critical: Broken server API integration in sidepanel.ts — The sidebar calls POST /v4/sessions/start, /v4/sessions/:id/end, and /v4/sessions/:id/act|observe|extract|agentExecute, but none of these routes exist. The actual endpoints are POST /v4/browsersession, POST /v4/browsersession/:id/end, and action routes under /v4/page/*. The request body format is also mismatched. As a result, every HTTP call from the extension will 404, making the core automation features non-functional.

  • Logic: InMemorySessionStore.deleteSession leaks action entriesPageAction and BrowserSessionAction map entries are never removed when a session is deleted; they only clear on full destroy(). Long-running servers will accumulate unbounded orphaned action entries.

  • Security: Unauthenticated relay WebSocket endpoints on all interfaces/v4/cdp and /v4/extension require no credentials and are bound to 0.0.0.0, so any host on the same network can connect and execute arbitrary CDP commands against the user's browser via the extension.

  • Style: Dead types in types.tsCdpCommandRequest / CdpCommandResponse and the "cdp-command" / "cdp-response" message protocol are defined but never used, indicating an incomplete refactor.

The background.ts service worker, the relay server logic, and the new server-v4 route structure are all well-implemented; the primary blocker is the sidepanel's HTTP API integration being entirely disconnected from the actual server routes.

Confidence Score: 2/5

  • Not safe to merge — the extension's HTTP API integration is completely broken and the relay endpoints lack authentication.
  • The background service worker and server-side relay logic are solid, but the sidepanel's session and action API calls target non-existent endpoints, making every core automation feature non-functional. The unauthenticated relay on 0.0.0.0 is a meaningful security concern that should be addressed before shipping. The InMemorySessionStore action leak will cause gradual memory growth in production.
  • packages/extension/src/sidepanel.ts (broken API endpoints), packages/server-v4/src/routes/v4/extensionRelay.ts (no auth), packages/server-v4/src/lib/InMemorySessionStore.ts (action memory leak)

Important Files Changed

Filename Overview
packages/extension/src/sidepanel.ts Calls non-existent API endpoints (/v4/sessions/start, /v4/sessions/:id/act, etc.) — the entire HTTP integration with the server is broken; no automation action can succeed.
packages/extension/src/background.ts Well-structured MV3 service worker that manages chrome.debugger attachment, WebSocket relay reconnection with exponential backoff, OOPIF child-session tracking, and synthetic Target events; no critical bugs found.
packages/server-v4/src/routes/v4/extensionRelay.ts WebSocket relay correctly proxies CDP commands/events between extension and CDP clients, but /v4/cdp and /v4/extension have no authentication and are reachable from any network interface — a meaningful remote code execution risk.
packages/server-v4/src/lib/InMemorySessionStore.ts Adds action persistence (PageAction / BrowserSessionAction maps) and fixes a concurrent-init race via stagehandInitPromise, but deleteSession never purges its session's action entries, causing unbounded memory growth.
packages/extension/src/types.ts Defines shared message types; CdpCommandRequest and CdpCommandResponse are unused dead types that suggest an incomplete refactor.
packages/server-v4/src/server.ts Registers the new extension relay alongside existing routes; CORS is only enabled in development, but the relay WebSocket endpoints themselves bypass CORS and Fastify auth entirely.

Sequence Diagram

sequenceDiagram
    participant SP as SidePanel (sidepanel.ts)
    participant BG as Background SW (background.ts)
    participant RS as Relay Server (/v4/extension)
    participant CDP as CDP Client (/v4/cdp)
    participant SH as Stagehand Server

    SP->>BG: port.postMessage(attach-tab)
    BG->>BG: chrome.debugger.attach(tabId)
    BG-->>SP: tab-state (attached)

    SP->>SH: POST /v4/sessions/start ❌ (route DNE)
    Note over SP,SH: Should be POST /v4/browsersession

    BG->>RS: WebSocket connect ws://host/v4/extension
    RS-->>BG: connected

    CDP->>RS: WebSocket connect ws://host/v4/cdp
    Note over CDP,RS: No auth check

    CDP->>RS: {id, method, params}
    RS->>BG: {id, method:"forwardCDPCommand", params}
    BG->>BG: chrome.debugger.sendCommand(tabId, method, params)
    BG-->>RS: {id, result}
    RS-->>CDP: {id, result}

    BG->>RS: {method:"forwardCDPEvent", params:{method, sessionId, params}}
    RS-->>CDP: {method, sessionId, params}
Loading

Comments Outside Diff (1)

  1. packages/server-v4/src/lib/InMemorySessionStore.ts, line 303-334 (link)

    Action entries leak when a session is deleted

    deleteSession removes the session's LruNode and closes its V3 instance, but it never removes the associated PageAction and BrowserSessionAction entries from this.actions and this.browserSessionActions. These maps only get cleared globally in destroy().

    On a long-running server that creates and ends many sessions, these maps will grow without bound — every action persisted with putPageAction / putBrowserSessionAction accumulates indefinitely, even after the parent session is gone. The fix is to filter and delete the relevant entries in deleteSession:

    // After unlinking from the LRU list:
    for (const [id, action] of this.actions) {
      if (action.sessionId === sessionId) this.actions.delete(id);
    }
    for (const [id, action] of this.browserSessionActions) {
      if (action.sessionId === sessionId) this.browserSessionActions.delete(id);
    }

Last reviewed commit: 2824dee

Comment on lines +19 to +21
"dependencies": {
"@anthropic-ai/sdk": "^0.39.0"
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unused dependency @anthropic-ai/sdk

The @anthropic-ai/sdk package is listed as a dependency but is never imported anywhere in the extension code. The extension makes direct fetch calls to the Anthropic API instead (in sidepanel.ts). This adds unnecessary bundle size and install weight.

Suggested change
"dependencies": {
"@anthropic-ai/sdk": "^0.39.0"
}
"dependencies": {}

claude added 8 commits March 15, 2026 04:57
Add comprehensive E2E tests verifying the extension's CDP proxy and
stagehand library integration with a real Chromium instance:

- Extension CDP proxy tests: service worker loading, navigation,
  screenshots, DOM modification, CDP event reception (6 tests)
- Stagehand integration tests: CDP connection, navigation, DOM
  manipulation, screenshots, locator interaction, element creation
  (6 tests + 3 conditional LLM tests)
- Test helpers: chrome-launcher utilities, CdpClient, local HTTP
  test server (for Docker environments without external network)

https://claude.ai/code/session_01TCsbC7eC1Fm7w1VoVPWkpR
Previously the LLM-dependent tests were skipped without an API key.
Now they always run and assert on the real API key error from the
LLM layer, proving the full stagehand pipeline (CDP → page control →
agent/act → LLM client) executes end-to-end.

https://claude.ai/code/session_01TCsbC7eC1Fm7w1VoVPWkpR
- Add server-v4 package from server-v4-understudy-routes branch
- Add WebSocket relay route (/v4/extension + /v4/cdp) to server-v4
- Begin rewriting extension background.ts to connect to server relay
- Begin rewriting extension sidebar as thin REST client to server API
- Work in progress: agents implementing parallel changes

https://claude.ai/code/session_01TCsbC7eC1Fm7w1VoVPWkpR
…integration

- Extension background.ts now connects to relay via WebSocket and proxies
  CDP commands through chrome.debugger, with auto-attach to active tab
- Handle Target.setAutoAttach, Target.attachToTarget, Target.getTargets etc.
  by synthesizing Target.attachedToTarget events for stagehand compatibility
- Simplify tests to rely on auto-attach instead of manual service worker
  debugger attachment
- Delete cdp-adapter.ts (no longer needed with relay architecture)
- All 11 E2E tests pass: 5 CDP proxy + 6 stagehand integration

https://claude.ai/code/session_01TCsbC7eC1Fm7w1VoVPWkpR
The extension uses direct fetch calls to the server API rather than
the Anthropic SDK.

https://claude.ai/code/session_01TCsbC7eC1Fm7w1VoVPWkpR
- sidepanel.ts: reconnect the chrome.runtime port when the MV3 service
  worker restarts, preventing the UI from getting stuck in a loading state
- background.ts: isRestrictedUrl now returns true for undefined URLs,
  preventing confusing debugger attachment errors on tabs without URLs

https://claude.ai/code/session_01TCsbC7eC1Fm7w1VoVPWkpR
@miguelg719 miguelg719 marked this pull request as ready for review March 16, 2026 14:34
Comment on lines +162 to +174
async function startSession(): Promise<string> {
const cdpUrl = `ws://${serverHost}:${serverPort}/v4/cdp`;

const result = (await serverFetch("/v4/sessions/start", {
browser: {
type: "local",
cdpUrl,
},
modelApiKey: apiKeyInput.value.trim(),
})) as { sessionId: string };

return result.sessionId;
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All session management endpoints point to non-existent routes

startSession calls POST /v4/sessions/start, but the server only exposes POST /v4/browsersession (registered in packages/server-v4/src/routes/v4/browsersession/routes.ts). The request body shape is also wrong: the server schema expects { env: "LOCAL", cdpUrl, modelName, ... } (per BrowserSessionCreateRequestSchema) but this code sends { browser: { type: "local", cdpUrl }, modelApiKey }.

endSession (line 177) calls /v4/sessions/${sessionId}/end — the actual endpoint is /v4/browsersession/${sessionId}/end.

executeAction (line 434) calls /v4/sessions/${sessionId}/act|observe|extract|agentExecute — none of these paths exist in the server routes. The act/observe/extract operations are under /v4/page/* and agent operations are not yet exposed as HTTP endpoints.

As written, every server API call from the extension will result in a 404, making the core automation functionality completely non-functional.

Comment on lines +114 to +148
cdpWss.on("connection", (ws: WebSocket) => {
cdpClients.add(ws);
log.info(
{ clientCount: cdpClients.size },
"CDP client connected to /v4/cdp",
);

ws.on("message", (data: WebSocket.Data) => {
let msg: {
id?: number;
method?: string;
params?: unknown;
sessionId?: string;
};
try {
msg = JSON.parse(data.toString());
} catch {
log.error("Failed to parse CDP client message");
return;
}

if (msg.id === undefined || !msg.method) {
log.warn("CDP client message missing id or method");
return;
}

if (!extensionWs || extensionWs.readyState !== WebSocket.OPEN) {
// No extension connected — respond with error
const errorResponse = JSON.stringify({
id: msg.id,
error: { code: -32000, message: "No extension connected" },
});
ws.send(errorResponse);
return;
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

/v4/cdp and /v4/extension endpoints are unauthenticated and exposed on all interfaces

The relay WebSocket endpoints attach to the same Fastify HTTP server that listens on 0.0.0.0 (see server.ts:336). Any host on the network — not just localhost — can connect to /v4/cdp and issue arbitrary CDP commands to the user's browser via the extension. Since the extension uses chrome.debugger with full page privileges, this is a significant remote execution vector.

Neither /v4/cdp nor /v4/extension validate any credentials (API key, origin check, etc.) before handling messages.

Consider at minimum validating the Host / Origin header to ensure connections originate from 127.0.0.1 / localhost, or reuse the existing authMiddleware pattern.

Comment on lines +32 to +61
* Used by the background service worker when responding to CDP commands.
*/
export interface CdpCommandResponse {
type: "cdp-response";
id: number;
result?: unknown;
error?: string;
}

/**
* Background -> Sidebar: forwarded CDP event.
* Used by the background service worker when forwarding CDP events.
*/
export interface CdpEventMessage {
type: "cdp-event";
tabId: number;
sessionId?: string;
method: string;
params?: unknown;
}

/** Sidebar -> Background: request to send a CDP command */
export interface CdpCommandRequest {
type: "cdp-command";
id: number;
tabId: number;
sessionId?: string;
method: string;
params?: Record<string, unknown>;
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CdpCommandRequest and CdpCommandResponse are dead types

These two message types — and the "cdp-command" / "cdp-response" message protocol they describe — are never used. The background service worker's port.onMessage handler (background.ts lines 781-800) only handles "get-state", "attach-tab", and "detach-tab". No code in sidepanel.ts ever emits a "cdp-command" message.

CdpCommandRequest is still included in the SidebarMessage union (line 81), which means TypeScript sees it as a valid inbound message type even though the handler ignores it silently.

These should either be removed or implemented to avoid confusion about the intended architecture.

@miguelg719 miguelg719 marked this pull request as draft March 16, 2026 14:45
Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

15 issues found across 88 files

Confidence score: 1/5

  • There are merge-blocking risks with concrete impact: packages/server-v4/src/routes/v4/extensionRelay.ts currently upgrades /v4/cdp and /v4/extension WebSockets without auth/origin validation, which can expose CDP relay access to any reachable client.
  • Core v4 flows appear broken in their current form: packages/extension/src/sidepanel.ts calls unregistered /v4/sessions/* routes instead of /v4/browsersession and /v4/page/*, and packages/server-v4/src/routes/v4/page/waitForSelector.ts returns a non-boolean matched value that is expected to fail response validation.
  • There is additional high regression risk in request handling/state management (packages/server-v4/src/routes/v4/page/shared.ts strict schema parsing on GET params, plus WebSocket lifecycle races in packages/extension/src/background.ts), so this is not yet safe to merge.
  • Pay close attention to packages/server-v4/src/routes/v4/extensionRelay.ts, packages/extension/src/sidepanel.ts, packages/server-v4/src/routes/v4/page/waitForSelector.ts, and packages/server-v4/src/routes/v4/page/shared.ts - security exposure and API/schema mismatches are likely to break functionality immediately.

Note: This PR contains a large number of files. cubic only reviews up to 75 files per PR, so some files may not have been reviewed.

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="packages/server-v4/src/routes/v4/browsersession/index.ts">

<violation number="1" location="packages/server-v4/src/routes/v4/browsersession/index.ts:63">
P1: Custom agent: **Ensure we never check against hardcoded lists of allowed LLM model names**

This check validates `modelName` against the hardcoded `AISDK_PROVIDERS` list and rejects unknown providers with a 400 error. Per the project rule, new code should accept any provider/model-name and let the LLM provider itself throw an error for unsupported models. Hardcoded provider lists require scattered code updates every time a new provider becomes available.

Remove the `AISDK_PROVIDERS` validation block entirely and let the downstream provider call fail naturally if the provider is invalid.</violation>

<violation number="2" location="packages/server-v4/src/routes/v4/browsersession/index.ts:98">
P2: These `error()` calls default to HTTP 400 (BAD_REQUEST), but they represent upstream Browserbase SDK failures (successful call, unexpected response), not malformed client input. Use `StatusCodes.INTERNAL_SERVER_ERROR` so clients can distinguish between "fix your request" and "something went wrong server-side".</violation>
</file>

<file name="packages/server-v4/src/routes/v4/page/waitForSelector.ts">

<violation number="1" location="packages/server-v4/src/routes/v4/page/waitForSelector.ts:33">
P1: `page.waitForSelector()` returns `ElementHandle | null`, but the result schema expects `matched` to be a `boolean`. This will fail Zod validation on every successful call. Coerce the result to a boolean.</violation>
</file>

<file name="packages/extension/public/sidepanel.html">

<violation number="1" location="packages/extension/public/sidepanel.html:290">
P2: The API key `<input>` should have `autocomplete="off"` to prevent browsers from offering to save or autofill the secret. The regular prompt input already sets this attribute, but it's missing on the more sensitive field.</violation>
</file>

<file name="packages/server-v4/src/lib/InMemorySessionStore.ts">

<violation number="1" location="packages/server-v4/src/lib/InMemorySessionStore.ts:317">
P2: Actions stored in `this.actions` and `this.browserSessionActions` are never cleaned up when a session is deleted, only when the entire store is destroyed. In a long-running server, orphaned actions for deleted sessions will accumulate indefinitely, leaking memory. Add cleanup of session-scoped actions inside `deleteSession`.</violation>
</file>

<file name="packages/server-v4/src/routes/v4/extensionRelay.ts">

<violation number="1" location="packages/server-v4/src/routes/v4/extensionRelay.ts:16">
P2: No timeout on `inflightRequests` entries — if the extension stays connected but fails to respond to a command (e.g., it silently drops it), that entry is never cleaned up. Over time this can leak memory and cause stale CDP client references to accumulate. Consider adding a per-request timeout (e.g., 30–60s) that removes the entry and sends an error response to the originating CDP client.</violation>

<violation number="2" location="packages/server-v4/src/routes/v4/extensionRelay.ts:208">
P0: Add authentication and origin/host validation before upgrading `/v4/cdp` and `/v4/extension` WebSocket requests; right now any reachable client can connect to the relay and send CDP traffic.</violation>
</file>

<file name="packages/extension/README.md">

<violation number="1" location="packages/extension/README.md:18">
P2: Node.js version prerequisite is incorrect. The project requires `^20.19.0 || >=22.12.0`, but this says 18+. Users on Node 18 will hit unsupported-engine errors.</violation>
</file>

<file name="packages/server-v4/src/routes/v4/browsersession/_id/index.ts">

<violation number="1" location="packages/server-v4/src/routes/v4/browsersession/_id/index.ts:37">
P2: Session status and availability are hardcoded to `"running"` / `true` instead of being derived from actual session state. If the session has ended or is otherwise unavailable, clients of this "Get browser session status" endpoint will receive stale/incorrect information.</violation>
</file>

<file name="packages/server-v4/src/routes/v4/browsersession/shared.ts">

<violation number="1" location="packages/server-v4/src/routes/v4/browsersession/shared.ts:167">
P2: Empty string is falsy in JS, so `toStringOrRegExp("")` silently returns `undefined` instead of `""`. If a caller passes an empty-string filter (e.g. for cookie `name`), the filter is dropped rather than applied. Use an explicit `undefined`/`null` check instead.</violation>
</file>

<file name="packages/extension/src/background.ts">

<violation number="1" location="packages/extension/src/background.ts:113">
P1: Race condition: old WebSocket's `onclose` fires after new one is created, nulling out the new connection and triggering a spurious reconnect. When `connectWebSocket` is called while a previous socket exists, the old socket's handlers remain active. Since `wsIntentionallyClosed` is reset to `false` before the old `onclose` fires asynchronously, the handler sets `ws = null` (destroying the new socket reference) and calls `scheduleReconnect()`.

Null out the old socket's event handlers before closing to prevent interference.</violation>

<violation number="2" location="packages/extension/src/background.ts:699">
P1: When the debugger is externally detached (e.g., user clicks "Cancel" on the debugger infobar, or DevTools opens), `onDebuggerDetach` cleans up local state but never notifies the relay server. The relay will have stale sessions and keep sending CDP commands that fail.

Consider replacing the body with a call to `detachTab(tabId)`, which already handles both local cleanup and relay notification. The redundant `chrome.debugger.detach()` inside `detachTab` will fail silently due to `.catch(() => {})`.</violation>
</file>

<file name="packages/server-v4/src/routes/v4/page/shared.ts">

<violation number="1" location="packages/server-v4/src/routes/v4/page/shared.ts:168">
P1: GET endpoints will fail at `actionSchema.parse()` because `sessionId` and `id` from the query string leak into `params`. The params schemas are `.strict()` and reject these unknown keys. Destructure out the routing fields before treating the remainder as params.</violation>
</file>

<file name="packages/extension/src/sidepanel.ts">

<violation number="1" location="packages/extension/src/sidepanel.ts:165">
P0: Update the extension to call the registered v4 endpoints (`/v4/browsersession` and `/v4/page/*`) instead of `/v4/sessions/*`; the current paths are not registered, so automation requests fail at the API layer.</violation>
</file>

<file name="packages/extension/src/types.ts">

<violation number="1" location="packages/extension/src/types.ts:81">
P3: Remove `CdpCommandRequest` from the `SidebarMessage` union (or implement a corresponding handler) so the message contract matches actual runtime behavior and unsupported messages are not silently accepted.</violation>
</file>
Architecture diagram
sequenceDiagram
    participant UI as Extension Sidebar
    participant BG as Background Service Worker
    participant Srv as Server V4 (Relay + API)
    participant SDK as Stagehand SDK (Local)
    participant Tab as Target Browser Tab

    Note over UI, Tab: NEW: Stagehand Chrome Extension Flow

    rect rgb(30, 41, 59)
    Note right of UI: Initialization & Attachment
    UI->>BG: NEW: Connect via chrome.runtime.Port
    UI->>BG: NEW: Request 'attach-tab' (tabId)
    BG->>Tab: NEW: chrome.debugger.attach()
    Tab-->>BG: Attached
    BG->>Tab: NEW: Target.setAutoAttach (OOPIF support)
    BG-->>UI: Broadcast 'tab-state' (attached)
    end

    rect rgb(23, 37, 84)
    Note right of UI: Action Flow (Sidebar UI)
    UI->>Srv: NEW: POST /v4/browsersession (start)
    Srv-->>UI: sessionId
    UI->>Srv: NEW: POST /v4/page/act (instruction)
    Note over Srv: LLM Processing (Anthropic)
    Srv->>Srv: NEW: Convert instruction to CDP commands
    Srv->>BG: NEW: WS (/v4/extension) forwardCDPCommand
    BG->>Tab: NEW: chrome.debugger.sendCommand(method, params)
    Tab-->>BG: Command Result
    BG-->>Srv: NEW: WS forwardCDPEvent / Response
    Srv-->>UI: 200 OK + Action Result
    end

    rect rgb(5, 46, 22)
    Note right of SDK: Direct SDK Integration (Remote Control)
    SDK->>Srv: NEW: Connect WS to /v4/cdp
    SDK->>Srv: Send Raw CDP (e.g., Page.navigate)
    Srv->>BG: NEW: Relay message to extension WS
    BG->>Tab: NEW: chrome.debugger.sendCommand()
    Tab-->>BG: result
    BG-->>Srv: Relay back to /v4/cdp
    Srv-->>SDK: CDP Response
    end

    rect rgb(127, 29, 29)
    Note right of BG: Event Forwarding
    Tab->>BG: NEW: chrome.debugger.onEvent (Runtime/Page/Target)
    BG->>Srv: NEW: WS forwardCDPEvent
    Srv->>SDK: Broadcast to attached SDK clients
    BG->>UI: Broadcast to sidebar (CDP logs/state)
    end

    alt Connection Error
        BG->>BG: CHANGED: Exponential backoff reconnect to /v4/extension
    else Restricted URL (chrome://)
        BG->>UI: NEW: Error - Cannot attach to restricted tab
    end
Loading

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

extensionWss.emit("connection", ws, request);
});
} else if (pathname === "/v4/cdp") {
cdpWss.handleUpgrade(request, socket, head, (ws) => {
Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot Mar 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P0: Add authentication and origin/host validation before upgrading /v4/cdp and /v4/extension WebSocket requests; right now any reachable client can connect to the relay and send CDP traffic.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/server-v4/src/routes/v4/extensionRelay.ts, line 208:

<comment>Add authentication and origin/host validation before upgrading `/v4/cdp` and `/v4/extension` WebSocket requests; right now any reachable client can connect to the relay and send CDP traffic.</comment>

<file context>
@@ -0,0 +1,217 @@
+          extensionWss.emit("connection", ws, request);
+        });
+      } else if (pathname === "/v4/cdp") {
+        cdpWss.handleUpgrade(request, socket, head, (ws) => {
+          cdpWss.emit("connection", ws, request);
+        });
</file context>
Fix with Cubic

async function startSession(): Promise<string> {
const cdpUrl = `ws://${serverHost}:${serverPort}/v4/cdp`;

const result = (await serverFetch("/v4/sessions/start", {
Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot Mar 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P0: Update the extension to call the registered v4 endpoints (/v4/browsersession and /v4/page/*) instead of /v4/sessions/*; the current paths are not registered, so automation requests fail at the API layer.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/extension/src/sidepanel.ts, line 165:

<comment>Update the extension to call the registered v4 endpoints (`/v4/browsersession` and `/v4/page/*`) instead of `/v4/sessions/*`; the current paths are not registered, so automation requests fail at the API layer.</comment>

<file context>
@@ -0,0 +1,446 @@
+async function startSession(): Promise<string> {
+  const cdpUrl = `ws://${serverHost}:${serverPort}/v4/cdp`;
+
+  const result = (await serverFetch("/v4/sessions/start", {
+    browser: {
+      type: "local",
</file context>
Fix with Cubic

StatusCodes.BAD_REQUEST,
);
}
if (!(AISDK_PROVIDERS as readonly string[]).includes(providerName)) {
Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot Mar 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Custom agent: Ensure we never check against hardcoded lists of allowed LLM model names

This check validates modelName against the hardcoded AISDK_PROVIDERS list and rejects unknown providers with a 400 error. Per the project rule, new code should accept any provider/model-name and let the LLM provider itself throw an error for unsupported models. Hardcoded provider lists require scattered code updates every time a new provider becomes available.

Remove the AISDK_PROVIDERS validation block entirely and let the downstream provider call fail naturally if the provider is invalid.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/server-v4/src/routes/v4/browsersession/index.ts, line 63:

<comment>This check validates `modelName` against the hardcoded `AISDK_PROVIDERS` list and rejects unknown providers with a 400 error. Per the project rule, new code should accept any provider/model-name and let the LLM provider itself throw an error for unsupported models. Hardcoded provider lists require scattered code updates every time a new provider becomes available.

Remove the `AISDK_PROVIDERS` validation block entirely and let the downstream provider call fail naturally if the provider is invalid.</comment>

<file context>
@@ -0,0 +1,244 @@
+          StatusCodes.BAD_REQUEST,
+        );
+      }
+      if (!(AISDK_PROVIDERS as readonly string[]).includes(providerName)) {
+        return error(
+          reply,
</file context>
Fix with Cubic

method: "waitForSelector",
actionSchema: PageWaitForSelectorActionSchema,
execute: async ({ page, params }) => {
const matched = await page.waitForSelector(
Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot Mar 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: page.waitForSelector() returns ElementHandle | null, but the result schema expects matched to be a boolean. This will fail Zod validation on every successful call. Coerce the result to a boolean.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/server-v4/src/routes/v4/page/waitForSelector.ts, line 33:

<comment>`page.waitForSelector()` returns `ElementHandle | null`, but the result schema expects `matched` to be a `boolean`. This will fail Zod validation on every successful call. Coerce the result to a boolean.</comment>

<file context>
@@ -0,0 +1,50 @@
+    method: "waitForSelector",
+    actionSchema: PageWaitForSelectorActionSchema,
+    execute: async ({ page, params }) => {
+      const matched = await page.waitForSelector(
+        normalizeXPath(params.selector.xpath),
+        {
</file context>
Fix with Cubic

Comment on lines +113 to +117
if (ws) {
wsIntentionallyClosed = true;
ws.close();
ws = null;
}
Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot Mar 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Race condition: old WebSocket's onclose fires after new one is created, nulling out the new connection and triggering a spurious reconnect. When connectWebSocket is called while a previous socket exists, the old socket's handlers remain active. Since wsIntentionallyClosed is reset to false before the old onclose fires asynchronously, the handler sets ws = null (destroying the new socket reference) and calls scheduleReconnect().

Null out the old socket's event handlers before closing to prevent interference.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/extension/src/background.ts, line 113:

<comment>Race condition: old WebSocket's `onclose` fires after new one is created, nulling out the new connection and triggering a spurious reconnect. When `connectWebSocket` is called while a previous socket exists, the old socket's handlers remain active. Since `wsIntentionallyClosed` is reset to `false` before the old `onclose` fires asynchronously, the handler sets `ws = null` (destroying the new socket reference) and calls `scheduleReconnect()`.

Null out the old socket's event handlers before closing to prevent interference.</comment>

<file context>
@@ -0,0 +1,839 @@
+/** Connect (or reconnect) to the relay WebSocket */
+async function connectWebSocket(): Promise<void> {
+  // Clean up any existing connection
+  if (ws) {
+    wsIntentionallyClosed = true;
+    ws.close();
</file context>
Suggested change
if (ws) {
wsIntentionallyClosed = true;
ws.close();
ws = null;
}
if (ws) {
ws.onopen = null;
ws.onmessage = null;
ws.onclose = null;
ws.onerror = null;
ws.close();
ws = null;
}
Fix with Cubic


let extensionWs: WebSocket | null = null;
const cdpClients = new Set<WebSocket>();
const inflightRequests = new Map<number, InflightRequest>();
Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot Mar 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: No timeout on inflightRequests entries — if the extension stays connected but fails to respond to a command (e.g., it silently drops it), that entry is never cleaned up. Over time this can leak memory and cause stale CDP client references to accumulate. Consider adding a per-request timeout (e.g., 30–60s) that removes the entry and sends an error response to the originating CDP client.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/server-v4/src/routes/v4/extensionRelay.ts, line 16:

<comment>No timeout on `inflightRequests` entries — if the extension stays connected but fails to respond to a command (e.g., it silently drops it), that entry is never cleaned up. Over time this can leak memory and cause stale CDP client references to accumulate. Consider adding a per-request timeout (e.g., 30–60s) that removes the entry and sends an error response to the originating CDP client.</comment>

<file context>
@@ -0,0 +1,217 @@
+
+  let extensionWs: WebSocket | null = null;
+  const cdpClients = new Set<WebSocket>();
+  const inflightRequests = new Map<number, InflightRequest>();
+  let nextRelayId = 1;
+
</file context>
Fix with Cubic


## Prerequisites

- Node.js 18+
Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot Mar 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Node.js version prerequisite is incorrect. The project requires ^20.19.0 || >=22.12.0, but this says 18+. Users on Node 18 will hit unsupported-engine errors.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/extension/README.md, line 18:

<comment>Node.js version prerequisite is incorrect. The project requires `^20.19.0 || >=22.12.0`, but this says 18+. Users on Node 18 will hit unsupported-engine errors.</comment>

<file context>
@@ -0,0 +1,132 @@
+
+## Prerequisites
+
+- Node.js 18+
+- pnpm
+- Google Chrome or Chromium
</file context>
Fix with Cubic

browserSession: buildBrowserSession({
id,
params,
status: "running",
Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot Mar 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Session status and availability are hardcoded to "running" / true instead of being derived from actual session state. If the session has ended or is otherwise unavailable, clients of this "Get browser session status" endpoint will receive stale/incorrect information.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/server-v4/src/routes/v4/browsersession/_id/index.ts, line 37:

<comment>Session status and availability are hardcoded to `"running"` / `true` instead of being derived from actual session state. If the session has ended or is otherwise unavailable, clients of this "Get browser session status" endpoint will receive stale/incorrect information.</comment>

<file context>
@@ -0,0 +1,62 @@
+      browserSession: buildBrowserSession({
+        id,
+        params,
+        status: "running",
+        available: true,
+      }),
</file context>
Fix with Cubic

flags?: string;
},
): string | RegExp | undefined {
if (!value) {
Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot Mar 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Empty string is falsy in JS, so toStringOrRegExp("") silently returns undefined instead of "". If a caller passes an empty-string filter (e.g. for cookie name), the filter is dropped rather than applied. Use an explicit undefined/null check instead.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/server-v4/src/routes/v4/browsersession/shared.ts, line 167:

<comment>Empty string is falsy in JS, so `toStringOrRegExp("")` silently returns `undefined` instead of `""`. If a caller passes an empty-string filter (e.g. for cookie `name`), the filter is dropped rather than applied. Use an explicit `undefined`/`null` check instead.</comment>

<file context>
@@ -0,0 +1,354 @@
+        flags?: string;
+      },
+): string | RegExp | undefined {
+  if (!value) {
+    return undefined;
+  }
</file context>
Fix with Cubic


/** Any message from sidebar to background */
export type SidebarMessage =
| CdpCommandRequest
Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot Mar 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: Remove CdpCommandRequest from the SidebarMessage union (or implement a corresponding handler) so the message contract matches actual runtime behavior and unsupported messages are not silently accepted.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/extension/src/types.ts, line 81:

<comment>Remove `CdpCommandRequest` from the `SidebarMessage` union (or implement a corresponding handler) so the message contract matches actual runtime behavior and unsupported messages are not silently accepted.</comment>

<file context>
@@ -0,0 +1,101 @@
+
+/** Any message from sidebar to background */
+export type SidebarMessage =
+  | CdpCommandRequest
+  | AttachRequest
+  | DetachRequest
</file context>
Fix with Cubic

@pirate pirate changed the title Add Chrome extension for Stagehand browser automation Mockup: Stagehand Chrome extension that links live browser to cli and library Mar 17, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants