From 3a65c9ce364d3bac25c99c82e0f90c979f65c12e Mon Sep 17 00:00:00 2001 From: rgarcia <72655+rgarcia@users.noreply.github.com> Date: Tue, 1 Sep 2026 00:22:53 +0000 Subject: [PATCH 01/18] Add WebMCP discovery and invocation API --- server/cmd/api/api/api.go | 11 + server/cmd/api/api/webmcp.go | 119 +++ server/cmd/api/api/webmcp_test.go | 147 ++++ server/lib/events/category_gen.go | 2 + server/lib/oapi/oapi.go | 1007 ++++++++++++++++++++---- server/lib/webmcpclient/client.go | 348 ++++++++ server/lib/webmcpclient/client_test.go | 249 ++++++ server/lib/webmcpclient/events.go | 312 ++++++++ server/lib/webmcpclient/manager.go | 97 +++ server/lib/webmcpclient/types.go | 123 +++ server/openapi.yaml | 182 +++++ 11 files changed, 2429 insertions(+), 168 deletions(-) create mode 100644 server/cmd/api/api/webmcp.go create mode 100644 server/cmd/api/api/webmcp_test.go create mode 100644 server/lib/webmcpclient/client.go create mode 100644 server/lib/webmcpclient/client_test.go create mode 100644 server/lib/webmcpclient/events.go create mode 100644 server/lib/webmcpclient/manager.go create mode 100644 server/lib/webmcpclient/types.go diff --git a/server/cmd/api/api/api.go b/server/cmd/api/api/api.go index 2fd6580b8..16c93711d 100644 --- a/server/cmd/api/api/api.go +++ b/server/cmd/api/api/api.go @@ -20,6 +20,7 @@ import ( "github.com/kernel/kernel-images/server/lib/recorder" "github.com/kernel/kernel-images/server/lib/scaletozero" "github.com/kernel/kernel-images/server/lib/telemetry" + "github.com/kernel/kernel-images/server/lib/webmcpclient" ) type cdpMonitorController interface { @@ -40,6 +41,12 @@ type OTLPExporter interface { var _ OTLPExporter = (*events.OTLPExportController)(nil) +type webMCPClient interface { + Tools(ctx context.Context, targetID string) ([]webmcpclient.Tool, error) + Invoke(ctx context.Context, toolRef string, input map[string]any) (webmcpclient.InvocationResult, error) + Close() error +} + type ApiService struct { // defaultRecorderID is used whenever the caller doesn't specify an explicit ID. defaultRecorderID string @@ -77,6 +84,8 @@ type ApiService struct { // playwrightDaemonCmd holds the daemon process for cleanup playwrightDaemonCmd *exec.Cmd + webmcp webMCPClient + // policy management policy *policy.Policy @@ -159,6 +168,7 @@ func New( telemetrySession: telemetrySession, cdpMonitor: mon, otlpExport: otlpExport, + webmcp: webmcpclient.NewManager(upstreamMgr), lifecycleCtx: ctx, lifecycleCancel: cancel, }, nil @@ -421,6 +431,7 @@ func (s *ApiService) ListRecorders(ctx context.Context, _ oapi.ListRecordersRequ } func (s *ApiService) Shutdown(ctx context.Context) error { + _ = s.webmcp.Close() s.monitorMu.Lock() s.lifecycleCancel() s.cdpMonitor.Stop() diff --git a/server/cmd/api/api/webmcp.go b/server/cmd/api/api/webmcp.go new file mode 100644 index 000000000..6d34d575b --- /dev/null +++ b/server/cmd/api/api/webmcp.go @@ -0,0 +1,119 @@ +package api + +import ( + "context" + "encoding/json" + "errors" + "strings" + "time" + + "github.com/kernel/kernel-images/server/lib/logger" + "github.com/kernel/kernel-images/server/lib/oapi" + "github.com/kernel/kernel-images/server/lib/webmcpclient" +) + +const ( + defaultWebMCPInvocationTimeout = 60 * time.Second + maxWebMCPInputBytes = 1 << 20 +) + +func (s *ApiService) GetWebMCPTools(ctx context.Context, request oapi.GetWebMCPToolsRequestObject) (oapi.GetWebMCPToolsResponseObject, error) { + targetID := "" + if request.Params.TargetId != nil { + targetID = *request.Params.TargetId + } + tools, err := s.webmcp.Tools(ctx, targetID) + if err != nil { + if errors.Is(err, webmcpclient.ErrNoPageTarget) { + return oapi.GetWebMCPTools404JSONResponse{NotFoundErrorJSONResponse: oapi.NotFoundErrorJSONResponse{Message: err.Error()}}, nil + } + logger.FromContext(ctx).Error("failed to discover WebMCP tools", "err", err) + return oapi.GetWebMCPTools500JSONResponse{InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{Message: "failed to discover WebMCP tools"}}, nil + } + + responseTools := make([]oapi.WebMCPTool, 0, len(tools)) + for _, tool := range tools { + inputSchema := tool.InputSchema + if inputSchema == nil { + inputSchema = make(map[string]any) + } + responseTool := oapi.WebMCPTool{ + ToolRef: tool.Ref, + Name: tool.Name, + Description: tool.Description, + InputSchema: inputSchema, + PageTargetId: tool.PageTargetID, + TargetId: tool.TargetID, + TargetType: tool.TargetType, + FrameId: tool.FrameID, + } + if tool.Annotations != nil { + responseTool.Annotations = &oapi.WebMCPToolAnnotations{ + ReadOnly: tool.Annotations.ReadOnly, + UntrustedContent: tool.Annotations.UntrustedContent, + Consequential: tool.Annotations.Consequential, + Autosubmit: tool.Annotations.Autosubmit, + } + } + responseTool.TargetUrl = nonEmptyString(tool.TargetURL) + responseTool.FrameUrl = nonEmptyString(tool.FrameURL) + responseTool.ParentFrameId = nonEmptyString(tool.ParentFrameID) + responseTool.DocumentRef = nonEmptyString(tool.DocumentRef) + responseTools = append(responseTools, responseTool) + } + return oapi.GetWebMCPTools200JSONResponse{Tools: responseTools}, nil +} + +func (s *ApiService) InvokeWebMCPTool(ctx context.Context, request oapi.InvokeWebMCPToolRequestObject) (oapi.InvokeWebMCPToolResponseObject, error) { + if request.Body == nil { + return oapi.InvokeWebMCPTool400JSONResponse{BadRequestErrorJSONResponse: oapi.BadRequestErrorJSONResponse{Message: "request body is required"}}, nil + } + inputJSON, err := json.Marshal(request.Body.Input) + if err != nil || len(inputJSON) > maxWebMCPInputBytes { + return oapi.InvokeWebMCPTool400JSONResponse{BadRequestErrorJSONResponse: oapi.BadRequestErrorJSONResponse{Message: "input must be valid JSON no larger than 1 MiB"}}, nil + } + timeout := defaultWebMCPInvocationTimeout + if request.Body.TimeoutSec != nil { + timeout = time.Duration(*request.Body.TimeoutSec) * time.Second + } + invokeCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + result, err := s.webmcp.Invoke(invokeCtx, request.Body.ToolRef, request.Body.Input) + if err != nil { + switch { + case errors.Is(err, webmcpclient.ErrToolNotFound): + return oapi.InvokeWebMCPTool404JSONResponse{NotFoundErrorJSONResponse: oapi.NotFoundErrorJSONResponse{Message: "WebMCP tool is no longer available; discover tools again"}}, nil + case errors.Is(err, webmcpclient.ErrOutcomeUnknown): + failure := oapi.WebMCPInvocationFailure{ + Code: oapi.OutcomeUnknown, + Message: "the invocation started, but its final outcome could not be observed; do not retry automatically", + } + failure.InvocationId = nonEmptyString(result.InvocationID) + return oapi.InvokeWebMCPTool504JSONResponse(failure), nil + default: + logger.FromContext(ctx).Error("failed to invoke WebMCP tool", "err", err) + return oapi.InvokeWebMCPTool500JSONResponse{InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{Message: "failed to invoke WebMCP tool"}}, nil + } + } + + status := oapi.WebMCPInvocationResultStatus(strings.ToLower(result.Status)) + if !status.Valid() { + logger.FromContext(ctx).Error("WebMCP tool returned unknown status", "status", result.Status) + return oapi.InvokeWebMCPTool500JSONResponse{InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{Message: "WebMCP tool returned an unknown status"}}, nil + } + response := oapi.InvokeWebMCPTool200JSONResponse{ + InvocationId: result.InvocationID, + Status: status, + Output: result.Output, + } + response.ErrorText = nonEmptyString(result.ErrorText) + return response, nil +} + +func nonEmptyString(value string) *string { + if value == "" { + return nil + } + return &value +} diff --git a/server/cmd/api/api/webmcp_test.go b/server/cmd/api/api/webmcp_test.go new file mode 100644 index 000000000..dddf2a0a4 --- /dev/null +++ b/server/cmd/api/api/webmcp_test.go @@ -0,0 +1,147 @@ +package api + +import ( + "context" + "errors" + "strings" + "testing" + + "github.com/kernel/kernel-images/server/lib/oapi" + "github.com/kernel/kernel-images/server/lib/webmcpclient" + "github.com/stretchr/testify/require" +) + +type fakeWebMCPClient struct { + tools []webmcpclient.Tool + toolsErr error + result webmcpclient.InvocationResult + invokeErr error + targetID string + toolRef string + input map[string]any +} + +func (f *fakeWebMCPClient) Tools(_ context.Context, targetID string) ([]webmcpclient.Tool, error) { + f.targetID = targetID + return f.tools, f.toolsErr +} + +func (f *fakeWebMCPClient) Invoke(_ context.Context, toolRef string, input map[string]any) (webmcpclient.InvocationResult, error) { + f.toolRef = toolRef + f.input = input + return f.result, f.invokeErr +} + +func (f *fakeWebMCPClient) Close() error { return nil } + +func TestGetWebMCPToolsMapsRegistrationContext(t *testing.T) { + client := &fakeWebMCPClient{tools: []webmcpclient.Tool{{ + Ref: "wmcp_test", + Name: "pay", + Description: "Pay for the order", + Annotations: &webmcpclient.Annotations{Consequential: true}, + PageTargetID: "page-target", + TargetID: "iframe-target", + TargetType: "iframe", + TargetURL: "https://payments.example/element", + FrameID: "iframe-frame", + FrameURL: "https://payments.example/element", + ParentFrameID: "page-frame", + DocumentRef: "iframe-target:loader-1", + }}} + service := &ApiService{webmcp: client} + targetID := "page-target" + + response, err := service.GetWebMCPTools(context.Background(), oapi.GetWebMCPToolsRequestObject{ + Params: oapi.GetWebMCPToolsParams{TargetId: &targetID}, + }) + require.NoError(t, err) + body := response.(oapi.GetWebMCPTools200JSONResponse) + require.Equal(t, targetID, client.targetID) + require.Len(t, body.Tools, 1) + tool := body.Tools[0] + require.Equal(t, "wmcp_test", tool.ToolRef) + require.Equal(t, "iframe-target", tool.TargetId) + require.Equal(t, "iframe-frame", tool.FrameId) + require.Equal(t, "iframe-target:loader-1", *tool.DocumentRef) + require.Empty(t, tool.InputSchema) + require.True(t, tool.Annotations.Consequential) +} + +func TestInvokeWebMCPToolReturnsPageResult(t *testing.T) { + client := &fakeWebMCPClient{result: webmcpclient.InvocationResult{ + InvocationID: "invocation-1", + Status: "Completed", + Output: map[string]any{"ok": true}, + }} + service := &ApiService{webmcp: client} + + response, err := service.InvokeWebMCPTool(context.Background(), oapi.InvokeWebMCPToolRequestObject{ + Body: &oapi.WebMCPInvokeRequest{ToolRef: "wmcp_test", Input: map[string]any{"amount": 2900}}, + }) + require.NoError(t, err) + body := response.(oapi.InvokeWebMCPTool200JSONResponse) + require.Equal(t, "wmcp_test", client.toolRef) + require.Equal(t, 2900, client.input["amount"]) + require.Equal(t, oapi.WebMCPInvocationResultStatusCompleted, body.Status) + require.Equal(t, true, body.Output.(map[string]any)["ok"]) +} + +func TestInvokeWebMCPToolReportsUnknownOutcome(t *testing.T) { + client := &fakeWebMCPClient{ + result: webmcpclient.InvocationResult{InvocationID: "invocation-1"}, + invokeErr: webmcpclient.ErrOutcomeUnknown, + } + service := &ApiService{webmcp: client} + + response, err := service.InvokeWebMCPTool(context.Background(), oapi.InvokeWebMCPToolRequestObject{ + Body: &oapi.WebMCPInvokeRequest{ToolRef: "wmcp_test", Input: map[string]any{}}, + }) + require.NoError(t, err) + body := response.(oapi.InvokeWebMCPTool504JSONResponse) + require.Equal(t, oapi.OutcomeUnknown, body.Code) + require.Equal(t, "invocation-1", *body.InvocationId) +} + +func TestGetWebMCPToolsReturnsNotFoundWithoutPage(t *testing.T) { + service := &ApiService{webmcp: &fakeWebMCPClient{toolsErr: webmcpclient.ErrNoPageTarget}} + response, err := service.GetWebMCPTools(context.Background(), oapi.GetWebMCPToolsRequestObject{}) + require.NoError(t, err) + _, ok := response.(oapi.GetWebMCPTools404JSONResponse) + require.True(t, ok) +} + +func TestInvokeWebMCPToolReturnsNotFoundForStaleReference(t *testing.T) { + service := &ApiService{webmcp: &fakeWebMCPClient{invokeErr: webmcpclient.ErrToolNotFound}} + response, err := service.InvokeWebMCPTool(context.Background(), oapi.InvokeWebMCPToolRequestObject{ + Body: &oapi.WebMCPInvokeRequest{ToolRef: "wmcp_stale", Input: map[string]any{}}, + }) + require.NoError(t, err) + _, ok := response.(oapi.InvokeWebMCPTool404JSONResponse) + require.True(t, ok) +} + +func TestInvokeWebMCPToolRejectsOversizedInput(t *testing.T) { + client := &fakeWebMCPClient{} + service := &ApiService{webmcp: client} + response, err := service.InvokeWebMCPTool(context.Background(), oapi.InvokeWebMCPToolRequestObject{ + Body: &oapi.WebMCPInvokeRequest{ + ToolRef: "wmcp_test", + Input: map[string]any{"value": strings.Repeat("a", maxWebMCPInputBytes)}, + }, + }) + require.NoError(t, err) + _, ok := response.(oapi.InvokeWebMCPTool400JSONResponse) + require.True(t, ok) + require.Empty(t, client.toolRef) +} + +func TestInvokeWebMCPToolRejectsUnexpectedClientError(t *testing.T) { + service := &ApiService{webmcp: &fakeWebMCPClient{invokeErr: errors.New("CDP failed")}} + response, err := service.InvokeWebMCPTool(context.Background(), oapi.InvokeWebMCPToolRequestObject{ + Body: &oapi.WebMCPInvokeRequest{ToolRef: "wmcp_test", Input: map[string]any{}}, + }) + require.NoError(t, err) + _, ok := response.(oapi.InvokeWebMCPTool500JSONResponse) + require.True(t, ok) +} diff --git a/server/lib/events/category_gen.go b/server/lib/events/category_gen.go index 09e06c216..a6cfa1720 100644 --- a/server/lib/events/category_gen.go +++ b/server/lib/events/category_gen.go @@ -68,6 +68,8 @@ var categoryByOperationID = map[string]oapi.TelemetryEventCategory{ "FileInfo": oapi.TelemetryEventCategory("platform"), "GetMousePosition": oapi.TelemetryEventCategory("control"), "GetTelemetry": oapi.TelemetryEventCategory("platform"), + "GetWebMCPTools": oapi.TelemetryEventCategory("control"), + "InvokeWebMCPTool": oapi.TelemetryEventCategory("control"), "ListFiles": oapi.TelemetryEventCategory("platform"), "ListRecorders": oapi.TelemetryEventCategory("platform"), "LogsStream": oapi.TelemetryEventCategory("platform"), diff --git a/server/lib/oapi/oapi.go b/server/lib/oapi/oapi.go index 502db1cab..97ba180f0 100644 --- a/server/lib/oapi/oapi.go +++ b/server/lib/oapi/oapi.go @@ -2903,6 +2903,42 @@ func (e TelemetryEventCategory) Valid() bool { } } +// Defines values for WebMCPInvocationFailureCode. +const ( + OutcomeUnknown WebMCPInvocationFailureCode = "outcome_unknown" +) + +// Valid indicates whether the value is a known member of the WebMCPInvocationFailureCode enum. +func (e WebMCPInvocationFailureCode) Valid() bool { + switch e { + case OutcomeUnknown: + return true + default: + return false + } +} + +// Defines values for WebMCPInvocationResultStatus. +const ( + WebMCPInvocationResultStatusCanceled WebMCPInvocationResultStatus = "canceled" + WebMCPInvocationResultStatusCompleted WebMCPInvocationResultStatus = "completed" + WebMCPInvocationResultStatusError WebMCPInvocationResultStatus = "error" +) + +// Valid indicates whether the value is a known member of the WebMCPInvocationResultStatus enum. +func (e WebMCPInvocationResultStatus) Valid() bool { + switch e { + case WebMCPInvocationResultStatusCanceled: + return true + case WebMCPInvocationResultStatusCompleted: + return true + case WebMCPInvocationResultStatusError: + return true + default: + return false + } +} + // Defines values for ChromiumConfigureParamsExtensionLoadStrategy. const ( PreferCdp ChromiumConfigureParamsExtensionLoadStrategy = "prefer_cdp" @@ -6659,6 +6695,84 @@ type TypeTextRequest struct { TypoChance *float32 `json:"typo_chance,omitempty"` } +// WebMCPInvocationFailure defines model for WebMCPInvocationFailure. +type WebMCPInvocationFailure struct { + Code WebMCPInvocationFailureCode `json:"code"` + InvocationId *string `json:"invocation_id,omitempty"` + Message string `json:"message"` +} + +// WebMCPInvocationFailureCode defines model for WebMCPInvocationFailure.Code. +type WebMCPInvocationFailureCode string + +// WebMCPInvocationResult defines model for WebMCPInvocationResult. +type WebMCPInvocationResult struct { + ErrorText *string `json:"error_text,omitempty"` + InvocationId string `json:"invocation_id"` + + // Output Untrusted page-provided output. Callers must treat it as potentially malicious input. + Output interface{} `json:"output,omitempty"` + Status WebMCPInvocationResultStatus `json:"status"` +} + +// WebMCPInvocationResultStatus defines model for WebMCPInvocationResult.Status. +type WebMCPInvocationResultStatus string + +// WebMCPInvokeRequest defines model for WebMCPInvokeRequest. +type WebMCPInvokeRequest struct { + // Input Tool input, limited to 1 MiB after JSON serialization. + Input map[string]interface{} `json:"input"` + TimeoutSec *int `json:"timeout_sec,omitempty"` + ToolRef string `json:"tool_ref"` +} + +// WebMCPTool defines model for WebMCPTool. +type WebMCPTool struct { + // Annotations Page-provided behavioral hints. These values are untrusted and are not enforced by Kernel. + Annotations *WebMCPToolAnnotations `json:"annotations,omitempty"` + Description string `json:"description"` + + // DocumentRef Reference derived from the owning target and current Page.LoaderId. It changes when the frame commits a new document. + DocumentRef *string `json:"document_ref,omitempty"` + + // FrameId Page.FrameId supplied by Chromium's WebMCP domain. + FrameId string `json:"frame_id"` + + // FrameUrl Current frame URL with the fragment omitted. + FrameUrl *string `json:"frame_url,omitempty"` + InputSchema map[string]interface{} `json:"input_schema"` + Name string `json:"name"` + + // PageTargetId CDP target ID of the selected top-level page. + PageTargetId string `json:"page_target_id"` + ParentFrameId *string `json:"parent_frame_id,omitempty"` + + // TargetId CDP target ID owning the registration. For an out-of-process iframe this differs from page_target_id. + TargetId string `json:"target_id"` + + // TargetType CDP target type, normally page or iframe. + TargetType string `json:"target_type"` + + // TargetUrl Owning target URL with the fragment omitted. + TargetUrl *string `json:"target_url,omitempty"` + + // ToolRef Opaque reference for invoking this exact live registration. It becomes invalid when its document or CDP session is replaced. + ToolRef string `json:"tool_ref"` +} + +// WebMCPToolAnnotations Page-provided behavioral hints. These values are untrusted and are not enforced by Kernel. +type WebMCPToolAnnotations struct { + Autosubmit bool `json:"autosubmit"` + Consequential bool `json:"consequential"` + ReadOnly bool `json:"read_only"` + UntrustedContent bool `json:"untrusted_content"` +} + +// WebMCPToolsResponse defines model for WebMCPToolsResponse. +type WebMCPToolsResponse struct { + Tools []WebMCPTool `json:"tools"` +} + // WriteClipboardRequest defines model for WriteClipboardRequest. type WriteClipboardRequest struct { // Text Text to write to the system clipboard @@ -6832,6 +6946,12 @@ type StreamTelemetryEventsParams struct { // StreamTelemetryEventsParamsReplay defines parameters for StreamTelemetryEvents. type StreamTelemetryEventsParamsReplay string +// GetWebMCPToolsParams defines parameters for GetWebMCPTools. +type GetWebMCPToolsParams struct { + // TargetId Optional page target to inspect. When omitted, the first page target reported by Chromium is used. + TargetId *string `form:"target_id,omitempty" json:"target_id,omitempty"` +} + // PatchChromiumFlagsJSONRequestBody defines body for PatchChromiumFlags for application/json ContentType. type PatchChromiumFlagsJSONRequestBody PatchChromiumFlagsJSONBody @@ -6946,6 +7066,9 @@ type PutTelemetryJSONRequestBody = BrowserTelemetryConfig // PublishTelemetryEventJSONRequestBody defines body for PublishTelemetryEvent for application/json ContentType. type PublishTelemetryEventJSONRequestBody = PublishEventRequest +// InvokeWebMCPToolJSONRequestBody defines body for InvokeWebMCPTool for application/json ContentType. +type InvokeWebMCPToolJSONRequestBody = WebMCPInvokeRequest + // AsBrowserCdpInputDispatchMouseEventCommandData returns the union data inside the BrowserCdpCommandEventData as a BrowserCdpInputDispatchMouseEventCommandData func (t BrowserCdpCommandEventData) AsBrowserCdpInputDispatchMouseEventCommandData() (BrowserCdpInputDispatchMouseEventCommandData, error) { var body BrowserCdpInputDispatchMouseEventCommandData @@ -9531,6 +9654,14 @@ type ClientInterface interface { // StreamTelemetryEvents request StreamTelemetryEvents(ctx context.Context, params *StreamTelemetryEventsParams, reqEditors ...RequestEditorFn) (*http.Response, error) + + // InvokeWebMCPToolWithBody request with any body + InvokeWebMCPToolWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + + InvokeWebMCPTool(ctx context.Context, body InvokeWebMCPToolJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) + + // GetWebMCPTools request + GetWebMCPTools(ctx context.Context, params *GetWebMCPToolsParams, reqEditors ...RequestEditorFn) (*http.Response, error) } func (c *Client) PatchChromiumFlagsWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { @@ -10601,6 +10732,42 @@ func (c *Client) StreamTelemetryEvents(ctx context.Context, params *StreamTeleme return c.Client.Do(req) } +func (c *Client) InvokeWebMCPToolWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewInvokeWebMCPToolRequestWithBody(c.Server, contentType, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) InvokeWebMCPTool(ctx context.Context, body InvokeWebMCPToolJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewInvokeWebMCPToolRequest(c.Server, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) GetWebMCPTools(ctx context.Context, params *GetWebMCPToolsParams, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewGetWebMCPToolsRequest(c.Server, params) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + // NewPatchChromiumFlagsRequest calls the generic PatchChromiumFlags builder with application/json body func NewPatchChromiumFlagsRequest(server string, body PatchChromiumFlagsJSONRequestBody) (*http.Request, error) { var bodyReader io.Reader @@ -12906,6 +13073,95 @@ func NewStreamTelemetryEventsRequest(server string, params *StreamTelemetryEvent return req, nil } +// NewInvokeWebMCPToolRequest calls the generic InvokeWebMCPTool builder with application/json body +func NewInvokeWebMCPToolRequest(server string, body InvokeWebMCPToolJSONRequestBody) (*http.Request, error) { + var bodyReader io.Reader + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(buf) + return NewInvokeWebMCPToolRequestWithBody(server, "application/json", bodyReader) +} + +// NewInvokeWebMCPToolRequestWithBody generates requests for InvokeWebMCPTool with any type of body +func NewInvokeWebMCPToolRequestWithBody(server string, contentType string, body io.Reader) (*http.Request, error) { + var err error + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/webmcp/invoke") + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("POST", queryURL.String(), body) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", contentType) + + return req, nil +} + +// NewGetWebMCPToolsRequest generates requests for GetWebMCPTools +func NewGetWebMCPToolsRequest(server string, params *GetWebMCPToolsParams) (*http.Request, error) { + var err error + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/webmcp/tools") + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + if params != nil { + queryValues := queryURL.Query() + + if params.TargetId != nil { + + if queryFrag, err := runtime.StyleParamWithOptions("form", true, "target_id", *params.TargetId, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil { + return nil, err + } else if parsed, err := url.ParseQuery(queryFrag); err != nil { + return nil, err + } else { + for k, v := range parsed { + for _, v2 := range v { + queryValues.Add(k, v2) + } + } + } + + } + + queryURL.RawQuery = queryValues.Encode() + } + + req, err := http.NewRequest("GET", queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + func (c *Client) applyEditors(ctx context.Context, req *http.Request, additionalEditors []RequestEditorFn) error { for _, r := range c.RequestEditors { if err := r(ctx, req); err != nil { @@ -13183,6 +13439,14 @@ type ClientWithResponsesInterface interface { // StreamTelemetryEventsWithResponse request StreamTelemetryEventsWithResponse(ctx context.Context, params *StreamTelemetryEventsParams, reqEditors ...RequestEditorFn) (*StreamTelemetryEventsResponse, error) + + // InvokeWebMCPToolWithBodyWithResponse request with any body + InvokeWebMCPToolWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*InvokeWebMCPToolResponse, error) + + InvokeWebMCPToolWithResponse(ctx context.Context, body InvokeWebMCPToolJSONRequestBody, reqEditors ...RequestEditorFn) (*InvokeWebMCPToolResponse, error) + + // GetWebMCPToolsWithResponse request + GetWebMCPToolsWithResponse(ctx context.Context, params *GetWebMCPToolsParams, reqEditors ...RequestEditorFn) (*GetWebMCPToolsResponse, error) } type PatchChromiumFlagsResponse struct { @@ -14540,6 +14804,56 @@ func (r StreamTelemetryEventsResponse) StatusCode() int { return 0 } +type InvokeWebMCPToolResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *WebMCPInvocationResult + JSON400 *BadRequestError + JSON404 *NotFoundError + JSON500 *InternalError + JSON504 *WebMCPInvocationFailure +} + +// Status returns HTTPResponse.Status +func (r InvokeWebMCPToolResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r InvokeWebMCPToolResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +type GetWebMCPToolsResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *WebMCPToolsResponse + JSON404 *NotFoundError + JSON500 *InternalError +} + +// Status returns HTTPResponse.Status +func (r GetWebMCPToolsResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r GetWebMCPToolsResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + // PatchChromiumFlagsWithBodyWithResponse request with arbitrary body returning *PatchChromiumFlagsResponse func (c *ClientWithResponses) PatchChromiumFlagsWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*PatchChromiumFlagsResponse, error) { rsp, err := c.PatchChromiumFlagsWithBody(ctx, contentType, body, reqEditors...) @@ -15309,6 +15623,32 @@ func (c *ClientWithResponses) StreamTelemetryEventsWithResponse(ctx context.Cont return ParseStreamTelemetryEventsResponse(rsp) } +// InvokeWebMCPToolWithBodyWithResponse request with arbitrary body returning *InvokeWebMCPToolResponse +func (c *ClientWithResponses) InvokeWebMCPToolWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*InvokeWebMCPToolResponse, error) { + rsp, err := c.InvokeWebMCPToolWithBody(ctx, contentType, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseInvokeWebMCPToolResponse(rsp) +} + +func (c *ClientWithResponses) InvokeWebMCPToolWithResponse(ctx context.Context, body InvokeWebMCPToolJSONRequestBody, reqEditors ...RequestEditorFn) (*InvokeWebMCPToolResponse, error) { + rsp, err := c.InvokeWebMCPTool(ctx, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseInvokeWebMCPToolResponse(rsp) +} + +// GetWebMCPToolsWithResponse request returning *GetWebMCPToolsResponse +func (c *ClientWithResponses) GetWebMCPToolsWithResponse(ctx context.Context, params *GetWebMCPToolsParams, reqEditors ...RequestEditorFn) (*GetWebMCPToolsResponse, error) { + rsp, err := c.GetWebMCPTools(ctx, params, reqEditors...) + if err != nil { + return nil, err + } + return ParseGetWebMCPToolsResponse(rsp) +} + // ParsePatchChromiumFlagsResponse parses an HTTP response from a PatchChromiumFlagsWithResponse call func ParsePatchChromiumFlagsResponse(rsp *http.Response) (*PatchChromiumFlagsResponse, error) { bodyBytes, err := io.ReadAll(rsp.Body) @@ -17474,6 +17814,100 @@ func ParseStreamTelemetryEventsResponse(rsp *http.Response) (*StreamTelemetryEve return response, nil } +// ParseInvokeWebMCPToolResponse parses an HTTP response from a InvokeWebMCPToolWithResponse call +func ParseInvokeWebMCPToolResponse(rsp *http.Response) (*InvokeWebMCPToolResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &InvokeWebMCPToolResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest WebMCPInvocationResult + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: + var dest BadRequestError + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest NotFoundError + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest InternalError + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON500 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 504: + var dest WebMCPInvocationFailure + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON504 = &dest + + } + + return response, nil +} + +// ParseGetWebMCPToolsResponse parses an HTTP response from a GetWebMCPToolsWithResponse call +func ParseGetWebMCPToolsResponse(rsp *http.Response) (*GetWebMCPToolsResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &GetWebMCPToolsResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest WebMCPToolsResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest NotFoundError + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest InternalError + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON500 = &dest + + } + + return response, nil +} + // ServerInterface represents all server handlers. type ServerInterface interface { // Update Chromium launch flags and restart @@ -17647,6 +18081,12 @@ type ServerInterface interface { // Stream telemetry events as Server-Sent Events // (GET /telemetry/stream) StreamTelemetryEvents(w http.ResponseWriter, r *http.Request, params StreamTelemetryEventsParams) + // Invoke a discovered WebMCP tool + // (POST /webmcp/invoke) + InvokeWebMCPTool(w http.ResponseWriter, r *http.Request) + // Discover WebMCP tools in the active page and its frames + // (GET /webmcp/tools) + GetWebMCPTools(w http.ResponseWriter, r *http.Request, params GetWebMCPToolsParams) } // Unimplemented server implementation that returns http.StatusNotImplemented for each endpoint. @@ -17995,6 +18435,18 @@ func (_ Unimplemented) StreamTelemetryEvents(w http.ResponseWriter, r *http.Requ w.WriteHeader(http.StatusNotImplemented) } +// Invoke a discovered WebMCP tool +// (POST /webmcp/invoke) +func (_ Unimplemented) InvokeWebMCPTool(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotImplemented) +} + +// Discover WebMCP tools in the active page and its frames +// (GET /webmcp/tools) +func (_ Unimplemented) GetWebMCPTools(w http.ResponseWriter, r *http.Request, params GetWebMCPToolsParams) { + w.WriteHeader(http.StatusNotImplemented) +} + // ServerInterfaceWrapper converts contexts to parameters. type ServerInterfaceWrapper struct { Handler ServerInterface @@ -19119,6 +19571,47 @@ func (siw *ServerInterfaceWrapper) StreamTelemetryEvents(w http.ResponseWriter, handler.ServeHTTP(w, r) } +// InvokeWebMCPTool operation middleware +func (siw *ServerInterfaceWrapper) InvokeWebMCPTool(w http.ResponseWriter, r *http.Request) { + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.InvokeWebMCPTool(w, r) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// GetWebMCPTools operation middleware +func (siw *ServerInterfaceWrapper) GetWebMCPTools(w http.ResponseWriter, r *http.Request) { + + var err error + + // Parameter object where we will unmarshal all parameters from the context + var params GetWebMCPToolsParams + + // ------------- Optional query parameter "target_id" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, false, "target_id", r.URL.Query(), ¶ms.TargetId, runtime.BindQueryParameterOptions{Type: "string", Format: ""}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "target_id", Err: err}) + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.GetWebMCPTools(w, r, params) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + type UnescapedCookieParamError struct { ParamName string Err error @@ -19403,6 +19896,12 @@ func HandlerWithOptions(si ServerInterface, options ChiServerOptions) http.Handl r.Group(func(r chi.Router) { r.Get(options.BaseURL+"/telemetry/stream", wrapper.StreamTelemetryEvents) }) + r.Group(func(r chi.Router) { + r.Post(options.BaseURL+"/webmcp/invoke", wrapper.InvokeWebMCPTool) + }) + r.Group(func(r chi.Router) { + r.Get(options.BaseURL+"/webmcp/tools", wrapper.GetWebMCPTools) + }) return r } @@ -21805,6 +22304,94 @@ func (response StreamTelemetryEvents200TexteventStreamResponse) VisitStreamTelem } } +type InvokeWebMCPToolRequestObject struct { + Body *InvokeWebMCPToolJSONRequestBody +} + +type InvokeWebMCPToolResponseObject interface { + VisitInvokeWebMCPToolResponse(w http.ResponseWriter) error +} + +type InvokeWebMCPTool200JSONResponse WebMCPInvocationResult + +func (response InvokeWebMCPTool200JSONResponse) VisitInvokeWebMCPToolResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type InvokeWebMCPTool400JSONResponse struct{ BadRequestErrorJSONResponse } + +func (response InvokeWebMCPTool400JSONResponse) VisitInvokeWebMCPToolResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(400) + + return json.NewEncoder(w).Encode(response) +} + +type InvokeWebMCPTool404JSONResponse struct{ NotFoundErrorJSONResponse } + +func (response InvokeWebMCPTool404JSONResponse) VisitInvokeWebMCPToolResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(404) + + return json.NewEncoder(w).Encode(response) +} + +type InvokeWebMCPTool500JSONResponse struct{ InternalErrorJSONResponse } + +func (response InvokeWebMCPTool500JSONResponse) VisitInvokeWebMCPToolResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(500) + + return json.NewEncoder(w).Encode(response) +} + +type InvokeWebMCPTool504JSONResponse WebMCPInvocationFailure + +func (response InvokeWebMCPTool504JSONResponse) VisitInvokeWebMCPToolResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(504) + + return json.NewEncoder(w).Encode(response) +} + +type GetWebMCPToolsRequestObject struct { + Params GetWebMCPToolsParams +} + +type GetWebMCPToolsResponseObject interface { + VisitGetWebMCPToolsResponse(w http.ResponseWriter) error +} + +type GetWebMCPTools200JSONResponse WebMCPToolsResponse + +func (response GetWebMCPTools200JSONResponse) VisitGetWebMCPToolsResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type GetWebMCPTools404JSONResponse struct{ NotFoundErrorJSONResponse } + +func (response GetWebMCPTools404JSONResponse) VisitGetWebMCPToolsResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(404) + + return json.NewEncoder(w).Encode(response) +} + +type GetWebMCPTools500JSONResponse struct{ InternalErrorJSONResponse } + +func (response GetWebMCPTools500JSONResponse) VisitGetWebMCPToolsResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(500) + + return json.NewEncoder(w).Encode(response) +} + // StrictServerInterface represents all server handlers. type StrictServerInterface interface { // Update Chromium launch flags and restart @@ -21978,6 +22565,12 @@ type StrictServerInterface interface { // Stream telemetry events as Server-Sent Events // (GET /telemetry/stream) StreamTelemetryEvents(ctx context.Context, request StreamTelemetryEventsRequestObject) (StreamTelemetryEventsResponseObject, error) + // Invoke a discovered WebMCP tool + // (POST /webmcp/invoke) + InvokeWebMCPTool(ctx context.Context, request InvokeWebMCPToolRequestObject) (InvokeWebMCPToolResponseObject, error) + // Discover WebMCP tools in the active page and its frames + // (GET /webmcp/tools) + GetWebMCPTools(ctx context.Context, request GetWebMCPToolsRequestObject) (GetWebMCPToolsResponseObject, error) } type StrictHandlerFunc = strictnethttp.StrictHTTPHandlerFunc @@ -23694,6 +24287,63 @@ func (sh *strictHandler) StreamTelemetryEvents(w http.ResponseWriter, r *http.Re } } +// InvokeWebMCPTool operation middleware +func (sh *strictHandler) InvokeWebMCPTool(w http.ResponseWriter, r *http.Request) { + var request InvokeWebMCPToolRequestObject + + var body InvokeWebMCPToolJSONRequestBody + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + sh.options.RequestErrorHandlerFunc(w, r, fmt.Errorf("can't decode JSON body: %w", err)) + return + } + request.Body = &body + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.InvokeWebMCPTool(ctx, request.(InvokeWebMCPToolRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "InvokeWebMCPTool") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(InvokeWebMCPToolResponseObject); ok { + if err := validResponse.VisitInvokeWebMCPToolResponse(w); err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } + } else if response != nil { + sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response)) + } +} + +// GetWebMCPTools operation middleware +func (sh *strictHandler) GetWebMCPTools(w http.ResponseWriter, r *http.Request, params GetWebMCPToolsParams) { + var request GetWebMCPToolsRequestObject + + request.Params = params + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.GetWebMCPTools(ctx, request.(GetWebMCPToolsRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "GetWebMCPTools") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(GetWebMCPToolsResponseObject); ok { + if err := validResponse.VisitGetWebMCPToolsResponse(w); err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } + } else if response != nil { + sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response)) + } +} + // Base64 encoded, gzipped, json marshaled Swagger object var swaggerSpec = []string{ @@ -24097,174 +24747,195 @@ var swaggerSpec = []string{ "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", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/server/lib/webmcpclient/client.go b/server/lib/webmcpclient/client.go new file mode 100644 index 000000000..48e1b1cb6 --- /dev/null +++ b/server/lib/webmcpclient/client.go @@ -0,0 +1,348 @@ +package webmcpclient + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "sync" + "sync/atomic" + "time" + + "github.com/coder/websocket" +) + +const ( + settleDelay = 200 * time.Millisecond + settleLimit = 2 * time.Second +) + +type connection struct { + conn *websocket.Conn + ctx context.Context + cancel context.CancelFunc + + nextID atomic.Int64 + writeMu sync.Mutex + + pendingMu sync.Mutex + pending map[int64]chan commandResult + + targetMu sync.Mutex + + stateMu sync.RWMutex + rootTargetID string + rootSessionID string + sessions map[string]session + frames map[string]map[string]frameInfo + tools map[string]*registeredTool + toolRefs map[string]string + invocations map[string]invocationResponse + abandonedInvocations map[string]time.Time + stateChangedCh chan struct{} + + closed chan struct{} + closeOnce sync.Once +} + +func dial(ctx context.Context, url string) (*connection, error) { + ws, _, err := websocket.Dial(ctx, url, &websocket.DialOptions{CompressionMode: websocket.CompressionDisabled}) + if err != nil { + return nil, fmt.Errorf("WebMCP: dial Chromium DevTools: %w", err) + } + ws.SetReadLimit(8 * 1024 * 1024) + clientCtx, cancel := context.WithCancel(context.Background()) + c := &connection{ + conn: ws, + ctx: clientCtx, + cancel: cancel, + pending: make(map[int64]chan commandResult), + sessions: make(map[string]session), + frames: make(map[string]map[string]frameInfo), + tools: make(map[string]*registeredTool), + toolRefs: make(map[string]string), + invocations: make(map[string]invocationResponse), + abandonedInvocations: make(map[string]time.Time), + stateChangedCh: make(chan struct{}, 1), + closed: make(chan struct{}), + } + go c.readLoop() + return c, nil +} + +func (c *connection) close() error { + c.closeOnce.Do(func() { + c.cancel() + c.conn.CloseNow() + close(c.closed) + c.failPending(errors.New("WebMCP: CDP connection closed")) + }) + return nil +} + +func (c *connection) isClosed() bool { + select { + case <-c.closed: + return true + default: + return false + } +} + +func (c *connection) readLoop() { + for { + _, payload, err := c.conn.Read(c.ctx) + if err != nil { + _ = c.close() + return + } + var message cdpMessage + if json.Unmarshal(payload, &message) != nil { + continue + } + if message.ID != 0 { + c.pendingMu.Lock() + responseCh := c.pending[message.ID] + delete(c.pending, message.ID) + c.pendingMu.Unlock() + if responseCh != nil { + if message.Error != nil { + responseCh <- commandResult{err: message.Error} + } else { + responseCh <- commandResult{result: message.Result} + } + } + continue + } + c.handleEvent(message) + } +} + +func (c *connection) failPending(err error) { + c.pendingMu.Lock() + defer c.pendingMu.Unlock() + for id, responseCh := range c.pending { + responseCh <- commandResult{err: err} + delete(c.pending, id) + } +} + +func (c *connection) send(ctx context.Context, method string, params any, sessionID string) (json.RawMessage, error) { + id := c.nextID.Add(1) + var rawParams json.RawMessage + if params != nil { + encoded, err := json.Marshal(params) + if err != nil { + return nil, fmt.Errorf("WebMCP: marshal %s parameters: %w", method, err) + } + rawParams = encoded + } + payload, err := json.Marshal(cdpRequest{ID: id, Method: method, Params: rawParams, SessionID: sessionID}) + if err != nil { + return nil, fmt.Errorf("WebMCP: marshal %s request: %w", method, err) + } + responseCh := make(chan commandResult, 1) + c.pendingMu.Lock() + c.pending[id] = responseCh + c.pendingMu.Unlock() + + c.writeMu.Lock() + err = c.conn.Write(ctx, websocket.MessageText, payload) + c.writeMu.Unlock() + if err != nil { + c.pendingMu.Lock() + delete(c.pending, id) + c.pendingMu.Unlock() + return nil, fmt.Errorf("WebMCP: write %s: %w", method, err) + } + + select { + case response := <-responseCh: + if response.err != nil { + return nil, fmt.Errorf("WebMCP: %s: %w", method, response.err) + } + return response.result, nil + case <-ctx.Done(): + c.pendingMu.Lock() + delete(c.pending, id) + c.pendingMu.Unlock() + return nil, fmt.Errorf("WebMCP: %s: %w", method, ctx.Err()) + case <-c.closed: + return nil, errors.New("WebMCP: CDP connection closed") + } +} + +func (c *connection) selectTarget(ctx context.Context, requestedTargetID string) error { + c.targetMu.Lock() + defer c.targetMu.Unlock() + + raw, err := c.send(ctx, "Target.getTargets", nil, "") + if err != nil { + return err + } + var result struct { + TargetInfos []targetInfo `json:"targetInfos"` + } + if err := json.Unmarshal(raw, &result); err != nil { + return fmt.Errorf("WebMCP: decode Target.getTargets: %w", err) + } + var selected targetInfo + for _, target := range result.TargetInfos { + if target.Type != "page" { + continue + } + if requestedTargetID == "" || requestedTargetID == target.TargetID { + selected = target + break + } + } + if selected.TargetID == "" { + return ErrNoPageTarget + } + + c.stateMu.RLock() + alreadySelected := c.rootTargetID == selected.TargetID && c.sessions[c.rootSessionID].id != "" + previousTargetID := c.rootTargetID + c.stateMu.RUnlock() + if alreadySelected { + return nil + } + if previousTargetID != "" { + _ = c.close() + return errors.New("WebMCP: selected page target changed") + } + + c.stateMu.Lock() + c.clearStateLocked() + c.rootTargetID = selected.TargetID + c.stateMu.Unlock() + if _, err := c.send(ctx, "Target.autoAttachRelated", map[string]any{ + "targetId": selected.TargetID, + "waitForDebuggerOnStart": false, + "filter": []map[string]any{ + {"type": "page"}, + {"type": "iframe"}, + }, + }, ""); err != nil { + return err + } + + c.stateMu.Lock() + for id, sess := range c.sessions { + if sess.target.TargetID == selected.TargetID { + c.rootSessionID = id + break + } + } + rootSessionID := c.rootSessionID + c.stateMu.Unlock() + if rootSessionID == "" { + return fmt.Errorf("WebMCP: Chromium did not attach the selected page target") + } + return c.initializeSession(rootSessionID, selected.Type) +} + +func (c *connection) waitForSettled(ctx context.Context) { + limit := time.NewTimer(settleLimit) + defer limit.Stop() + quiet := time.NewTimer(settleDelay) + defer quiet.Stop() + for { + select { + case <-c.stateChangedCh: + if !quiet.Stop() { + select { + case <-quiet.C: + default: + } + } + quiet.Reset(settleDelay) + case <-quiet.C: + return + case <-limit.C: + return + case <-ctx.Done(): + return + case <-c.closed: + return + } + } +} + +func (c *connection) invoke(ctx context.Context, toolRef string, input map[string]any) (InvocationResult, error) { + c.stateMu.RLock() + tool, ok := c.tools[toolRef] + if !ok { + c.stateMu.RUnlock() + return InvocationResult{}, ErrToolNotFound + } + sessionID, frameID, name := tool.sessionID, tool.frameID, tool.name + _, sessionAlive := c.sessions[sessionID] + c.stateMu.RUnlock() + if !sessionAlive { + return InvocationResult{}, ErrToolNotFound + } + + raw, err := c.send(ctx, "WebMCP.invokeTool", map[string]any{ + "frameId": frameID, + "toolName": name, + "input": input, + }, sessionID) + if err != nil { + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + _ = c.close() + return InvocationResult{}, ErrOutcomeUnknown + } + if !c.sessionExists(sessionID) { + return InvocationResult{}, ErrOutcomeUnknown + } + return InvocationResult{}, err + } + var started struct { + InvocationID string `json:"invocationId"` + } + if err := json.Unmarshal(raw, &started); err != nil || started.InvocationID == "" { + return InvocationResult{}, fmt.Errorf("WebMCP: invalid invokeTool response") + } + + ticker := time.NewTicker(25 * time.Millisecond) + defer ticker.Stop() + for { + c.stateMu.Lock() + if response, ok := c.invocations[started.InvocationID]; ok { + delete(c.invocations, started.InvocationID) + c.stateMu.Unlock() + return InvocationResult{ + InvocationID: response.InvocationID, + Status: response.Status, + Output: response.Output, + ErrorText: response.ErrorText, + }, nil + } + _, alive := c.sessions[sessionID] + c.stateMu.Unlock() + if !alive || c.isClosed() { + return InvocationResult{InvocationID: started.InvocationID}, ErrOutcomeUnknown + } + select { + case <-c.stateChangedCh: + case <-ticker.C: + case <-ctx.Done(): + c.stateMu.Lock() + delete(c.invocations, started.InvocationID) + c.abandonedInvocations[started.InvocationID] = time.Now() + c.pruneAbandonedInvocationsLocked() + c.stateMu.Unlock() + return InvocationResult{InvocationID: started.InvocationID}, ErrOutcomeUnknown + case <-c.closed: + return InvocationResult{InvocationID: started.InvocationID}, ErrOutcomeUnknown + } + } +} + +func (c *connection) sessionExists(sessionID string) bool { + c.stateMu.RLock() + defer c.stateMu.RUnlock() + _, ok := c.sessions[sessionID] + return ok +} diff --git a/server/lib/webmcpclient/client_test.go b/server/lib/webmcpclient/client_test.go new file mode 100644 index 000000000..1d56b99fe --- /dev/null +++ b/server/lib/webmcpclient/client_test.go @@ -0,0 +1,249 @@ +package webmcpclient + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "github.com/coder/websocket" + "github.com/stretchr/testify/require" +) + +type staticUpstream struct { + url string +} + +func (u staticUpstream) Current() string { return u.url } + +type fakeCDP struct { + server *httptest.Server + url string + + mu sync.Mutex + connections int + relatedAttaches int + enabledSessions map[string]int + omitResponse bool +} + +func newFakeCDP(t *testing.T, omitResponse bool) *fakeCDP { + t.Helper() + fake := &fakeCDP{enabledSessions: make(map[string]int), omitResponse: omitResponse} + fake.server = httptest.NewServer(http.HandlerFunc(fake.serve)) + fake.url = "ws" + strings.TrimPrefix(fake.server.URL, "http") + t.Cleanup(fake.server.Close) + return fake +} + +func (f *fakeCDP) serve(w http.ResponseWriter, r *http.Request) { + conn, err := websocket.Accept(w, r, nil) + if err != nil { + return + } + defer conn.CloseNow() + f.mu.Lock() + f.connections++ + f.mu.Unlock() + + var writeMu sync.Mutex + write := func(value any) { + payload, err := json.Marshal(value) + if err != nil { + return + } + writeMu.Lock() + defer writeMu.Unlock() + _ = conn.Write(r.Context(), websocket.MessageText, payload) + } + for { + _, payload, err := conn.Read(r.Context()) + if err != nil { + return + } + var request cdpRequest + if json.Unmarshal(payload, &request) != nil { + continue + } + respond := func(result any) { + write(map[string]any{"id": request.ID, "result": result}) + } + switch request.Method { + case "Target.getTargets": + respond(map[string]any{"targetInfos": []map[string]any{{ + "targetId": "page-target", "type": "page", "url": "https://merchant.example/", + }}}) + case "Target.autoAttachRelated": + f.mu.Lock() + f.relatedAttaches++ + f.mu.Unlock() + write(map[string]any{ + "method": "Target.attachedToTarget", + "params": map[string]any{ + "sessionId": "page-session", + "targetInfo": map[string]any{ + "targetId": "page-target", "type": "page", "url": "https://merchant.example/", + }, + }, + }) + write(map[string]any{ + "method": "Target.attachedToTarget", + "params": map[string]any{ + "sessionId": "iframe-session", + "targetInfo": map[string]any{ + "targetId": "iframe-target", "type": "iframe", "url": "https://payments.example/element#private-state", + "parentFrameId": "page-frame", + }, + }, + }) + respond(map[string]any{}) + case "Page.enable": + respond(map[string]any{}) + case "Page.getFrameTree": + frame := map[string]any{ + "id": "page-frame", "loaderId": "page-loader", "url": "https://merchant.example/", + } + if request.SessionID == "iframe-session" { + frame = map[string]any{ + "id": "iframe-frame", "loaderId": "iframe-loader", "url": "https://payments.example/element#private-state", + "parentId": "page-frame", + } + } + respond(map[string]any{"frameTree": map[string]any{"frame": frame}}) + case "WebMCP.enable": + f.mu.Lock() + f.enabledSessions[request.SessionID]++ + f.mu.Unlock() + respond(map[string]any{}) + name, frameID := "merchant_tool", "page-frame" + if request.SessionID == "iframe-session" { + name, frameID = "payment_tool", "iframe-frame" + } + write(map[string]any{ + "method": "WebMCP.toolsAdded", + "sessionId": request.SessionID, + "params": map[string]any{"tools": []map[string]any{{ + "name": name, "description": name + " description", "frameId": frameID, + "inputSchema": map[string]any{"type": "object"}, + }}}, + }) + case "WebMCP.invokeTool": + respond(map[string]any{"invocationId": "invocation-1"}) + if !f.omitResponse { + write(map[string]any{ + "method": "Page.frameNavigated", + "sessionId": request.SessionID, + "params": map[string]any{"frame": map[string]any{ + "id": "iframe-frame", "loaderId": "next-loader", "url": "https://payments.example/success", + }}, + }) + write(map[string]any{ + "method": "WebMCP.toolResponded", + "sessionId": request.SessionID, + "params": map[string]any{ + "invocationId": "invocation-1", "status": "Completed", + "output": map[string]any{"content": []map[string]any{{"type": "text", "text": "paid"}}}, + }, + }) + } + default: + write(map[string]any{"id": request.ID, "error": map[string]any{"code": -32601, "message": "unknown method"}}) + } + } +} + +func TestManagerDiscoversTopPageAndOOPIFToolsOverOneConnection(t *testing.T) { + fake := newFakeCDP(t, false) + manager := NewManager(staticUpstream{url: fake.url}) + t.Cleanup(func() { _ = manager.Close() }) + + tools, err := manager.Tools(context.Background(), "") + require.NoError(t, err) + require.Len(t, tools, 2) + + byName := make(map[string]Tool, len(tools)) + for _, tool := range tools { + byName[tool.Name] = tool + } + require.Equal(t, "page-target", byName["merchant_tool"].PageTargetID) + require.Equal(t, "page-target", byName["merchant_tool"].TargetID) + require.Equal(t, "page-target:page-loader", byName["merchant_tool"].DocumentRef) + require.Equal(t, "page-target", byName["payment_tool"].PageTargetID) + require.Equal(t, "iframe-target", byName["payment_tool"].TargetID) + require.Equal(t, "iframe-frame", byName["payment_tool"].FrameID) + require.Equal(t, "https://payments.example/element", byName["payment_tool"].TargetURL) + require.Equal(t, "https://payments.example/element", byName["payment_tool"].FrameURL) + require.Equal(t, "iframe-target:iframe-loader", byName["payment_tool"].DocumentRef) + + fake.mu.Lock() + defer fake.mu.Unlock() + require.Equal(t, 1, fake.connections) + require.Equal(t, 1, fake.relatedAttaches) + require.GreaterOrEqual(t, fake.enabledSessions["page-session"], 1) + require.GreaterOrEqual(t, fake.enabledSessions["iframe-session"], 1) +} + +func TestManagerReusesConnectionAndToolReferences(t *testing.T) { + fake := newFakeCDP(t, false) + manager := NewManager(staticUpstream{url: fake.url}) + t.Cleanup(func() { _ = manager.Close() }) + + first, err := manager.Tools(context.Background(), "") + require.NoError(t, err) + second, err := manager.Tools(context.Background(), "") + require.NoError(t, err) + require.Equal(t, first, second) + + fake.mu.Lock() + defer fake.mu.Unlock() + require.Equal(t, 1, fake.connections) + require.Equal(t, 1, fake.relatedAttaches) +} + +func TestInvocationCanCompleteAfterFrameNavigation(t *testing.T) { + fake := newFakeCDP(t, false) + manager := NewManager(staticUpstream{url: fake.url}) + t.Cleanup(func() { _ = manager.Close() }) + tools, err := manager.Tools(context.Background(), "") + require.NoError(t, err) + var paymentTool Tool + for _, tool := range tools { + if tool.Name == "payment_tool" { + paymentTool = tool + } + } + require.NotEmpty(t, paymentTool.Ref) + + result, err := manager.Invoke(context.Background(), paymentTool.Ref, map[string]any{"amount": 2900}) + require.NoError(t, err) + require.Equal(t, "invocation-1", result.InvocationID) + require.Equal(t, "Completed", result.Status) + require.Equal(t, "paid", result.Output.(map[string]any)["content"].([]any)[0].(map[string]any)["text"]) + + _, err = manager.Invoke(context.Background(), paymentTool.Ref, map[string]any{}) + require.ErrorIs(t, err, ErrToolNotFound) +} + +func TestInvocationTimeoutHasUnknownOutcome(t *testing.T) { + fake := newFakeCDP(t, true) + manager := NewManager(staticUpstream{url: fake.url}) + t.Cleanup(func() { _ = manager.Close() }) + tools, err := manager.Tools(context.Background(), "") + require.NoError(t, err) + var toolRef string + for _, tool := range tools { + if tool.Name == "payment_tool" { + toolRef = tool.Ref + } + } + + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + defer cancel() + result, err := manager.Invoke(ctx, toolRef, map[string]any{}) + require.ErrorIs(t, err, ErrOutcomeUnknown) + require.Equal(t, "invocation-1", result.InvocationID) +} diff --git a/server/lib/webmcpclient/events.go b/server/lib/webmcpclient/events.go new file mode 100644 index 000000000..526a05119 --- /dev/null +++ b/server/lib/webmcpclient/events.go @@ -0,0 +1,312 @@ +package webmcpclient + +import ( + "context" + "encoding/json" + "fmt" + "sort" + "strings" + "time" + + "github.com/nrednav/cuid2" +) + +func (c *connection) handleEvent(message cdpMessage) { + switch message.Method { + case "Target.attachedToTarget": + var event struct { + SessionID string `json:"sessionId"` + TargetInfo targetInfo `json:"targetInfo"` + } + if json.Unmarshal(message.Params, &event) != nil { + return + } + c.stateMu.Lock() + if message.SessionID != "" { + if _, ok := c.sessions[message.SessionID]; !ok { + c.stateMu.Unlock() + return + } + } + c.sessions[event.SessionID] = session{id: event.SessionID, parentID: message.SessionID, target: event.TargetInfo} + c.stateMu.Unlock() + c.signalStateChanged() + c.stateMu.RLock() + rootTargetID := c.rootTargetID + c.stateMu.RUnlock() + if event.TargetInfo.TargetID != rootTargetID { + go func() { _ = c.initializeSession(event.SessionID, event.TargetInfo.Type) }() + } + case "Target.detachedFromTarget": + var event struct { + SessionID string `json:"sessionId"` + } + if json.Unmarshal(message.Params, &event) == nil { + c.removeSession(event.SessionID) + } + case "Target.targetInfoChanged": + var event struct { + TargetInfo targetInfo `json:"targetInfo"` + } + if json.Unmarshal(message.Params, &event) == nil { + c.stateMu.Lock() + for id, sess := range c.sessions { + if sess.target.TargetID == event.TargetInfo.TargetID { + sess.target = event.TargetInfo + c.sessions[id] = sess + } + } + c.stateMu.Unlock() + c.signalStateChanged() + } + case "Page.frameNavigated": + var event struct { + Frame frameInfo `json:"frame"` + } + if json.Unmarshal(message.Params, &event) == nil { + c.stateMu.Lock() + if _, ok := c.sessions[message.SessionID]; !ok { + c.stateMu.Unlock() + return + } + if c.frames[message.SessionID] == nil { + c.frames[message.SessionID] = make(map[string]frameInfo) + } + c.frames[message.SessionID][event.Frame.ID] = event.Frame + if sess, ok := c.sessions[message.SessionID]; ok && sess.target.TargetID == event.Frame.ID { + sess.target.URL = event.Frame.URL + c.sessions[message.SessionID] = sess + } + c.removeFrameToolsLocked(message.SessionID, event.Frame.ID) + c.stateMu.Unlock() + c.signalStateChanged() + } + case "Page.frameDetached": + var event struct { + FrameID string `json:"frameId"` + } + if json.Unmarshal(message.Params, &event) == nil { + c.stateMu.Lock() + delete(c.frames[message.SessionID], event.FrameID) + c.removeFrameToolsLocked(message.SessionID, event.FrameID) + c.stateMu.Unlock() + c.signalStateChanged() + } + case "WebMCP.toolsAdded": + var event struct { + Tools []toolEvent `json:"tools"` + } + if json.Unmarshal(message.Params, &event) == nil { + c.addTools(message.SessionID, event.Tools) + } + case "WebMCP.toolsRemoved": + var event struct { + Tools []struct { + Name string `json:"name"` + FrameID string `json:"frameId"` + } `json:"tools"` + } + if json.Unmarshal(message.Params, &event) == nil { + c.stateMu.Lock() + for _, tool := range event.Tools { + c.removeToolLocked(toolKey(message.SessionID, tool.FrameID, tool.Name)) + } + c.stateMu.Unlock() + c.signalStateChanged() + } + case "WebMCP.toolResponded": + var response invocationResponse + if json.Unmarshal(message.Params, &response) == nil { + c.stateMu.Lock() + c.pruneAbandonedInvocationsLocked() + if _, abandoned := c.abandonedInvocations[response.InvocationID]; abandoned { + delete(c.abandonedInvocations, response.InvocationID) + } else { + c.invocations[response.InvocationID] = response + } + c.stateMu.Unlock() + c.signalStateChanged() + } + } +} + +func (c *connection) initializeSession(sessionID, targetType string) error { + if targetType != "page" && targetType != "iframe" { + return nil + } + ctx, cancel := context.WithTimeout(c.ctx, 5*time.Second) + defer cancel() + if _, err := c.send(ctx, "Page.enable", nil, sessionID); err != nil { + return err + } + raw, err := c.send(ctx, "Page.getFrameTree", nil, sessionID) + if err != nil { + return err + } + var result struct { + FrameTree frameTree `json:"frameTree"` + } + if err := json.Unmarshal(raw, &result); err != nil { + return fmt.Errorf("WebMCP: decode Page.getFrameTree: %w", err) + } + c.stateMu.Lock() + if c.frames[sessionID] == nil { + c.frames[sessionID] = make(map[string]frameInfo) + } + addFrameTree(c.frames[sessionID], result.FrameTree) + c.stateMu.Unlock() + if _, err := c.send(ctx, "WebMCP.enable", nil, sessionID); err != nil { + return err + } + c.signalStateChanged() + return nil +} + +func addFrameTree(frames map[string]frameInfo, tree frameTree) { + frames[tree.Frame.ID] = tree.Frame + for _, child := range tree.ChildFrames { + addFrameTree(frames, child) + } +} + +func (c *connection) addTools(sessionID string, tools []toolEvent) { + c.stateMu.Lock() + defer c.stateMu.Unlock() + if _, ok := c.sessions[sessionID]; !ok { + return + } + for _, tool := range tools { + key := toolKey(sessionID, tool.FrameID, tool.Name) + ref := c.toolRefs[key] + if ref == "" { + ref = "wmcp_" + cuid2.Generate() + c.toolRefs[key] = ref + } + c.tools[ref] = ®isteredTool{ + ref: ref, + sessionID: sessionID, + name: tool.Name, + description: tool.Description, + inputSchema: tool.InputSchema, + annotations: tool.Annotations, + frameID: tool.FrameID, + } + } + c.signalStateChanged() +} + +func (c *connection) toolsSnapshot() []Tool { + c.stateMu.RLock() + defer c.stateMu.RUnlock() + result := make([]Tool, 0, len(c.tools)) + for _, tool := range c.tools { + sess, ok := c.sessions[tool.sessionID] + if !ok { + continue + } + frame := c.frames[tool.sessionID][tool.frameID] + documentRef := "" + if frame.LoaderID != "" { + documentRef = sess.target.TargetID + ":" + frame.LoaderID + } + result = append(result, Tool{ + Ref: tool.ref, + Name: tool.name, + Description: tool.description, + InputSchema: tool.inputSchema, + Annotations: tool.annotations, + PageTargetID: c.rootTargetID, + TargetID: sess.target.TargetID, + TargetType: sess.target.Type, + TargetURL: stripURLFragment(sess.target.URL), + FrameID: tool.frameID, + FrameURL: stripURLFragment(frame.URL), + ParentFrameID: frame.ParentID, + DocumentRef: documentRef, + }) + } + sort.Slice(result, func(i, j int) bool { + if result[i].FrameID != result[j].FrameID { + return result[i].FrameID < result[j].FrameID + } + return result[i].Name < result[j].Name + }) + return result +} + +func (c *connection) removeSession(sessionID string) { + c.stateMu.Lock() + defer c.stateMu.Unlock() + toRemove := map[string]bool{sessionID: true} + for changed := true; changed; { + changed = false + for id, sess := range c.sessions { + if toRemove[sess.parentID] && !toRemove[id] { + toRemove[id] = true + changed = true + } + } + } + for id := range toRemove { + delete(c.sessions, id) + delete(c.frames, id) + for ref, tool := range c.tools { + if tool.sessionID == id { + delete(c.toolRefs, toolKey(tool.sessionID, tool.frameID, tool.name)) + delete(c.tools, ref) + } + } + } + c.signalStateChanged() +} + +func (c *connection) clearStateLocked() { + c.rootTargetID = "" + c.rootSessionID = "" + c.sessions = make(map[string]session) + c.frames = make(map[string]map[string]frameInfo) + c.tools = make(map[string]*registeredTool) + c.toolRefs = make(map[string]string) + c.invocations = make(map[string]invocationResponse) + c.abandonedInvocations = make(map[string]time.Time) +} + +func (c *connection) removeFrameToolsLocked(sessionID, frameID string) { + for ref, tool := range c.tools { + if tool.sessionID == sessionID && tool.frameID == frameID { + delete(c.toolRefs, toolKey(tool.sessionID, tool.frameID, tool.name)) + delete(c.tools, ref) + } + } +} + +func (c *connection) removeToolLocked(key string) { + ref := c.toolRefs[key] + delete(c.toolRefs, key) + delete(c.tools, ref) +} + +func (c *connection) pruneAbandonedInvocationsLocked() { + cutoff := time.Now().Add(-10 * time.Minute) + for invocationID, abandonedAt := range c.abandonedInvocations { + if abandonedAt.Before(cutoff) { + delete(c.abandonedInvocations, invocationID) + } + } +} + +func (c *connection) signalStateChanged() { + select { + case c.stateChangedCh <- struct{}{}: + default: + } +} + +func stripURLFragment(value string) string { + withoutFragment, _, _ := strings.Cut(value, "#") + return withoutFragment +} + +func toolKey(sessionID, frameID, name string) string { + return sessionID + "\x00" + frameID + "\x00" + name +} diff --git a/server/lib/webmcpclient/manager.go b/server/lib/webmcpclient/manager.go new file mode 100644 index 000000000..335a0c94c --- /dev/null +++ b/server/lib/webmcpclient/manager.go @@ -0,0 +1,97 @@ +package webmcpclient + +import ( + "context" + "fmt" + "sync" +) + +type UpstreamManager interface { + Current() string +} + +type Manager struct { + upstream UpstreamManager + + mu sync.Mutex + connection *connection + url string +} + +func NewManager(upstream UpstreamManager) *Manager { + return &Manager{upstream: upstream} +} + +func (m *Manager) Tools(ctx context.Context, targetID string) ([]Tool, error) { + conn, err := m.getConnection(ctx) + if err != nil { + return nil, err + } + if err := conn.selectTarget(ctx, targetID); err != nil { + if !conn.isClosed() { + return nil, err + } + m.discardConnection(conn) + conn, err = m.getConnection(ctx) + if err != nil { + return nil, err + } + if err := conn.selectTarget(ctx, targetID); err != nil { + return nil, err + } + } + conn.waitForSettled(ctx) + return conn.toolsSnapshot(), nil +} + +func (m *Manager) Invoke(ctx context.Context, toolRef string, input map[string]any) (InvocationResult, error) { + conn, err := m.getConnection(ctx) + if err != nil { + return InvocationResult{}, err + } + return conn.invoke(ctx, toolRef, input) +} + +func (m *Manager) Close() error { + m.mu.Lock() + defer m.mu.Unlock() + if m.connection == nil { + return nil + } + err := m.connection.close() + m.connection = nil + m.url = "" + return err +} + +func (m *Manager) getConnection(ctx context.Context) (*connection, error) { + m.mu.Lock() + defer m.mu.Unlock() + + url := m.upstream.Current() + if url == "" { + return nil, fmt.Errorf("WebMCP: Chromium DevTools endpoint is unavailable") + } + if m.connection != nil && m.url == url && !m.connection.isClosed() { + return m.connection, nil + } + if m.connection != nil { + _ = m.connection.close() + } + conn, err := dial(ctx, url) + if err != nil { + return nil, err + } + m.connection = conn + m.url = url + return conn, nil +} + +func (m *Manager) discardConnection(old *connection) { + m.mu.Lock() + defer m.mu.Unlock() + if m.connection == old { + m.connection = nil + m.url = "" + } +} diff --git a/server/lib/webmcpclient/types.go b/server/lib/webmcpclient/types.go new file mode 100644 index 000000000..89573db5b --- /dev/null +++ b/server/lib/webmcpclient/types.go @@ -0,0 +1,123 @@ +package webmcpclient + +import ( + "encoding/json" + "errors" + "fmt" +) + +var ( + ErrNoPageTarget = errors.New("no page target found") + ErrToolNotFound = errors.New("WebMCP tool not found") + ErrOutcomeUnknown = errors.New("WebMCP invocation outcome is unknown") +) + +type Tool struct { + Ref string + Name string + Description string + InputSchema map[string]any + Annotations *Annotations + PageTargetID string + TargetID string + TargetType string + TargetURL string + FrameID string + FrameURL string + ParentFrameID string + DocumentRef string +} + +type Annotations struct { + ReadOnly bool `json:"readOnly"` + UntrustedContent bool `json:"untrustedContent"` + Consequential bool `json:"consequential"` + Autosubmit bool `json:"autosubmit"` +} + +type InvocationResult struct { + InvocationID string + Status string + Output any + ErrorText string +} + +type cdpRequest struct { + ID int64 `json:"id"` + Method string `json:"method"` + Params json.RawMessage `json:"params,omitempty"` + SessionID string `json:"sessionId,omitempty"` +} + +type cdpMessage struct { + ID int64 `json:"id,omitempty"` + Method string `json:"method,omitempty"` + Params json.RawMessage `json:"params,omitempty"` + Result json.RawMessage `json:"result,omitempty"` + Error *cdpError `json:"error,omitempty"` + SessionID string `json:"sessionId,omitempty"` +} + +type cdpError struct { + Code int `json:"code"` + Message string `json:"message"` +} + +func (e *cdpError) Error() string { + return fmt.Sprintf("CDP error %d: %s", e.Code, e.Message) +} + +type commandResult struct { + result json.RawMessage + err error +} + +type targetInfo struct { + TargetID string `json:"targetId"` + Type string `json:"type"` + URL string `json:"url"` + ParentFrameID string `json:"parentFrameId,omitempty"` +} + +type session struct { + id string + parentID string + target targetInfo +} + +type frameInfo struct { + ID string `json:"id"` + ParentID string `json:"parentId,omitempty"` + LoaderID string `json:"loaderId"` + URL string `json:"url"` +} + +type frameTree struct { + Frame frameInfo `json:"frame"` + ChildFrames []frameTree `json:"childFrames,omitempty"` +} + +type registeredTool struct { + ref string + sessionID string + name string + description string + inputSchema map[string]any + annotations *Annotations + frameID string +} + +type toolEvent struct { + Name string `json:"name"` + Description string `json:"description"` + InputSchema map[string]any `json:"inputSchema"` + Annotations *Annotations `json:"annotations,omitempty"` + FrameID string `json:"frameId"` +} + +type invocationResponse struct { + InvocationID string `json:"invocationId"` + Status string `json:"status"` + Output any `json:"output,omitempty"` + ErrorText string `json:"errorText,omitempty"` +} diff --git a/server/openapi.yaml b/server/openapi.yaml index 495ee52dd..be228baa9 100644 --- a/server/openapi.yaml +++ b/server/openapi.yaml @@ -1356,6 +1356,73 @@ paths: schema: $ref: "#/components/schemas/ChromiumConfigureError" + /webmcp/tools: + get: + summary: Discover WebMCP tools in the active page and its frames + description: | + Lazily attaches one internal CDP connection to a page target and returns a snapshot of + native WebMCP tools registered by its current documents. Cross-origin iframe targets are + followed through CDP auto-attach. The server consumes WebMCP events internally; callers do + not need to maintain a CDP connection or subscribe to an event stream. + operationId: getWebMCPTools + x-telemetry-category: control + parameters: + - name: target_id + in: query + required: false + description: Optional page target to inspect. When omitted, the first page target reported by Chromium is used. + schema: + type: string + minLength: 1 + maxLength: 128 + responses: + "200": + description: Current WebMCP tool snapshot. + content: + application/json: + schema: + $ref: "#/components/schemas/WebMCPToolsResponse" + "404": + $ref: "#/components/responses/NotFoundError" + "500": + $ref: "#/components/responses/InternalError" + + /webmcp/invoke: + post: + summary: Invoke a discovered WebMCP tool + description: | + Invokes the registration identified by tool_ref and waits for Chromium's matching + WebMCP.toolResponded event. Navigation during execution is allowed. If the owning CDP + session disappears or the request times out after invocation begins, the server reports + outcome_unknown and never retries the tool automatically. + operationId: invokeWebMCPTool + x-telemetry-category: control + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/WebMCPInvokeRequest" + responses: + "200": + description: Chromium reported the tool's terminal result. + content: + application/json: + schema: + $ref: "#/components/schemas/WebMCPInvocationResult" + "400": + $ref: "#/components/responses/BadRequestError" + "404": + $ref: "#/components/responses/NotFoundError" + "504": + description: Invocation began, but its final outcome could not be observed. + content: + application/json: + schema: + $ref: "#/components/schemas/WebMCPInvocationFailure" + "500": + $ref: "#/components/responses/InternalError" + /playwright/execute: post: summary: Execute Playwright/TypeScript code against the browser @@ -6170,6 +6237,121 @@ components: Wall-clock time at which the current configuration was applied. Omitted when telemetry is not configured. additionalProperties: false + WebMCPToolAnnotations: + type: object + description: Page-provided behavioral hints. These values are untrusted and are not enforced by Kernel. + additionalProperties: false + required: [read_only, untrusted_content, consequential, autosubmit] + properties: + read_only: + type: boolean + untrusted_content: + type: boolean + consequential: + type: boolean + autosubmit: + type: boolean + WebMCPTool: + type: object + additionalProperties: false + required: + - tool_ref + - name + - description + - input_schema + - page_target_id + - target_id + - target_type + - frame_id + properties: + tool_ref: + type: string + description: Opaque reference for invoking this exact live registration. It becomes invalid when its document or CDP session is replaced. + name: + type: string + description: + type: string + input_schema: + type: object + additionalProperties: true + annotations: + $ref: "#/components/schemas/WebMCPToolAnnotations" + page_target_id: + type: string + description: CDP target ID of the selected top-level page. + target_id: + type: string + description: CDP target ID owning the registration. For an out-of-process iframe this differs from page_target_id. + target_type: + type: string + description: CDP target type, normally page or iframe. + target_url: + type: string + description: Owning target URL with the fragment omitted. + frame_id: + type: string + description: Page.FrameId supplied by Chromium's WebMCP domain. + frame_url: + type: string + description: Current frame URL with the fragment omitted. + parent_frame_id: + type: string + document_ref: + type: string + description: Reference derived from the owning target and current Page.LoaderId. It changes when the frame commits a new document. + WebMCPToolsResponse: + type: object + additionalProperties: false + required: [tools] + properties: + tools: + type: array + items: + $ref: "#/components/schemas/WebMCPTool" + WebMCPInvokeRequest: + type: object + additionalProperties: false + required: [tool_ref, input] + properties: + tool_ref: + type: string + minLength: 1 + maxLength: 128 + input: + type: object + description: Tool input, limited to 1 MiB after JSON serialization. + additionalProperties: true + timeout_sec: + type: integer + minimum: 1 + maximum: 120 + default: 60 + WebMCPInvocationResult: + type: object + additionalProperties: false + required: [invocation_id, status] + properties: + invocation_id: + type: string + status: + type: string + enum: [completed, canceled, error] + output: + description: Untrusted page-provided output. Callers must treat it as potentially malicious input. + error_text: + type: string + WebMCPInvocationFailure: + type: object + additionalProperties: false + required: [code, message] + properties: + code: + type: string + enum: [outcome_unknown] + message: + type: string + invocation_id: + type: string StartRecordingRequest: type: object properties: From 2ea79e4eeca38603659231d2717ca1cad16aa256 Mon Sep 17 00:00:00 2001 From: rgarcia <72655+rgarcia@users.noreply.github.com> Date: Tue, 1 Sep 2026 00:37:22 +0000 Subject: [PATCH 02/18] Harden WebMCP connection lifecycle --- server/cmd/api/api/webmcp.go | 4 + server/cmd/api/api/webmcp_test.go | 17 ++ server/lib/oapi/oapi.go | 8 +- server/lib/webmcpclient/client.go | 83 +++++++--- server/lib/webmcpclient/client_test.go | 212 +++++++++++++++++++++++-- server/lib/webmcpclient/events.go | 68 +++++++- server/lib/webmcpclient/types.go | 5 + server/openapi.yaml | 5 +- 8 files changed, 359 insertions(+), 43 deletions(-) diff --git a/server/cmd/api/api/webmcp.go b/server/cmd/api/api/webmcp.go index 6d34d575b..d493e4963 100644 --- a/server/cmd/api/api/webmcp.go +++ b/server/cmd/api/api/webmcp.go @@ -14,6 +14,7 @@ import ( const ( defaultWebMCPInvocationTimeout = 60 * time.Second + maxWebMCPInvocationTimeoutSec = 120 maxWebMCPInputBytes = 1 << 20 ) @@ -74,6 +75,9 @@ func (s *ApiService) InvokeWebMCPTool(ctx context.Context, request oapi.InvokeWe } timeout := defaultWebMCPInvocationTimeout if request.Body.TimeoutSec != nil { + if *request.Body.TimeoutSec < 1 || *request.Body.TimeoutSec > maxWebMCPInvocationTimeoutSec { + return oapi.InvokeWebMCPTool400JSONResponse{BadRequestErrorJSONResponse: oapi.BadRequestErrorJSONResponse{Message: "timeout_sec must be between 1 and 120"}}, nil + } timeout = time.Duration(*request.Body.TimeoutSec) * time.Second } invokeCtx, cancel := context.WithTimeout(ctx, timeout) diff --git a/server/cmd/api/api/webmcp_test.go b/server/cmd/api/api/webmcp_test.go index dddf2a0a4..550bedd7c 100644 --- a/server/cmd/api/api/webmcp_test.go +++ b/server/cmd/api/api/webmcp_test.go @@ -3,6 +3,7 @@ package api import ( "context" "errors" + "fmt" "strings" "testing" @@ -121,6 +122,22 @@ func TestInvokeWebMCPToolReturnsNotFoundForStaleReference(t *testing.T) { require.True(t, ok) } +func TestInvokeWebMCPToolRejectsTimeoutOutsideBounds(t *testing.T) { + for _, timeoutSec := range []int{0, -1, 121} { + t.Run(fmt.Sprintf("timeout_%d", timeoutSec), func(t *testing.T) { + client := &fakeWebMCPClient{} + service := &ApiService{webmcp: client} + response, err := service.InvokeWebMCPTool(context.Background(), oapi.InvokeWebMCPToolRequestObject{ + Body: &oapi.WebMCPInvokeRequest{ToolRef: "wmcp_test", Input: map[string]any{}, TimeoutSec: &timeoutSec}, + }) + require.NoError(t, err) + _, ok := response.(oapi.InvokeWebMCPTool400JSONResponse) + require.True(t, ok) + require.Empty(t, client.toolRef) + }) + } +} + func TestInvokeWebMCPToolRejectsOversizedInput(t *testing.T) { client := &fakeWebMCPClient{} service := &ApiService{webmcp: client} diff --git a/server/lib/oapi/oapi.go b/server/lib/oapi/oapi.go index 97ba180f0..28afb8cc3 100644 --- a/server/lib/oapi/oapi.go +++ b/server/lib/oapi/oapi.go @@ -6948,7 +6948,7 @@ type StreamTelemetryEventsParamsReplay string // GetWebMCPToolsParams defines parameters for GetWebMCPTools. type GetWebMCPToolsParams struct { - // TargetId Optional page target to inspect. When omitted, the first page target reported by Chromium is used. + // TargetId Optional page target to inspect. When omitted, the current discovery target is retained; the first page target reported by Chromium is used only on initial discovery. Explicitly switching targets resets the WebMCP attachment and makes running invocation outcomes unknown. TargetId *string `form:"target_id,omitempty" json:"target_id,omitempty"` } @@ -24934,8 +24934,10 @@ var swaggerSpec = []string{ "MhJMj0Dk/WmkfakB8gNwUjbzO67IGfYFVmfDamEyNP4N23mKeMs/yWLOuHM8mwqs1Jc0p169mz6404xj", "5ToVynuFNJFpySpe2ql2TI+HSiFZbaPXtnl3PZojYiZ5NMGpswP20h8Z+trIiVRM4pEFm4MddqgQlwSE", "1ehqMoVegquBg0DHlGxJphU6HdQRMuBhhMX8RXQwcz1Ufl0UJevNuAQmKMYX58H7aNXIz+JIUHoGusZ0", - "FElf4tS2Z62zGIGGmpPttPeYSpEl00jRv2g+vwT7QHTjle3OJsVXLxaSSmf841sgVj54/uTpnwC0Mv7/", - "71vC1JjDlQzDJFYN6YvSObjPlEJS57Za0OmUAhewhF6xwHZ4IbGb6Pvnz/8ZAAD//02rPePrbwMA", + "FElf4tS2Z62zGIGGmpPttPeYSpEl00jjTJLEzMNr0kZ/5gUhRXtPpPnlJYAIIiavrKDjg98ElHSSF3UD", + "A/a6cTlyI8m/DmvlfUGKCdPc4/LM4ASvcjYD15LgNZpbAqmKZWT3u1JfsaWLhQzYGf/4FligD54/efon", + "QNiM///71ls1FnwlHTKtXENVoioN7jP/kRa6rcN0lKYoC0iRX0wwdF6i7SbG6fPn/wwAAP//EuxzAJhw", + "AwA=", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/server/lib/webmcpclient/client.go b/server/lib/webmcpclient/client.go index 48e1b1cb6..03fd89f18 100644 --- a/server/lib/webmcpclient/client.go +++ b/server/lib/webmcpclient/client.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "log/slog" "sync" "sync/atomic" "time" @@ -13,10 +14,15 @@ import ( ) const ( - settleDelay = 200 * time.Millisecond - settleLimit = 2 * time.Second + settleDelay = 200 * time.Millisecond + settleLimit = 2 * time.Second + maxToolsPerSession = 256 + maxCompletedInvocations = 256 + maxAbandonedInvocations = 256 ) +var errCommandOutcomeUnknown = errors.New("CDP command outcome is unknown") + type connection struct { conn *websocket.Conn ctx context.Context @@ -37,9 +43,12 @@ type connection struct { frames map[string]map[string]frameInfo tools map[string]*registeredTool toolRefs map[string]string - invocations map[string]invocationResponse - abandonedInvocations map[string]time.Time + toolLimitWarned map[string]bool + invocations map[invocationKey]invocationResponse + waitingInvocations map[invocationKey]struct{} + abandonedInvocations map[invocationKey]time.Time stateChangedCh chan struct{} + logger *slog.Logger closed chan struct{} closeOnce sync.Once @@ -61,9 +70,12 @@ func dial(ctx context.Context, url string) (*connection, error) { frames: make(map[string]map[string]frameInfo), tools: make(map[string]*registeredTool), toolRefs: make(map[string]string), - invocations: make(map[string]invocationResponse), - abandonedInvocations: make(map[string]time.Time), + toolLimitWarned: make(map[string]bool), + invocations: make(map[invocationKey]invocationResponse), + waitingInvocations: make(map[invocationKey]struct{}), + abandonedInvocations: make(map[invocationKey]time.Time), stateChangedCh: make(chan struct{}, 1), + logger: slog.Default(), closed: make(chan struct{}), } go c.readLoop() @@ -75,7 +87,7 @@ func (c *connection) close() error { c.cancel() c.conn.CloseNow() close(c.closed) - c.failPending(errors.New("WebMCP: CDP connection closed")) + c.failPending(errCommandOutcomeUnknown) }) return nil } @@ -168,7 +180,7 @@ func (c *connection) send(ctx context.Context, method string, params any, sessio c.pendingMu.Unlock() return nil, fmt.Errorf("WebMCP: %s: %w", method, ctx.Err()) case <-c.closed: - return nil, errors.New("WebMCP: CDP connection closed") + return nil, fmt.Errorf("WebMCP: %s: %w", method, errCommandOutcomeUnknown) } } @@ -186,14 +198,28 @@ func (c *connection) selectTarget(ctx context.Context, requestedTargetID string) if err := json.Unmarshal(raw, &result); err != nil { return fmt.Errorf("WebMCP: decode Target.getTargets: %w", err) } + c.stateMu.RLock() + currentTargetID := c.rootTargetID + _, currentSessionAlive := c.sessions[c.rootSessionID] + c.stateMu.RUnlock() var selected targetInfo - for _, target := range result.TargetInfos { - if target.Type != "page" { - continue + if requestedTargetID == "" && currentSessionAlive { + for _, target := range result.TargetInfos { + if target.Type == "page" && target.TargetID == currentTargetID { + selected = target + break + } } - if requestedTargetID == "" || requestedTargetID == target.TargetID { - selected = target - break + } + if selected.TargetID == "" { + for _, target := range result.TargetInfos { + if target.Type != "page" { + continue + } + if requestedTargetID == "" || requestedTargetID == target.TargetID { + selected = target + break + } } } if selected.TargetID == "" { @@ -289,11 +315,11 @@ func (c *connection) invoke(ctx context.Context, toolRef string, input map[strin "input": input, }, sessionID) if err != nil { - if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { - _ = c.close() - return InvocationResult{}, ErrOutcomeUnknown - } - if !c.sessionExists(sessionID) { + unknownOutcome := errors.Is(err, context.Canceled) || + errors.Is(err, context.DeadlineExceeded) || + errors.Is(err, errCommandOutcomeUnknown) || + !c.sessionExists(sessionID) + if unknownOutcome { return InvocationResult{}, ErrOutcomeUnknown } return InvocationResult{}, err @@ -305,12 +331,17 @@ func (c *connection) invoke(ctx context.Context, toolRef string, input map[strin return InvocationResult{}, fmt.Errorf("WebMCP: invalid invokeTool response") } + key := invocationKey{sessionID: sessionID, invocationID: started.InvocationID} + c.stateMu.Lock() + c.waitingInvocations[key] = struct{}{} + c.stateMu.Unlock() ticker := time.NewTicker(25 * time.Millisecond) defer ticker.Stop() for { c.stateMu.Lock() - if response, ok := c.invocations[started.InvocationID]; ok { - delete(c.invocations, started.InvocationID) + if response, ok := c.invocations[key]; ok { + delete(c.invocations, key) + delete(c.waitingInvocations, key) c.stateMu.Unlock() return InvocationResult{ InvocationID: response.InvocationID, @@ -322,6 +353,9 @@ func (c *connection) invoke(ctx context.Context, toolRef string, input map[strin _, alive := c.sessions[sessionID] c.stateMu.Unlock() if !alive || c.isClosed() { + c.stateMu.Lock() + c.abandonInvocationLocked(key) + c.stateMu.Unlock() return InvocationResult{InvocationID: started.InvocationID}, ErrOutcomeUnknown } select { @@ -329,12 +363,13 @@ func (c *connection) invoke(ctx context.Context, toolRef string, input map[strin case <-ticker.C: case <-ctx.Done(): c.stateMu.Lock() - delete(c.invocations, started.InvocationID) - c.abandonedInvocations[started.InvocationID] = time.Now() - c.pruneAbandonedInvocationsLocked() + c.abandonInvocationLocked(key) c.stateMu.Unlock() return InvocationResult{InvocationID: started.InvocationID}, ErrOutcomeUnknown case <-c.closed: + c.stateMu.Lock() + c.abandonInvocationLocked(key) + c.stateMu.Unlock() return InvocationResult{InvocationID: started.InvocationID}, ErrOutcomeUnknown } } diff --git a/server/lib/webmcpclient/client_test.go b/server/lib/webmcpclient/client_test.go index 1d56b99fe..06dfbe9a8 100644 --- a/server/lib/webmcpclient/client_test.go +++ b/server/lib/webmcpclient/client_test.go @@ -3,6 +3,9 @@ package webmcpclient import ( "context" "encoding/json" + "fmt" + "io" + "log/slog" "net/http" "net/http/httptest" "strings" @@ -20,15 +23,35 @@ type staticUpstream struct { func (u staticUpstream) Current() string { return u.url } +type mutableUpstream struct { + mu sync.RWMutex + url string +} + +func (u *mutableUpstream) Current() string { + u.mu.RLock() + defer u.mu.RUnlock() + return u.url +} + +func (u *mutableUpstream) Set(url string) { + u.mu.Lock() + u.url = url + u.mu.Unlock() +} + type fakeCDP struct { server *httptest.Server url string - mu sync.Mutex - connections int - relatedAttaches int - enabledSessions map[string]int - omitResponse bool + mu sync.Mutex + connections int + relatedAttaches int + enabledSessions map[string]int + omitResponse bool + closeOnInvoke bool + invokeResponseDelay time.Duration + extraPageFirst bool } func newFakeCDP(t *testing.T, omitResponse bool) *fakeCDP { @@ -74,9 +97,18 @@ func (f *fakeCDP) serve(w http.ResponseWriter, r *http.Request) { } switch request.Method { case "Target.getTargets": - respond(map[string]any{"targetInfos": []map[string]any{{ + f.mu.Lock() + extraPageFirst := f.extraPageFirst + f.mu.Unlock() + targets := []map[string]any{{ "targetId": "page-target", "type": "page", "url": "https://merchant.example/", - }}}) + }} + if extraPageFirst { + targets = append([]map[string]any{{ + "targetId": "other-page", "type": "page", "url": "https://other.example/", + }}, targets...) + } + respond(map[string]any{"targetInfos": targets}) case "Target.autoAttachRelated": f.mu.Lock() f.relatedAttaches++ @@ -132,6 +164,13 @@ func (f *fakeCDP) serve(w http.ResponseWriter, r *http.Request) { }}}, }) case "WebMCP.invokeTool": + if f.closeOnInvoke { + conn.CloseNow() + return + } + if f.invokeResponseDelay > 0 { + time.Sleep(f.invokeResponseDelay) + } respond(map[string]any{"invocationId": "invocation-1"}) if !f.omitResponse { write(map[string]any{ @@ -146,7 +185,7 @@ func (f *fakeCDP) serve(w http.ResponseWriter, r *http.Request) { "sessionId": request.SessionID, "params": map[string]any{ "invocationId": "invocation-1", "status": "Completed", - "output": map[string]any{"content": []map[string]any{{"type": "text", "text": "paid"}}}, + "output": map[string]any{"content": []map[string]any{{"type": "text", "text": request.SessionID}}}, }, }) } @@ -222,7 +261,7 @@ func TestInvocationCanCompleteAfterFrameNavigation(t *testing.T) { require.NoError(t, err) require.Equal(t, "invocation-1", result.InvocationID) require.Equal(t, "Completed", result.Status) - require.Equal(t, "paid", result.Output.(map[string]any)["content"].([]any)[0].(map[string]any)["text"]) + require.Equal(t, "iframe-session", result.Output.(map[string]any)["content"].([]any)[0].(map[string]any)["text"]) _, err = manager.Invoke(context.Background(), paymentTool.Ref, map[string]any{}) require.ErrorIs(t, err, ErrToolNotFound) @@ -247,3 +286,158 @@ func TestInvocationTimeoutHasUnknownOutcome(t *testing.T) { require.ErrorIs(t, err, ErrOutcomeUnknown) require.Equal(t, "invocation-1", result.InvocationID) } + +func TestInvokeCancellationBeforeCommandResponseKeepsConnection(t *testing.T) { + fake := newFakeCDP(t, true) + fake.invokeResponseDelay = 100 * time.Millisecond + manager := NewManager(staticUpstream{url: fake.url}) + t.Cleanup(func() { _ = manager.Close() }) + tools, err := manager.Tools(context.Background(), "") + require.NoError(t, err) + var toolRef string + for _, tool := range tools { + if tool.Name == "payment_tool" { + toolRef = tool.Ref + } + } + + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond) + defer cancel() + _, err = manager.Invoke(ctx, toolRef, map[string]any{}) + require.ErrorIs(t, err, ErrOutcomeUnknown) + + _, err = manager.Tools(context.Background(), "") + require.NoError(t, err) + fake.mu.Lock() + defer fake.mu.Unlock() + require.Equal(t, 1, fake.connections) +} + +func TestConnectionDeathMidInvokeHasUnknownOutcome(t *testing.T) { + fake := newFakeCDP(t, false) + fake.closeOnInvoke = true + manager := NewManager(staticUpstream{url: fake.url}) + t.Cleanup(func() { _ = manager.Close() }) + tools, err := manager.Tools(context.Background(), "") + require.NoError(t, err) + var toolRef string + for _, tool := range tools { + if tool.Name == "payment_tool" { + toolRef = tool.Ref + } + } + + _, err = manager.Invoke(context.Background(), toolRef, map[string]any{}) + require.ErrorIs(t, err, ErrOutcomeUnknown) +} + +func TestToolResponsesAreScopedBySession(t *testing.T) { + client := &connection{ + invocations: make(map[invocationKey]invocationResponse), + waitingInvocations: make(map[invocationKey]struct{}), + abandonedInvocations: make(map[invocationKey]time.Time), + stateChangedCh: make(chan struct{}, 1), + } + for _, sessionID := range []string{"page-session", "iframe-session"} { + params, err := json.Marshal(invocationResponse{ + InvocationID: "invocation-1", + Status: "Completed", + Output: sessionID, + }) + require.NoError(t, err) + client.handleEvent(cdpMessage{Method: "WebMCP.toolResponded", SessionID: sessionID, Params: params}) + } + + require.Len(t, client.invocations, 2) + require.Equal(t, "page-session", client.invocations[invocationKey{sessionID: "page-session", invocationID: "invocation-1"}].Output) + require.Equal(t, "iframe-session", client.invocations[invocationKey{sessionID: "iframe-session", invocationID: "invocation-1"}].Output) +} + +func TestDiscoveryKeepsSelectedPageWhenTargetOrderChanges(t *testing.T) { + fake := newFakeCDP(t, false) + manager := NewManager(staticUpstream{url: fake.url}) + t.Cleanup(func() { _ = manager.Close() }) + first, err := manager.Tools(context.Background(), "") + require.NoError(t, err) + fake.mu.Lock() + fake.extraPageFirst = true + fake.mu.Unlock() + + second, err := manager.Tools(context.Background(), "") + require.NoError(t, err) + require.Equal(t, first, second) + fake.mu.Lock() + defer fake.mu.Unlock() + require.Equal(t, 1, fake.connections) + require.Equal(t, 1, fake.relatedAttaches) +} + +func TestManagerReconnectsWhenChromiumUpstreamChanges(t *testing.T) { + first := newFakeCDP(t, false) + second := newFakeCDP(t, false) + upstream := &mutableUpstream{url: first.url} + manager := NewManager(upstream) + t.Cleanup(func() { _ = manager.Close() }) + firstTools, err := manager.Tools(context.Background(), "") + require.NoError(t, err) + upstream.Set(second.url) + + secondTools, err := manager.Tools(context.Background(), "") + require.NoError(t, err) + require.NotEqual(t, firstTools[0].Ref, secondTools[0].Ref) + first.mu.Lock() + require.Equal(t, 1, first.connections) + first.mu.Unlock() + second.mu.Lock() + require.Equal(t, 1, second.connections) + second.mu.Unlock() +} + +func TestToolRegistryIsBoundedPerSession(t *testing.T) { + client := &connection{ + sessions: map[string]session{"page-session": {id: "page-session"}}, + tools: make(map[string]*registeredTool), + toolRefs: make(map[string]string), + toolLimitWarned: make(map[string]bool), + stateChangedCh: make(chan struct{}, 1), + logger: slog.New(slog.NewTextHandler(io.Discard, nil)), + } + tools := make([]toolEvent, maxToolsPerSession+10) + for i := range tools { + tools[i] = toolEvent{Name: fmt.Sprintf("tool-%d", i), FrameID: "page-frame"} + } + client.addTools("page-session", tools) + + require.Len(t, client.tools, maxToolsPerSession) + require.True(t, client.toolLimitWarned["page-session"]) +} + +func TestDetachedTargetInvalidatesToolReferences(t *testing.T) { + client := &connection{ + sessions: map[string]session{ + "page-session": {id: "page-session"}, + "iframe-session": {id: "iframe-session", parentID: "page-session"}, + }, + frames: make(map[string]map[string]frameInfo), + tools: make(map[string]*registeredTool), + toolRefs: make(map[string]string), + toolLimitWarned: make(map[string]bool), + invocations: make(map[invocationKey]invocationResponse), + waitingInvocations: make(map[invocationKey]struct{}), + abandonedInvocations: make(map[invocationKey]time.Time), + stateChangedCh: make(chan struct{}, 1), + } + key := toolKey("iframe-session", "iframe-frame", "pay") + client.toolRefs[key] = "wmcp_pay" + client.tools["wmcp_pay"] = ®isteredTool{ + ref: "wmcp_pay", sessionID: "iframe-session", frameID: "iframe-frame", name: "pay", + } + params, err := json.Marshal(map[string]any{"sessionId": "iframe-session"}) + require.NoError(t, err) + + client.handleEvent(cdpMessage{Method: "Target.detachedFromTarget", Params: params}) + + require.NotContains(t, client.sessions, "iframe-session") + require.NotContains(t, client.tools, "wmcp_pay") + require.NotContains(t, client.toolRefs, key) +} diff --git a/server/lib/webmcpclient/events.go b/server/lib/webmcpclient/events.go index 526a05119..d044e09d3 100644 --- a/server/lib/webmcpclient/events.go +++ b/server/lib/webmcpclient/events.go @@ -35,7 +35,14 @@ func (c *connection) handleEvent(message cdpMessage) { rootTargetID := c.rootTargetID c.stateMu.RUnlock() if event.TargetInfo.TargetID != rootTargetID { - go func() { _ = c.initializeSession(event.SessionID, event.TargetInfo.Type) }() + go func() { + if err := c.initializeSession(event.SessionID, event.TargetInfo.Type); err != nil && !c.isClosed() { + c.logger.Warn("failed to initialize WebMCP target", + "target_id", event.TargetInfo.TargetID, + "target_type", event.TargetInfo.Type, + "err", err) + } + }() } case "Target.detachedFromTarget": var event struct { @@ -117,12 +124,21 @@ func (c *connection) handleEvent(message cdpMessage) { case "WebMCP.toolResponded": var response invocationResponse if json.Unmarshal(message.Params, &response) == nil { + key := invocationKey{sessionID: message.SessionID, invocationID: response.InvocationID} c.stateMu.Lock() c.pruneAbandonedInvocationsLocked() - if _, abandoned := c.abandonedInvocations[response.InvocationID]; abandoned { - delete(c.abandonedInvocations, response.InvocationID) + if _, abandoned := c.abandonedInvocations[key]; abandoned { + delete(c.abandonedInvocations, key) } else { - c.invocations[response.InvocationID] = response + if len(c.invocations) >= maxCompletedInvocations { + for existing := range c.invocations { + if _, waiting := c.waitingInvocations[existing]; !waiting { + delete(c.invocations, existing) + break + } + } + } + c.invocations[key] = response } c.stateMu.Unlock() c.signalStateChanged() @@ -175,12 +191,26 @@ func (c *connection) addTools(sessionID string, tools []toolEvent) { if _, ok := c.sessions[sessionID]; !ok { return } + tracked := 0 + for _, existing := range c.tools { + if existing.sessionID == sessionID { + tracked++ + } + } for _, tool := range tools { key := toolKey(sessionID, tool.FrameID, tool.Name) ref := c.toolRefs[key] if ref == "" { + if tracked >= maxToolsPerSession { + if !c.toolLimitWarned[sessionID] { + c.toolLimitWarned[sessionID] = true + c.logger.Warn("WebMCP tool limit reached", "session_id", sessionID, "limit", maxToolsPerSession) + } + continue + } ref = "wmcp_" + cuid2.Generate() c.toolRefs[key] = ref + tracked++ } c.tools[ref] = ®isteredTool{ ref: ref, @@ -250,6 +280,12 @@ func (c *connection) removeSession(sessionID string) { for id := range toRemove { delete(c.sessions, id) delete(c.frames, id) + delete(c.toolLimitWarned, id) + for key := range c.waitingInvocations { + if key.sessionID == id { + c.abandonInvocationLocked(key) + } + } for ref, tool := range c.tools { if tool.sessionID == id { delete(c.toolRefs, toolKey(tool.sessionID, tool.frameID, tool.name)) @@ -267,8 +303,10 @@ func (c *connection) clearStateLocked() { c.frames = make(map[string]map[string]frameInfo) c.tools = make(map[string]*registeredTool) c.toolRefs = make(map[string]string) - c.invocations = make(map[string]invocationResponse) - c.abandonedInvocations = make(map[string]time.Time) + c.toolLimitWarned = make(map[string]bool) + c.invocations = make(map[invocationKey]invocationResponse) + c.waitingInvocations = make(map[invocationKey]struct{}) + c.abandonedInvocations = make(map[invocationKey]time.Time) } func (c *connection) removeFrameToolsLocked(sessionID, frameID string) { @@ -286,6 +324,24 @@ func (c *connection) removeToolLocked(key string) { delete(c.tools, ref) } +func (c *connection) abandonInvocationLocked(key invocationKey) { + delete(c.invocations, key) + delete(c.waitingInvocations, key) + c.pruneAbandonedInvocationsLocked() + if len(c.abandonedInvocations) >= maxAbandonedInvocations { + var oldest invocationKey + var oldestAt time.Time + for candidate, abandonedAt := range c.abandonedInvocations { + if oldestAt.IsZero() || abandonedAt.Before(oldestAt) { + oldest = candidate + oldestAt = abandonedAt + } + } + delete(c.abandonedInvocations, oldest) + } + c.abandonedInvocations[key] = time.Now() +} + func (c *connection) pruneAbandonedInvocationsLocked() { cutoff := time.Now().Add(-10 * time.Minute) for invocationID, abandonedAt := range c.abandonedInvocations { diff --git a/server/lib/webmcpclient/types.go b/server/lib/webmcpclient/types.go index 89573db5b..6db926966 100644 --- a/server/lib/webmcpclient/types.go +++ b/server/lib/webmcpclient/types.go @@ -121,3 +121,8 @@ type invocationResponse struct { Output any `json:"output,omitempty"` ErrorText string `json:"errorText,omitempty"` } + +type invocationKey struct { + sessionID string + invocationID string +} diff --git a/server/openapi.yaml b/server/openapi.yaml index be228baa9..b408a169e 100644 --- a/server/openapi.yaml +++ b/server/openapi.yaml @@ -1370,7 +1370,10 @@ paths: - name: target_id in: query required: false - description: Optional page target to inspect. When omitted, the first page target reported by Chromium is used. + description: >- + Optional page target to inspect. When omitted, the current discovery target is retained; + the first page target reported by Chromium is used only on initial discovery. Explicitly + switching targets resets the WebMCP attachment and makes running invocation outcomes unknown. schema: type: string minLength: 1 From 7a701eea555d33c0bd459a8cd75272b250285dfe Mon Sep 17 00:00:00 2001 From: rgarcia <72655+rgarcia@users.noreply.github.com> Date: Tue, 1 Sep 2026 00:53:20 +0000 Subject: [PATCH 03/18] Preserve completed WebMCP results on detach --- server/lib/webmcpclient/client.go | 15 +++++++++++--- server/lib/webmcpclient/client_test.go | 27 ++++++++++++++++++++++++++ server/lib/webmcpclient/events.go | 7 +++++-- 3 files changed, 44 insertions(+), 5 deletions(-) diff --git a/server/lib/webmcpclient/client.go b/server/lib/webmcpclient/client.go index 03fd89f18..21a066c80 100644 --- a/server/lib/webmcpclient/client.go +++ b/server/lib/webmcpclient/client.go @@ -354,8 +354,11 @@ func (c *connection) invoke(ctx context.Context, toolRef string, input map[strin c.stateMu.Unlock() if !alive || c.isClosed() { c.stateMu.Lock() - c.abandonInvocationLocked(key) + abandoned := c.abandonInvocationLocked(key) c.stateMu.Unlock() + if !abandoned { + continue + } return InvocationResult{InvocationID: started.InvocationID}, ErrOutcomeUnknown } select { @@ -363,13 +366,19 @@ func (c *connection) invoke(ctx context.Context, toolRef string, input map[strin case <-ticker.C: case <-ctx.Done(): c.stateMu.Lock() - c.abandonInvocationLocked(key) + abandoned := c.abandonInvocationLocked(key) c.stateMu.Unlock() + if !abandoned { + continue + } return InvocationResult{InvocationID: started.InvocationID}, ErrOutcomeUnknown case <-c.closed: c.stateMu.Lock() - c.abandonInvocationLocked(key) + abandoned := c.abandonInvocationLocked(key) c.stateMu.Unlock() + if !abandoned { + continue + } return InvocationResult{InvocationID: started.InvocationID}, ErrOutcomeUnknown } } diff --git a/server/lib/webmcpclient/client_test.go b/server/lib/webmcpclient/client_test.go index 06dfbe9a8..ce1975050 100644 --- a/server/lib/webmcpclient/client_test.go +++ b/server/lib/webmcpclient/client_test.go @@ -50,6 +50,7 @@ type fakeCDP struct { enabledSessions map[string]int omitResponse bool closeOnInvoke bool + detachAfterResponse bool invokeResponseDelay time.Duration extraPageFirst bool } @@ -188,6 +189,12 @@ func (f *fakeCDP) serve(w http.ResponseWriter, r *http.Request) { "output": map[string]any{"content": []map[string]any{{"type": "text", "text": request.SessionID}}}, }, }) + if f.detachAfterResponse { + write(map[string]any{ + "method": "Target.detachedFromTarget", + "params": map[string]any{"sessionId": request.SessionID}, + }) + } } default: write(map[string]any{"id": request.ID, "error": map[string]any{"code": -32601, "message": "unknown method"}}) @@ -267,6 +274,26 @@ func TestInvocationCanCompleteAfterFrameNavigation(t *testing.T) { require.ErrorIs(t, err, ErrToolNotFound) } +func TestInvocationReturnsCompletedResponseBeforeTargetDetach(t *testing.T) { + fake := newFakeCDP(t, false) + fake.detachAfterResponse = true + manager := NewManager(staticUpstream{url: fake.url}) + t.Cleanup(func() { _ = manager.Close() }) + tools, err := manager.Tools(context.Background(), "") + require.NoError(t, err) + var paymentTool Tool + for _, tool := range tools { + if tool.Name == "payment_tool" { + paymentTool = tool + } + } + + result, err := manager.Invoke(context.Background(), paymentTool.Ref, map[string]any{}) + require.NoError(t, err) + require.Equal(t, "Completed", result.Status) + require.Equal(t, "iframe-session", result.Output.(map[string]any)["content"].([]any)[0].(map[string]any)["text"]) +} + func TestInvocationTimeoutHasUnknownOutcome(t *testing.T) { fake := newFakeCDP(t, true) manager := NewManager(staticUpstream{url: fake.url}) diff --git a/server/lib/webmcpclient/events.go b/server/lib/webmcpclient/events.go index d044e09d3..a934cdc2c 100644 --- a/server/lib/webmcpclient/events.go +++ b/server/lib/webmcpclient/events.go @@ -324,8 +324,10 @@ func (c *connection) removeToolLocked(key string) { delete(c.tools, ref) } -func (c *connection) abandonInvocationLocked(key invocationKey) { - delete(c.invocations, key) +func (c *connection) abandonInvocationLocked(key invocationKey) bool { + if _, completed := c.invocations[key]; completed { + return false + } delete(c.waitingInvocations, key) c.pruneAbandonedInvocationsLocked() if len(c.abandonedInvocations) >= maxAbandonedInvocations { @@ -340,6 +342,7 @@ func (c *connection) abandonInvocationLocked(key invocationKey) { delete(c.abandonedInvocations, oldest) } c.abandonedInvocations[key] = time.Now() + return true } func (c *connection) pruneAbandonedInvocationsLocked() { From 31f10a65701d865c91462988c745d5cda59e1ebb Mon Sep 17 00:00:00 2001 From: rgarcia <72655+rgarcia@users.noreply.github.com> Date: Tue, 1 Sep 2026 02:23:16 +0000 Subject: [PATCH 04/18] Track WebMCP tools across browser tabs --- server/cmd/api/api/api.go | 2 +- server/cmd/api/api/webmcp.go | 36 +- server/cmd/api/api/webmcp_test.go | 56 ++- server/lib/browsersurface/events.go | 71 +++ server/lib/browsersurface/frames.go | 270 ++++++++++++ server/lib/browsersurface/targets.go | 179 ++++++++ server/lib/browsersurface/tracker.go | 317 ++++++++++++++ server/lib/browsersurface/tracker_test.go | 199 +++++++++ server/lib/browsersurface/types.go | 120 +++++ server/lib/cdpconnection/client.go | 197 +++++++++ server/lib/cdpconnection/client_test.go | 97 +++++ server/lib/oapi/oapi.go | 506 ++++++++++------------ server/lib/webmcpclient/client.go | 489 +++++++++++++-------- server/lib/webmcpclient/client_test.go | 413 +++++++++--------- server/lib/webmcpclient/events.go | 371 ---------------- server/lib/webmcpclient/manager.go | 24 +- server/lib/webmcpclient/types.go | 91 +--- server/openapi.yaml | 110 ++--- 18 files changed, 2343 insertions(+), 1205 deletions(-) create mode 100644 server/lib/browsersurface/events.go create mode 100644 server/lib/browsersurface/frames.go create mode 100644 server/lib/browsersurface/targets.go create mode 100644 server/lib/browsersurface/tracker.go create mode 100644 server/lib/browsersurface/tracker_test.go create mode 100644 server/lib/browsersurface/types.go create mode 100644 server/lib/cdpconnection/client.go create mode 100644 server/lib/cdpconnection/client_test.go delete mode 100644 server/lib/webmcpclient/events.go diff --git a/server/cmd/api/api/api.go b/server/cmd/api/api/api.go index 16c93711d..222ac9a50 100644 --- a/server/cmd/api/api/api.go +++ b/server/cmd/api/api/api.go @@ -42,7 +42,7 @@ type OTLPExporter interface { var _ OTLPExporter = (*events.OTLPExportController)(nil) type webMCPClient interface { - Tools(ctx context.Context, targetID string) ([]webmcpclient.Tool, error) + Tools(ctx context.Context) ([]webmcpclient.Tool, error) Invoke(ctx context.Context, toolRef string, input map[string]any) (webmcpclient.InvocationResult, error) Close() error } diff --git a/server/cmd/api/api/webmcp.go b/server/cmd/api/api/webmcp.go index d493e4963..1584a02bc 100644 --- a/server/cmd/api/api/webmcp.go +++ b/server/cmd/api/api/webmcp.go @@ -18,12 +18,8 @@ const ( maxWebMCPInputBytes = 1 << 20 ) -func (s *ApiService) GetWebMCPTools(ctx context.Context, request oapi.GetWebMCPToolsRequestObject) (oapi.GetWebMCPToolsResponseObject, error) { - targetID := "" - if request.Params.TargetId != nil { - targetID = *request.Params.TargetId - } - tools, err := s.webmcp.Tools(ctx, targetID) +func (s *ApiService) GetWebMCPTools(ctx context.Context, _ oapi.GetWebMCPToolsRequestObject) (oapi.GetWebMCPToolsResponseObject, error) { + tools, err := s.webmcp.Tools(ctx) if err != nil { if errors.Is(err, webmcpclient.ErrNoPageTarget) { return oapi.GetWebMCPTools404JSONResponse{NotFoundErrorJSONResponse: oapi.NotFoundErrorJSONResponse{Message: err.Error()}}, nil @@ -39,14 +35,22 @@ func (s *ApiService) GetWebMCPTools(ctx context.Context, request oapi.GetWebMCPT inputSchema = make(map[string]any) } responseTool := oapi.WebMCPTool{ - ToolRef: tool.Ref, - Name: tool.Name, - Description: tool.Description, - InputSchema: inputSchema, - PageTargetId: tool.PageTargetID, - TargetId: tool.TargetID, - TargetType: tool.TargetType, - FrameId: tool.FrameID, + ToolRef: tool.Ref, + Name: tool.Name, + Description: tool.Description, + InputSchema: inputSchema, + Source: oapi.WebMCPToolSource{ + WindowId: tool.Source.WindowID, + TabId: tool.Source.TabID, + PageTitle: tool.Source.PageTitle, + PageUrl: tool.Source.PageURL, + }, + } + if tool.Source.Frame != nil { + responseTool.Source.Frame = &oapi.WebMCPToolFrame{ + FrameId: tool.Source.Frame.FrameID, + Url: tool.Source.Frame.URL, + } } if tool.Annotations != nil { responseTool.Annotations = &oapi.WebMCPToolAnnotations{ @@ -56,10 +60,6 @@ func (s *ApiService) GetWebMCPTools(ctx context.Context, request oapi.GetWebMCPT Autosubmit: tool.Annotations.Autosubmit, } } - responseTool.TargetUrl = nonEmptyString(tool.TargetURL) - responseTool.FrameUrl = nonEmptyString(tool.FrameURL) - responseTool.ParentFrameId = nonEmptyString(tool.ParentFrameID) - responseTool.DocumentRef = nonEmptyString(tool.DocumentRef) responseTools = append(responseTools, responseTool) } return oapi.GetWebMCPTools200JSONResponse{Tools: responseTools}, nil diff --git a/server/cmd/api/api/webmcp_test.go b/server/cmd/api/api/webmcp_test.go index 550bedd7c..accf24f97 100644 --- a/server/cmd/api/api/webmcp_test.go +++ b/server/cmd/api/api/webmcp_test.go @@ -2,6 +2,7 @@ package api import ( "context" + "encoding/json" "errors" "fmt" "strings" @@ -17,13 +18,11 @@ type fakeWebMCPClient struct { toolsErr error result webmcpclient.InvocationResult invokeErr error - targetID string toolRef string input map[string]any } -func (f *fakeWebMCPClient) Tools(_ context.Context, targetID string) ([]webmcpclient.Tool, error) { - f.targetID = targetID +func (f *fakeWebMCPClient) Tools(_ context.Context) ([]webmcpclient.Tool, error) { return f.tools, f.toolsErr } @@ -37,38 +36,49 @@ func (f *fakeWebMCPClient) Close() error { return nil } func TestGetWebMCPToolsMapsRegistrationContext(t *testing.T) { client := &fakeWebMCPClient{tools: []webmcpclient.Tool{{ - Ref: "wmcp_test", - Name: "pay", - Description: "Pay for the order", - Annotations: &webmcpclient.Annotations{Consequential: true}, - PageTargetID: "page-target", - TargetID: "iframe-target", - TargetType: "iframe", - TargetURL: "https://payments.example/element", - FrameID: "iframe-frame", - FrameURL: "https://payments.example/element", - ParentFrameID: "page-frame", - DocumentRef: "iframe-target:loader-1", + Ref: "wmcp_test", + Name: "pay", + Description: "Pay for the order", + Annotations: &webmcpclient.Annotations{Consequential: true}, + Source: webmcpclient.ToolSource{ + WindowID: 2, + TabID: 3, + PageTitle: "Store", + PageURL: "https://merchant.example/cart", + Frame: &webmcpclient.ToolFrame{FrameID: 7, URL: "https://payments.example/element"}, + }, }}} service := &ApiService{webmcp: client} - targetID := "page-target" - response, err := service.GetWebMCPTools(context.Background(), oapi.GetWebMCPToolsRequestObject{ - Params: oapi.GetWebMCPToolsParams{TargetId: &targetID}, - }) + response, err := service.GetWebMCPTools(context.Background(), oapi.GetWebMCPToolsRequestObject{}) require.NoError(t, err) body := response.(oapi.GetWebMCPTools200JSONResponse) - require.Equal(t, targetID, client.targetID) require.Len(t, body.Tools, 1) tool := body.Tools[0] require.Equal(t, "wmcp_test", tool.ToolRef) - require.Equal(t, "iframe-target", tool.TargetId) - require.Equal(t, "iframe-frame", tool.FrameId) - require.Equal(t, "iframe-target:loader-1", *tool.DocumentRef) + require.Equal(t, 2, tool.Source.WindowId) + require.Equal(t, 3, tool.Source.TabId) + require.Equal(t, "Store", tool.Source.PageTitle) + require.Equal(t, 7, tool.Source.Frame.FrameId) + require.Equal(t, "https://payments.example/element", tool.Source.Frame.Url) require.Empty(t, tool.InputSchema) require.True(t, tool.Annotations.Consequential) } +func TestGetWebMCPToolsSerializesNullFrameForTopLevelTool(t *testing.T) { + client := &fakeWebMCPClient{tools: []webmcpclient.Tool{{ + Ref: "wmcp_test", Name: "search", Source: webmcpclient.ToolSource{ + WindowID: 1, TabID: 1, PageTitle: "Travel", PageURL: "https://travel.example/", + }, + }}} + service := &ApiService{webmcp: client} + response, err := service.GetWebMCPTools(context.Background(), oapi.GetWebMCPToolsRequestObject{}) + require.NoError(t, err) + payload, err := json.Marshal(response.(oapi.GetWebMCPTools200JSONResponse)) + require.NoError(t, err) + require.JSONEq(t, `{"tools":[{"tool_ref":"wmcp_test","name":"search","description":"","input_schema":{},"source":{"frame":null,"page_title":"Travel","page_url":"https://travel.example/","tab_id":1,"window_id":1}}]}`, string(payload)) +} + func TestInvokeWebMCPToolReturnsPageResult(t *testing.T) { client := &fakeWebMCPClient{result: webmcpclient.InvocationResult{ InvocationID: "invocation-1", diff --git a/server/lib/browsersurface/events.go b/server/lib/browsersurface/events.go new file mode 100644 index 000000000..37b0432f5 --- /dev/null +++ b/server/lib/browsersurface/events.go @@ -0,0 +1,71 @@ +package browsersurface + +import ( + "encoding/json" + + "github.com/kernel/kernel-images/server/lib/cdpconnection" +) + +func (t *Tracker) handleProtocolEvent(message cdpconnection.Message) { + switch message.Method { + case "Target.targetCreated": + var event struct { + TargetInfo targetInfo `json:"targetInfo"` + } + if json.Unmarshal(message.Params, &event) == nil && event.TargetInfo.Type == "page" { + go t.trackPage(t.ctx, event.TargetInfo) + } + case "Target.targetInfoChanged": + var event struct { + TargetInfo targetInfo `json:"targetInfo"` + } + if json.Unmarshal(message.Params, &event) == nil { + t.updateTarget(event.TargetInfo) + } + case "Target.targetDestroyed": + var event struct { + TargetID string `json:"targetId"` + } + if json.Unmarshal(message.Params, &event) == nil { + t.removeTarget(event.TargetID) + } + case "Target.attachedToTarget": + var event struct { + SessionID string `json:"sessionId"` + TargetInfo targetInfo `json:"targetInfo"` + } + if json.Unmarshal(message.Params, &event) == nil { + t.addSession(event.SessionID, message.SessionID, event.TargetInfo) + } + case "Target.detachedFromTarget": + var event struct { + SessionID string `json:"sessionId"` + } + if json.Unmarshal(message.Params, &event) == nil { + t.removeSession(event.SessionID) + } + case "Page.frameAttached": + var event struct { + FrameID string `json:"frameId"` + ParentFrameID string `json:"parentFrameId"` + } + if json.Unmarshal(message.Params, &event) == nil { + t.attachFrame(message.SessionID, event.FrameID, event.ParentFrameID) + } + case "Page.frameNavigated": + var event struct { + Frame frameInfo `json:"frame"` + } + if json.Unmarshal(message.Params, &event) == nil { + t.navigateFrame(message.SessionID, event.Frame) + } + case "Page.frameDetached": + var event struct { + FrameID string `json:"frameId"` + } + if json.Unmarshal(message.Params, &event) == nil { + t.removeFrame(event.FrameID) + } + } + t.publish(Event{Kind: EventProtocol, SessionID: message.SessionID, Message: message}) +} diff --git a/server/lib/browsersurface/frames.go b/server/lib/browsersurface/frames.go new file mode 100644 index 000000000..e17a87fe0 --- /dev/null +++ b/server/lib/browsersurface/frames.go @@ -0,0 +1,270 @@ +package browsersurface + +import ( + "context" + "encoding/json" + "sort" + "time" +) + +func (t *Tracker) addSession(sessionID, parentSessionID string, target targetInfo) { + if target.Type != "page" && target.Type != "iframe" { + return + } + t.stateMu.Lock() + if _, exists := t.sessions[sessionID]; exists { + t.stateMu.Unlock() + return + } + tabID := t.tabsByTarget[target.TargetID] + if tabID == 0 && parentSessionID != "" { + if parent := t.sessions[parentSessionID]; parent != nil { + tabID = parent.tabID + } + } + if tabID == 0 && target.ParentFrameID != "" { + if parentFrame := t.frames[target.ParentFrameID]; parentFrame != nil { + tabID = parentFrame.tabID + } + } + if tabID == 0 && target.Type == "iframe" { + if ownFrame := t.frames[target.TargetID]; ownFrame != nil { + tabID = ownFrame.tabID + } + } + t.sessions[sessionID] = &session{id: sessionID, parentID: parentSessionID, target: target, tabID: tabID} + t.bindSessionsLocked() + t.stateMu.Unlock() + t.signalChanged() + go t.initializeSession(sessionID) +} + +func (t *Tracker) initializeSession(sessionID string) { + deadline := time.Now().Add(sessionInitTimeout) + var sess session + for { + t.stateMu.Lock() + tracked := t.sessions[sessionID] + if tracked == nil || tracked.initialized || tracked.initializing { + t.stateMu.Unlock() + return + } + parentReady := true + if parent := t.sessions[tracked.parentID]; parent != nil { + parentReady = parent.initialized + } + if tracked.tabID != 0 && parentReady { + tracked.initializing = true + sess = *tracked + t.stateMu.Unlock() + break + } + t.stateMu.Unlock() + if time.Now().After(deadline) || t.protocol.IsClosed() { + return + } + time.Sleep(10 * time.Millisecond) + } + + ctx, cancel := context.WithTimeout(t.ctx, sessionInitTimeout) + defer cancel() + if _, err := t.protocol.Send(ctx, "Page.enable", nil, sessionID); err != nil { + t.failSessionInitialization(sessionID, err) + return + } + raw, err := t.protocol.Send(ctx, "Page.getFrameTree", nil, sessionID) + if err != nil { + t.failSessionInitialization(sessionID, err) + return + } + var result struct { + FrameTree frameTree `json:"frameTree"` + } + if err := json.Unmarshal(raw, &result); err != nil { + t.failSessionInitialization(sessionID, err) + return + } + + t.stateMu.Lock() + tracked := t.sessions[sessionID] + if tracked == nil { + t.stateMu.Unlock() + return + } + if result.FrameTree.Frame.ParentID == "" && sess.target.Type == "iframe" { + result.FrameTree.Frame.ParentID = sess.target.ParentFrameID + if existing := t.frames[result.FrameTree.Frame.ID]; existing != nil && existing.parentID != "" { + result.FrameTree.Frame.ParentID = existing.parentID + } + } + t.addFrameTreeLocked(sess.tabID, result.FrameTree) + if sess.target.Type == "page" { + if trackedTab := t.tabs[sess.tabID]; trackedTab != nil { + trackedTab.rootFrameID = result.FrameTree.Frame.ID + } + } + tracked.initializing = false + tracked.initialized = true + t.bindSessionsLocked() + t.stateMu.Unlock() + t.signalChanged() + t.publish(Event{Kind: EventSessionReady, SessionID: sessionID}) +} + +func (t *Tracker) failSessionInitialization(sessionID string, err error) { + t.stateMu.Lock() + if tracked := t.sessions[sessionID]; tracked != nil { + tracked.initializing = false + } + t.stateMu.Unlock() + if !t.protocol.IsClosed() { + t.logger.Warn("failed to initialize browser surface session", "session_id", sessionID, "err", err) + } +} + +func (t *Tracker) addFrameTreeLocked(tabID int, tree frameTree) { + t.upsertFrameLocked(tabID, tree.Frame) + for _, child := range tree.ChildFrames { + t.addFrameTreeLocked(tabID, child) + } +} + +func (t *Tracker) upsertFrameLocked(tabID int, info frameInfo) { + tracked := t.frames[info.ID] + if tracked == nil { + publicID := 0 + if info.ParentID != "" { + t.nextFrameID++ + publicID = t.nextFrameID + } + tracked = &frame{id: publicID, rawID: info.ID, tabID: tabID} + t.frames[info.ID] = tracked + } else if tracked.id == 0 && info.ParentID != "" { + t.nextFrameID++ + tracked.id = t.nextFrameID + } + tracked.parentID = info.ParentID + tracked.tabID = tabID + tracked.url = info.URL +} + +func (t *Tracker) bindSessionsLocked() { + for _, sess := range t.sessions { + if sess.tabID != 0 { + continue + } + if parent := t.sessions[sess.parentID]; parent != nil && parent.tabID != 0 { + sess.tabID = parent.tabID + continue + } + if parentFrame := t.frames[sess.target.ParentFrameID]; parentFrame != nil { + sess.tabID = parentFrame.tabID + continue + } + if sess.target.Type == "iframe" { + if ownFrame := t.frames[sess.target.TargetID]; ownFrame != nil { + sess.tabID = ownFrame.tabID + } + } + } +} + +func (t *Tracker) attachFrame(sessionID, frameID, parentFrameID string) { + t.stateMu.Lock() + if sess := t.sessions[sessionID]; sess != nil && sess.tabID != 0 { + t.upsertFrameLocked(sess.tabID, frameInfo{ID: frameID, ParentID: parentFrameID}) + t.bindSessionsLocked() + } + t.stateMu.Unlock() + t.signalChanged() +} + +func (t *Tracker) navigateFrame(sessionID string, info frameInfo) { + var removed []string + t.stateMu.Lock() + if sess := t.sessions[sessionID]; sess != nil && sess.tabID != 0 { + removed = t.removeFrameChildrenLocked(info.ID) + if info.ParentID == "" && sess.target.Type == "iframe" { + if existing := t.frames[info.ID]; existing != nil { + info.ParentID = existing.parentID + } + } + t.upsertFrameLocked(sess.tabID, info) + if trackedTab := t.tabs[sess.tabID]; trackedTab != nil && trackedTab.rootFrameID == info.ID { + trackedTab.url = info.URL + } + } + t.stateMu.Unlock() + for _, frameID := range removed { + t.publish(Event{Kind: EventFrameRemoved, FrameID: frameID}) + } + t.publish(Event{Kind: EventDocumentChanged, SessionID: sessionID, FrameID: info.ID}) + t.signalChanged() +} + +func (t *Tracker) removeFrame(frameID string) { + t.stateMu.Lock() + removed := append(t.removeFrameChildrenLocked(frameID), frameID) + delete(t.frames, frameID) + t.stateMu.Unlock() + for _, removedID := range removed { + t.publish(Event{Kind: EventFrameRemoved, FrameID: removedID}) + } + t.signalChanged() +} + +func (t *Tracker) removeFrameChildrenLocked(parentID string) []string { + var removed []string + for changed := true; changed; { + changed = false + for rawID, tracked := range t.frames { + if tracked.parentID == parentID || contains(removed, tracked.parentID) { + removed = append(removed, rawID) + delete(t.frames, rawID) + changed = true + } + } + } + return removed +} + +func (t *Tracker) removeSession(sessionID string) { + t.stateMu.Lock() + removed := t.removeSessionLocked(sessionID) + t.stateMu.Unlock() + for _, id := range removed { + t.publish(Event{Kind: EventSessionRemoved, SessionID: id}) + } + t.signalChanged() +} + +func (t *Tracker) removeSessionLocked(sessionID string) []string { + toRemove := map[string]bool{sessionID: true} + for changed := true; changed; { + changed = false + for id, sess := range t.sessions { + if toRemove[sess.parentID] && !toRemove[id] { + toRemove[id] = true + changed = true + } + } + } + removed := make([]string, 0, len(toRemove)) + for id := range toRemove { + if _, ok := t.sessions[id]; ok { + delete(t.sessions, id) + removed = append(removed, id) + } + } + sort.Strings(removed) + return removed +} + +func contains(values []string, value string) bool { + for _, candidate := range values { + if candidate == value { + return true + } + } + return false +} diff --git a/server/lib/browsersurface/targets.go b/server/lib/browsersurface/targets.go new file mode 100644 index 000000000..61b36bd2c --- /dev/null +++ b/server/lib/browsersurface/targets.go @@ -0,0 +1,179 @@ +package browsersurface + +import ( + "context" + "encoding/json" + "time" +) + +func (t *Tracker) trackPage(ctx context.Context, target targetInfo) { + t.stateMu.Lock() + if t.trackingTarget[target.TargetID] { + t.updateTabTargetLocked(target) + t.stateMu.Unlock() + return + } + t.trackingTarget[target.TargetID] = true + t.nextTabID++ + tab := &tab{id: t.nextTabID, targetID: target.TargetID, title: target.Title, url: target.URL} + t.tabs[tab.id] = tab + t.tabsByTarget[target.TargetID] = tab.id + t.stateMu.Unlock() + t.signalChanged() + + windowCtx, cancel := context.WithTimeout(ctx, 2*time.Second) + raw, err := t.protocol.Send(windowCtx, "Browser.getWindowForTarget", map[string]any{"targetId": target.TargetID}, "") + cancel() + windowAssigned := false + if err == nil { + var result struct { + WindowID int64 `json:"windowId"` + } + if json.Unmarshal(raw, &result) == nil { + t.assignWindow(target.TargetID, result.WindowID) + windowAssigned = true + } + } + if !windowAssigned { + t.assignWindow(target.TargetID, -int64(tab.id)) + } + attachCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + _, err = t.protocol.Send(attachCtx, "Target.autoAttachRelated", map[string]any{ + "targetId": target.TargetID, + "waitForDebuggerOnStart": false, + "filter": []map[string]any{ + {"type": "page"}, + {"type": "iframe"}, + }, + }, "") + cancel() + if err != nil && !t.protocol.IsClosed() { + t.logger.Warn("failed to attach browser tab", "tab_id", tab.id, "err", err) + } +} + +func (t *Tracker) assignWindow(targetID string, rawWindowID int64) { + t.stateMu.Lock() + defer t.stateMu.Unlock() + tabID := t.tabsByTarget[targetID] + trackedTab := t.tabs[tabID] + if trackedTab == nil { + return + } + oldRawWindowID := trackedTab.rawWindowID + trackedWindow, ok := t.windows[rawWindowID] + if !ok { + t.nextWindowID++ + trackedWindow = window{id: t.nextWindowID, rawID: rawWindowID} + t.windows[rawWindowID] = trackedWindow + } + trackedTab.windowID = trackedWindow.id + trackedTab.rawWindowID = rawWindowID + if oldRawWindowID != rawWindowID { + t.removeEmptyWindowLocked(oldRawWindowID) + } + t.signalChanged() +} + +func (t *Tracker) updateTarget(target targetInfo) { + t.stateMu.Lock() + _, knownTab := t.tabsByTarget[target.TargetID] + t.updateTabTargetLocked(target) + for _, sess := range t.sessions { + if sess.target.TargetID == target.TargetID { + sess.target = target + } + } + t.stateMu.Unlock() + t.signalChanged() + if target.Type == "page" && !knownTab { + go t.trackPage(t.ctx, target) + return + } + if target.Type == "page" { + go func() { + ctx, cancel := context.WithTimeout(t.ctx, 2*time.Second) + defer cancel() + raw, err := t.protocol.Send(ctx, "Browser.getWindowForTarget", map[string]any{"targetId": target.TargetID}, "") + if err != nil { + return + } + var result struct { + WindowID int64 `json:"windowId"` + } + if json.Unmarshal(raw, &result) == nil { + t.assignWindow(target.TargetID, result.WindowID) + } + }() + } +} + +func (t *Tracker) updateTabTargetLocked(target targetInfo) { + if tracked := t.tabs[t.tabsByTarget[target.TargetID]]; tracked != nil { + tracked.title = target.Title + tracked.url = target.URL + } +} + +func (t *Tracker) removeTarget(targetID string) { + t.stateMu.Lock() + var removedSessions []string + if tabID := t.tabsByTarget[targetID]; tabID != 0 { + removedSessions = t.removeTabLocked(tabID) + } else { + for id, sess := range t.sessions { + if sess.target.TargetID == targetID { + removedSessions = append(removedSessions, t.removeSessionLocked(id)...) + } + } + } + t.stateMu.Unlock() + for _, sessionID := range unique(removedSessions) { + t.publish(Event{Kind: EventSessionRemoved, SessionID: sessionID}) + } + t.signalChanged() +} + +func (t *Tracker) removeTabLocked(tabID int) []string { + trackedTab := t.tabs[tabID] + if trackedTab == nil { + return nil + } + var removed []string + for id, sess := range t.sessions { + if sess.tabID == tabID { + removed = append(removed, t.removeSessionLocked(id)...) + } + } + for rawID, trackedFrame := range t.frames { + if trackedFrame.tabID == tabID { + delete(t.frames, rawID) + } + } + delete(t.tabsByTarget, trackedTab.targetID) + delete(t.trackingTarget, trackedTab.targetID) + delete(t.tabs, tabID) + t.removeEmptyWindowLocked(trackedTab.rawWindowID) + return removed +} + +func (t *Tracker) removeEmptyWindowLocked(rawWindowID int64) { + for _, trackedTab := range t.tabs { + if trackedTab.rawWindowID == rawWindowID { + return + } + } + delete(t.windows, rawWindowID) +} + +func unique(values []string) []string { + seen := make(map[string]bool, len(values)) + result := make([]string, 0, len(values)) + for _, value := range values { + if !seen[value] { + seen[value] = true + result = append(result, value) + } + } + return result +} diff --git a/server/lib/browsersurface/tracker.go b/server/lib/browsersurface/tracker.go new file mode 100644 index 000000000..fa7333df3 --- /dev/null +++ b/server/lib/browsersurface/tracker.go @@ -0,0 +1,317 @@ +package browsersurface + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "sort" + "strings" + "sync" + "time" +) + +const sessionInitTimeout = 5 * time.Second + +type subscriber struct { + events chan Event + done chan struct{} +} + +// Tracker maps CDP targets, sessions, and frames into stable browser windows, +// tabs, and embedded frames. IDs are monotonically increasing for the lifetime +// of the Chromium process and are never reused. +type Tracker struct { + protocol Protocol + ctx context.Context + cancel context.CancelFunc + logger *slog.Logger + + startMu sync.Mutex + started bool + startErr error + + stateMu sync.RWMutex + nextWindowID int + nextTabID int + nextFrameID int + windows map[int64]window + tabs map[int]*tab + tabsByTarget map[string]int + frames map[string]*frame + sessions map[string]*session + trackingTarget map[string]bool + stateChanged chan struct{} + + subMu sync.Mutex + nextSubID int + subscribers map[int]*subscriber + closed chan struct{} + closeOnce sync.Once +} + +func New(protocol Protocol) *Tracker { + ctx, cancel := context.WithCancel(context.Background()) + return &Tracker{ + protocol: protocol, + ctx: ctx, + cancel: cancel, + logger: slog.Default(), + windows: make(map[int64]window), + tabs: make(map[int]*tab), + tabsByTarget: make(map[string]int), + frames: make(map[string]*frame), + sessions: make(map[string]*session), + trackingTarget: make(map[string]bool), + stateChanged: make(chan struct{}, 1), + subscribers: make(map[int]*subscriber), + closed: make(chan struct{}), + } +} + +func (t *Tracker) Start(ctx context.Context) error { + t.startMu.Lock() + defer t.startMu.Unlock() + if t.started { + return t.startErr + } + t.started = true + go t.eventLoop() + + if _, err := t.protocol.Send(ctx, "Target.setDiscoverTargets", map[string]any{ + "discover": true, + "filter": []map[string]any{{"type": "page"}}, + }, ""); err != nil { + t.startErr = fmt.Errorf("start browser surface discovery: %w", err) + return t.startErr + } + raw, err := t.protocol.Send(ctx, "Target.getTargets", nil, "") + if err != nil { + t.startErr = fmt.Errorf("list browser tabs: %w", err) + return t.startErr + } + var result struct { + TargetInfos []targetInfo `json:"targetInfos"` + } + if err := json.Unmarshal(raw, &result); err != nil { + t.startErr = fmt.Errorf("decode browser tabs: %w", err) + return t.startErr + } + for _, target := range result.TargetInfos { + if target.Type == "page" { + t.trackPage(ctx, target) + } + } + return nil +} + +func (t *Tracker) Subscribe() (<-chan Event, func()) { + t.subMu.Lock() + defer t.subMu.Unlock() + t.nextSubID++ + id := t.nextSubID + sub := &subscriber{events: make(chan Event, 256), done: make(chan struct{})} + t.subscribers[id] = sub + var once sync.Once + cancel := func() { + once.Do(func() { + close(sub.done) + t.subMu.Lock() + if _, ok := t.subscribers[id]; ok { + delete(t.subscribers, id) + close(sub.events) + } + t.subMu.Unlock() + }) + } + return sub.events, cancel +} + +func (t *Tracker) Send(ctx context.Context, method string, params any, sessionID string) (json.RawMessage, error) { + return t.protocol.Send(ctx, method, params, sessionID) +} + +func (t *Tracker) Done() <-chan struct{} { + return t.closed +} + +func (t *Tracker) IsClosed() bool { + select { + case <-t.closed: + return true + default: + return false + } +} + +func (t *Tracker) HasTabs() bool { + t.stateMu.RLock() + defer t.stateMu.RUnlock() + return len(t.tabs) != 0 +} + +func (t *Tracker) RefreshWindows(ctx context.Context) { + t.stateMu.RLock() + tabs := make([]tab, 0, len(t.tabs)) + for _, tracked := range t.tabs { + tabs = append(tabs, *tracked) + } + t.stateMu.RUnlock() + sort.Slice(tabs, func(i, j int) bool { return tabs[i].id < tabs[j].id }) + ctx, cancel := context.WithTimeout(ctx, 2*time.Second) + defer cancel() + for _, tracked := range tabs { + raw, err := t.protocol.Send(ctx, "Browser.getWindowForTarget", map[string]any{"targetId": tracked.targetID}, "") + if err != nil { + continue + } + var result struct { + WindowID int64 `json:"windowId"` + } + if json.Unmarshal(raw, &result) == nil { + t.assignWindow(tracked.targetID, result.WindowID) + } + } +} + +func (t *Tracker) Snapshot() Snapshot { + t.stateMu.RLock() + defer t.stateMu.RUnlock() + snapshot := Snapshot{ + Windows: make([]WindowInfo, 0, len(t.windows)), + Tabs: make([]TabInfo, 0, len(t.tabs)), + Frames: make([]FrameInfo, 0, len(t.frames)), + } + for _, tracked := range t.windows { + snapshot.Windows = append(snapshot.Windows, WindowInfo{ID: tracked.id}) + } + for _, tracked := range t.tabs { + snapshot.Tabs = append(snapshot.Tabs, TabInfo{ + ID: tracked.id, WindowID: tracked.windowID, + PageTitle: tracked.title, PageURL: stripFragment(tracked.url), + }) + } + for _, tracked := range t.frames { + if tracked.id == 0 { + continue + } + parentFrameID := 0 + if parent := t.frames[tracked.parentID]; parent != nil { + parentFrameID = parent.id + } + snapshot.Frames = append(snapshot.Frames, FrameInfo{ + ID: tracked.id, TabID: tracked.tabID, ParentFrameID: parentFrameID, + URL: stripFragment(tracked.url), + }) + } + sort.Slice(snapshot.Windows, func(i, j int) bool { return snapshot.Windows[i].ID < snapshot.Windows[j].ID }) + sort.Slice(snapshot.Tabs, func(i, j int) bool { return snapshot.Tabs[i].ID < snapshot.Tabs[j].ID }) + sort.Slice(snapshot.Frames, func(i, j int) bool { return snapshot.Frames[i].ID < snapshot.Frames[j].ID }) + return snapshot +} + +func (t *Tracker) SessionExists(sessionID string) bool { + t.stateMu.RLock() + defer t.stateMu.RUnlock() + _, ok := t.sessions[sessionID] + return ok +} + +func (t *Tracker) Resolve(sessionID, frameID string) (Location, bool) { + t.stateMu.RLock() + defer t.stateMu.RUnlock() + sess, ok := t.sessions[sessionID] + if !ok || sess.tabID == 0 { + return Location{}, false + } + tab, ok := t.tabs[sess.tabID] + if !ok { + return Location{}, false + } + location := Location{ + WindowID: tab.windowID, + TabID: tab.id, + PageTitle: tab.title, + PageURL: stripFragment(tab.url), + } + trackedFrame, ok := t.frames[frameID] + if !ok || trackedFrame.tabID != tab.id { + return Location{}, false + } + if trackedFrame.parentID != "" { + location.Frame = &FrameLocation{ID: trackedFrame.id, URL: stripFragment(trackedFrame.url)} + } + return location, true +} + +func (t *Tracker) WaitForSettled(ctx context.Context) { + limit := time.NewTimer(2 * time.Second) + defer limit.Stop() + quiet := time.NewTimer(200 * time.Millisecond) + defer quiet.Stop() + for { + select { + case <-t.stateChanged: + if !quiet.Stop() { + select { + case <-quiet.C: + default: + } + } + quiet.Reset(200 * time.Millisecond) + case <-quiet.C: + return + case <-limit.C: + return + case <-ctx.Done(): + return + case <-t.closed: + return + } + } +} + +func (t *Tracker) eventLoop() { + defer t.stop() + for message := range t.protocol.Events() { + t.handleProtocolEvent(message) + } +} + +func (t *Tracker) publish(event Event) { + t.subMu.Lock() + defer t.subMu.Unlock() + for _, sub := range t.subscribers { + select { + case sub.events <- event: + case <-sub.done: + case <-t.closed: + return + } + } +} + +func (t *Tracker) signalChanged() { + select { + case t.stateChanged <- struct{}{}: + default: + } +} + +func (t *Tracker) stop() { + t.closeOnce.Do(func() { + t.cancel() + close(t.closed) + t.subMu.Lock() + for id, sub := range t.subscribers { + close(sub.events) + delete(t.subscribers, id) + } + t.subMu.Unlock() + }) +} + +func stripFragment(value string) string { + withoutFragment, _, _ := strings.Cut(value, "#") + return withoutFragment +} diff --git a/server/lib/browsersurface/tracker_test.go b/server/lib/browsersurface/tracker_test.go new file mode 100644 index 000000000..ce7ff7643 --- /dev/null +++ b/server/lib/browsersurface/tracker_test.go @@ -0,0 +1,199 @@ +package browsersurface + +import ( + "context" + "encoding/json" + "sync" + "testing" + "time" + + "github.com/kernel/kernel-images/server/lib/cdpconnection" + "github.com/stretchr/testify/require" +) + +type fakeProtocol struct { + mu sync.Mutex + windowIDs map[string]int + events chan cdpconnection.Message + closed chan struct{} +} + +func newFakeProtocol() *fakeProtocol { + return &fakeProtocol{ + windowIDs: map[string]int{"page-a": 10, "page-b": 20}, + events: make(chan cdpconnection.Message, 64), + closed: make(chan struct{}), + } +} + +func (f *fakeProtocol) Send(_ context.Context, method string, params any, sessionID string) (json.RawMessage, error) { + marshal := func(value any) json.RawMessage { + raw, _ := json.Marshal(value) + return raw + } + switch method { + case "Target.setDiscoverTargets": + return marshal(map[string]any{}), nil + case "Target.getTargets": + return marshal(map[string]any{"targetInfos": []map[string]any{ + {"targetId": "page-a", "type": "page", "title": "Store", "url": "https://store.example/"}, + {"targetId": "page-b", "type": "page", "title": "Travel", "url": "https://travel.example/"}, + }}), nil + case "Browser.getWindowForTarget": + targetID := params.(map[string]any)["targetId"].(string) + f.mu.Lock() + windowID := f.windowIDs[targetID] + f.mu.Unlock() + return marshal(map[string]any{"windowId": windowID}), nil + case "Target.autoAttachRelated": + targetID := params.(map[string]any)["targetId"].(string) + if targetID == "page-a" { + f.emitAttached("session-a", "page-a", "Store", "https://store.example/") + } else { + f.emitAttached("session-b", "page-b", "Travel", "https://travel.example/") + } + return marshal(map[string]any{}), nil + case "Page.enable": + return marshal(map[string]any{}), nil + case "Page.getFrameTree": + if sessionID == "oopif-session" { + return marshal(map[string]any{"frameTree": map[string]any{ + "frame": map[string]any{"id": "inner", "url": "https://bank.example/"}, + }}), nil + } + if sessionID == "session-a" { + return marshal(map[string]any{"frameTree": map[string]any{ + "frame": map[string]any{"id": "root-a", "url": "https://store.example/"}, + "childFrames": []any{map[string]any{ + "frame": map[string]any{"id": "outer", "parentId": "root-a", "url": "https://payments.example/"}, + "childFrames": []any{map[string]any{ + "frame": map[string]any{"id": "inner", "parentId": "outer", "url": "https://bank.example/"}, + }}, + }}, + }}), nil + } + return marshal(map[string]any{"frameTree": map[string]any{ + "frame": map[string]any{"id": "root-b", "url": "https://travel.example/"}, + }}), nil + default: + return marshal(map[string]any{}), nil + } +} + +func (f *fakeProtocol) Events() <-chan cdpconnection.Message { return f.events } +func (f *fakeProtocol) Done() <-chan struct{} { return f.closed } +func (f *fakeProtocol) IsClosed() bool { return false } + +func (f *fakeProtocol) emitAttached(sessionID, targetID, title, url string) { + params, _ := json.Marshal(map[string]any{ + "sessionId": sessionID, + "targetInfo": map[string]any{ + "targetId": targetID, "type": "page", "title": title, "url": url, + }, + }) + f.events <- cdpconnection.Message{Method: "Target.attachedToTarget", Params: params} +} + +func (f *fakeProtocol) emitTarget(method string, params any) { + raw, _ := json.Marshal(params) + f.events <- cdpconnection.Message{Method: method, Params: raw} +} + +func (f *fakeProtocol) setWindow(targetID string, windowID int) { + f.mu.Lock() + f.windowIDs[targetID] = windowID + f.mu.Unlock() +} + +func TestTrackerMapsBrowserSurfaceAndPublishesLifecycleEvents(t *testing.T) { + protocol := newFakeProtocol() + tracker := New(protocol) + events, cancel := tracker.Subscribe() + defer cancel() + require.NoError(t, tracker.Start(context.Background())) + + require.Eventually(t, func() bool { + location, ok := tracker.Resolve("session-a", "inner") + return ok && location.Frame != nil + }, time.Second, 10*time.Millisecond) + location, ok := tracker.Resolve("session-a", "inner") + require.True(t, ok) + require.Equal(t, Location{ + WindowID: 1, + TabID: 1, + PageTitle: "Store", + PageURL: "https://store.example/", + Frame: &FrameLocation{ID: 2, URL: "https://bank.example/"}, + }, location) + root, ok := tracker.Resolve("session-a", "root-a") + require.True(t, ok) + require.Nil(t, root.Frame) + + protocol.emitTarget("Target.attachedToTarget", map[string]any{ + "sessionId": "oopif-session", + "targetInfo": map[string]any{ + "targetId": "inner", "type": "iframe", "url": "https://bank.example/", + }, + }) + require.Eventually(t, func() bool { + location, resolved := tracker.Resolve("oopif-session", "inner") + return resolved && location.Frame != nil && location.Frame.ID == 2 + }, time.Second, 10*time.Millisecond) + + require.Equal(t, Snapshot{ + Windows: []WindowInfo{{ID: 1}, {ID: 2}}, + Tabs: []TabInfo{ + {ID: 1, WindowID: 1, PageTitle: "Store", PageURL: "https://store.example/"}, + {ID: 2, WindowID: 2, PageTitle: "Travel", PageURL: "https://travel.example/"}, + }, + Frames: []FrameInfo{ + {ID: 1, TabID: 1, URL: "https://payments.example/"}, + {ID: 2, TabID: 1, ParentFrameID: 1, URL: "https://bank.example/"}, + }, + }, tracker.Snapshot()) + + ready := make(map[string]bool) + require.Eventually(t, func() bool { + select { + case event := <-events: + if event.Kind == EventSessionReady { + ready[event.SessionID] = true + } + default: + } + return ready["session-a"] && ready["session-b"] + }, time.Second, 10*time.Millisecond) + + protocol.setWindow("page-a", 20) + tracker.RefreshWindows(context.Background()) + moved, ok := tracker.Resolve("session-a", "root-a") + require.True(t, ok) + require.Equal(t, 1, moved.TabID) + require.Equal(t, 2, moved.WindowID) +} + +func TestTrackerNeverReusesWindowTabOrFrameIDs(t *testing.T) { + protocol := newFakeProtocol() + tracker := New(protocol) + require.NoError(t, tracker.Start(context.Background())) + require.Eventually(t, func() bool { + _, ok := tracker.Resolve("session-a", "inner") + return ok + }, time.Second, 10*time.Millisecond) + + protocol.emitTarget("Target.targetDestroyed", map[string]any{"targetId": "page-a"}) + require.Eventually(t, func() bool { return !tracker.SessionExists("session-a") }, time.Second, 10*time.Millisecond) + protocol.emitTarget("Target.targetCreated", map[string]any{"targetInfo": map[string]any{ + "targetId": "page-a", "type": "page", "title": "Store Again", "url": "https://store.example/again", + }}) + + require.Eventually(t, func() bool { + location, ok := tracker.Resolve("session-a", "inner") + return ok && location.TabID == 3 + }, time.Second, 10*time.Millisecond) + location, ok := tracker.Resolve("session-a", "inner") + require.True(t, ok) + require.Equal(t, 3, location.WindowID) + require.Equal(t, 3, location.TabID) + require.Equal(t, 4, location.Frame.ID) +} diff --git a/server/lib/browsersurface/types.go b/server/lib/browsersurface/types.go new file mode 100644 index 000000000..a630cc186 --- /dev/null +++ b/server/lib/browsersurface/types.go @@ -0,0 +1,120 @@ +package browsersurface + +import ( + "context" + "encoding/json" + + "github.com/kernel/kernel-images/server/lib/cdpconnection" +) + +type Protocol interface { + Send(ctx context.Context, method string, params any, sessionID string) (json.RawMessage, error) + Events() <-chan cdpconnection.Message + IsClosed() bool +} + +type EventKind int + +const ( + EventProtocol EventKind = iota + EventSessionReady + EventSessionRemoved + EventDocumentChanged + EventFrameRemoved +) + +type Event struct { + Kind EventKind + SessionID string + FrameID string + Message cdpconnection.Message +} + +type WindowInfo struct { + ID int +} + +type TabInfo struct { + ID int + WindowID int + PageTitle string + PageURL string +} + +type FrameInfo struct { + ID int + TabID int + ParentFrameID int + URL string +} + +type Snapshot struct { + Windows []WindowInfo + Tabs []TabInfo + Frames []FrameInfo +} + +type FrameLocation struct { + ID int + URL string +} + +type Location struct { + WindowID int + TabID int + PageTitle string + PageURL string + Frame *FrameLocation +} + +type targetInfo struct { + TargetID string `json:"targetId"` + Type string `json:"type"` + Title string `json:"title"` + URL string `json:"url"` + ParentFrameID string `json:"parentFrameId,omitempty"` +} + +type frameInfo struct { + ID string `json:"id"` + ParentID string `json:"parentId,omitempty"` + LoaderID string `json:"loaderId"` + URL string `json:"url"` +} + +type frameTree struct { + Frame frameInfo `json:"frame"` + ChildFrames []frameTree `json:"childFrames,omitempty"` +} + +type session struct { + id string + parentID string + target targetInfo + tabID int + initializing bool + initialized bool +} + +type window struct { + id int + rawID int64 +} + +type tab struct { + id int + targetID string + windowID int + rawWindowID int64 + title string + url string + rootFrameID string +} + +type frame struct { + id int + rawID string + parentID string + tabID int + url string +} diff --git a/server/lib/cdpconnection/client.go b/server/lib/cdpconnection/client.go new file mode 100644 index 000000000..36b0e1398 --- /dev/null +++ b/server/lib/cdpconnection/client.go @@ -0,0 +1,197 @@ +package cdpconnection + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "sync" + "sync/atomic" + + "github.com/coder/websocket" +) + +var ErrOutcomeUnknown = errors.New("CDP command outcome is unknown") + +type Message struct { + ID int64 `json:"id,omitempty"` + Method string `json:"method,omitempty"` + Params json.RawMessage `json:"params,omitempty"` + Result json.RawMessage `json:"result,omitempty"` + Error *Error `json:"error,omitempty"` + SessionID string `json:"sessionId,omitempty"` +} + +type Error struct { + Code int `json:"code"` + Message string `json:"message"` +} + +func (e *Error) Error() string { + return fmt.Sprintf("CDP error %d: %s", e.Code, e.Message) +} + +type request struct { + ID int64 `json:"id"` + Method string `json:"method"` + Params json.RawMessage `json:"params,omitempty"` + SessionID string `json:"sessionId,omitempty"` +} + +type commandResult struct { + result json.RawMessage + err error +} + +// Client maintains one event-aware browser-level CDP connection. Send may be +// called concurrently; Events delivers protocol events in receive order. +type Client struct { + conn *websocket.Conn + ctx context.Context + cancel context.CancelFunc + + nextID atomic.Int64 + writeMu sync.Mutex + + pendingMu sync.Mutex + pending map[int64]chan commandResult + events chan Message + + closed chan struct{} + closeOnce sync.Once +} + +func Dial(ctx context.Context, url string) (*Client, error) { + conn, _, err := websocket.Dial(ctx, url, &websocket.DialOptions{CompressionMode: websocket.CompressionDisabled}) + if err != nil { + return nil, fmt.Errorf("dial Chromium DevTools: %w", err) + } + conn.SetReadLimit(8 * 1024 * 1024) + clientCtx, cancel := context.WithCancel(context.Background()) + client := &Client{ + conn: conn, + ctx: clientCtx, + cancel: cancel, + pending: make(map[int64]chan commandResult), + events: make(chan Message, 256), + closed: make(chan struct{}), + } + go client.readLoop() + return client, nil +} + +func (c *Client) Send(ctx context.Context, method string, params any, sessionID string) (json.RawMessage, error) { + id := c.nextID.Add(1) + var rawParams json.RawMessage + if params != nil { + encoded, err := json.Marshal(params) + if err != nil { + return nil, fmt.Errorf("marshal %s parameters: %w", method, err) + } + rawParams = encoded + } + payload, err := json.Marshal(request{ID: id, Method: method, Params: rawParams, SessionID: sessionID}) + if err != nil { + return nil, fmt.Errorf("marshal %s request: %w", method, err) + } + responseCh := make(chan commandResult, 1) + c.pendingMu.Lock() + c.pending[id] = responseCh + c.pendingMu.Unlock() + + c.writeMu.Lock() + err = c.conn.Write(ctx, websocket.MessageText, payload) + c.writeMu.Unlock() + if err != nil { + c.pendingMu.Lock() + delete(c.pending, id) + c.pendingMu.Unlock() + return nil, fmt.Errorf("write %s: %w", method, err) + } + + select { + case response := <-responseCh: + if response.err != nil { + return nil, fmt.Errorf("%s: %w", method, response.err) + } + return response.result, nil + case <-ctx.Done(): + c.pendingMu.Lock() + delete(c.pending, id) + c.pendingMu.Unlock() + return nil, fmt.Errorf("%s: %w", method, ctx.Err()) + case <-c.closed: + return nil, fmt.Errorf("%s: %w", method, ErrOutcomeUnknown) + } +} + +func (c *Client) Events() <-chan Message { + return c.events +} + +func (c *Client) Done() <-chan struct{} { + return c.closed +} + +func (c *Client) IsClosed() bool { + select { + case <-c.closed: + return true + default: + return false + } +} + +func (c *Client) Close() error { + c.closeOnce.Do(func() { + c.cancel() + c.conn.CloseNow() + close(c.closed) + c.failPending(ErrOutcomeUnknown) + }) + return nil +} + +func (c *Client) readLoop() { + defer close(c.events) + for { + _, payload, err := c.conn.Read(c.ctx) + if err != nil { + _ = c.Close() + return + } + var message Message + if json.Unmarshal(payload, &message) != nil { + continue + } + if message.ID == 0 { + select { + case c.events <- message: + case <-c.closed: + return + } + continue + } + c.pendingMu.Lock() + responseCh := c.pending[message.ID] + delete(c.pending, message.ID) + c.pendingMu.Unlock() + if responseCh == nil { + continue + } + if message.Error != nil { + responseCh <- commandResult{err: message.Error} + } else { + responseCh <- commandResult{result: message.Result} + } + } +} + +func (c *Client) failPending(err error) { + c.pendingMu.Lock() + defer c.pendingMu.Unlock() + for id, responseCh := range c.pending { + responseCh <- commandResult{err: err} + delete(c.pending, id) + } +} diff --git a/server/lib/cdpconnection/client_test.go b/server/lib/cdpconnection/client_test.go new file mode 100644 index 000000000..faa3f1e53 --- /dev/null +++ b/server/lib/cdpconnection/client_test.go @@ -0,0 +1,97 @@ +package cdpconnection + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + + "github.com/coder/websocket" + "github.com/stretchr/testify/require" +) + +func TestClientCorrelatesConcurrentCommandsAndPreservesEvents(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + conn, err := websocket.Accept(w, r, nil) + if err != nil { + return + } + defer conn.CloseNow() + for { + _, payload, err := conn.Read(r.Context()) + if err != nil { + return + } + var request request + if json.Unmarshal(payload, &request) != nil { + continue + } + event, _ := json.Marshal(map[string]any{ + "method": "Test.event", "sessionId": request.SessionID, + "params": map[string]any{"command": request.Method}, + }) + _ = conn.Write(r.Context(), websocket.MessageText, event) + response, _ := json.Marshal(map[string]any{ + "id": request.ID, "result": map[string]any{"command": request.Method}, + }) + _ = conn.Write(r.Context(), websocket.MessageText, response) + } + })) + defer server.Close() + + client, err := Dial(context.Background(), "ws"+strings.TrimPrefix(server.URL, "http")) + require.NoError(t, err) + defer client.Close() + + commands := []string{"Browser.getVersion", "Target.getTargets"} + type commandResult struct { + command string + err error + } + results := make(chan commandResult, len(commands)) + var wg sync.WaitGroup + for _, command := range commands { + wg.Add(1) + go func() { + defer wg.Done() + raw, sendErr := client.Send(context.Background(), command, nil, "session-1") + if sendErr != nil { + results <- commandResult{err: sendErr} + return + } + var result struct { + Command string `json:"command"` + } + if unmarshalErr := json.Unmarshal(raw, &result); unmarshalErr != nil { + results <- commandResult{err: unmarshalErr} + return + } + results <- commandResult{command: result.Command} + }() + } + wg.Wait() + close(results) + seenResults := make(map[string]bool) + for result := range results { + require.NoError(t, result.err) + seenResults[result.command] = true + } + require.True(t, seenResults[commands[0]]) + require.True(t, seenResults[commands[1]]) + + seenEvents := make(map[string]bool) + for range commands { + event := <-client.Events() + require.Equal(t, "Test.event", event.Method) + var params struct { + Command string `json:"command"` + } + require.NoError(t, json.Unmarshal(event.Params, ¶ms)) + seenEvents[params.Command] = true + } + require.True(t, seenEvents[commands[0]]) + require.True(t, seenEvents[commands[1]]) +} diff --git a/server/lib/oapi/oapi.go b/server/lib/oapi/oapi.go index 28afb8cc3..d0c1ccfef 100644 --- a/server/lib/oapi/oapi.go +++ b/server/lib/oapi/oapi.go @@ -6731,32 +6731,11 @@ type WebMCPTool struct { // Annotations Page-provided behavioral hints. These values are untrusted and are not enforced by Kernel. Annotations *WebMCPToolAnnotations `json:"annotations,omitempty"` Description string `json:"description"` - - // DocumentRef Reference derived from the owning target and current Page.LoaderId. It changes when the frame commits a new document. - DocumentRef *string `json:"document_ref,omitempty"` - - // FrameId Page.FrameId supplied by Chromium's WebMCP domain. - FrameId string `json:"frame_id"` - - // FrameUrl Current frame URL with the fragment omitted. - FrameUrl *string `json:"frame_url,omitempty"` InputSchema map[string]interface{} `json:"input_schema"` Name string `json:"name"` + Source WebMCPToolSource `json:"source"` - // PageTargetId CDP target ID of the selected top-level page. - PageTargetId string `json:"page_target_id"` - ParentFrameId *string `json:"parent_frame_id,omitempty"` - - // TargetId CDP target ID owning the registration. For an out-of-process iframe this differs from page_target_id. - TargetId string `json:"target_id"` - - // TargetType CDP target type, normally page or iframe. - TargetType string `json:"target_type"` - - // TargetUrl Owning target URL with the fragment omitted. - TargetUrl *string `json:"target_url,omitempty"` - - // ToolRef Opaque reference for invoking this exact live registration. It becomes invalid when its document or CDP session is replaced. + // ToolRef Opaque reference for invoking this exact live registration. It becomes invalid when its document or browser process is replaced. ToolRef string `json:"tool_ref"` } @@ -6768,6 +6747,33 @@ type WebMCPToolAnnotations struct { UntrustedContent bool `json:"untrusted_content"` } +// WebMCPToolFrame defines model for WebMCPToolFrame. +type WebMCPToolFrame struct { + // FrameId Monotonically increasing identifier for this embedded frame during the current browser process. + FrameId int `json:"frame_id"` + + // Url Current frame URL with the fragment omitted. + Url string `json:"url"` +} + +// WebMCPToolSource defines model for WebMCPToolSource. +type WebMCPToolSource struct { + // Frame Embedded frame that registered the tool, or null when the top-level page registered it. + Frame *WebMCPToolFrame `json:"frame"` + + // PageTitle Current title of the top-level page. + PageTitle string `json:"page_title"` + + // PageUrl Current URL of the top-level page with the fragment omitted. + PageUrl string `json:"page_url"` + + // TabId Monotonically increasing identifier for the tab during the current browser process. + TabId int `json:"tab_id"` + + // WindowId Monotonically increasing identifier for the browser window during the current browser process. + WindowId int `json:"window_id"` +} + // WebMCPToolsResponse defines model for WebMCPToolsResponse. type WebMCPToolsResponse struct { Tools []WebMCPTool `json:"tools"` @@ -6946,12 +6952,6 @@ type StreamTelemetryEventsParams struct { // StreamTelemetryEventsParamsReplay defines parameters for StreamTelemetryEvents. type StreamTelemetryEventsParamsReplay string -// GetWebMCPToolsParams defines parameters for GetWebMCPTools. -type GetWebMCPToolsParams struct { - // TargetId Optional page target to inspect. When omitted, the current discovery target is retained; the first page target reported by Chromium is used only on initial discovery. Explicitly switching targets resets the WebMCP attachment and makes running invocation outcomes unknown. - TargetId *string `form:"target_id,omitempty" json:"target_id,omitempty"` -} - // PatchChromiumFlagsJSONRequestBody defines body for PatchChromiumFlags for application/json ContentType. type PatchChromiumFlagsJSONRequestBody PatchChromiumFlagsJSONBody @@ -9661,7 +9661,7 @@ type ClientInterface interface { InvokeWebMCPTool(ctx context.Context, body InvokeWebMCPToolJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) // GetWebMCPTools request - GetWebMCPTools(ctx context.Context, params *GetWebMCPToolsParams, reqEditors ...RequestEditorFn) (*http.Response, error) + GetWebMCPTools(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) } func (c *Client) PatchChromiumFlagsWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { @@ -10756,8 +10756,8 @@ func (c *Client) InvokeWebMCPTool(ctx context.Context, body InvokeWebMCPToolJSON return c.Client.Do(req) } -func (c *Client) GetWebMCPTools(ctx context.Context, params *GetWebMCPToolsParams, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewGetWebMCPToolsRequest(c.Server, params) +func (c *Client) GetWebMCPTools(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewGetWebMCPToolsRequest(c.Server) if err != nil { return nil, err } @@ -13114,7 +13114,7 @@ func NewInvokeWebMCPToolRequestWithBody(server string, contentType string, body } // NewGetWebMCPToolsRequest generates requests for GetWebMCPTools -func NewGetWebMCPToolsRequest(server string, params *GetWebMCPToolsParams) (*http.Request, error) { +func NewGetWebMCPToolsRequest(server string) (*http.Request, error) { var err error serverURL, err := url.Parse(server) @@ -13132,28 +13132,6 @@ func NewGetWebMCPToolsRequest(server string, params *GetWebMCPToolsParams) (*htt return nil, err } - if params != nil { - queryValues := queryURL.Query() - - if params.TargetId != nil { - - if queryFrag, err := runtime.StyleParamWithOptions("form", true, "target_id", *params.TargetId, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil { - return nil, err - } else if parsed, err := url.ParseQuery(queryFrag); err != nil { - return nil, err - } else { - for k, v := range parsed { - for _, v2 := range v { - queryValues.Add(k, v2) - } - } - } - - } - - queryURL.RawQuery = queryValues.Encode() - } - req, err := http.NewRequest("GET", queryURL.String(), nil) if err != nil { return nil, err @@ -13446,7 +13424,7 @@ type ClientWithResponsesInterface interface { InvokeWebMCPToolWithResponse(ctx context.Context, body InvokeWebMCPToolJSONRequestBody, reqEditors ...RequestEditorFn) (*InvokeWebMCPToolResponse, error) // GetWebMCPToolsWithResponse request - GetWebMCPToolsWithResponse(ctx context.Context, params *GetWebMCPToolsParams, reqEditors ...RequestEditorFn) (*GetWebMCPToolsResponse, error) + GetWebMCPToolsWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*GetWebMCPToolsResponse, error) } type PatchChromiumFlagsResponse struct { @@ -15641,8 +15619,8 @@ func (c *ClientWithResponses) InvokeWebMCPToolWithResponse(ctx context.Context, } // GetWebMCPToolsWithResponse request returning *GetWebMCPToolsResponse -func (c *ClientWithResponses) GetWebMCPToolsWithResponse(ctx context.Context, params *GetWebMCPToolsParams, reqEditors ...RequestEditorFn) (*GetWebMCPToolsResponse, error) { - rsp, err := c.GetWebMCPTools(ctx, params, reqEditors...) +func (c *ClientWithResponses) GetWebMCPToolsWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*GetWebMCPToolsResponse, error) { + rsp, err := c.GetWebMCPTools(ctx, reqEditors...) if err != nil { return nil, err } @@ -18084,9 +18062,9 @@ type ServerInterface interface { // Invoke a discovered WebMCP tool // (POST /webmcp/invoke) InvokeWebMCPTool(w http.ResponseWriter, r *http.Request) - // Discover WebMCP tools in the active page and its frames + // Discover WebMCP tools across the browser // (GET /webmcp/tools) - GetWebMCPTools(w http.ResponseWriter, r *http.Request, params GetWebMCPToolsParams) + GetWebMCPTools(w http.ResponseWriter, r *http.Request) } // Unimplemented server implementation that returns http.StatusNotImplemented for each endpoint. @@ -18441,9 +18419,9 @@ func (_ Unimplemented) InvokeWebMCPTool(w http.ResponseWriter, r *http.Request) w.WriteHeader(http.StatusNotImplemented) } -// Discover WebMCP tools in the active page and its frames +// Discover WebMCP tools across the browser // (GET /webmcp/tools) -func (_ Unimplemented) GetWebMCPTools(w http.ResponseWriter, r *http.Request, params GetWebMCPToolsParams) { +func (_ Unimplemented) GetWebMCPTools(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotImplemented) } @@ -19588,21 +19566,8 @@ func (siw *ServerInterfaceWrapper) InvokeWebMCPTool(w http.ResponseWriter, r *ht // GetWebMCPTools operation middleware func (siw *ServerInterfaceWrapper) GetWebMCPTools(w http.ResponseWriter, r *http.Request) { - var err error - - // Parameter object where we will unmarshal all parameters from the context - var params GetWebMCPToolsParams - - // ------------- Optional query parameter "target_id" ------------- - - err = runtime.BindQueryParameterWithOptions("form", true, false, "target_id", r.URL.Query(), ¶ms.TargetId, runtime.BindQueryParameterOptions{Type: "string", Format: ""}) - if err != nil { - siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "target_id", Err: err}) - return - } - handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - siw.Handler.GetWebMCPTools(w, r, params) + siw.Handler.GetWebMCPTools(w, r) })) for _, middleware := range siw.HandlerMiddlewares { @@ -22358,7 +22323,6 @@ func (response InvokeWebMCPTool504JSONResponse) VisitInvokeWebMCPToolResponse(w } type GetWebMCPToolsRequestObject struct { - Params GetWebMCPToolsParams } type GetWebMCPToolsResponseObject interface { @@ -22568,7 +22532,7 @@ type StrictServerInterface interface { // Invoke a discovered WebMCP tool // (POST /webmcp/invoke) InvokeWebMCPTool(ctx context.Context, request InvokeWebMCPToolRequestObject) (InvokeWebMCPToolResponseObject, error) - // Discover WebMCP tools in the active page and its frames + // Discover WebMCP tools across the browser // (GET /webmcp/tools) GetWebMCPTools(ctx context.Context, request GetWebMCPToolsRequestObject) (GetWebMCPToolsResponseObject, error) } @@ -24319,11 +24283,9 @@ func (sh *strictHandler) InvokeWebMCPTool(w http.ResponseWriter, r *http.Request } // GetWebMCPTools operation middleware -func (sh *strictHandler) GetWebMCPTools(w http.ResponseWriter, r *http.Request, params GetWebMCPToolsParams) { +func (sh *strictHandler) GetWebMCPTools(w http.ResponseWriter, r *http.Request) { var request GetWebMCPToolsRequestObject - request.Params = params - handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { return sh.ssi.GetWebMCPTools(ctx, request.(GetWebMCPToolsRequestObject)) } @@ -24747,197 +24709,197 @@ var swaggerSpec = []string{ "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", - "AwA=", + "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", + "uA9yAwA=", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/server/lib/webmcpclient/client.go b/server/lib/webmcpclient/client.go index 21a066c80..13553b050 100644 --- a/server/lib/webmcpclient/client.go +++ b/server/lib/webmcpclient/client.go @@ -6,11 +6,13 @@ import ( "errors" "fmt" "log/slog" + "sort" "sync" - "sync/atomic" "time" - "github.com/coder/websocket" + "github.com/kernel/kernel-images/server/lib/browsersurface" + "github.com/kernel/kernel-images/server/lib/cdpconnection" + "github.com/nrednav/cuid2" ) const ( @@ -21,26 +23,15 @@ const ( maxAbandonedInvocations = 256 ) -var errCommandOutcomeUnknown = errors.New("CDP command outcome is unknown") - type connection struct { - conn *websocket.Conn - ctx context.Context - cancel context.CancelFunc - - nextID atomic.Int64 - writeMu sync.Mutex - - pendingMu sync.Mutex - pending map[int64]chan commandResult + protocol *cdpconnection.Client + surface *browsersurface.Tracker - targetMu sync.Mutex + startMu sync.Mutex + started bool stateMu sync.RWMutex - rootTargetID string - rootSessionID string - sessions map[string]session - frames map[string]map[string]frameInfo + enabledSessions map[string]bool tools map[string]*registeredTool toolRefs map[string]string toolLimitWarned map[string]bool @@ -50,24 +41,19 @@ type connection struct { stateChangedCh chan struct{} logger *slog.Logger - closed chan struct{} - closeOnce sync.Once + eventsCancel func() + eventsDone chan struct{} + closed chan struct{} + closeOnce sync.Once } -func dial(ctx context.Context, url string) (*connection, error) { - ws, _, err := websocket.Dial(ctx, url, &websocket.DialOptions{CompressionMode: websocket.CompressionDisabled}) - if err != nil { - return nil, fmt.Errorf("WebMCP: dial Chromium DevTools: %w", err) - } - ws.SetReadLimit(8 * 1024 * 1024) - clientCtx, cancel := context.WithCancel(context.Background()) - c := &connection{ - conn: ws, - ctx: clientCtx, - cancel: cancel, - pending: make(map[int64]chan commandResult), - sessions: make(map[string]session), - frames: make(map[string]map[string]frameInfo), +func newConnection(protocol *cdpconnection.Client) *connection { + surface := browsersurface.New(protocol) + events, cancel := surface.Subscribe() + client := &connection{ + protocol: protocol, + surface: surface, + enabledSessions: make(map[string]bool), tools: make(map[string]*registeredTool), toolRefs: make(map[string]string), toolLimitWarned: make(map[string]bool), @@ -76,18 +62,32 @@ func dial(ctx context.Context, url string) (*connection, error) { abandonedInvocations: make(map[invocationKey]time.Time), stateChangedCh: make(chan struct{}, 1), logger: slog.Default(), + eventsCancel: cancel, + eventsDone: make(chan struct{}), closed: make(chan struct{}), } - go c.readLoop() - return c, nil + go client.eventLoop(events) + return client +} + +func (c *connection) start(ctx context.Context) error { + c.startMu.Lock() + defer c.startMu.Unlock() + if c.started { + return nil + } + if err := c.surface.Start(ctx); err != nil { + return err + } + c.started = true + return nil } func (c *connection) close() error { c.closeOnce.Do(func() { - c.cancel() - c.conn.CloseNow() + c.eventsCancel() + _ = c.protocol.Close() close(c.closed) - c.failPending(errCommandOutcomeUnknown) }) return nil } @@ -97,175 +97,194 @@ func (c *connection) isClosed() bool { case <-c.closed: return true default: - return false + return c.protocol.IsClosed() } } -func (c *connection) readLoop() { - for { - _, payload, err := c.conn.Read(c.ctx) - if err != nil { - _ = c.close() - return - } - var message cdpMessage - if json.Unmarshal(payload, &message) != nil { - continue - } - if message.ID != 0 { - c.pendingMu.Lock() - responseCh := c.pending[message.ID] - delete(c.pending, message.ID) - c.pendingMu.Unlock() - if responseCh != nil { - if message.Error != nil { - responseCh <- commandResult{err: message.Error} - } else { - responseCh <- commandResult{result: message.Result} - } - } - continue +func (c *connection) eventLoop(events <-chan browsersurface.Event) { + defer close(c.eventsDone) + for event := range events { + switch event.Kind { + case browsersurface.EventSessionReady: + go c.enableSession(event.SessionID) + case browsersurface.EventSessionRemoved: + c.removeSession(event.SessionID) + case browsersurface.EventDocumentChanged: + c.stateMu.Lock() + c.removeFrameToolsLocked(event.SessionID, event.FrameID) + c.stateMu.Unlock() + c.signalStateChanged() + case browsersurface.EventFrameRemoved: + c.stateMu.Lock() + c.removeFrameToolsAcrossSessionsLocked(event.FrameID) + c.stateMu.Unlock() + c.signalStateChanged() + case browsersurface.EventProtocol: + c.handleProtocolEvent(event.Message) } - c.handleEvent(message) } } -func (c *connection) failPending(err error) { - c.pendingMu.Lock() - defer c.pendingMu.Unlock() - for id, responseCh := range c.pending { - responseCh <- commandResult{err: err} - delete(c.pending, id) +func (c *connection) enableSession(sessionID string) { + c.stateMu.Lock() + if c.enabledSessions[sessionID] { + c.stateMu.Unlock() + return } -} + c.enabledSessions[sessionID] = true + c.stateMu.Unlock() -func (c *connection) send(ctx context.Context, method string, params any, sessionID string) (json.RawMessage, error) { - id := c.nextID.Add(1) - var rawParams json.RawMessage - if params != nil { - encoded, err := json.Marshal(params) - if err != nil { - return nil, fmt.Errorf("WebMCP: marshal %s parameters: %w", method, err) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if _, err := c.surface.Send(ctx, "WebMCP.enable", nil, sessionID); err != nil { + c.stateMu.Lock() + delete(c.enabledSessions, sessionID) + c.stateMu.Unlock() + if !c.isClosed() && c.surface.SessionExists(sessionID) { + c.logger.Warn("failed to enable WebMCP session", "session_id", sessionID, "err", err) } - rawParams = encoded - } - payload, err := json.Marshal(cdpRequest{ID: id, Method: method, Params: rawParams, SessionID: sessionID}) - if err != nil { - return nil, fmt.Errorf("WebMCP: marshal %s request: %w", method, err) - } - responseCh := make(chan commandResult, 1) - c.pendingMu.Lock() - c.pending[id] = responseCh - c.pendingMu.Unlock() - - c.writeMu.Lock() - err = c.conn.Write(ctx, websocket.MessageText, payload) - c.writeMu.Unlock() - if err != nil { - c.pendingMu.Lock() - delete(c.pending, id) - c.pendingMu.Unlock() - return nil, fmt.Errorf("WebMCP: write %s: %w", method, err) + return } + c.signalStateChanged() +} - select { - case response := <-responseCh: - if response.err != nil { - return nil, fmt.Errorf("WebMCP: %s: %w", method, response.err) +func (c *connection) handleProtocolEvent(message cdpconnection.Message) { + switch message.Method { + case "WebMCP.toolsAdded": + var event struct { + Tools []toolEvent `json:"tools"` + } + if json.Unmarshal(message.Params, &event) == nil { + c.addTools(message.SessionID, event.Tools) + } + case "WebMCP.toolsRemoved": + var event struct { + Tools []struct { + Name string `json:"name"` + FrameID string `json:"frameId"` + } `json:"tools"` + } + if json.Unmarshal(message.Params, &event) == nil { + c.stateMu.Lock() + for _, tool := range event.Tools { + c.removeToolLocked(toolKey(message.SessionID, tool.FrameID, tool.Name)) + } + c.stateMu.Unlock() + c.signalStateChanged() + } + case "WebMCP.toolResponded": + var response invocationResponse + if json.Unmarshal(message.Params, &response) == nil { + key := invocationKey{sessionID: message.SessionID, invocationID: response.InvocationID} + c.stateMu.Lock() + c.pruneAbandonedInvocationsLocked() + if _, abandoned := c.abandonedInvocations[key]; abandoned { + delete(c.abandonedInvocations, key) + } else { + if len(c.invocations) >= maxCompletedInvocations { + for existing := range c.invocations { + if _, waiting := c.waitingInvocations[existing]; !waiting { + delete(c.invocations, existing) + break + } + } + } + c.invocations[key] = response + } + c.stateMu.Unlock() + c.signalStateChanged() } - return response.result, nil - case <-ctx.Done(): - c.pendingMu.Lock() - delete(c.pending, id) - c.pendingMu.Unlock() - return nil, fmt.Errorf("WebMCP: %s: %w", method, ctx.Err()) - case <-c.closed: - return nil, fmt.Errorf("WebMCP: %s: %w", method, errCommandOutcomeUnknown) } } -func (c *connection) selectTarget(ctx context.Context, requestedTargetID string) error { - c.targetMu.Lock() - defer c.targetMu.Unlock() - - raw, err := c.send(ctx, "Target.getTargets", nil, "") - if err != nil { - return err - } - var result struct { - TargetInfos []targetInfo `json:"targetInfos"` +func (c *connection) addTools(sessionID string, tools []toolEvent) { + if !c.surface.SessionExists(sessionID) { + return } - if err := json.Unmarshal(raw, &result); err != nil { - return fmt.Errorf("WebMCP: decode Target.getTargets: %w", err) - } - c.stateMu.RLock() - currentTargetID := c.rootTargetID - _, currentSessionAlive := c.sessions[c.rootSessionID] - c.stateMu.RUnlock() - var selected targetInfo - if requestedTargetID == "" && currentSessionAlive { - for _, target := range result.TargetInfos { - if target.Type == "page" && target.TargetID == currentTargetID { - selected = target - break - } + c.stateMu.Lock() + defer c.stateMu.Unlock() + tracked := 0 + for _, existing := range c.tools { + if existing.sessionID == sessionID { + tracked++ } } - if selected.TargetID == "" { - for _, target := range result.TargetInfos { - if target.Type != "page" { + for _, tool := range tools { + key := toolKey(sessionID, tool.FrameID, tool.Name) + ref := c.toolRefs[key] + if ref == "" { + if tracked >= maxToolsPerSession { + if !c.toolLimitWarned[sessionID] { + c.toolLimitWarned[sessionID] = true + c.logger.Warn("WebMCP tool limit reached", "session_id", sessionID, "limit", maxToolsPerSession) + } continue } - if requestedTargetID == "" || requestedTargetID == target.TargetID { - selected = target - break - } + ref = "wmcp_" + cuid2.Generate() + c.toolRefs[key] = ref + tracked++ + } + c.tools[ref] = ®isteredTool{ + ref: ref, + sessionID: sessionID, + name: tool.Name, + description: tool.Description, + inputSchema: tool.InputSchema, + annotations: tool.Annotations, + frameID: tool.FrameID, } } - if selected.TargetID == "" { - return ErrNoPageTarget - } + c.signalStateChanged() +} +func (c *connection) toolsSnapshot() []Tool { c.stateMu.RLock() - alreadySelected := c.rootTargetID == selected.TargetID && c.sessions[c.rootSessionID].id != "" - previousTargetID := c.rootTargetID - c.stateMu.RUnlock() - if alreadySelected { - return nil - } - if previousTargetID != "" { - _ = c.close() - return errors.New("WebMCP: selected page target changed") - } - - c.stateMu.Lock() - c.clearStateLocked() - c.rootTargetID = selected.TargetID - c.stateMu.Unlock() - if _, err := c.send(ctx, "Target.autoAttachRelated", map[string]any{ - "targetId": selected.TargetID, - "waitForDebuggerOnStart": false, - "filter": []map[string]any{ - {"type": "page"}, - {"type": "iframe"}, - }, - }, ""); err != nil { - return err - } - - c.stateMu.Lock() - for id, sess := range c.sessions { - if sess.target.TargetID == selected.TargetID { - c.rootSessionID = id - break + defer c.stateMu.RUnlock() + result := make([]Tool, 0, len(c.tools)) + for _, tool := range c.tools { + location, ok := c.surface.Resolve(tool.sessionID, tool.frameID) + if !ok { + continue } + source := ToolSource{ + WindowID: location.WindowID, + TabID: location.TabID, + PageTitle: location.PageTitle, + PageURL: location.PageURL, + } + if location.Frame != nil { + source.Frame = &ToolFrame{FrameID: location.Frame.ID, URL: location.Frame.URL} + } + result = append(result, Tool{ + Ref: tool.ref, + Name: tool.name, + Description: tool.description, + InputSchema: tool.inputSchema, + Annotations: tool.annotations, + Source: source, + }) } - rootSessionID := c.rootSessionID - c.stateMu.Unlock() - if rootSessionID == "" { - return fmt.Errorf("WebMCP: Chromium did not attach the selected page target") - } - return c.initializeSession(rootSessionID, selected.Type) + sort.Slice(result, func(i, j int) bool { + left, right := result[i], result[j] + if left.Source.WindowID != right.Source.WindowID { + return left.Source.WindowID < right.Source.WindowID + } + if left.Source.TabID != right.Source.TabID { + return left.Source.TabID < right.Source.TabID + } + leftFrame, rightFrame := 0, 0 + if left.Source.Frame != nil { + leftFrame = left.Source.Frame.FrameID + } + if right.Source.Frame != nil { + rightFrame = right.Source.Frame.FrameID + } + if leftFrame != rightFrame { + return leftFrame < rightFrame + } + return left.Name < right.Name + }) + return result } func (c *connection) waitForSettled(ctx context.Context) { @@ -303,13 +322,12 @@ func (c *connection) invoke(ctx context.Context, toolRef string, input map[strin return InvocationResult{}, ErrToolNotFound } sessionID, frameID, name := tool.sessionID, tool.frameID, tool.name - _, sessionAlive := c.sessions[sessionID] c.stateMu.RUnlock() - if !sessionAlive { + if !c.sessionExists(sessionID) { return InvocationResult{}, ErrToolNotFound } - raw, err := c.send(ctx, "WebMCP.invokeTool", map[string]any{ + raw, err := c.surface.Send(ctx, "WebMCP.invokeTool", map[string]any{ "frameId": frameID, "toolName": name, "input": input, @@ -317,7 +335,7 @@ func (c *connection) invoke(ctx context.Context, toolRef string, input map[strin if err != nil { unknownOutcome := errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) || - errors.Is(err, errCommandOutcomeUnknown) || + errors.Is(err, cdpconnection.ErrOutcomeUnknown) || !c.sessionExists(sessionID) if unknownOutcome { return InvocationResult{}, ErrOutcomeUnknown @@ -350,9 +368,8 @@ func (c *connection) invoke(ctx context.Context, toolRef string, input map[strin ErrorText: response.ErrorText, }, nil } - _, alive := c.sessions[sessionID] c.stateMu.Unlock() - if !alive || c.isClosed() { + if !c.sessionExists(sessionID) || c.executionClosed() { c.stateMu.Lock() abandoned := c.abandonInvocationLocked(key) c.stateMu.Unlock() @@ -384,9 +401,103 @@ func (c *connection) invoke(ctx context.Context, toolRef string, input map[strin } } +func (c *connection) executionClosed() bool { + select { + case <-c.closed: + return true + case <-c.eventsDone: + return true + default: + return false + } +} + func (c *connection) sessionExists(sessionID string) bool { c.stateMu.RLock() defer c.stateMu.RUnlock() - _, ok := c.sessions[sessionID] - return ok + return c.enabledSessions[sessionID] +} + +func (c *connection) removeSession(sessionID string) { + c.stateMu.Lock() + defer c.stateMu.Unlock() + delete(c.enabledSessions, sessionID) + delete(c.toolLimitWarned, sessionID) + for key := range c.waitingInvocations { + if key.sessionID == sessionID { + c.abandonInvocationLocked(key) + } + } + for ref, tool := range c.tools { + if tool.sessionID == sessionID { + delete(c.toolRefs, toolKey(tool.sessionID, tool.frameID, tool.name)) + delete(c.tools, ref) + } + } + c.signalStateChanged() +} + +func (c *connection) removeFrameToolsLocked(sessionID, frameID string) { + for ref, tool := range c.tools { + if tool.sessionID == sessionID && tool.frameID == frameID { + delete(c.toolRefs, toolKey(tool.sessionID, tool.frameID, tool.name)) + delete(c.tools, ref) + } + } +} + +func (c *connection) removeFrameToolsAcrossSessionsLocked(frameID string) { + for ref, tool := range c.tools { + if tool.frameID == frameID { + delete(c.toolRefs, toolKey(tool.sessionID, tool.frameID, tool.name)) + delete(c.tools, ref) + } + } +} + +func (c *connection) removeToolLocked(key string) { + ref := c.toolRefs[key] + delete(c.toolRefs, key) + delete(c.tools, ref) +} + +func (c *connection) abandonInvocationLocked(key invocationKey) bool { + if _, completed := c.invocations[key]; completed { + return false + } + delete(c.waitingInvocations, key) + c.pruneAbandonedInvocationsLocked() + if len(c.abandonedInvocations) >= maxAbandonedInvocations { + var oldest invocationKey + var oldestAt time.Time + for candidate, abandonedAt := range c.abandonedInvocations { + if oldestAt.IsZero() || abandonedAt.Before(oldestAt) { + oldest = candidate + oldestAt = abandonedAt + } + } + delete(c.abandonedInvocations, oldest) + } + c.abandonedInvocations[key] = time.Now() + return true +} + +func (c *connection) pruneAbandonedInvocationsLocked() { + cutoff := time.Now().Add(-10 * time.Minute) + for invocationID, abandonedAt := range c.abandonedInvocations { + if abandonedAt.Before(cutoff) { + delete(c.abandonedInvocations, invocationID) + } + } +} + +func (c *connection) signalStateChanged() { + select { + case c.stateChangedCh <- struct{}{}: + default: + } +} + +func toolKey(sessionID, frameID, name string) string { + return sessionID + "\x00" + frameID + "\x00" + name } diff --git a/server/lib/webmcpclient/client_test.go b/server/lib/webmcpclient/client_test.go index ce1975050..39b3f345f 100644 --- a/server/lib/webmcpclient/client_test.go +++ b/server/lib/webmcpclient/client_test.go @@ -4,8 +4,6 @@ import ( "context" "encoding/json" "fmt" - "io" - "log/slog" "net/http" "net/http/httptest" "strings" @@ -14,6 +12,7 @@ import ( "time" "github.com/coder/websocket" + "github.com/kernel/kernel-images/server/lib/cdpconnection" "github.com/stretchr/testify/require" ) @@ -40,6 +39,13 @@ func (u *mutableUpstream) Set(url string) { u.mu.Unlock() } +type wireRequest struct { + ID int64 `json:"id"` + Method string `json:"method"` + Params json.RawMessage `json:"params"` + SessionID string `json:"sessionId"` +} + type fakeCDP struct { server *httptest.Server url string @@ -52,18 +58,37 @@ type fakeCDP struct { closeOnInvoke bool detachAfterResponse bool invokeResponseDelay time.Duration - extraPageFirst bool + toolCount int + write func(any) } func newFakeCDP(t *testing.T, omitResponse bool) *fakeCDP { t.Helper() - fake := &fakeCDP{enabledSessions: make(map[string]int), omitResponse: omitResponse} + fake := &fakeCDP{enabledSessions: make(map[string]int), omitResponse: omitResponse, toolCount: 1} fake.server = httptest.NewServer(http.HandlerFunc(fake.serve)) fake.url = "ws" + strings.TrimPrefix(fake.server.URL, "http") t.Cleanup(fake.server.Close) return fake } +func (f *fakeCDP) emit(value any) { + f.mu.Lock() + write := f.write + f.mu.Unlock() + if write != nil { + write(value) + } +} + +func (f *fakeCDP) openPopup() { + f.emit(map[string]any{ + "method": "Target.targetCreated", + "params": map[string]any{"targetInfo": map[string]any{ + "targetId": "popup-target", "type": "page", "title": "Popup", "url": "https://popup.example/", + }}, + }) +} + func (f *fakeCDP) serve(w http.ResponseWriter, r *http.Request) { conn, err := websocket.Accept(w, r, nil) if err != nil { @@ -84,12 +109,15 @@ func (f *fakeCDP) serve(w http.ResponseWriter, r *http.Request) { defer writeMu.Unlock() _ = conn.Write(r.Context(), websocket.MessageText, payload) } + f.mu.Lock() + f.write = write + f.mu.Unlock() for { _, payload, err := conn.Read(r.Context()) if err != nil { return } - var request cdpRequest + var request wireRequest if json.Unmarshal(payload, &request) != nil { continue } @@ -97,72 +125,77 @@ func (f *fakeCDP) serve(w http.ResponseWriter, r *http.Request) { write(map[string]any{"id": request.ID, "result": result}) } switch request.Method { + case "Target.setDiscoverTargets": + respond(map[string]any{}) case "Target.getTargets": - f.mu.Lock() - extraPageFirst := f.extraPageFirst - f.mu.Unlock() - targets := []map[string]any{{ - "targetId": "page-target", "type": "page", "url": "https://merchant.example/", - }} - if extraPageFirst { - targets = append([]map[string]any{{ - "targetId": "other-page", "type": "page", "url": "https://other.example/", - }}, targets...) + respond(map[string]any{"targetInfos": []map[string]any{ + {"targetId": "page-target", "type": "page", "title": "Store", "url": "https://merchant.example/"}, + {"targetId": "other-page", "type": "page", "title": "Travel", "url": "https://travel.example/"}, + }}) + case "Browser.getWindowForTarget": + var params struct { + TargetID string `json:"targetId"` } - respond(map[string]any{"targetInfos": targets}) + _ = json.Unmarshal(request.Params, ¶ms) + windowID := 10 + if params.TargetID == "other-page" { + windowID = 20 + } + respond(map[string]any{"windowId": windowID}) case "Target.autoAttachRelated": f.mu.Lock() f.relatedAttaches++ f.mu.Unlock() - write(map[string]any{ - "method": "Target.attachedToTarget", - "params": map[string]any{ - "sessionId": "page-session", - "targetInfo": map[string]any{ - "targetId": "page-target", "type": "page", "url": "https://merchant.example/", - }, - }, - }) - write(map[string]any{ - "method": "Target.attachedToTarget", - "params": map[string]any{ - "sessionId": "iframe-session", - "targetInfo": map[string]any{ - "targetId": "iframe-target", "type": "iframe", "url": "https://payments.example/element#private-state", - "parentFrameId": "page-frame", - }, - }, - }) + var params struct { + TargetID string `json:"targetId"` + } + _ = json.Unmarshal(request.Params, ¶ms) + switch params.TargetID { + case "page-target": + writeAttached(write, "", "page-session", "page-target", "page", "Store", "https://merchant.example/", "") + writeAttached(write, "page-session", "iframe-session", "iframe-target", "iframe", "", "https://payments.example/element#private-state", "page-frame") + writeAttached(write, "iframe-session", "nested-session", "nested-target", "iframe", "", "https://bank.example/challenge", "iframe-frame") + case "other-page": + writeAttached(write, "", "other-session", "other-page", "page", "Travel", "https://travel.example/", "") + case "popup-target": + writeAttached(write, "", "popup-session", "popup-target", "page", "Popup", "https://popup.example/", "") + } respond(map[string]any{}) case "Page.enable": respond(map[string]any{}) case "Page.getFrameTree": - frame := map[string]any{ - "id": "page-frame", "loaderId": "page-loader", "url": "https://merchant.example/", - } - if request.SessionID == "iframe-session" { - frame = map[string]any{ - "id": "iframe-frame", "loaderId": "iframe-loader", "url": "https://payments.example/element#private-state", - "parentId": "page-frame", - } - } - respond(map[string]any{"frameTree": map[string]any{"frame": frame}}) + respond(map[string]any{"frameTree": frameTreeForSession(request.SessionID)}) case "WebMCP.enable": f.mu.Lock() f.enabledSessions[request.SessionID]++ + toolCount := f.toolCount f.mu.Unlock() respond(map[string]any{}) name, frameID := "merchant_tool", "page-frame" - if request.SessionID == "iframe-session" { + switch request.SessionID { + case "iframe-session": name, frameID = "payment_tool", "iframe-frame" + case "nested-session": + name, frameID = "bank_tool", "nested-frame" + case "other-session": + name, frameID = "search_flights", "other-frame" + case "popup-session": + name, frameID = "popup_tool", "popup-frame" } - write(map[string]any{ - "method": "WebMCP.toolsAdded", - "sessionId": request.SessionID, - "params": map[string]any{"tools": []map[string]any{{ - "name": name, "description": name + " description", "frameId": frameID, + tools := make([]map[string]any, toolCount) + for i := range tools { + toolName := name + if toolCount > 1 { + toolName = fmt.Sprintf("%s_%d", name, i) + } + tools[i] = map[string]any{ + "name": toolName, "description": toolName + " description", "frameId": frameID, "inputSchema": map[string]any{"type": "object"}, - }}}, + } + } + write(map[string]any{ + "method": "WebMCP.toolsAdded", "sessionId": request.SessionID, + "params": map[string]any{"tools": tools}, }) case "WebMCP.invokeTool": if f.closeOnInvoke { @@ -175,15 +208,13 @@ func (f *fakeCDP) serve(w http.ResponseWriter, r *http.Request) { respond(map[string]any{"invocationId": "invocation-1"}) if !f.omitResponse { write(map[string]any{ - "method": "Page.frameNavigated", - "sessionId": request.SessionID, + "method": "Page.frameNavigated", "sessionId": request.SessionID, "params": map[string]any{"frame": map[string]any{ "id": "iframe-frame", "loaderId": "next-loader", "url": "https://payments.example/success", }}, }) write(map[string]any{ - "method": "WebMCP.toolResponded", - "sessionId": request.SessionID, + "method": "WebMCP.toolResponded", "sessionId": request.SessionID, "params": map[string]any{ "invocationId": "invocation-1", "status": "Completed", "output": map[string]any{"content": []map[string]any{{"type": "text", "text": request.SessionID}}}, @@ -202,35 +233,114 @@ func (f *fakeCDP) serve(w http.ResponseWriter, r *http.Request) { } } -func TestManagerDiscoversTopPageAndOOPIFToolsOverOneConnection(t *testing.T) { +func writeAttached(write func(any), parentSessionID, sessionID, targetID, targetType, title, url, parentFrameID string) { + message := map[string]any{ + "method": "Target.attachedToTarget", + "params": map[string]any{ + "sessionId": sessionID, + "targetInfo": map[string]any{ + "targetId": targetID, "type": targetType, "title": title, "url": url, + "parentFrameId": parentFrameID, + }, + }, + } + if parentSessionID != "" { + message["sessionId"] = parentSessionID + } + write(message) +} + +func frameTreeForSession(sessionID string) map[string]any { + switch sessionID { + case "page-session": + return map[string]any{ + "frame": map[string]any{"id": "page-frame", "loaderId": "page-loader", "url": "https://merchant.example/"}, + "childFrames": []any{ + map[string]any{ + "frame": map[string]any{"id": "iframe-frame", "parentId": "page-frame", "loaderId": "iframe-loader", "url": "https://payments.example/element#private-state"}, + "childFrames": []any{ + map[string]any{"frame": map[string]any{"id": "nested-frame", "parentId": "iframe-frame", "loaderId": "nested-loader", "url": "https://bank.example/challenge"}}, + }, + }, + }, + } + case "iframe-session": + return map[string]any{ + "frame": map[string]any{"id": "iframe-frame", "parentId": "page-frame", "loaderId": "iframe-loader", "url": "https://payments.example/element#private-state"}, + "childFrames": []any{ + map[string]any{"frame": map[string]any{"id": "nested-frame", "parentId": "iframe-frame", "loaderId": "nested-loader", "url": "https://bank.example/challenge"}}, + }, + } + case "nested-session": + return map[string]any{"frame": map[string]any{"id": "nested-frame", "parentId": "iframe-frame", "loaderId": "nested-loader", "url": "https://bank.example/challenge"}} + case "other-session": + return map[string]any{"frame": map[string]any{"id": "other-frame", "loaderId": "other-loader", "url": "https://travel.example/"}} + case "popup-session": + return map[string]any{"frame": map[string]any{"id": "popup-frame", "loaderId": "popup-loader", "url": "https://popup.example/"}} + default: + return map[string]any{"frame": map[string]any{}} + } +} + +func TestManagerDiscoversToolsAcrossWindowsTabsAndNestedFrames(t *testing.T) { fake := newFakeCDP(t, false) manager := NewManager(staticUpstream{url: fake.url}) t.Cleanup(func() { _ = manager.Close() }) - tools, err := manager.Tools(context.Background(), "") + tools, err := manager.Tools(context.Background()) require.NoError(t, err) - require.Len(t, tools, 2) + require.Len(t, tools, 4) byName := make(map[string]Tool, len(tools)) for _, tool := range tools { byName[tool.Name] = tool } - require.Equal(t, "page-target", byName["merchant_tool"].PageTargetID) - require.Equal(t, "page-target", byName["merchant_tool"].TargetID) - require.Equal(t, "page-target:page-loader", byName["merchant_tool"].DocumentRef) - require.Equal(t, "page-target", byName["payment_tool"].PageTargetID) - require.Equal(t, "iframe-target", byName["payment_tool"].TargetID) - require.Equal(t, "iframe-frame", byName["payment_tool"].FrameID) - require.Equal(t, "https://payments.example/element", byName["payment_tool"].TargetURL) - require.Equal(t, "https://payments.example/element", byName["payment_tool"].FrameURL) - require.Equal(t, "iframe-target:iframe-loader", byName["payment_tool"].DocumentRef) + require.Equal(t, 1, byName["merchant_tool"].Source.WindowID) + require.Equal(t, 1, byName["merchant_tool"].Source.TabID) + require.Nil(t, byName["merchant_tool"].Source.Frame) + require.Equal(t, "Store", byName["payment_tool"].Source.PageTitle) + require.Equal(t, "https://merchant.example/", byName["payment_tool"].Source.PageURL) + require.Equal(t, 1, byName["payment_tool"].Source.Frame.FrameID) + require.Equal(t, "https://payments.example/element", byName["payment_tool"].Source.Frame.URL) + require.Equal(t, 2, byName["bank_tool"].Source.Frame.FrameID) + require.Equal(t, "https://bank.example/challenge", byName["bank_tool"].Source.Frame.URL) + require.Equal(t, 2, byName["search_flights"].Source.WindowID) + require.Equal(t, 2, byName["search_flights"].Source.TabID) + require.Nil(t, byName["search_flights"].Source.Frame) fake.mu.Lock() defer fake.mu.Unlock() require.Equal(t, 1, fake.connections) - require.Equal(t, 1, fake.relatedAttaches) - require.GreaterOrEqual(t, fake.enabledSessions["page-session"], 1) - require.GreaterOrEqual(t, fake.enabledSessions["iframe-session"], 1) + require.Equal(t, 2, fake.relatedAttaches) + for _, sessionID := range []string{"page-session", "iframe-session", "nested-session", "other-session"} { + require.GreaterOrEqual(t, fake.enabledSessions[sessionID], 1) + } +} + +func TestManagerTracksTabsOpenedAfterDiscovery(t *testing.T) { + fake := newFakeCDP(t, false) + manager := NewManager(staticUpstream{url: fake.url}) + t.Cleanup(func() { _ = manager.Close() }) + _, err := manager.Tools(context.Background()) + require.NoError(t, err) + + fake.openPopup() + var popup Tool + require.Eventually(t, func() bool { + tools, toolsErr := manager.Tools(context.Background()) + if toolsErr != nil { + return false + } + for _, tool := range tools { + if tool.Name == "popup_tool" { + popup = tool + return true + } + } + return false + }, 3*time.Second, 20*time.Millisecond) + require.Equal(t, 1, popup.Source.WindowID) + require.Equal(t, 3, popup.Source.TabID) } func TestManagerReusesConnectionAndToolReferences(t *testing.T) { @@ -238,39 +348,44 @@ func TestManagerReusesConnectionAndToolReferences(t *testing.T) { manager := NewManager(staticUpstream{url: fake.url}) t.Cleanup(func() { _ = manager.Close() }) - first, err := manager.Tools(context.Background(), "") + first, err := manager.Tools(context.Background()) require.NoError(t, err) - second, err := manager.Tools(context.Background(), "") + second, err := manager.Tools(context.Background()) require.NoError(t, err) require.Equal(t, first, second) fake.mu.Lock() defer fake.mu.Unlock() require.Equal(t, 1, fake.connections) - require.Equal(t, 1, fake.relatedAttaches) + require.Equal(t, 2, fake.relatedAttaches) } -func TestInvocationCanCompleteAfterFrameNavigation(t *testing.T) { - fake := newFakeCDP(t, false) - manager := NewManager(staticUpstream{url: fake.url}) - t.Cleanup(func() { _ = manager.Close() }) - tools, err := manager.Tools(context.Background(), "") +func paymentToolRef(t *testing.T, manager *Manager) string { + t.Helper() + tools, err := manager.Tools(context.Background()) require.NoError(t, err) - var paymentTool Tool for _, tool := range tools { if tool.Name == "payment_tool" { - paymentTool = tool + return tool.Ref } } - require.NotEmpty(t, paymentTool.Ref) + t.Fatal("payment tool not found") + return "" +} + +func TestInvocationCanCompleteAfterFrameNavigation(t *testing.T) { + fake := newFakeCDP(t, false) + manager := NewManager(staticUpstream{url: fake.url}) + t.Cleanup(func() { _ = manager.Close() }) + toolRef := paymentToolRef(t, manager) - result, err := manager.Invoke(context.Background(), paymentTool.Ref, map[string]any{"amount": 2900}) + result, err := manager.Invoke(context.Background(), toolRef, map[string]any{"amount": 2900}) require.NoError(t, err) require.Equal(t, "invocation-1", result.InvocationID) require.Equal(t, "Completed", result.Status) require.Equal(t, "iframe-session", result.Output.(map[string]any)["content"].([]any)[0].(map[string]any)["text"]) - _, err = manager.Invoke(context.Background(), paymentTool.Ref, map[string]any{}) + _, err = manager.Invoke(context.Background(), toolRef, map[string]any{}) require.ErrorIs(t, err, ErrToolNotFound) } @@ -279,33 +394,17 @@ func TestInvocationReturnsCompletedResponseBeforeTargetDetach(t *testing.T) { fake.detachAfterResponse = true manager := NewManager(staticUpstream{url: fake.url}) t.Cleanup(func() { _ = manager.Close() }) - tools, err := manager.Tools(context.Background(), "") - require.NoError(t, err) - var paymentTool Tool - for _, tool := range tools { - if tool.Name == "payment_tool" { - paymentTool = tool - } - } - result, err := manager.Invoke(context.Background(), paymentTool.Ref, map[string]any{}) + result, err := manager.Invoke(context.Background(), paymentToolRef(t, manager), map[string]any{}) require.NoError(t, err) require.Equal(t, "Completed", result.Status) - require.Equal(t, "iframe-session", result.Output.(map[string]any)["content"].([]any)[0].(map[string]any)["text"]) } func TestInvocationTimeoutHasUnknownOutcome(t *testing.T) { fake := newFakeCDP(t, true) manager := NewManager(staticUpstream{url: fake.url}) t.Cleanup(func() { _ = manager.Close() }) - tools, err := manager.Tools(context.Background(), "") - require.NoError(t, err) - var toolRef string - for _, tool := range tools { - if tool.Name == "payment_tool" { - toolRef = tool.Ref - } - } + toolRef := paymentToolRef(t, manager) ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) defer cancel() @@ -319,21 +418,14 @@ func TestInvokeCancellationBeforeCommandResponseKeepsConnection(t *testing.T) { fake.invokeResponseDelay = 100 * time.Millisecond manager := NewManager(staticUpstream{url: fake.url}) t.Cleanup(func() { _ = manager.Close() }) - tools, err := manager.Tools(context.Background(), "") - require.NoError(t, err) - var toolRef string - for _, tool := range tools { - if tool.Name == "payment_tool" { - toolRef = tool.Ref - } - } + toolRef := paymentToolRef(t, manager) ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond) defer cancel() - _, err = manager.Invoke(ctx, toolRef, map[string]any{}) + _, err := manager.Invoke(ctx, toolRef, map[string]any{}) require.ErrorIs(t, err, ErrOutcomeUnknown) - _, err = manager.Tools(context.Background(), "") + _, err = manager.Tools(context.Background()) require.NoError(t, err) fake.mu.Lock() defer fake.mu.Unlock() @@ -345,16 +437,8 @@ func TestConnectionDeathMidInvokeHasUnknownOutcome(t *testing.T) { fake.closeOnInvoke = true manager := NewManager(staticUpstream{url: fake.url}) t.Cleanup(func() { _ = manager.Close() }) - tools, err := manager.Tools(context.Background(), "") - require.NoError(t, err) - var toolRef string - for _, tool := range tools { - if tool.Name == "payment_tool" { - toolRef = tool.Ref - } - } - _, err = manager.Invoke(context.Background(), toolRef, map[string]any{}) + _, err := manager.Invoke(context.Background(), paymentToolRef(t, manager), map[string]any{}) require.ErrorIs(t, err, ErrOutcomeUnknown) } @@ -366,13 +450,9 @@ func TestToolResponsesAreScopedBySession(t *testing.T) { stateChangedCh: make(chan struct{}, 1), } for _, sessionID := range []string{"page-session", "iframe-session"} { - params, err := json.Marshal(invocationResponse{ - InvocationID: "invocation-1", - Status: "Completed", - Output: sessionID, - }) + params, err := json.Marshal(invocationResponse{InvocationID: "invocation-1", Status: "Completed", Output: sessionID}) require.NoError(t, err) - client.handleEvent(cdpMessage{Method: "WebMCP.toolResponded", SessionID: sessionID, Params: params}) + client.handleProtocolEvent(cdpconnection.Message{Method: "WebMCP.toolResponded", SessionID: sessionID, Params: params}) } require.Len(t, client.invocations, 2) @@ -380,91 +460,28 @@ func TestToolResponsesAreScopedBySession(t *testing.T) { require.Equal(t, "iframe-session", client.invocations[invocationKey{sessionID: "iframe-session", invocationID: "invocation-1"}].Output) } -func TestDiscoveryKeepsSelectedPageWhenTargetOrderChanges(t *testing.T) { - fake := newFakeCDP(t, false) - manager := NewManager(staticUpstream{url: fake.url}) - t.Cleanup(func() { _ = manager.Close() }) - first, err := manager.Tools(context.Background(), "") - require.NoError(t, err) - fake.mu.Lock() - fake.extraPageFirst = true - fake.mu.Unlock() - - second, err := manager.Tools(context.Background(), "") - require.NoError(t, err) - require.Equal(t, first, second) - fake.mu.Lock() - defer fake.mu.Unlock() - require.Equal(t, 1, fake.connections) - require.Equal(t, 1, fake.relatedAttaches) -} - func TestManagerReconnectsWhenChromiumUpstreamChanges(t *testing.T) { first := newFakeCDP(t, false) second := newFakeCDP(t, false) upstream := &mutableUpstream{url: first.url} manager := NewManager(upstream) t.Cleanup(func() { _ = manager.Close() }) - firstTools, err := manager.Tools(context.Background(), "") + firstTools, err := manager.Tools(context.Background()) require.NoError(t, err) upstream.Set(second.url) - secondTools, err := manager.Tools(context.Background(), "") + secondTools, err := manager.Tools(context.Background()) require.NoError(t, err) require.NotEqual(t, firstTools[0].Ref, secondTools[0].Ref) - first.mu.Lock() - require.Equal(t, 1, first.connections) - first.mu.Unlock() - second.mu.Lock() - require.Equal(t, 1, second.connections) - second.mu.Unlock() } func TestToolRegistryIsBoundedPerSession(t *testing.T) { - client := &connection{ - sessions: map[string]session{"page-session": {id: "page-session"}}, - tools: make(map[string]*registeredTool), - toolRefs: make(map[string]string), - toolLimitWarned: make(map[string]bool), - stateChangedCh: make(chan struct{}, 1), - logger: slog.New(slog.NewTextHandler(io.Discard, nil)), - } - tools := make([]toolEvent, maxToolsPerSession+10) - for i := range tools { - tools[i] = toolEvent{Name: fmt.Sprintf("tool-%d", i), FrameID: "page-frame"} - } - client.addTools("page-session", tools) - - require.Len(t, client.tools, maxToolsPerSession) - require.True(t, client.toolLimitWarned["page-session"]) -} + fake := newFakeCDP(t, false) + fake.toolCount = maxToolsPerSession + 10 + manager := NewManager(staticUpstream{url: fake.url}) + t.Cleanup(func() { _ = manager.Close() }) -func TestDetachedTargetInvalidatesToolReferences(t *testing.T) { - client := &connection{ - sessions: map[string]session{ - "page-session": {id: "page-session"}, - "iframe-session": {id: "iframe-session", parentID: "page-session"}, - }, - frames: make(map[string]map[string]frameInfo), - tools: make(map[string]*registeredTool), - toolRefs: make(map[string]string), - toolLimitWarned: make(map[string]bool), - invocations: make(map[invocationKey]invocationResponse), - waitingInvocations: make(map[invocationKey]struct{}), - abandonedInvocations: make(map[invocationKey]time.Time), - stateChangedCh: make(chan struct{}, 1), - } - key := toolKey("iframe-session", "iframe-frame", "pay") - client.toolRefs[key] = "wmcp_pay" - client.tools["wmcp_pay"] = ®isteredTool{ - ref: "wmcp_pay", sessionID: "iframe-session", frameID: "iframe-frame", name: "pay", - } - params, err := json.Marshal(map[string]any{"sessionId": "iframe-session"}) + tools, err := manager.Tools(context.Background()) require.NoError(t, err) - - client.handleEvent(cdpMessage{Method: "Target.detachedFromTarget", Params: params}) - - require.NotContains(t, client.sessions, "iframe-session") - require.NotContains(t, client.tools, "wmcp_pay") - require.NotContains(t, client.toolRefs, key) + require.Len(t, tools, maxToolsPerSession*4) } diff --git a/server/lib/webmcpclient/events.go b/server/lib/webmcpclient/events.go deleted file mode 100644 index a934cdc2c..000000000 --- a/server/lib/webmcpclient/events.go +++ /dev/null @@ -1,371 +0,0 @@ -package webmcpclient - -import ( - "context" - "encoding/json" - "fmt" - "sort" - "strings" - "time" - - "github.com/nrednav/cuid2" -) - -func (c *connection) handleEvent(message cdpMessage) { - switch message.Method { - case "Target.attachedToTarget": - var event struct { - SessionID string `json:"sessionId"` - TargetInfo targetInfo `json:"targetInfo"` - } - if json.Unmarshal(message.Params, &event) != nil { - return - } - c.stateMu.Lock() - if message.SessionID != "" { - if _, ok := c.sessions[message.SessionID]; !ok { - c.stateMu.Unlock() - return - } - } - c.sessions[event.SessionID] = session{id: event.SessionID, parentID: message.SessionID, target: event.TargetInfo} - c.stateMu.Unlock() - c.signalStateChanged() - c.stateMu.RLock() - rootTargetID := c.rootTargetID - c.stateMu.RUnlock() - if event.TargetInfo.TargetID != rootTargetID { - go func() { - if err := c.initializeSession(event.SessionID, event.TargetInfo.Type); err != nil && !c.isClosed() { - c.logger.Warn("failed to initialize WebMCP target", - "target_id", event.TargetInfo.TargetID, - "target_type", event.TargetInfo.Type, - "err", err) - } - }() - } - case "Target.detachedFromTarget": - var event struct { - SessionID string `json:"sessionId"` - } - if json.Unmarshal(message.Params, &event) == nil { - c.removeSession(event.SessionID) - } - case "Target.targetInfoChanged": - var event struct { - TargetInfo targetInfo `json:"targetInfo"` - } - if json.Unmarshal(message.Params, &event) == nil { - c.stateMu.Lock() - for id, sess := range c.sessions { - if sess.target.TargetID == event.TargetInfo.TargetID { - sess.target = event.TargetInfo - c.sessions[id] = sess - } - } - c.stateMu.Unlock() - c.signalStateChanged() - } - case "Page.frameNavigated": - var event struct { - Frame frameInfo `json:"frame"` - } - if json.Unmarshal(message.Params, &event) == nil { - c.stateMu.Lock() - if _, ok := c.sessions[message.SessionID]; !ok { - c.stateMu.Unlock() - return - } - if c.frames[message.SessionID] == nil { - c.frames[message.SessionID] = make(map[string]frameInfo) - } - c.frames[message.SessionID][event.Frame.ID] = event.Frame - if sess, ok := c.sessions[message.SessionID]; ok && sess.target.TargetID == event.Frame.ID { - sess.target.URL = event.Frame.URL - c.sessions[message.SessionID] = sess - } - c.removeFrameToolsLocked(message.SessionID, event.Frame.ID) - c.stateMu.Unlock() - c.signalStateChanged() - } - case "Page.frameDetached": - var event struct { - FrameID string `json:"frameId"` - } - if json.Unmarshal(message.Params, &event) == nil { - c.stateMu.Lock() - delete(c.frames[message.SessionID], event.FrameID) - c.removeFrameToolsLocked(message.SessionID, event.FrameID) - c.stateMu.Unlock() - c.signalStateChanged() - } - case "WebMCP.toolsAdded": - var event struct { - Tools []toolEvent `json:"tools"` - } - if json.Unmarshal(message.Params, &event) == nil { - c.addTools(message.SessionID, event.Tools) - } - case "WebMCP.toolsRemoved": - var event struct { - Tools []struct { - Name string `json:"name"` - FrameID string `json:"frameId"` - } `json:"tools"` - } - if json.Unmarshal(message.Params, &event) == nil { - c.stateMu.Lock() - for _, tool := range event.Tools { - c.removeToolLocked(toolKey(message.SessionID, tool.FrameID, tool.Name)) - } - c.stateMu.Unlock() - c.signalStateChanged() - } - case "WebMCP.toolResponded": - var response invocationResponse - if json.Unmarshal(message.Params, &response) == nil { - key := invocationKey{sessionID: message.SessionID, invocationID: response.InvocationID} - c.stateMu.Lock() - c.pruneAbandonedInvocationsLocked() - if _, abandoned := c.abandonedInvocations[key]; abandoned { - delete(c.abandonedInvocations, key) - } else { - if len(c.invocations) >= maxCompletedInvocations { - for existing := range c.invocations { - if _, waiting := c.waitingInvocations[existing]; !waiting { - delete(c.invocations, existing) - break - } - } - } - c.invocations[key] = response - } - c.stateMu.Unlock() - c.signalStateChanged() - } - } -} - -func (c *connection) initializeSession(sessionID, targetType string) error { - if targetType != "page" && targetType != "iframe" { - return nil - } - ctx, cancel := context.WithTimeout(c.ctx, 5*time.Second) - defer cancel() - if _, err := c.send(ctx, "Page.enable", nil, sessionID); err != nil { - return err - } - raw, err := c.send(ctx, "Page.getFrameTree", nil, sessionID) - if err != nil { - return err - } - var result struct { - FrameTree frameTree `json:"frameTree"` - } - if err := json.Unmarshal(raw, &result); err != nil { - return fmt.Errorf("WebMCP: decode Page.getFrameTree: %w", err) - } - c.stateMu.Lock() - if c.frames[sessionID] == nil { - c.frames[sessionID] = make(map[string]frameInfo) - } - addFrameTree(c.frames[sessionID], result.FrameTree) - c.stateMu.Unlock() - if _, err := c.send(ctx, "WebMCP.enable", nil, sessionID); err != nil { - return err - } - c.signalStateChanged() - return nil -} - -func addFrameTree(frames map[string]frameInfo, tree frameTree) { - frames[tree.Frame.ID] = tree.Frame - for _, child := range tree.ChildFrames { - addFrameTree(frames, child) - } -} - -func (c *connection) addTools(sessionID string, tools []toolEvent) { - c.stateMu.Lock() - defer c.stateMu.Unlock() - if _, ok := c.sessions[sessionID]; !ok { - return - } - tracked := 0 - for _, existing := range c.tools { - if existing.sessionID == sessionID { - tracked++ - } - } - for _, tool := range tools { - key := toolKey(sessionID, tool.FrameID, tool.Name) - ref := c.toolRefs[key] - if ref == "" { - if tracked >= maxToolsPerSession { - if !c.toolLimitWarned[sessionID] { - c.toolLimitWarned[sessionID] = true - c.logger.Warn("WebMCP tool limit reached", "session_id", sessionID, "limit", maxToolsPerSession) - } - continue - } - ref = "wmcp_" + cuid2.Generate() - c.toolRefs[key] = ref - tracked++ - } - c.tools[ref] = ®isteredTool{ - ref: ref, - sessionID: sessionID, - name: tool.Name, - description: tool.Description, - inputSchema: tool.InputSchema, - annotations: tool.Annotations, - frameID: tool.FrameID, - } - } - c.signalStateChanged() -} - -func (c *connection) toolsSnapshot() []Tool { - c.stateMu.RLock() - defer c.stateMu.RUnlock() - result := make([]Tool, 0, len(c.tools)) - for _, tool := range c.tools { - sess, ok := c.sessions[tool.sessionID] - if !ok { - continue - } - frame := c.frames[tool.sessionID][tool.frameID] - documentRef := "" - if frame.LoaderID != "" { - documentRef = sess.target.TargetID + ":" + frame.LoaderID - } - result = append(result, Tool{ - Ref: tool.ref, - Name: tool.name, - Description: tool.description, - InputSchema: tool.inputSchema, - Annotations: tool.annotations, - PageTargetID: c.rootTargetID, - TargetID: sess.target.TargetID, - TargetType: sess.target.Type, - TargetURL: stripURLFragment(sess.target.URL), - FrameID: tool.frameID, - FrameURL: stripURLFragment(frame.URL), - ParentFrameID: frame.ParentID, - DocumentRef: documentRef, - }) - } - sort.Slice(result, func(i, j int) bool { - if result[i].FrameID != result[j].FrameID { - return result[i].FrameID < result[j].FrameID - } - return result[i].Name < result[j].Name - }) - return result -} - -func (c *connection) removeSession(sessionID string) { - c.stateMu.Lock() - defer c.stateMu.Unlock() - toRemove := map[string]bool{sessionID: true} - for changed := true; changed; { - changed = false - for id, sess := range c.sessions { - if toRemove[sess.parentID] && !toRemove[id] { - toRemove[id] = true - changed = true - } - } - } - for id := range toRemove { - delete(c.sessions, id) - delete(c.frames, id) - delete(c.toolLimitWarned, id) - for key := range c.waitingInvocations { - if key.sessionID == id { - c.abandonInvocationLocked(key) - } - } - for ref, tool := range c.tools { - if tool.sessionID == id { - delete(c.toolRefs, toolKey(tool.sessionID, tool.frameID, tool.name)) - delete(c.tools, ref) - } - } - } - c.signalStateChanged() -} - -func (c *connection) clearStateLocked() { - c.rootTargetID = "" - c.rootSessionID = "" - c.sessions = make(map[string]session) - c.frames = make(map[string]map[string]frameInfo) - c.tools = make(map[string]*registeredTool) - c.toolRefs = make(map[string]string) - c.toolLimitWarned = make(map[string]bool) - c.invocations = make(map[invocationKey]invocationResponse) - c.waitingInvocations = make(map[invocationKey]struct{}) - c.abandonedInvocations = make(map[invocationKey]time.Time) -} - -func (c *connection) removeFrameToolsLocked(sessionID, frameID string) { - for ref, tool := range c.tools { - if tool.sessionID == sessionID && tool.frameID == frameID { - delete(c.toolRefs, toolKey(tool.sessionID, tool.frameID, tool.name)) - delete(c.tools, ref) - } - } -} - -func (c *connection) removeToolLocked(key string) { - ref := c.toolRefs[key] - delete(c.toolRefs, key) - delete(c.tools, ref) -} - -func (c *connection) abandonInvocationLocked(key invocationKey) bool { - if _, completed := c.invocations[key]; completed { - return false - } - delete(c.waitingInvocations, key) - c.pruneAbandonedInvocationsLocked() - if len(c.abandonedInvocations) >= maxAbandonedInvocations { - var oldest invocationKey - var oldestAt time.Time - for candidate, abandonedAt := range c.abandonedInvocations { - if oldestAt.IsZero() || abandonedAt.Before(oldestAt) { - oldest = candidate - oldestAt = abandonedAt - } - } - delete(c.abandonedInvocations, oldest) - } - c.abandonedInvocations[key] = time.Now() - return true -} - -func (c *connection) pruneAbandonedInvocationsLocked() { - cutoff := time.Now().Add(-10 * time.Minute) - for invocationID, abandonedAt := range c.abandonedInvocations { - if abandonedAt.Before(cutoff) { - delete(c.abandonedInvocations, invocationID) - } - } -} - -func (c *connection) signalStateChanged() { - select { - case c.stateChangedCh <- struct{}{}: - default: - } -} - -func stripURLFragment(value string) string { - withoutFragment, _, _ := strings.Cut(value, "#") - return withoutFragment -} - -func toolKey(sessionID, frameID, name string) string { - return sessionID + "\x00" + frameID + "\x00" + name -} diff --git a/server/lib/webmcpclient/manager.go b/server/lib/webmcpclient/manager.go index 335a0c94c..4ae50d8a1 100644 --- a/server/lib/webmcpclient/manager.go +++ b/server/lib/webmcpclient/manager.go @@ -4,6 +4,8 @@ import ( "context" "fmt" "sync" + + "github.com/kernel/kernel-images/server/lib/cdpconnection" ) type UpstreamManager interface { @@ -22,25 +24,30 @@ func NewManager(upstream UpstreamManager) *Manager { return &Manager{upstream: upstream} } -func (m *Manager) Tools(ctx context.Context, targetID string) ([]Tool, error) { +func (m *Manager) Tools(ctx context.Context) ([]Tool, error) { conn, err := m.getConnection(ctx) if err != nil { return nil, err } - if err := conn.selectTarget(ctx, targetID); err != nil { - if !conn.isClosed() { - return nil, err - } + if err := conn.start(ctx); err != nil { + _ = conn.close() m.discardConnection(conn) conn, err = m.getConnection(ctx) if err != nil { return nil, err } - if err := conn.selectTarget(ctx, targetID); err != nil { + if err := conn.start(ctx); err != nil { + _ = conn.close() + m.discardConnection(conn) return nil, err } } + conn.surface.RefreshWindows(ctx) + conn.surface.WaitForSettled(ctx) conn.waitForSettled(ctx) + if !conn.surface.HasTabs() { + return nil, ErrNoPageTarget + } return conn.toolsSnapshot(), nil } @@ -78,10 +85,11 @@ func (m *Manager) getConnection(ctx context.Context) (*connection, error) { if m.connection != nil { _ = m.connection.close() } - conn, err := dial(ctx, url) + protocol, err := cdpconnection.Dial(ctx, url) if err != nil { - return nil, err + return nil, fmt.Errorf("WebMCP: %w", err) } + conn := newConnection(protocol) m.connection = conn m.url = url return conn, nil diff --git a/server/lib/webmcpclient/types.go b/server/lib/webmcpclient/types.go index 6db926966..8a13a0a5a 100644 --- a/server/lib/webmcpclient/types.go +++ b/server/lib/webmcpclient/types.go @@ -1,31 +1,35 @@ package webmcpclient import ( - "encoding/json" "errors" - "fmt" ) var ( - ErrNoPageTarget = errors.New("no page target found") + ErrNoPageTarget = errors.New("no browser tabs found") ErrToolNotFound = errors.New("WebMCP tool not found") ErrOutcomeUnknown = errors.New("WebMCP invocation outcome is unknown") ) type Tool struct { - Ref string - Name string - Description string - InputSchema map[string]any - Annotations *Annotations - PageTargetID string - TargetID string - TargetType string - TargetURL string - FrameID string - FrameURL string - ParentFrameID string - DocumentRef string + Ref string + Name string + Description string + InputSchema map[string]any + Annotations *Annotations + Source ToolSource +} + +type ToolSource struct { + WindowID int + TabID int + PageTitle string + PageURL string + Frame *ToolFrame +} + +type ToolFrame struct { + FrameID int + URL string } type Annotations struct { @@ -42,61 +46,6 @@ type InvocationResult struct { ErrorText string } -type cdpRequest struct { - ID int64 `json:"id"` - Method string `json:"method"` - Params json.RawMessage `json:"params,omitempty"` - SessionID string `json:"sessionId,omitempty"` -} - -type cdpMessage struct { - ID int64 `json:"id,omitempty"` - Method string `json:"method,omitempty"` - Params json.RawMessage `json:"params,omitempty"` - Result json.RawMessage `json:"result,omitempty"` - Error *cdpError `json:"error,omitempty"` - SessionID string `json:"sessionId,omitempty"` -} - -type cdpError struct { - Code int `json:"code"` - Message string `json:"message"` -} - -func (e *cdpError) Error() string { - return fmt.Sprintf("CDP error %d: %s", e.Code, e.Message) -} - -type commandResult struct { - result json.RawMessage - err error -} - -type targetInfo struct { - TargetID string `json:"targetId"` - Type string `json:"type"` - URL string `json:"url"` - ParentFrameID string `json:"parentFrameId,omitempty"` -} - -type session struct { - id string - parentID string - target targetInfo -} - -type frameInfo struct { - ID string `json:"id"` - ParentID string `json:"parentId,omitempty"` - LoaderID string `json:"loaderId"` - URL string `json:"url"` -} - -type frameTree struct { - Frame frameInfo `json:"frame"` - ChildFrames []frameTree `json:"childFrames,omitempty"` -} - type registeredTool struct { ref string sessionID string diff --git a/server/openapi.yaml b/server/openapi.yaml index b408a169e..f2f69bdfc 100644 --- a/server/openapi.yaml +++ b/server/openapi.yaml @@ -1358,26 +1358,21 @@ paths: /webmcp/tools: get: - summary: Discover WebMCP tools in the active page and its frames + summary: Discover WebMCP tools across the browser description: | - Lazily attaches one internal CDP connection to a page target and returns a snapshot of - native WebMCP tools registered by its current documents. Cross-origin iframe targets are - followed through CDP auto-attach. The server consumes WebMCP events internally; callers do - not need to maintain a CDP connection or subscribe to an event stream. + Returns a snapshot of native WebMCP tools available across the browser. The snapshot includes + tools registered by every open tab and by embedded content within those tabs. + + On the first request, Kernel starts monitoring the browser. Tabs and windows that are already + open are included, and tabs, windows, and embedded content opened later are tracked automatically. + Tools are removed when the tab or embedded frame that registered them closes or navigates away. + + Each tool includes an opaque tool_ref that identifies its exact live registration, along with + information about the window, tab, and optional embedded frame that provided it. Use that tool_ref + to invoke the tool. Callers do not need to manage browser debugging connections or subscribe to + browser events. operationId: getWebMCPTools x-telemetry-category: control - parameters: - - name: target_id - in: query - required: false - description: >- - Optional page target to inspect. When omitted, the current discovery target is retained; - the first page target reported by Chromium is used only on initial discovery. Explicitly - switching targets resets the WebMCP attachment and makes running invocation outcomes unknown. - schema: - type: string - minLength: 1 - maxLength: 128 responses: "200": description: Current WebMCP tool snapshot. @@ -1394,10 +1389,10 @@ paths: post: summary: Invoke a discovered WebMCP tool description: | - Invokes the registration identified by tool_ref and waits for Chromium's matching - WebMCP.toolResponded event. Navigation during execution is allowed. If the owning CDP - session disappears or the request times out after invocation begins, the server reports - outcome_unknown and never retries the tool automatically. + Invokes the exact live registration identified by tool_ref and waits for its result. + Navigation during execution is allowed. If the tab or embedded frame disappears, or the + request times out after invocation begins, the server reports outcome_unknown and never + retries the tool automatically. operationId: invokeWebMCPTool x-telemetry-category: control requestBody: @@ -6254,22 +6249,50 @@ components: type: boolean autosubmit: type: boolean + WebMCPToolFrame: + type: object + additionalProperties: false + required: [frame_id, url] + properties: + frame_id: + type: integer + minimum: 1 + description: Monotonically increasing identifier for this embedded frame during the current browser process. + url: + type: string + description: Current frame URL with the fragment omitted. + WebMCPToolSource: + type: object + additionalProperties: false + required: [window_id, tab_id, page_title, page_url, frame] + properties: + window_id: + type: integer + minimum: 1 + description: Monotonically increasing identifier for the browser window during the current browser process. + tab_id: + type: integer + minimum: 1 + description: Monotonically increasing identifier for the tab during the current browser process. + page_title: + type: string + description: Current title of the top-level page. + page_url: + type: string + description: Current URL of the top-level page with the fragment omitted. + frame: + allOf: + - $ref: "#/components/schemas/WebMCPToolFrame" + nullable: true + description: Embedded frame that registered the tool, or null when the top-level page registered it. WebMCPTool: type: object additionalProperties: false - required: - - tool_ref - - name - - description - - input_schema - - page_target_id - - target_id - - target_type - - frame_id + required: [tool_ref, name, description, input_schema, source] properties: tool_ref: type: string - description: Opaque reference for invoking this exact live registration. It becomes invalid when its document or CDP session is replaced. + description: Opaque reference for invoking this exact live registration. It becomes invalid when its document or browser process is replaced. name: type: string description: @@ -6279,29 +6302,8 @@ components: additionalProperties: true annotations: $ref: "#/components/schemas/WebMCPToolAnnotations" - page_target_id: - type: string - description: CDP target ID of the selected top-level page. - target_id: - type: string - description: CDP target ID owning the registration. For an out-of-process iframe this differs from page_target_id. - target_type: - type: string - description: CDP target type, normally page or iframe. - target_url: - type: string - description: Owning target URL with the fragment omitted. - frame_id: - type: string - description: Page.FrameId supplied by Chromium's WebMCP domain. - frame_url: - type: string - description: Current frame URL with the fragment omitted. - parent_frame_id: - type: string - document_ref: - type: string - description: Reference derived from the owning target and current Page.LoaderId. It changes when the frame commits a new document. + source: + $ref: "#/components/schemas/WebMCPToolSource" WebMCPToolsResponse: type: object additionalProperties: false From 92a490e969e20c07a17750a2e7c2e2b19e1465a0 Mon Sep 17 00:00:00 2001 From: rgarcia <72655+rgarcia@users.noreply.github.com> Date: Tue, 1 Sep 2026 02:44:23 +0000 Subject: [PATCH 05/18] Harden browser surface lifecycle tracking --- server/lib/browsersurface/events.go | 2 +- server/lib/browsersurface/frames.go | 11 ++- server/lib/browsersurface/targets.go | 98 +++++++++++++++++--- server/lib/browsersurface/tracker.go | 2 +- server/lib/browsersurface/tracker_test.go | 105 +++++++++++++++++++--- server/lib/webmcpclient/client_test.go | 25 +++--- server/lib/webmcpclient/manager.go | 3 + 7 files changed, 204 insertions(+), 42 deletions(-) diff --git a/server/lib/browsersurface/events.go b/server/lib/browsersurface/events.go index 37b0432f5..f35d6612a 100644 --- a/server/lib/browsersurface/events.go +++ b/server/lib/browsersurface/events.go @@ -13,7 +13,7 @@ func (t *Tracker) handleProtocolEvent(message cdpconnection.Message) { TargetInfo targetInfo `json:"targetInfo"` } if json.Unmarshal(message.Params, &event) == nil && event.TargetInfo.Type == "page" { - go t.trackPage(t.ctx, event.TargetInfo) + t.trackPage(event.TargetInfo, true) } case "Target.targetInfoChanged": var event struct { diff --git a/server/lib/browsersurface/frames.go b/server/lib/browsersurface/frames.go index e17a87fe0..22b778d6b 100644 --- a/server/lib/browsersurface/frames.go +++ b/server/lib/browsersurface/frames.go @@ -44,6 +44,7 @@ func (t *Tracker) initializeSession(sessionID string) { var sess session for { t.stateMu.Lock() + t.bindSessionsLocked() tracked := t.sessions[sessionID] if tracked == nil || tracked.initialized || tracked.initializing { t.stateMu.Unlock() @@ -60,7 +61,11 @@ func (t *Tracker) initializeSession(sessionID string) { break } t.stateMu.Unlock() - if time.Now().After(deadline) || t.protocol.IsClosed() { + if time.Now().After(deadline) { + t.removeSession(sessionID) + return + } + if t.protocol.IsClosed() { return } time.Sleep(10 * time.Millisecond) @@ -153,6 +158,10 @@ func (t *Tracker) bindSessionsLocked() { if sess.tabID != 0 { continue } + if tabID := t.tabsByTarget[sess.target.TargetID]; tabID != 0 { + sess.tabID = tabID + continue + } if parent := t.sessions[sess.parentID]; parent != nil && parent.tabID != 0 { sess.tabID = parent.tabID continue diff --git a/server/lib/browsersurface/targets.go b/server/lib/browsersurface/targets.go index 61b36bd2c..79f1f0614 100644 --- a/server/lib/browsersurface/targets.go +++ b/server/lib/browsersurface/targets.go @@ -6,22 +6,42 @@ import ( "time" ) -func (t *Tracker) trackPage(ctx context.Context, target targetInfo) { +func (t *Tracker) trackPage(target targetInfo, async bool) { + tabID, attach := t.registerPage(target) + if !attach { + return + } + if async { + go t.attachPage(tabID, target) + return + } + t.attachPage(tabID, target) +} + +func (t *Tracker) registerPage(target targetInfo) (int, bool) { t.stateMu.Lock() - if t.trackingTarget[target.TargetID] { + if tabID := t.tabsByTarget[target.TargetID]; tabID != 0 { t.updateTabTargetLocked(target) + attach := !t.trackingTarget[target.TargetID] + t.trackingTarget[target.TargetID] = true + t.bindSessionsLocked() t.stateMu.Unlock() - return + t.signalChanged() + return tabID, attach } - t.trackingTarget[target.TargetID] = true t.nextTabID++ - tab := &tab{id: t.nextTabID, targetID: target.TargetID, title: target.Title, url: target.URL} - t.tabs[tab.id] = tab - t.tabsByTarget[target.TargetID] = tab.id + tabID := t.nextTabID + t.tabs[tabID] = &tab{id: tabID, targetID: target.TargetID, title: target.Title, url: target.URL} + t.tabsByTarget[target.TargetID] = tabID + t.trackingTarget[target.TargetID] = true + t.bindSessionsLocked() t.stateMu.Unlock() t.signalChanged() + return tabID, true +} - windowCtx, cancel := context.WithTimeout(ctx, 2*time.Second) +func (t *Tracker) attachPage(tabID int, target targetInfo) { + windowCtx, cancel := context.WithTimeout(t.ctx, 2*time.Second) raw, err := t.protocol.Send(windowCtx, "Browser.getWindowForTarget", map[string]any{"targetId": target.TargetID}, "") cancel() windowAssigned := false @@ -35,9 +55,9 @@ func (t *Tracker) trackPage(ctx context.Context, target targetInfo) { } } if !windowAssigned { - t.assignWindow(target.TargetID, -int64(tab.id)) + t.assignFallbackWindow(target.TargetID, tabID) } - attachCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + attachCtx, cancel := context.WithTimeout(t.ctx, 5*time.Second) _, err = t.protocol.Send(attachCtx, "Target.autoAttachRelated", map[string]any{ "targetId": target.TargetID, "waitForDebuggerOnStart": false, @@ -47,8 +67,60 @@ func (t *Tracker) trackPage(ctx context.Context, target targetInfo) { }, }, "") cancel() - if err != nil && !t.protocol.IsClosed() { - t.logger.Warn("failed to attach browser tab", "tab_id", tab.id, "err", err) + if err == nil { + return + } + t.stateMu.Lock() + if t.tabsByTarget[target.TargetID] == tabID { + t.trackingTarget[target.TargetID] = false + } + t.stateMu.Unlock() + t.signalChanged() + if !t.protocol.IsClosed() { + t.logger.Warn("failed to attach browser tab", "tab_id", tabID, "err", err) + } +} + +func (t *Tracker) RefreshTargets(ctx context.Context) error { + raw, err := t.protocol.Send(ctx, "Target.getTargets", nil, "") + if err != nil { + return err + } + var result struct { + TargetInfos []targetInfo `json:"targetInfos"` + } + if err := json.Unmarshal(raw, &result); err != nil { + return err + } + active := make(map[string]bool) + for _, target := range result.TargetInfos { + if target.Type != "page" { + continue + } + active[target.TargetID] = true + t.trackPage(target, true) + } + t.stateMu.RLock() + var removed []string + for targetID := range t.tabsByTarget { + if !active[targetID] { + removed = append(removed, targetID) + } + } + t.stateMu.RUnlock() + for _, targetID := range removed { + t.removeTarget(targetID) + } + return nil +} + +func (t *Tracker) assignFallbackWindow(targetID string, tabID int) { + t.stateMu.RLock() + tracked := t.tabs[t.tabsByTarget[targetID]] + assigned := tracked != nil && tracked.windowID != 0 + t.stateMu.RUnlock() + if !assigned { + t.assignWindow(targetID, -int64(tabID)) } } @@ -87,7 +159,7 @@ func (t *Tracker) updateTarget(target targetInfo) { t.stateMu.Unlock() t.signalChanged() if target.Type == "page" && !knownTab { - go t.trackPage(t.ctx, target) + t.trackPage(target, true) return } if target.Type == "page" { diff --git a/server/lib/browsersurface/tracker.go b/server/lib/browsersurface/tracker.go index fa7333df3..4ecd11fee 100644 --- a/server/lib/browsersurface/tracker.go +++ b/server/lib/browsersurface/tracker.go @@ -99,7 +99,7 @@ func (t *Tracker) Start(ctx context.Context) error { } for _, target := range result.TargetInfos { if target.Type == "page" { - t.trackPage(ctx, target) + t.trackPage(target, false) } } return nil diff --git a/server/lib/browsersurface/tracker_test.go b/server/lib/browsersurface/tracker_test.go index ce7ff7643..d97aa940f 100644 --- a/server/lib/browsersurface/tracker_test.go +++ b/server/lib/browsersurface/tracker_test.go @@ -3,6 +3,7 @@ package browsersurface import ( "context" "encoding/json" + "errors" "sync" "testing" "time" @@ -12,17 +13,20 @@ import ( ) type fakeProtocol struct { - mu sync.Mutex - windowIDs map[string]int - events chan cdpconnection.Message - closed chan struct{} + mu sync.Mutex + windowIDs map[string]int + attachFailures int + attachCalls map[string]int + events chan cdpconnection.Message + closed chan struct{} } func newFakeProtocol() *fakeProtocol { return &fakeProtocol{ - windowIDs: map[string]int{"page-a": 10, "page-b": 20}, - events: make(chan cdpconnection.Message, 64), - closed: make(chan struct{}), + windowIDs: map[string]int{"page-a": 10, "page-b": 20}, + attachCalls: make(map[string]int), + events: make(chan cdpconnection.Message, 64), + closed: make(chan struct{}), } } @@ -47,10 +51,21 @@ func (f *fakeProtocol) Send(_ context.Context, method string, params any, sessio return marshal(map[string]any{"windowId": windowID}), nil case "Target.autoAttachRelated": targetID := params.(map[string]any)["targetId"].(string) - if targetID == "page-a" { + f.mu.Lock() + f.attachCalls[targetID]++ + if f.attachFailures > 0 { + f.attachFailures-- + f.mu.Unlock() + return nil, errors.New("temporary attach failure") + } + f.mu.Unlock() + switch targetID { + case "page-a": f.emitAttached("session-a", "page-a", "Store", "https://store.example/") - } else { + case "page-b": f.emitAttached("session-b", "page-b", "Travel", "https://travel.example/") + case "late-page": + f.emitAttached("late-session", "late-page", "Late", "https://late.example/") } return marshal(map[string]any{}), nil case "Page.enable": @@ -58,7 +73,12 @@ func (f *fakeProtocol) Send(_ context.Context, method string, params any, sessio case "Page.getFrameTree": if sessionID == "oopif-session" { return marshal(map[string]any{"frameTree": map[string]any{ - "frame": map[string]any{"id": "inner", "url": "https://bank.example/"}, + "frame": map[string]any{"id": "oopif", "url": "https://cross-origin.example/"}, + }}), nil + } + if sessionID == "late-session" { + return marshal(map[string]any{"frameTree": map[string]any{ + "frame": map[string]any{"id": "root-late", "url": "https://late.example/"}, }}), nil } if sessionID == "session-a" { @@ -132,12 +152,13 @@ func TestTrackerMapsBrowserSurfaceAndPublishesLifecycleEvents(t *testing.T) { protocol.emitTarget("Target.attachedToTarget", map[string]any{ "sessionId": "oopif-session", "targetInfo": map[string]any{ - "targetId": "inner", "type": "iframe", "url": "https://bank.example/", + "targetId": "oopif", "type": "iframe", "url": "https://cross-origin.example/", + "parentFrameId": "root-a", }, }) require.Eventually(t, func() bool { - location, resolved := tracker.Resolve("oopif-session", "inner") - return resolved && location.Frame != nil && location.Frame.ID == 2 + location, resolved := tracker.Resolve("oopif-session", "oopif") + return resolved && location.Frame != nil && location.Frame.ID == 3 }, time.Second, 10*time.Millisecond) require.Equal(t, Snapshot{ @@ -149,6 +170,7 @@ func TestTrackerMapsBrowserSurfaceAndPublishesLifecycleEvents(t *testing.T) { Frames: []FrameInfo{ {ID: 1, TabID: 1, URL: "https://payments.example/"}, {ID: 2, TabID: 1, ParentFrameID: 1, URL: "https://bank.example/"}, + {ID: 3, TabID: 1, URL: "https://cross-origin.example/"}, }, }, tracker.Snapshot()) @@ -172,6 +194,63 @@ func TestTrackerMapsBrowserSurfaceAndPublishesLifecycleEvents(t *testing.T) { require.Equal(t, 2, moved.WindowID) } +func TestTrackerRetriesTabsAfterAttachFailure(t *testing.T) { + protocol := newFakeProtocol() + protocol.attachFailures = 1 + tracker := New(protocol) + require.NoError(t, tracker.Start(context.Background())) + require.False(t, tracker.SessionExists("session-a")) + + require.NoError(t, tracker.RefreshTargets(context.Background())) + require.Eventually(t, func() bool { + location, ok := tracker.Resolve("session-a", "root-a") + return ok && location.TabID == 1 + }, time.Second, 10*time.Millisecond) + protocol.mu.Lock() + require.Equal(t, 2, protocol.attachCalls["page-a"]) + protocol.mu.Unlock() +} + +func TestTrackerDoesNotRetainTabClosedImmediatelyAfterCreation(t *testing.T) { + protocol := newFakeProtocol() + tracker := New(protocol) + events, cancel := tracker.Subscribe() + defer cancel() + require.NoError(t, tracker.Start(context.Background())) + + protocol.emitTarget("Target.targetCreated", map[string]any{"targetInfo": map[string]any{ + "targetId": "short-lived", "type": "page", "title": "Short", "url": "https://short.example/", + }}) + protocol.emitTarget("Target.targetDestroyed", map[string]any{"targetId": "short-lived"}) + require.Eventually(t, func() bool { + select { + case event := <-events: + return event.Kind == EventProtocol && event.Message.Method == "Target.targetDestroyed" + default: + return false + } + }, time.Second, 10*time.Millisecond) + for _, tab := range tracker.Snapshot().Tabs { + require.NotEqual(t, "https://short.example/", tab.PageURL) + } +} + +func TestTrackerBindsPageSessionThatArrivesBeforeTarget(t *testing.T) { + protocol := newFakeProtocol() + protocol.windowIDs["late-page"] = 30 + tracker := New(protocol) + require.NoError(t, tracker.Start(context.Background())) + + protocol.emitAttached("late-session", "late-page", "Late", "https://late.example/") + protocol.emitTarget("Target.targetCreated", map[string]any{"targetInfo": map[string]any{ + "targetId": "late-page", "type": "page", "title": "Late", "url": "https://late.example/", + }}) + require.Eventually(t, func() bool { + _, ok := tracker.Resolve("late-session", "root-late") + return ok + }, time.Second, 10*time.Millisecond) +} + func TestTrackerNeverReusesWindowTabOrFrameIDs(t *testing.T) { protocol := newFakeProtocol() tracker := New(protocol) diff --git a/server/lib/webmcpclient/client_test.go b/server/lib/webmcpclient/client_test.go index 39b3f345f..fdc2f456e 100644 --- a/server/lib/webmcpclient/client_test.go +++ b/server/lib/webmcpclient/client_test.go @@ -59,6 +59,7 @@ type fakeCDP struct { detachAfterResponse bool invokeResponseDelay time.Duration toolCount int + popupOpen bool write func(any) } @@ -81,6 +82,9 @@ func (f *fakeCDP) emit(value any) { } func (f *fakeCDP) openPopup() { + f.mu.Lock() + f.popupOpen = true + f.mu.Unlock() f.emit(map[string]any{ "method": "Target.targetCreated", "params": map[string]any{"targetInfo": map[string]any{ @@ -128,10 +132,16 @@ func (f *fakeCDP) serve(w http.ResponseWriter, r *http.Request) { case "Target.setDiscoverTargets": respond(map[string]any{}) case "Target.getTargets": - respond(map[string]any{"targetInfos": []map[string]any{ + targets := []map[string]any{ {"targetId": "page-target", "type": "page", "title": "Store", "url": "https://merchant.example/"}, {"targetId": "other-page", "type": "page", "title": "Travel", "url": "https://travel.example/"}, - }}) + } + f.mu.Lock() + if f.popupOpen { + targets = append(targets, map[string]any{"targetId": "popup-target", "type": "page", "title": "Popup", "url": "https://popup.example/"}) + } + f.mu.Unlock() + respond(map[string]any{"targetInfos": targets}) case "Browser.getWindowForTarget": var params struct { TargetID string `json:"targetId"` @@ -255,21 +265,10 @@ func frameTreeForSession(sessionID string) map[string]any { case "page-session": return map[string]any{ "frame": map[string]any{"id": "page-frame", "loaderId": "page-loader", "url": "https://merchant.example/"}, - "childFrames": []any{ - map[string]any{ - "frame": map[string]any{"id": "iframe-frame", "parentId": "page-frame", "loaderId": "iframe-loader", "url": "https://payments.example/element#private-state"}, - "childFrames": []any{ - map[string]any{"frame": map[string]any{"id": "nested-frame", "parentId": "iframe-frame", "loaderId": "nested-loader", "url": "https://bank.example/challenge"}}, - }, - }, - }, } case "iframe-session": return map[string]any{ "frame": map[string]any{"id": "iframe-frame", "parentId": "page-frame", "loaderId": "iframe-loader", "url": "https://payments.example/element#private-state"}, - "childFrames": []any{ - map[string]any{"frame": map[string]any{"id": "nested-frame", "parentId": "iframe-frame", "loaderId": "nested-loader", "url": "https://bank.example/challenge"}}, - }, } case "nested-session": return map[string]any{"frame": map[string]any{"id": "nested-frame", "parentId": "iframe-frame", "loaderId": "nested-loader", "url": "https://bank.example/challenge"}} diff --git a/server/lib/webmcpclient/manager.go b/server/lib/webmcpclient/manager.go index 4ae50d8a1..77388ef2e 100644 --- a/server/lib/webmcpclient/manager.go +++ b/server/lib/webmcpclient/manager.go @@ -42,6 +42,9 @@ func (m *Manager) Tools(ctx context.Context) ([]Tool, error) { return nil, err } } + if err := conn.surface.RefreshTargets(ctx); err != nil { + return nil, fmt.Errorf("WebMCP: refresh browser tabs: %w", err) + } conn.surface.RefreshWindows(ctx) conn.surface.WaitForSettled(ctx) conn.waitForSettled(ctx) From 0b29f1422ca98390e4a7a9468a6b429485a3f46e Mon Sep 17 00:00:00 2001 From: rgarcia <72655+rgarcia@users.noreply.github.com> Date: Tue, 1 Sep 2026 02:53:09 +0000 Subject: [PATCH 06/18] Preserve tabs created during reconciliation --- server/lib/browsersurface/targets.go | 18 ++++---- server/lib/browsersurface/tracker_test.go | 50 ++++++++++++++++++++--- 2 files changed, 54 insertions(+), 14 deletions(-) diff --git a/server/lib/browsersurface/targets.go b/server/lib/browsersurface/targets.go index 79f1f0614..f6c3ace31 100644 --- a/server/lib/browsersurface/targets.go +++ b/server/lib/browsersurface/targets.go @@ -82,6 +82,13 @@ func (t *Tracker) attachPage(tabID int, target targetInfo) { } func (t *Tracker) RefreshTargets(ctx context.Context) error { + t.stateMu.RLock() + known := make([]string, 0, len(t.tabsByTarget)) + for targetID := range t.tabsByTarget { + known = append(known, targetID) + } + t.stateMu.RUnlock() + raw, err := t.protocol.Send(ctx, "Target.getTargets", nil, "") if err != nil { return err @@ -100,15 +107,10 @@ func (t *Tracker) RefreshTargets(ctx context.Context) error { active[target.TargetID] = true t.trackPage(target, true) } - t.stateMu.RLock() - var removed []string - for targetID := range t.tabsByTarget { - if !active[targetID] { - removed = append(removed, targetID) + for _, targetID := range known { + if active[targetID] { + continue } - } - t.stateMu.RUnlock() - for _, targetID := range removed { t.removeTarget(targetID) } return nil diff --git a/server/lib/browsersurface/tracker_test.go b/server/lib/browsersurface/tracker_test.go index d97aa940f..e6c5bfa0e 100644 --- a/server/lib/browsersurface/tracker_test.go +++ b/server/lib/browsersurface/tracker_test.go @@ -13,12 +13,13 @@ import ( ) type fakeProtocol struct { - mu sync.Mutex - windowIDs map[string]int - attachFailures int - attachCalls map[string]int - events chan cdpconnection.Message - closed chan struct{} + mu sync.Mutex + windowIDs map[string]int + attachFailures int + attachCalls map[string]int + beforeTargetsResult func() + events chan cdpconnection.Message + closed chan struct{} } func newFakeProtocol() *fakeProtocol { @@ -39,6 +40,13 @@ func (f *fakeProtocol) Send(_ context.Context, method string, params any, sessio case "Target.setDiscoverTargets": return marshal(map[string]any{}), nil case "Target.getTargets": + f.mu.Lock() + beforeResult := f.beforeTargetsResult + f.beforeTargetsResult = nil + f.mu.Unlock() + if beforeResult != nil { + beforeResult() + } return marshal(map[string]any{"targetInfos": []map[string]any{ {"targetId": "page-a", "type": "page", "title": "Store", "url": "https://store.example/"}, {"targetId": "page-b", "type": "page", "title": "Travel", "url": "https://travel.example/"}, @@ -194,6 +202,36 @@ func TestTrackerMapsBrowserSurfaceAndPublishesLifecycleEvents(t *testing.T) { require.Equal(t, 2, moved.WindowID) } +func TestTrackerRefreshDoesNotRemoveTabCreatedAfterSnapshot(t *testing.T) { + protocol := newFakeProtocol() + tracker := New(protocol) + require.NoError(t, tracker.Start(context.Background())) + protocol.mu.Lock() + protocol.beforeTargetsResult = func() { + protocol.emitTarget("Target.targetCreated", map[string]any{"targetInfo": map[string]any{ + "targetId": "concurrent", "type": "page", "title": "Concurrent", "url": "https://concurrent.example/", + }}) + require.Eventually(t, func() bool { + for _, tab := range tracker.Snapshot().Tabs { + if tab.PageURL == "https://concurrent.example/" { + return true + } + } + return false + }, time.Second, 10*time.Millisecond) + } + protocol.mu.Unlock() + + require.NoError(t, tracker.RefreshTargets(context.Background())) + found := false + for _, tab := range tracker.Snapshot().Tabs { + if tab.PageURL == "https://concurrent.example/" { + found = true + } + } + require.True(t, found) +} + func TestTrackerRetriesTabsAfterAttachFailure(t *testing.T) { protocol := newFakeProtocol() protocol.attachFailures = 1 From c9518b87f572e6cbcf066b1607faa156b1aa80ad Mon Sep 17 00:00:00 2001 From: rgarcia <72655+rgarcia@users.noreply.github.com> Date: Tue, 1 Sep 2026 03:08:42 +0000 Subject: [PATCH 07/18] Keep popup sessions bound to their own tabs --- server/lib/browsersurface/frames.go | 20 ++++++++++---------- server/lib/browsersurface/tracker_test.go | 14 ++++++++++---- 2 files changed, 20 insertions(+), 14 deletions(-) diff --git a/server/lib/browsersurface/frames.go b/server/lib/browsersurface/frames.go index 22b778d6b..6b35783ef 100644 --- a/server/lib/browsersurface/frames.go +++ b/server/lib/browsersurface/frames.go @@ -17,12 +17,12 @@ func (t *Tracker) addSession(sessionID, parentSessionID string, target targetInf return } tabID := t.tabsByTarget[target.TargetID] - if tabID == 0 && parentSessionID != "" { + if tabID == 0 && target.Type == "iframe" && parentSessionID != "" { if parent := t.sessions[parentSessionID]; parent != nil { tabID = parent.tabID } } - if tabID == 0 && target.ParentFrameID != "" { + if tabID == 0 && target.Type == "iframe" && target.ParentFrameID != "" { if parentFrame := t.frames[target.ParentFrameID]; parentFrame != nil { tabID = parentFrame.tabID } @@ -162,15 +162,15 @@ func (t *Tracker) bindSessionsLocked() { sess.tabID = tabID continue } - if parent := t.sessions[sess.parentID]; parent != nil && parent.tabID != 0 { - sess.tabID = parent.tabID - continue - } - if parentFrame := t.frames[sess.target.ParentFrameID]; parentFrame != nil { - sess.tabID = parentFrame.tabID - continue - } if sess.target.Type == "iframe" { + if parent := t.sessions[sess.parentID]; parent != nil && parent.tabID != 0 { + sess.tabID = parent.tabID + continue + } + if parentFrame := t.frames[sess.target.ParentFrameID]; parentFrame != nil { + sess.tabID = parentFrame.tabID + continue + } if ownFrame := t.frames[sess.target.TargetID]; ownFrame != nil { sess.tabID = ownFrame.tabID } diff --git a/server/lib/browsersurface/tracker_test.go b/server/lib/browsersurface/tracker_test.go index e6c5bfa0e..8eeffae27 100644 --- a/server/lib/browsersurface/tracker_test.go +++ b/server/lib/browsersurface/tracker_test.go @@ -113,13 +113,19 @@ func (f *fakeProtocol) Done() <-chan struct{} { return f.closed } func (f *fakeProtocol) IsClosed() bool { return false } func (f *fakeProtocol) emitAttached(sessionID, targetID, title, url string) { + f.emitAttachedFrom("", sessionID, targetID, title, url) +} + +func (f *fakeProtocol) emitAttachedFrom(parentSessionID, sessionID, targetID, title, url string) { params, _ := json.Marshal(map[string]any{ "sessionId": sessionID, "targetInfo": map[string]any{ "targetId": targetID, "type": "page", "title": title, "url": url, }, }) - f.events <- cdpconnection.Message{Method: "Target.attachedToTarget", Params: params} + f.events <- cdpconnection.Message{ + Method: "Target.attachedToTarget", SessionID: parentSessionID, Params: params, + } } func (f *fakeProtocol) emitTarget(method string, params any) { @@ -279,13 +285,13 @@ func TestTrackerBindsPageSessionThatArrivesBeforeTarget(t *testing.T) { tracker := New(protocol) require.NoError(t, tracker.Start(context.Background())) - protocol.emitAttached("late-session", "late-page", "Late", "https://late.example/") + protocol.emitAttachedFrom("session-a", "late-session", "late-page", "Late", "https://late.example/") protocol.emitTarget("Target.targetCreated", map[string]any{"targetInfo": map[string]any{ "targetId": "late-page", "type": "page", "title": "Late", "url": "https://late.example/", }}) require.Eventually(t, func() bool { - _, ok := tracker.Resolve("late-session", "root-late") - return ok + location, ok := tracker.Resolve("late-session", "root-late") + return ok && location.TabID == 3 && location.PageURL == "https://late.example/" }, time.Second, 10*time.Millisecond) } From 84c19d637598ec683eb13e09e09d6b7ae08798cc Mon Sep 17 00:00:00 2001 From: rgarcia <72655+rgarcia@users.noreply.github.com> Date: Tue, 1 Sep 2026 03:18:16 +0000 Subject: [PATCH 08/18] Decouple popup sessions from opener teardown --- server/lib/browsersurface/frames.go | 6 +++++- server/lib/browsersurface/tracker_test.go | 6 ++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/server/lib/browsersurface/frames.go b/server/lib/browsersurface/frames.go index 6b35783ef..76abfc9f1 100644 --- a/server/lib/browsersurface/frames.go +++ b/server/lib/browsersurface/frames.go @@ -32,7 +32,11 @@ func (t *Tracker) addSession(sessionID, parentSessionID string, target targetInf tabID = ownFrame.tabID } } - t.sessions[sessionID] = &session{id: sessionID, parentID: parentSessionID, target: target, tabID: tabID} + ownedParentID := "" + if target.Type == "iframe" { + ownedParentID = parentSessionID + } + t.sessions[sessionID] = &session{id: sessionID, parentID: ownedParentID, target: target, tabID: tabID} t.bindSessionsLocked() t.stateMu.Unlock() t.signalChanged() diff --git a/server/lib/browsersurface/tracker_test.go b/server/lib/browsersurface/tracker_test.go index 8eeffae27..e243fce0e 100644 --- a/server/lib/browsersurface/tracker_test.go +++ b/server/lib/browsersurface/tracker_test.go @@ -293,6 +293,12 @@ func TestTrackerBindsPageSessionThatArrivesBeforeTarget(t *testing.T) { location, ok := tracker.Resolve("late-session", "root-late") return ok && location.TabID == 3 && location.PageURL == "https://late.example/" }, time.Second, 10*time.Millisecond) + + protocol.emitTarget("Target.detachedFromTarget", map[string]any{"sessionId": "session-a"}) + require.Eventually(t, func() bool { return !tracker.SessionExists("session-a") }, time.Second, 10*time.Millisecond) + location, ok := tracker.Resolve("late-session", "root-late") + require.True(t, ok) + require.Equal(t, 3, location.TabID) } func TestTrackerNeverReusesWindowTabOrFrameIDs(t *testing.T) { From ac251e60c48c49f8b11e275f2ee76078bf4f05ae Mon Sep 17 00:00:00 2001 From: rgarcia <72655+rgarcia@users.noreply.github.com> Date: Tue, 1 Sep 2026 06:18:32 +0000 Subject: [PATCH 09/18] Harden WebMCP lifecycle and request handling --- server/cmd/api/api/webmcp.go | 8 +++ server/cmd/api/api/webmcp_test.go | 16 +++++ server/lib/browsersurface/events.go | 7 ++ server/lib/browsersurface/frames.go | 3 + server/lib/browsersurface/targets.go | 56 +++++++++++++-- server/lib/browsersurface/tracker.go | 2 + server/lib/browsersurface/tracker_test.go | 5 ++ server/lib/browsersurface/types.go | 1 + server/lib/oapi/webmcp_validation.go | 27 +++++++ server/lib/oapi/webmcp_validation_test.go | 35 +++++++++ server/lib/webmcpclient/client.go | 88 +++++++++++++++++++++-- server/lib/webmcpclient/client_test.go | 84 ++++++++++++++++------ server/lib/webmcpclient/types.go | 6 ++ 13 files changed, 305 insertions(+), 33 deletions(-) create mode 100644 server/lib/oapi/webmcp_validation.go create mode 100644 server/lib/oapi/webmcp_validation_test.go diff --git a/server/cmd/api/api/webmcp.go b/server/cmd/api/api/webmcp.go index 1584a02bc..1e1c072a2 100644 --- a/server/cmd/api/api/webmcp.go +++ b/server/cmd/api/api/webmcp.go @@ -6,6 +6,7 @@ import ( "errors" "strings" "time" + "unicode/utf8" "github.com/kernel/kernel-images/server/lib/logger" "github.com/kernel/kernel-images/server/lib/oapi" @@ -69,6 +70,13 @@ func (s *ApiService) InvokeWebMCPTool(ctx context.Context, request oapi.InvokeWe if request.Body == nil { return oapi.InvokeWebMCPTool400JSONResponse{BadRequestErrorJSONResponse: oapi.BadRequestErrorJSONResponse{Message: "request body is required"}}, nil } + toolRefLength := utf8.RuneCountInString(request.Body.ToolRef) + if toolRefLength < 1 || toolRefLength > 128 { + return oapi.InvokeWebMCPTool400JSONResponse{BadRequestErrorJSONResponse: oapi.BadRequestErrorJSONResponse{Message: "tool_ref must be between 1 and 128 characters"}}, nil + } + if request.Body.Input == nil { + return oapi.InvokeWebMCPTool400JSONResponse{BadRequestErrorJSONResponse: oapi.BadRequestErrorJSONResponse{Message: "input is required and must be an object"}}, nil + } inputJSON, err := json.Marshal(request.Body.Input) if err != nil || len(inputJSON) > maxWebMCPInputBytes { return oapi.InvokeWebMCPTool400JSONResponse{BadRequestErrorJSONResponse: oapi.BadRequestErrorJSONResponse{Message: "input must be valid JSON no larger than 1 MiB"}}, nil diff --git a/server/cmd/api/api/webmcp_test.go b/server/cmd/api/api/webmcp_test.go index accf24f97..3745e86ec 100644 --- a/server/cmd/api/api/webmcp_test.go +++ b/server/cmd/api/api/webmcp_test.go @@ -132,6 +132,22 @@ func TestInvokeWebMCPToolReturnsNotFoundForStaleReference(t *testing.T) { require.True(t, ok) } +func TestInvokeWebMCPToolRejectsMissingInputAndInvalidReference(t *testing.T) { + for _, body := range []*oapi.WebMCPInvokeRequest{ + {ToolRef: "wmcp_test"}, + {ToolRef: "", Input: map[string]any{}}, + {ToolRef: strings.Repeat("x", 129), Input: map[string]any{}}, + } { + client := &fakeWebMCPClient{} + service := &ApiService{webmcp: client} + response, err := service.InvokeWebMCPTool(context.Background(), oapi.InvokeWebMCPToolRequestObject{Body: body}) + require.NoError(t, err) + _, ok := response.(oapi.InvokeWebMCPTool400JSONResponse) + require.True(t, ok) + require.Empty(t, client.toolRef) + } +} + func TestInvokeWebMCPToolRejectsTimeoutOutsideBounds(t *testing.T) { for _, timeoutSec := range []int{0, -1, 121} { t.Run(fmt.Sprintf("timeout_%d", timeoutSec), func(t *testing.T) { diff --git a/server/lib/browsersurface/events.go b/server/lib/browsersurface/events.go index f35d6612a..575f0fe8e 100644 --- a/server/lib/browsersurface/events.go +++ b/server/lib/browsersurface/events.go @@ -52,6 +52,13 @@ func (t *Tracker) handleProtocolEvent(message cdpconnection.Message) { if json.Unmarshal(message.Params, &event) == nil { t.attachFrame(message.SessionID, event.FrameID, event.ParentFrameID) } + case "Page.frameStartedLoading": + var event struct { + FrameID string `json:"frameId"` + } + if json.Unmarshal(message.Params, &event) == nil { + t.publish(Event{Kind: EventDocumentInvalidated, SessionID: message.SessionID, FrameID: event.FrameID}) + } case "Page.frameNavigated": var event struct { Frame frameInfo `json:"frame"` diff --git a/server/lib/browsersurface/frames.go b/server/lib/browsersurface/frames.go index 76abfc9f1..7dd6a8e2f 100644 --- a/server/lib/browsersurface/frames.go +++ b/server/lib/browsersurface/frames.go @@ -40,6 +40,9 @@ func (t *Tracker) addSession(sessionID, parentSessionID string, target targetInf t.bindSessionsLocked() t.stateMu.Unlock() t.signalChanged() + if target.Type == "iframe" { + t.trackRelatedTarget(target.TargetID) + } go t.initializeSession(sessionID) } diff --git a/server/lib/browsersurface/targets.go b/server/lib/browsersurface/targets.go index f6c3ace31..2ea9290b1 100644 --- a/server/lib/browsersurface/targets.go +++ b/server/lib/browsersurface/targets.go @@ -81,12 +81,48 @@ func (t *Tracker) attachPage(tabID int, target targetInfo) { } } +func (t *Tracker) trackRelatedTarget(targetID string) { + t.stateMu.Lock() + if t.relatedTargets[targetID] { + t.stateMu.Unlock() + return + } + t.relatedTargets[targetID] = true + t.stateMu.Unlock() + + go func() { + ctx, cancel := context.WithTimeout(t.ctx, 5*time.Second) + defer cancel() + _, err := t.protocol.Send(ctx, "Target.autoAttachRelated", map[string]any{ + "targetId": targetID, + "waitForDebuggerOnStart": false, + "filter": []map[string]any{ + {"type": "iframe"}, + }, + }, "") + if err == nil { + return + } + t.stateMu.Lock() + delete(t.relatedTargets, targetID) + t.stateMu.Unlock() + t.signalChanged() + if !t.protocol.IsClosed() { + t.logger.Warn("failed to attach related browser frames", "target_id", targetID, "err", err) + } + }() +} + func (t *Tracker) RefreshTargets(ctx context.Context) error { t.stateMu.RLock() known := make([]string, 0, len(t.tabsByTarget)) for targetID := range t.tabsByTarget { known = append(known, targetID) } + knownRelated := make([]string, 0, len(t.relatedTargets)) + for targetID := range t.relatedTargets { + knownRelated = append(knownRelated, targetID) + } t.stateMu.RUnlock() raw, err := t.protocol.Send(ctx, "Target.getTargets", nil, "") @@ -100,12 +136,16 @@ func (t *Tracker) RefreshTargets(ctx context.Context) error { return err } active := make(map[string]bool) + activeRelated := make(map[string]bool) for _, target := range result.TargetInfos { - if target.Type != "page" { - continue + switch target.Type { + case "page": + active[target.TargetID] = true + t.trackPage(target, true) + case "iframe": + activeRelated[target.TargetID] = true + t.trackRelatedTarget(target.TargetID) } - active[target.TargetID] = true - t.trackPage(target, true) } for _, targetID := range known { if active[targetID] { @@ -113,6 +153,13 @@ func (t *Tracker) RefreshTargets(ctx context.Context) error { } t.removeTarget(targetID) } + t.stateMu.Lock() + for _, targetID := range knownRelated { + if !activeRelated[targetID] { + delete(t.relatedTargets, targetID) + } + } + t.stateMu.Unlock() return nil } @@ -191,6 +238,7 @@ func (t *Tracker) updateTabTargetLocked(target targetInfo) { func (t *Tracker) removeTarget(targetID string) { t.stateMu.Lock() + delete(t.relatedTargets, targetID) var removedSessions []string if tabID := t.tabsByTarget[targetID]; tabID != 0 { removedSessions = t.removeTabLocked(tabID) diff --git a/server/lib/browsersurface/tracker.go b/server/lib/browsersurface/tracker.go index 4ecd11fee..c51389bcf 100644 --- a/server/lib/browsersurface/tracker.go +++ b/server/lib/browsersurface/tracker.go @@ -41,6 +41,7 @@ type Tracker struct { frames map[string]*frame sessions map[string]*session trackingTarget map[string]bool + relatedTargets map[string]bool stateChanged chan struct{} subMu sync.Mutex @@ -63,6 +64,7 @@ func New(protocol Protocol) *Tracker { frames: make(map[string]*frame), sessions: make(map[string]*session), trackingTarget: make(map[string]bool), + relatedTargets: make(map[string]bool), stateChanged: make(chan struct{}, 1), subscribers: make(map[int]*subscriber), closed: make(chan struct{}), diff --git a/server/lib/browsersurface/tracker_test.go b/server/lib/browsersurface/tracker_test.go index e243fce0e..0c3b539f6 100644 --- a/server/lib/browsersurface/tracker_test.go +++ b/server/lib/browsersurface/tracker_test.go @@ -174,6 +174,11 @@ func TestTrackerMapsBrowserSurfaceAndPublishesLifecycleEvents(t *testing.T) { location, resolved := tracker.Resolve("oopif-session", "oopif") return resolved && location.Frame != nil && location.Frame.ID == 3 }, time.Second, 10*time.Millisecond) + require.Eventually(t, func() bool { + protocol.mu.Lock() + defer protocol.mu.Unlock() + return protocol.attachCalls["oopif"] == 1 + }, time.Second, 10*time.Millisecond) require.Equal(t, Snapshot{ Windows: []WindowInfo{{ID: 1}, {ID: 2}}, diff --git a/server/lib/browsersurface/types.go b/server/lib/browsersurface/types.go index a630cc186..85a505655 100644 --- a/server/lib/browsersurface/types.go +++ b/server/lib/browsersurface/types.go @@ -19,6 +19,7 @@ const ( EventProtocol EventKind = iota EventSessionReady EventSessionRemoved + EventDocumentInvalidated EventDocumentChanged EventFrameRemoved ) diff --git a/server/lib/oapi/webmcp_validation.go b/server/lib/oapi/webmcp_validation.go new file mode 100644 index 000000000..b45fac2af --- /dev/null +++ b/server/lib/oapi/webmcp_validation.go @@ -0,0 +1,27 @@ +package oapi + +import ( + "bytes" + "encoding/json" + "fmt" + "unicode/utf8" +) + +func (r *WebMCPInvokeRequest) UnmarshalJSON(data []byte) error { + type request WebMCPInvokeRequest + var decoded request + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&decoded); err != nil { + return err + } + if decoded.Input == nil { + return fmt.Errorf("input is required and must be an object") + } + toolRefLength := utf8.RuneCountInString(decoded.ToolRef) + if toolRefLength < 1 || toolRefLength > 128 { + return fmt.Errorf("tool_ref must be between 1 and 128 characters") + } + *r = WebMCPInvokeRequest(decoded) + return nil +} diff --git a/server/lib/oapi/webmcp_validation_test.go b/server/lib/oapi/webmcp_validation_test.go new file mode 100644 index 000000000..eedaad497 --- /dev/null +++ b/server/lib/oapi/webmcp_validation_test.go @@ -0,0 +1,35 @@ +package oapi + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestWebMCPInvokeRequestValidatesJSONContract(t *testing.T) { + for _, test := range []struct { + name string + payload string + valid bool + }{ + {name: "valid", payload: `{"tool_ref":"wmcp_test","input":{}}`, valid: true}, + {name: "missing input", payload: `{"tool_ref":"wmcp_test"}`}, + {name: "null input", payload: `{"tool_ref":"wmcp_test","input":null}`}, + {name: "empty ref", payload: `{"tool_ref":"","input":{}}`}, + {name: "long ref", payload: `{"tool_ref":"` + strings.Repeat("x", 129) + `","input":{}}`}, + {name: "unknown property", payload: `{"tool_ref":"wmcp_test","input":{},"extra":true}`}, + } { + t.Run(test.name, func(t *testing.T) { + var request WebMCPInvokeRequest + err := json.Unmarshal([]byte(test.payload), &request) + if test.valid { + require.NoError(t, err) + require.NotNil(t, request.Input) + return + } + require.Error(t, err) + }) + } +} diff --git a/server/lib/webmcpclient/client.go b/server/lib/webmcpclient/client.go index 13553b050..6ee3d60c5 100644 --- a/server/lib/webmcpclient/client.go +++ b/server/lib/webmcpclient/client.go @@ -21,6 +21,7 @@ const ( maxToolsPerSession = 256 maxCompletedInvocations = 256 maxAbandonedInvocations = 256 + maxDocumentChanges = 1024 ) type connection struct { @@ -36,8 +37,10 @@ type connection struct { toolRefs map[string]string toolLimitWarned map[string]bool invocations map[invocationKey]invocationResponse - waitingInvocations map[invocationKey]struct{} + waitingInvocations map[invocationKey]string abandonedInvocations map[invocationKey]time.Time + documentChanges map[documentKey]uint64 + eventSequence uint64 stateChangedCh chan struct{} logger *slog.Logger @@ -58,8 +61,9 @@ func newConnection(protocol *cdpconnection.Client) *connection { toolRefs: make(map[string]string), toolLimitWarned: make(map[string]bool), invocations: make(map[invocationKey]invocationResponse), - waitingInvocations: make(map[invocationKey]struct{}), + waitingInvocations: make(map[invocationKey]string), abandonedInvocations: make(map[invocationKey]time.Time), + documentChanges: make(map[documentKey]uint64), stateChangedCh: make(chan struct{}, 1), logger: slog.Default(), eventsCancel: cancel, @@ -109,6 +113,13 @@ func (c *connection) eventLoop(events <-chan browsersurface.Event) { go c.enableSession(event.SessionID) case browsersurface.EventSessionRemoved: c.removeSession(event.SessionID) + case browsersurface.EventDocumentInvalidated: + c.stateMu.Lock() + c.markDocumentChangedLocked(documentKey{sessionID: event.SessionID, frameID: event.FrameID}) + c.abandonFrameInvocationsLocked(event.SessionID, event.FrameID) + c.removeFrameToolsLocked(event.SessionID, event.FrameID) + c.stateMu.Unlock() + c.signalStateChanged() case browsersurface.EventDocumentChanged: c.stateMu.Lock() c.removeFrameToolsLocked(event.SessionID, event.FrameID) @@ -116,6 +127,12 @@ func (c *connection) eventLoop(events <-chan browsersurface.Event) { c.signalStateChanged() case browsersurface.EventFrameRemoved: c.stateMu.Lock() + for _, tool := range c.tools { + if tool.frameID == event.FrameID { + c.markDocumentChangedLocked(documentKey{sessionID: tool.sessionID, frameID: event.FrameID}) + } + } + c.abandonFrameInvocationsAcrossSessionsLocked(event.FrameID) c.removeFrameToolsAcrossSessionsLocked(event.FrameID) c.stateMu.Unlock() c.signalStateChanged() @@ -177,10 +194,10 @@ func (c *connection) handleProtocolEvent(message cdpconnection.Message) { if json.Unmarshal(message.Params, &response) == nil { key := invocationKey{sessionID: message.SessionID, invocationID: response.InvocationID} c.stateMu.Lock() + c.eventSequence++ + response.observedAt = c.eventSequence c.pruneAbandonedInvocationsLocked() - if _, abandoned := c.abandonedInvocations[key]; abandoned { - delete(c.abandonedInvocations, key) - } else { + if _, abandoned := c.abandonedInvocations[key]; !abandoned { if len(c.invocations) >= maxCompletedInvocations { for existing := range c.invocations { if _, waiting := c.waitingInvocations[existing]; !waiting { @@ -322,6 +339,8 @@ func (c *connection) invoke(ctx context.Context, toolRef string, input map[strin return InvocationResult{}, ErrToolNotFound } sessionID, frameID, name := tool.sessionID, tool.frameID, tool.name + document := documentKey{sessionID: sessionID, frameID: frameID} + startedAfterChange := c.documentChanges[document] c.stateMu.RUnlock() if !c.sessionExists(sessionID) { return InvocationResult{}, ErrToolNotFound @@ -351,7 +370,16 @@ func (c *connection) invoke(ctx context.Context, toolRef string, input map[strin key := invocationKey{sessionID: sessionID, invocationID: started.InvocationID} c.stateMu.Lock() - c.waitingInvocations[key] = struct{}{} + c.waitingInvocations[key] = frameID + if changedAt := c.documentChanges[document]; changedAt > startedAfterChange { + response, completed := c.invocations[key] + if !completed || response.observedAt >= changedAt { + delete(c.invocations, key) + c.forceAbandonInvocationLocked(key) + c.stateMu.Unlock() + return InvocationResult{InvocationID: started.InvocationID}, ErrOutcomeUnknown + } + } c.stateMu.Unlock() ticker := time.NewTicker(25 * time.Millisecond) defer ticker.Stop() @@ -368,6 +396,11 @@ func (c *connection) invoke(ctx context.Context, toolRef string, input map[strin ErrorText: response.ErrorText, }, nil } + if _, abandoned := c.abandonedInvocations[key]; abandoned { + delete(c.abandonedInvocations, key) + c.stateMu.Unlock() + return InvocationResult{InvocationID: started.InvocationID}, ErrOutcomeUnknown + } c.stateMu.Unlock() if !c.sessionExists(sessionID) || c.executionClosed() { c.stateMu.Lock() @@ -423,6 +456,11 @@ func (c *connection) removeSession(sessionID string) { defer c.stateMu.Unlock() delete(c.enabledSessions, sessionID) delete(c.toolLimitWarned, sessionID) + for document := range c.documentChanges { + if document.sessionID == sessionID { + delete(c.documentChanges, document) + } + } for key := range c.waitingInvocations { if key.sessionID == sessionID { c.abandonInvocationLocked(key) @@ -437,6 +475,38 @@ func (c *connection) removeSession(sessionID string) { c.signalStateChanged() } +func (c *connection) markDocumentChangedLocked(document documentKey) { + c.eventSequence++ + if _, exists := c.documentChanges[document]; !exists && len(c.documentChanges) >= maxDocumentChanges { + var oldest documentKey + oldestSequence := ^uint64(0) + for candidate, sequence := range c.documentChanges { + if sequence < oldestSequence { + oldest = candidate + oldestSequence = sequence + } + } + delete(c.documentChanges, oldest) + } + c.documentChanges[document] = c.eventSequence +} + +func (c *connection) abandonFrameInvocationsLocked(sessionID, frameID string) { + for key, invocationFrameID := range c.waitingInvocations { + if key.sessionID == sessionID && invocationFrameID == frameID { + c.abandonInvocationLocked(key) + } + } +} + +func (c *connection) abandonFrameInvocationsAcrossSessionsLocked(frameID string) { + for key, invocationFrameID := range c.waitingInvocations { + if invocationFrameID == frameID { + c.abandonInvocationLocked(key) + } + } +} + func (c *connection) removeFrameToolsLocked(sessionID, frameID string) { for ref, tool := range c.tools { if tool.sessionID == sessionID && tool.frameID == frameID { @@ -465,6 +535,11 @@ func (c *connection) abandonInvocationLocked(key invocationKey) bool { if _, completed := c.invocations[key]; completed { return false } + c.forceAbandonInvocationLocked(key) + return true +} + +func (c *connection) forceAbandonInvocationLocked(key invocationKey) { delete(c.waitingInvocations, key) c.pruneAbandonedInvocationsLocked() if len(c.abandonedInvocations) >= maxAbandonedInvocations { @@ -479,7 +554,6 @@ func (c *connection) abandonInvocationLocked(key invocationKey) bool { delete(c.abandonedInvocations, oldest) } c.abandonedInvocations[key] = time.Now() - return true } func (c *connection) pruneAbandonedInvocationsLocked() { diff --git a/server/lib/webmcpclient/client_test.go b/server/lib/webmcpclient/client_test.go index fdc2f456e..544da0a28 100644 --- a/server/lib/webmcpclient/client_test.go +++ b/server/lib/webmcpclient/client_test.go @@ -50,17 +50,18 @@ type fakeCDP struct { server *httptest.Server url string - mu sync.Mutex - connections int - relatedAttaches int - enabledSessions map[string]int - omitResponse bool - closeOnInvoke bool - detachAfterResponse bool - invokeResponseDelay time.Duration - toolCount int - popupOpen bool - write func(any) + mu sync.Mutex + connections int + relatedAttaches int + enabledSessions map[string]int + omitResponse bool + closeOnInvoke bool + detachAfterResponse bool + navigateBeforeResult bool + invokeResponseDelay time.Duration + toolCount int + popupOpen bool + write func(any) } func newFakeCDP(t *testing.T, omitResponse bool) *fakeCDP { @@ -164,6 +165,7 @@ func (f *fakeCDP) serve(w http.ResponseWriter, r *http.Request) { case "page-target": writeAttached(write, "", "page-session", "page-target", "page", "Store", "https://merchant.example/", "") writeAttached(write, "page-session", "iframe-session", "iframe-target", "iframe", "", "https://payments.example/element#private-state", "page-frame") + case "iframe-target": writeAttached(write, "iframe-session", "nested-session", "nested-target", "iframe", "", "https://bank.example/challenge", "iframe-frame") case "other-page": writeAttached(write, "", "other-session", "other-page", "page", "Travel", "https://travel.example/", "") @@ -217,19 +219,34 @@ func (f *fakeCDP) serve(w http.ResponseWriter, r *http.Request) { } respond(map[string]any{"invocationId": "invocation-1"}) if !f.omitResponse { + if f.navigateBeforeResult { + write(map[string]any{ + "method": "Page.frameStartedLoading", "sessionId": request.SessionID, + "params": map[string]any{"frameId": "iframe-frame"}, + }) + } + output := any(map[string]any{"content": []map[string]any{{"type": "text", "text": request.SessionID}}}) + if f.navigateBeforeResult { + output = []any{} + } + write(map[string]any{ + "method": "WebMCP.toolResponded", "sessionId": request.SessionID, + "params": map[string]any{ + "invocationId": "invocation-1", "status": "Completed", "output": output, + }, + }) + if !f.navigateBeforeResult { + write(map[string]any{ + "method": "Page.frameStartedLoading", "sessionId": request.SessionID, + "params": map[string]any{"frameId": "iframe-frame"}, + }) + } write(map[string]any{ "method": "Page.frameNavigated", "sessionId": request.SessionID, "params": map[string]any{"frame": map[string]any{ "id": "iframe-frame", "loaderId": "next-loader", "url": "https://payments.example/success", }}, }) - write(map[string]any{ - "method": "WebMCP.toolResponded", "sessionId": request.SessionID, - "params": map[string]any{ - "invocationId": "invocation-1", "status": "Completed", - "output": map[string]any{"content": []map[string]any{{"type": "text", "text": request.SessionID}}}, - }, - }) if f.detachAfterResponse { write(map[string]any{ "method": "Target.detachedFromTarget", @@ -310,7 +327,7 @@ func TestManagerDiscoversToolsAcrossWindowsTabsAndNestedFrames(t *testing.T) { fake.mu.Lock() defer fake.mu.Unlock() require.Equal(t, 1, fake.connections) - require.Equal(t, 2, fake.relatedAttaches) + require.Equal(t, 4, fake.relatedAttaches) for _, sessionID := range []string{"page-session", "iframe-session", "nested-session", "other-session"} { require.GreaterOrEqual(t, fake.enabledSessions[sessionID], 1) } @@ -356,7 +373,7 @@ func TestManagerReusesConnectionAndToolReferences(t *testing.T) { fake.mu.Lock() defer fake.mu.Unlock() require.Equal(t, 1, fake.connections) - require.Equal(t, 2, fake.relatedAttaches) + require.Equal(t, 4, fake.relatedAttaches) } func paymentToolRef(t *testing.T, manager *Manager) string { @@ -372,7 +389,7 @@ func paymentToolRef(t *testing.T, manager *Manager) string { return "" } -func TestInvocationCanCompleteAfterFrameNavigation(t *testing.T) { +func TestInvocationPreservesResponseObservedBeforeFrameNavigation(t *testing.T) { fake := newFakeCDP(t, false) manager := NewManager(staticUpstream{url: fake.url}) t.Cleanup(func() { _ = manager.Close() }) @@ -384,10 +401,33 @@ func TestInvocationCanCompleteAfterFrameNavigation(t *testing.T) { require.Equal(t, "Completed", result.Status) require.Equal(t, "iframe-session", result.Output.(map[string]any)["content"].([]any)[0].(map[string]any)["text"]) + require.Eventually(t, func() bool { + tools, toolsErr := manager.Tools(context.Background()) + if toolsErr != nil { + return false + } + for _, tool := range tools { + if tool.Ref == toolRef { + return false + } + } + return true + }, time.Second, 10*time.Millisecond) _, err = manager.Invoke(context.Background(), toolRef, map[string]any{}) require.ErrorIs(t, err, ErrToolNotFound) } +func TestInvocationNavigationBeforeResponseHasUnknownOutcome(t *testing.T) { + fake := newFakeCDP(t, false) + fake.navigateBeforeResult = true + manager := NewManager(staticUpstream{url: fake.url}) + t.Cleanup(func() { _ = manager.Close() }) + + result, err := manager.Invoke(context.Background(), paymentToolRef(t, manager), map[string]any{}) + require.ErrorIs(t, err, ErrOutcomeUnknown) + require.Equal(t, "invocation-1", result.InvocationID) +} + func TestInvocationReturnsCompletedResponseBeforeTargetDetach(t *testing.T) { fake := newFakeCDP(t, false) fake.detachAfterResponse = true @@ -444,7 +484,7 @@ func TestConnectionDeathMidInvokeHasUnknownOutcome(t *testing.T) { func TestToolResponsesAreScopedBySession(t *testing.T) { client := &connection{ invocations: make(map[invocationKey]invocationResponse), - waitingInvocations: make(map[invocationKey]struct{}), + waitingInvocations: make(map[invocationKey]string), abandonedInvocations: make(map[invocationKey]time.Time), stateChangedCh: make(chan struct{}, 1), } diff --git a/server/lib/webmcpclient/types.go b/server/lib/webmcpclient/types.go index 8a13a0a5a..71c22a46a 100644 --- a/server/lib/webmcpclient/types.go +++ b/server/lib/webmcpclient/types.go @@ -69,9 +69,15 @@ type invocationResponse struct { Status string `json:"status"` Output any `json:"output,omitempty"` ErrorText string `json:"errorText,omitempty"` + observedAt uint64 } type invocationKey struct { sessionID string invocationID string } + +type documentKey struct { + sessionID string + frameID string +} From 7d63a194a96d967e9ac51700d6c37644c115a572 Mon Sep 17 00:00:00 2001 From: rgarcia <72655+rgarcia@users.noreply.github.com> Date: Tue, 1 Sep 2026 06:30:29 +0000 Subject: [PATCH 10/18] Return JSON for strict API errors --- server/cmd/api/api/middleware.go | 14 ++++++++++++++ server/cmd/api/api/middleware_test.go | 20 ++++++++++++++++++++ server/cmd/api/main.go | 5 ++++- 3 files changed, 38 insertions(+), 1 deletion(-) diff --git a/server/cmd/api/api/middleware.go b/server/cmd/api/api/middleware.go index fe8ec194a..e2b6294cb 100644 --- a/server/cmd/api/api/middleware.go +++ b/server/cmd/api/api/middleware.go @@ -123,6 +123,20 @@ func apiCallEvent(operationID string) (string, oapi.TelemetryEventCategory) { // TelemetryStrictMiddleware records the matched OpenAPI operationId onto the // per-request scratch so TelemetryHTTPMiddleware can include it in the event. +func StrictRequestErrorHandler(w http.ResponseWriter, _ *http.Request, err error) { + writeStrictError(w, http.StatusBadRequest, err.Error()) +} + +func StrictResponseErrorHandler(w http.ResponseWriter, _ *http.Request, err error) { + writeStrictError(w, http.StatusInternalServerError, err.Error()) +} + +func writeStrictError(w http.ResponseWriter, status int, message string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(map[string]string{"message": message}) +} + func TelemetryStrictMiddleware() oapi.StrictMiddlewareFunc { return func(next oapi.StrictHandlerFunc, operationID string) oapi.StrictHandlerFunc { return func(ctx context.Context, w http.ResponseWriter, r *http.Request, request any) (any, error) { diff --git a/server/cmd/api/api/middleware_test.go b/server/cmd/api/api/middleware_test.go index e1d568e12..8ec667c7b 100644 --- a/server/cmd/api/api/middleware_test.go +++ b/server/cmd/api/api/middleware_test.go @@ -3,6 +3,7 @@ package api import ( "context" "encoding/json" + "errors" "net/http" "net/http/httptest" "strings" @@ -71,6 +72,25 @@ func withTelemetryMiddlewareEnabled(t *testing.T) { }) } +func TestStrictErrorHandlersReturnJSON(t *testing.T) { + for _, test := range []struct { + name string + handler func(http.ResponseWriter, *http.Request, error) + status int + }{ + {name: "request", handler: StrictRequestErrorHandler, status: http.StatusBadRequest}, + {name: "response", handler: StrictResponseErrorHandler, status: http.StatusInternalServerError}, + } { + t.Run(test.name, func(t *testing.T) { + recorder := httptest.NewRecorder() + test.handler(recorder, httptest.NewRequest(http.MethodPost, "/webmcp/invoke", nil), errors.New("invalid request")) + require.Equal(t, test.status, recorder.Code) + require.Equal(t, "application/json", recorder.Header().Get("Content-Type")) + require.JSONEq(t, `{"message":"invalid request"}`, recorder.Body.String()) + }) + } +} + func TestTelemetryMiddleware_EmitsApiCallEventOnDocumentedRoute(t *testing.T) { withTelemetryMiddlewareEnabled(t) rp := &recordingPublisher{} diff --git a/server/cmd/api/main.go b/server/cmd/api/main.go index 30155ede4..962fec0d2 100644 --- a/server/cmd/api/main.go +++ b/server/cmd/api/main.go @@ -250,8 +250,11 @@ func main() { // api_call event emission. Off until the telemetry handlers flip it on. r.Use(api.TelemetryHTTPMiddleware(telemetrySession.Publish)) - strictHandler := oapi.NewStrictHandler(apiService, []oapi.StrictMiddlewareFunc{ + strictHandler := oapi.NewStrictHandlerWithOptions(apiService, []oapi.StrictMiddlewareFunc{ api.TelemetryStrictMiddleware(), + }, oapi.StrictHTTPServerOptions{ + RequestErrorHandlerFunc: api.StrictRequestErrorHandler, + ResponseErrorHandlerFunc: api.StrictResponseErrorHandler, }) oapi.HandlerFromMux(strictHandler, r) From eb6c1955dab51c85306a044d14231bb7769c001f Mon Sep 17 00:00:00 2001 From: rgarcia <72655+rgarcia@users.noreply.github.com> Date: Tue, 1 Sep 2026 06:39:05 +0000 Subject: [PATCH 11/18] Preserve tools when navigation does not commit --- server/lib/webmcpclient/client.go | 2 - server/lib/webmcpclient/client_test.go | 93 ++++++++++++++++++-------- 2 files changed, 64 insertions(+), 31 deletions(-) diff --git a/server/lib/webmcpclient/client.go b/server/lib/webmcpclient/client.go index 6ee3d60c5..8fa916d17 100644 --- a/server/lib/webmcpclient/client.go +++ b/server/lib/webmcpclient/client.go @@ -117,7 +117,6 @@ func (c *connection) eventLoop(events <-chan browsersurface.Event) { c.stateMu.Lock() c.markDocumentChangedLocked(documentKey{sessionID: event.SessionID, frameID: event.FrameID}) c.abandonFrameInvocationsLocked(event.SessionID, event.FrameID) - c.removeFrameToolsLocked(event.SessionID, event.FrameID) c.stateMu.Unlock() c.signalStateChanged() case browsersurface.EventDocumentChanged: @@ -397,7 +396,6 @@ func (c *connection) invoke(ctx context.Context, toolRef string, input map[strin }, nil } if _, abandoned := c.abandonedInvocations[key]; abandoned { - delete(c.abandonedInvocations, key) c.stateMu.Unlock() return InvocationResult{InvocationID: started.InvocationID}, ErrOutcomeUnknown } diff --git a/server/lib/webmcpclient/client_test.go b/server/lib/webmcpclient/client_test.go index 544da0a28..a582de64d 100644 --- a/server/lib/webmcpclient/client_test.go +++ b/server/lib/webmcpclient/client_test.go @@ -50,18 +50,20 @@ type fakeCDP struct { server *httptest.Server url string - mu sync.Mutex - connections int - relatedAttaches int - enabledSessions map[string]int - omitResponse bool - closeOnInvoke bool - detachAfterResponse bool - navigateBeforeResult bool - invokeResponseDelay time.Duration - toolCount int - popupOpen bool - write func(any) + mu sync.Mutex + connections int + relatedAttaches int + invocationCount int + enabledSessions map[string]int + omitResponse bool + closeOnInvoke bool + detachAfterResponse bool + navigateBeforeResult bool + navigationDoesNotCommit bool + invokeResponseDelay time.Duration + toolCount int + popupOpen bool + write func(any) } func newFakeCDP(t *testing.T, omitResponse bool) *fakeCDP { @@ -210,44 +212,56 @@ func (f *fakeCDP) serve(w http.ResponseWriter, r *http.Request) { "params": map[string]any{"tools": tools}, }) case "WebMCP.invokeTool": - if f.closeOnInvoke { + f.mu.Lock() + f.invocationCount++ + invocationID := fmt.Sprintf("invocation-%d", f.invocationCount) + closeOnInvoke := f.closeOnInvoke + responseDelay := f.invokeResponseDelay + omitResponse := f.omitResponse + navigateBeforeResult := f.navigateBeforeResult + navigationDoesNotCommit := f.navigationDoesNotCommit + detachAfterResponse := f.detachAfterResponse + f.mu.Unlock() + if closeOnInvoke { conn.CloseNow() return } - if f.invokeResponseDelay > 0 { - time.Sleep(f.invokeResponseDelay) + if responseDelay > 0 { + time.Sleep(responseDelay) } - respond(map[string]any{"invocationId": "invocation-1"}) - if !f.omitResponse { - if f.navigateBeforeResult { + respond(map[string]any{"invocationId": invocationID}) + if !omitResponse { + if navigateBeforeResult { write(map[string]any{ "method": "Page.frameStartedLoading", "sessionId": request.SessionID, "params": map[string]any{"frameId": "iframe-frame"}, }) } output := any(map[string]any{"content": []map[string]any{{"type": "text", "text": request.SessionID}}}) - if f.navigateBeforeResult { + if navigateBeforeResult { output = []any{} } write(map[string]any{ "method": "WebMCP.toolResponded", "sessionId": request.SessionID, "params": map[string]any{ - "invocationId": "invocation-1", "status": "Completed", "output": output, + "invocationId": invocationID, "status": "Completed", "output": output, }, }) - if !f.navigateBeforeResult { + if !navigateBeforeResult { write(map[string]any{ "method": "Page.frameStartedLoading", "sessionId": request.SessionID, "params": map[string]any{"frameId": "iframe-frame"}, }) } - write(map[string]any{ - "method": "Page.frameNavigated", "sessionId": request.SessionID, - "params": map[string]any{"frame": map[string]any{ - "id": "iframe-frame", "loaderId": "next-loader", "url": "https://payments.example/success", - }}, - }) - if f.detachAfterResponse { + if !navigationDoesNotCommit { + write(map[string]any{ + "method": "Page.frameNavigated", "sessionId": request.SessionID, + "params": map[string]any{"frame": map[string]any{ + "id": "iframe-frame", "loaderId": "next-loader", "url": "https://payments.example/success", + }}, + }) + } + if detachAfterResponse { write(map[string]any{ "method": "Target.detachedFromTarget", "params": map[string]any{"sessionId": request.SessionID}, @@ -420,12 +434,33 @@ func TestInvocationPreservesResponseObservedBeforeFrameNavigation(t *testing.T) func TestInvocationNavigationBeforeResponseHasUnknownOutcome(t *testing.T) { fake := newFakeCDP(t, false) fake.navigateBeforeResult = true + fake.navigationDoesNotCommit = true manager := NewManager(staticUpstream{url: fake.url}) t.Cleanup(func() { _ = manager.Close() }) + toolRef := paymentToolRef(t, manager) - result, err := manager.Invoke(context.Background(), paymentToolRef(t, manager), map[string]any{}) + result, err := manager.Invoke(context.Background(), toolRef, map[string]any{}) require.ErrorIs(t, err, ErrOutcomeUnknown) require.Equal(t, "invocation-1", result.InvocationID) + + tools, err := manager.Tools(context.Background()) + require.NoError(t, err) + toolStillRegistered := false + for _, tool := range tools { + if tool.Ref == toolRef { + toolStillRegistered = true + break + } + } + require.True(t, toolStillRegistered) + + fake.mu.Lock() + fake.navigateBeforeResult = false + fake.navigationDoesNotCommit = false + fake.mu.Unlock() + result, err = manager.Invoke(context.Background(), toolRef, map[string]any{}) + require.NoError(t, err) + require.Equal(t, "Completed", result.Status) } func TestInvocationReturnsCompletedResponseBeforeTargetDetach(t *testing.T) { From 83bb267bda3c156648e7127cd5cfe1cdb69b1d44 Mon Sep 17 00:00:00 2001 From: rgarcia <72655+rgarcia@users.noreply.github.com> Date: Tue, 1 Sep 2026 10:33:58 +0000 Subject: [PATCH 12/18] Expose WebMCP helpers to Playwright execution --- images/chromium-headful/Dockerfile | 4 +- images/chromium-headless/image/Dockerfile | 4 +- server/e2e/e2e_playwright_test.go | 45 +++ server/lib/oapi/oapi.go | 384 +++++++++++----------- server/openapi.yaml | 8 +- server/runtime/playwright-daemon.ts | 8 +- server/runtime/webmcp.test.ts | 95 ++++++ server/runtime/webmcp.ts | 128 ++++++++ 8 files changed, 477 insertions(+), 199 deletions(-) create mode 100644 server/runtime/webmcp.test.ts create mode 100644 server/runtime/webmcp.ts diff --git a/images/chromium-headful/Dockerfile b/images/chromium-headful/Dockerfile index 452e0db52..8279d809e 100644 --- a/images/chromium-headful/Dockerfile +++ b/images/chromium-headful/Dockerfile @@ -372,7 +372,7 @@ COPY --from=server-builder /out/kernel-images-supervisord-shim /usr/local/bin/ke COPY --from=server-builder /out/wrapper /wrapper # Copy and compile the Playwright daemon -COPY server/runtime/playwright-daemon.ts server/runtime/page-target-id-cache.ts /tmp/ +COPY server/runtime/playwright-daemon.ts server/runtime/page-target-id-cache.ts server/runtime/webmcp.ts /tmp/ RUN esbuild /tmp/playwright-daemon.ts \ --bundle \ --platform=node \ @@ -382,7 +382,7 @@ RUN esbuild /tmp/playwright-daemon.ts \ --external:playwright-core \ --external:patchright \ --external:esbuild \ - && rm /tmp/playwright-daemon.ts /tmp/page-target-id-cache.ts + && rm /tmp/playwright-daemon.ts /tmp/page-target-id-cache.ts /tmp/webmcp.ts RUN useradd -m -s /bin/bash kernel diff --git a/images/chromium-headless/image/Dockerfile b/images/chromium-headless/image/Dockerfile index 920c988be..6c7582f8f 100644 --- a/images/chromium-headless/image/Dockerfile +++ b/images/chromium-headless/image/Dockerfile @@ -268,7 +268,7 @@ COPY --from=server-builder /out/chromium-launcher /usr/local/bin/chromium-launch COPY --from=server-builder /out/kernel-images-supervisord-shim /usr/local/bin/kernel-images-supervisord-shim # Copy and compile the Playwright daemon -COPY server/runtime/playwright-daemon.ts server/runtime/page-target-id-cache.ts /tmp/ +COPY server/runtime/playwright-daemon.ts server/runtime/page-target-id-cache.ts server/runtime/webmcp.ts /tmp/ RUN esbuild /tmp/playwright-daemon.ts \ --bundle \ --platform=node \ @@ -278,6 +278,6 @@ RUN esbuild /tmp/playwright-daemon.ts \ --external:playwright-core \ --external:patchright \ --external:esbuild \ - && rm /tmp/playwright-daemon.ts /tmp/page-target-id-cache.ts + && rm /tmp/playwright-daemon.ts /tmp/page-target-id-cache.ts /tmp/webmcp.ts ENTRYPOINT [ "/wrapper" ] diff --git a/server/e2e/e2e_playwright_test.go b/server/e2e/e2e_playwright_test.go index bd10cfca0..d455d7147 100644 --- a/server/e2e/e2e_playwright_test.go +++ b/server/e2e/e2e_playwright_test.go @@ -80,6 +80,51 @@ func TestPlaywrightExecuteAPI(t *testing.T) { t.Log("playwright execute API test passed") + t.Log("verifying WebMCP helpers in the Playwright execution scope") + webmcpRsp, err := client.ExecutePlaywrightCodeWithResponse(ctx, instanceoapi.ExecutePlaywrightCodeJSONRequestBody{ + Code: ` + const tools = await webmcp.listTools(); + let failure; + try { + await webmcp.invokeTool('wmcp_missing', {}); + } catch (error) { + failure = { + name: error.name, + statusCode: error.statusCode, + code: error.code ?? null, + invocationId: error.invocationId ?? null, + message: error.body?.message, + }; + } + return { frozen: Object.isFrozen(webmcp), tools, failure }; + `, + }) + require.NoError(t, err, "WebMCP helper request error: %v", err) + require.Equal(t, http.StatusOK, webmcpRsp.StatusCode(), "unexpected status: %s body=%s", webmcpRsp.Status(), string(webmcpRsp.Body)) + require.NotNil(t, webmcpRsp.JSON200) + require.True(t, webmcpRsp.JSON200.Success, "expected WebMCP helper execution to succeed") + helperResultBytes, err := json.Marshal(webmcpRsp.JSON200.Result) + require.NoError(t, err) + var helperResult struct { + Frozen bool `json:"frozen"` + Tools []any `json:"tools"` + Failure struct { + Name string `json:"name"` + StatusCode int `json:"statusCode"` + Code *string `json:"code"` + InvocationID *string `json:"invocationId"` + Message string `json:"message"` + } `json:"failure"` + } + require.NoError(t, json.Unmarshal(helperResultBytes, &helperResult)) + require.True(t, helperResult.Frozen) + require.NotNil(t, helperResult.Tools) + require.Equal(t, "WebMCPRequestError", helperResult.Failure.Name) + require.Equal(t, http.StatusNotFound, helperResult.Failure.StatusCode) + require.Nil(t, helperResult.Failure.Code) + require.Nil(t, helperResult.Failure.InvocationID) + require.NotEmpty(t, helperResult.Failure.Message) + // Reuse the same container/warm daemon connection to verify tab-binding // behavior: `page` must bind to the browser's actual foreground tab, not // just the most recently opened one (resolveActivePage in diff --git a/server/lib/oapi/oapi.go b/server/lib/oapi/oapi.go index d0c1ccfef..78fe009cb 100644 --- a/server/lib/oapi/oapi.go +++ b/server/lib/oapi/oapi.go @@ -24709,197 +24709,199 @@ var swaggerSpec = []string{ "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", - "uA9yAwA=", + "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", + "AAD//4st449OcwMA", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/server/openapi.yaml b/server/openapi.yaml index f2f69bdfc..da3dbae7d 100644 --- a/server/openapi.yaml +++ b/server/openapi.yaml @@ -1426,8 +1426,12 @@ paths: summary: Execute Playwright/TypeScript code against the browser description: | Execute arbitrary Playwright code in a fresh execution context against the browser running - on localhost:9222. The code has access to 'page', 'context', and 'browser' variables. - The result of the code execution is returned in the response. + on localhost:9222. The code has access to 'page', 'context', 'browser', and 'webmcp' + variables. 'webmcp.listTools()' returns the browser-wide WebMCP tool snapshot, and + 'webmcp.invokeTool(toolRef, input?, { timeoutSec? })' invokes an exact registration through + the image's synchronous WebMCP API. WebMCP request failures throw a WebMCPRequestError with + statusCode, code, invocationId, and body fields. The result of the code execution is returned + in the response. 'page' is bound to an active tab reported by Chrome. In single-window sessions, this is the foreground tab. When multiple browser windows are open, Chrome reports one active tab diff --git a/server/runtime/playwright-daemon.ts b/server/runtime/playwright-daemon.ts index 9823f350a..a95e69cb7 100644 --- a/server/runtime/playwright-daemon.ts +++ b/server/runtime/playwright-daemon.ts @@ -16,9 +16,12 @@ import { chromium as chromiumPW, Browser, CDPSession, Page } from 'playwright-co import { chromium as chromiumPR } from 'patchright'; import { PageTargetIdCache } from './page-target-id-cache'; +import { createWebMCPClient } from './webmcp'; const SOCKET_PATH = process.env.PLAYWRIGHT_DAEMON_SOCKET || '/tmp/playwright-daemon.sock'; const CDP_ENDPOINT = process.env.CDP_ENDPOINT || 'ws://127.0.0.1:9222'; +const KERNEL_API_ENDPOINT = + process.env.KERNEL_API_ENDPOINT || `http://127.0.0.1:${process.env.PORT || '10001'}`; const USE_PATCHRIGHT = process.env.PLAYWRIGHT_ENGINE !== 'playwright-core'; const RECONNECT_DELAY_MS = 1000; const MAX_RECONNECT_ATTEMPTS = 10; @@ -303,11 +306,12 @@ async function executeCode(request: ExecuteRequest, signal: AbortSignal): Promis (await defaultContext.newPage()); const context = page.context(); + const webmcp = createWebMCPClient({apiBaseUrl: KERNEL_API_ENDPOINT, signal}); const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor; - const userFunction = new AsyncFunction('page', 'context', 'browser', jsCode); + const userFunction = new AsyncFunction('page', 'context', 'browser', 'webmcp', jsCode); signal.throwIfAborted(); - const result = await userFunction(page, context, browserInstance); + const result = await userFunction(page, context, browserInstance, webmcp); return { id, diff --git a/server/runtime/webmcp.test.ts b/server/runtime/webmcp.test.ts new file mode 100644 index 000000000..cbe3758c8 --- /dev/null +++ b/server/runtime/webmcp.test.ts @@ -0,0 +1,95 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import {createWebMCPClient, WebMCPRequestError} from './webmcp.ts'; + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: {'content-type': 'application/json'}, + }); +} + +test('lists browser-wide tools through the image API', async () => { + const controller = new AbortController(); + const requests: Array<{url: string; init?: RequestInit}> = []; + const tool = { + tool_ref: 'wmcp_test', + name: 'search', + description: 'Search', + input_schema: {type: 'object'}, + source: { + window_id: 1, + tab_id: 2, + page_title: 'Travel', + page_url: 'https://travel.example/', + frame: null, + }, + }; + const client = createWebMCPClient({ + apiBaseUrl: 'http://127.0.0.1:10001/', + signal: controller.signal, + fetchImpl: async (url, init) => { + requests.push({url: String(url), init}); + return jsonResponse({tools: [tool]}); + }, + }); + + assert.equal(Object.isFrozen(client), true); + assert.deepEqual(await client.listTools(), [tool]); + assert.equal(requests[0].url, 'http://127.0.0.1:10001/webmcp/tools'); + assert.equal(requests[0].init?.signal, controller.signal); +}); + +test('invokes an exact tool reference with input and timeout', async () => { + let request: {url: string; init?: RequestInit} | undefined; + const client = createWebMCPClient({ + apiBaseUrl: 'http://127.0.0.1:10001', + fetchImpl: async (url, init) => { + request = {url: String(url), init}; + return jsonResponse({ + invocation_id: 'invocation-1', + status: 'completed', + output: {ok: true}, + }); + }, + }); + + const result = await client.invokeTool('wmcp_test', {query: 'SFO'}, {timeoutSec: 30}); + assert.deepEqual(result.output, {ok: true}); + assert.equal(request?.url, 'http://127.0.0.1:10001/webmcp/invoke'); + assert.equal(request?.init?.method, 'POST'); + assert.deepEqual(JSON.parse(String(request?.init?.body)), { + tool_ref: 'wmcp_test', + input: {query: 'SFO'}, + timeout_sec: 30, + }); +}); + +test('preserves structured WebMCP failures', async () => { + const client = createWebMCPClient({ + apiBaseUrl: 'http://127.0.0.1:10001', + fetchImpl: async () => + jsonResponse( + { + code: 'outcome_unknown', + message: 'do not retry automatically', + invocation_id: 'invocation-1', + }, + 504, + ), + }); + + await assert.rejects(client.invokeTool('wmcp_test'), error => { + assert.ok(error instanceof WebMCPRequestError); + assert.equal(error.statusCode, 504); + assert.equal(error.code, 'outcome_unknown'); + assert.equal(error.invocationId, 'invocation-1'); + assert.deepEqual(error.body, { + code: 'outcome_unknown', + message: 'do not retry automatically', + invocation_id: 'invocation-1', + }); + return true; + }); +}); diff --git a/server/runtime/webmcp.ts b/server/runtime/webmcp.ts new file mode 100644 index 000000000..db14d2653 --- /dev/null +++ b/server/runtime/webmcp.ts @@ -0,0 +1,128 @@ +export interface WebMCPToolFrame { + frame_id: number; + url: string; +} + +export interface WebMCPToolSource { + window_id: number; + tab_id: number; + page_title: string; + page_url: string; + frame: WebMCPToolFrame | null; +} + +export interface WebMCPTool { + tool_ref: string; + name: string; + description: string; + input_schema: Record; + annotations?: Record; + source: WebMCPToolSource; +} + +export interface WebMCPInvocationResult { + invocation_id: string; + status: 'completed' | 'canceled' | 'error'; + output?: unknown; + error_text?: string; +} + +export interface WebMCPInvokeOptions { + timeoutSec?: number; +} + +export interface WebMCPClient { + listTools(): Promise; + invokeTool( + toolRef: string, + input?: Record, + options?: WebMCPInvokeOptions, + ): Promise; +} + +export class WebMCPRequestError extends Error { + readonly statusCode: number; + readonly code?: string; + readonly invocationId?: string; + readonly body: unknown; + + constructor(statusCode: number, body: unknown) { + const fields = isRecord(body) ? body : {}; + const message = + typeof fields.message === 'string' + ? fields.message + : `WebMCP request failed with status ${statusCode}`; + super(message); + this.name = 'WebMCPRequestError'; + this.statusCode = statusCode; + this.code = typeof fields.code === 'string' ? fields.code : undefined; + this.invocationId = + typeof fields.invocation_id === 'string' ? fields.invocation_id : undefined; + this.body = body; + } +} + +interface WebMCPClientOptions { + apiBaseUrl: string; + signal?: AbortSignal; + fetchImpl?: typeof fetch; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +async function responseBody(response: Response): Promise { + const text = await response.text(); + if (text === '') return null; + try { + return JSON.parse(text); + } catch { + return text; + } +} + +export function createWebMCPClient({ + apiBaseUrl, + signal, + fetchImpl = fetch, +}: WebMCPClientOptions): WebMCPClient { + const baseUrl = apiBaseUrl.replace(/\/+$/, ''); + + const request = async (path: string, init?: RequestInit): Promise => { + const response = await fetchImpl(`${baseUrl}${path}`, {...init, signal}); + const body = await responseBody(response); + if (!response.ok) throw new WebMCPRequestError(response.status, body); + return body; + }; + + const client: WebMCPClient = { + async listTools() { + const body = await request('/webmcp/tools'); + if (!isRecord(body) || !Array.isArray(body.tools)) { + throw new Error('WebMCP tools response is invalid'); + } + return body.tools as WebMCPTool[]; + }, + + async invokeTool(toolRef, input = {}, options = {}) { + const payload: Record = {tool_ref: toolRef, input}; + if (options.timeoutSec !== undefined) payload.timeout_sec = options.timeoutSec; + const body = await request('/webmcp/invoke', { + method: 'POST', + headers: {'content-type': 'application/json'}, + body: JSON.stringify(payload), + }); + if ( + !isRecord(body) || + typeof body.invocation_id !== 'string' || + typeof body.status !== 'string' + ) { + throw new Error('WebMCP invocation response is invalid'); + } + return body as WebMCPInvocationResult; + }, + }; + + return Object.freeze(client); +} From 7f26744719c9b9da0e1a77ead0265c7eb939c7b2 Mon Sep 17 00:00:00 2001 From: rgarcia <72655+rgarcia@users.noreply.github.com> Date: Tue, 1 Sep 2026 11:22:16 +0000 Subject: [PATCH 13/18] Attach WebMCP targets independently --- server/lib/browsersurface/events.go | 9 ++- server/lib/browsersurface/frames.go | 11 +-- server/lib/browsersurface/targets.go | 87 ++++++++++++----------- server/lib/browsersurface/tracker.go | 62 +++++++++------- server/lib/browsersurface/tracker_test.go | 33 +++++---- server/lib/webmcpclient/client_test.go | 54 +++++++------- 6 files changed, 141 insertions(+), 115 deletions(-) diff --git a/server/lib/browsersurface/events.go b/server/lib/browsersurface/events.go index 575f0fe8e..4866d4944 100644 --- a/server/lib/browsersurface/events.go +++ b/server/lib/browsersurface/events.go @@ -12,8 +12,13 @@ func (t *Tracker) handleProtocolEvent(message cdpconnection.Message) { var event struct { TargetInfo targetInfo `json:"targetInfo"` } - if json.Unmarshal(message.Params, &event) == nil && event.TargetInfo.Type == "page" { - t.trackPage(event.TargetInfo, true) + if json.Unmarshal(message.Params, &event) == nil { + switch event.TargetInfo.Type { + case "page": + t.trackPage(event.TargetInfo, true) + case "iframe": + t.trackFrameTarget(event.TargetInfo) + } } case "Target.targetInfoChanged": var event struct { diff --git a/server/lib/browsersurface/frames.go b/server/lib/browsersurface/frames.go index 7dd6a8e2f..c3852fe78 100644 --- a/server/lib/browsersurface/frames.go +++ b/server/lib/browsersurface/frames.go @@ -40,9 +40,6 @@ func (t *Tracker) addSession(sessionID, parentSessionID string, target targetInf t.bindSessionsLocked() t.stateMu.Unlock() t.signalChanged() - if target.Type == "iframe" { - t.trackRelatedTarget(target.TargetID) - } go t.initializeSession(sessionID) } @@ -267,7 +264,13 @@ func (t *Tracker) removeSessionLocked(sessionID string) []string { } removed := make([]string, 0, len(toRemove)) for id := range toRemove { - if _, ok := t.sessions[id]; ok { + if sess := t.sessions[id]; sess != nil { + switch sess.target.Type { + case "page": + t.trackingTarget[sess.target.TargetID] = false + case "iframe": + delete(t.trackingFrameTarget, sess.target.TargetID) + } delete(t.sessions, id) removed = append(removed, id) } diff --git a/server/lib/browsersurface/targets.go b/server/lib/browsersurface/targets.go index 2ea9290b1..41b56c6bf 100644 --- a/server/lib/browsersurface/targets.go +++ b/server/lib/browsersurface/targets.go @@ -3,6 +3,7 @@ package browsersurface import ( "context" "encoding/json" + "fmt" "time" ) @@ -57,18 +58,10 @@ func (t *Tracker) attachPage(tabID int, target targetInfo) { if !windowAssigned { t.assignFallbackWindow(target.TargetID, tabID) } - attachCtx, cancel := context.WithTimeout(t.ctx, 5*time.Second) - _, err = t.protocol.Send(attachCtx, "Target.autoAttachRelated", map[string]any{ - "targetId": target.TargetID, - "waitForDebuggerOnStart": false, - "filter": []map[string]any{ - {"type": "page"}, - {"type": "iframe"}, - }, - }, "") - cancel() - if err == nil { + if err := t.attachTarget(target); err == nil { return + } else if !t.protocol.IsClosed() { + t.logger.Warn("failed to attach browser tab", "tab_id", tabID, "err", err) } t.stateMu.Lock() if t.tabsByTarget[target.TargetID] == tabID { @@ -76,40 +69,50 @@ func (t *Tracker) attachPage(tabID int, target targetInfo) { } t.stateMu.Unlock() t.signalChanged() - if !t.protocol.IsClosed() { - t.logger.Warn("failed to attach browser tab", "tab_id", tabID, "err", err) +} + +func (t *Tracker) attachTarget(target targetInfo) error { + ctx, cancel := context.WithTimeout(t.ctx, 5*time.Second) + defer cancel() + raw, err := t.protocol.Send(ctx, "Target.attachToTarget", map[string]any{ + "targetId": target.TargetID, + "flatten": true, + }, "") + if err != nil { + return err } + var result struct { + SessionID string `json:"sessionId"` + } + if err := json.Unmarshal(raw, &result); err != nil { + return err + } + if result.SessionID == "" { + return fmt.Errorf("attach browser target: missing session ID") + } + t.addSession(result.SessionID, "", target) + return nil } -func (t *Tracker) trackRelatedTarget(targetID string) { +func (t *Tracker) trackFrameTarget(target targetInfo) { t.stateMu.Lock() - if t.relatedTargets[targetID] { + if t.trackingFrameTarget[target.TargetID] { t.stateMu.Unlock() return } - t.relatedTargets[targetID] = true + t.trackingFrameTarget[target.TargetID] = true t.stateMu.Unlock() go func() { - ctx, cancel := context.WithTimeout(t.ctx, 5*time.Second) - defer cancel() - _, err := t.protocol.Send(ctx, "Target.autoAttachRelated", map[string]any{ - "targetId": targetID, - "waitForDebuggerOnStart": false, - "filter": []map[string]any{ - {"type": "iframe"}, - }, - }, "") - if err == nil { + if err := t.attachTarget(target); err == nil { return + } else if !t.protocol.IsClosed() { + t.logger.Warn("failed to attach browser frame", "target_id", target.TargetID, "err", err) } t.stateMu.Lock() - delete(t.relatedTargets, targetID) + delete(t.trackingFrameTarget, target.TargetID) t.stateMu.Unlock() t.signalChanged() - if !t.protocol.IsClosed() { - t.logger.Warn("failed to attach related browser frames", "target_id", targetID, "err", err) - } }() } @@ -119,9 +122,9 @@ func (t *Tracker) RefreshTargets(ctx context.Context) error { for targetID := range t.tabsByTarget { known = append(known, targetID) } - knownRelated := make([]string, 0, len(t.relatedTargets)) - for targetID := range t.relatedTargets { - knownRelated = append(knownRelated, targetID) + knownFrames := make([]string, 0, len(t.trackingFrameTarget)) + for targetID := range t.trackingFrameTarget { + knownFrames = append(knownFrames, targetID) } t.stateMu.RUnlock() @@ -136,15 +139,15 @@ func (t *Tracker) RefreshTargets(ctx context.Context) error { return err } active := make(map[string]bool) - activeRelated := make(map[string]bool) + activeFrames := make(map[string]bool) for _, target := range result.TargetInfos { switch target.Type { case "page": active[target.TargetID] = true t.trackPage(target, true) case "iframe": - activeRelated[target.TargetID] = true - t.trackRelatedTarget(target.TargetID) + activeFrames[target.TargetID] = true + t.trackFrameTarget(target) } } for _, targetID := range known { @@ -154,9 +157,9 @@ func (t *Tracker) RefreshTargets(ctx context.Context) error { t.removeTarget(targetID) } t.stateMu.Lock() - for _, targetID := range knownRelated { - if !activeRelated[targetID] { - delete(t.relatedTargets, targetID) + for _, targetID := range knownFrames { + if !activeFrames[targetID] { + delete(t.trackingFrameTarget, targetID) } } t.stateMu.Unlock() @@ -207,6 +210,10 @@ func (t *Tracker) updateTarget(target targetInfo) { } t.stateMu.Unlock() t.signalChanged() + if target.Type == "iframe" { + t.trackFrameTarget(target) + return + } if target.Type == "page" && !knownTab { t.trackPage(target, true) return @@ -238,7 +245,7 @@ func (t *Tracker) updateTabTargetLocked(target targetInfo) { func (t *Tracker) removeTarget(targetID string) { t.stateMu.Lock() - delete(t.relatedTargets, targetID) + delete(t.trackingFrameTarget, targetID) var removedSessions []string if tabID := t.tabsByTarget[targetID]; tabID != 0 { removedSessions = t.removeTabLocked(tabID) diff --git a/server/lib/browsersurface/tracker.go b/server/lib/browsersurface/tracker.go index c51389bcf..98a0b50f5 100644 --- a/server/lib/browsersurface/tracker.go +++ b/server/lib/browsersurface/tracker.go @@ -31,18 +31,18 @@ type Tracker struct { started bool startErr error - stateMu sync.RWMutex - nextWindowID int - nextTabID int - nextFrameID int - windows map[int64]window - tabs map[int]*tab - tabsByTarget map[string]int - frames map[string]*frame - sessions map[string]*session - trackingTarget map[string]bool - relatedTargets map[string]bool - stateChanged chan struct{} + stateMu sync.RWMutex + nextWindowID int + nextTabID int + nextFrameID int + windows map[int64]window + tabs map[int]*tab + tabsByTarget map[string]int + frames map[string]*frame + sessions map[string]*session + trackingTarget map[string]bool + trackingFrameTarget map[string]bool + stateChanged chan struct{} subMu sync.Mutex nextSubID int @@ -54,20 +54,20 @@ type Tracker struct { func New(protocol Protocol) *Tracker { ctx, cancel := context.WithCancel(context.Background()) return &Tracker{ - protocol: protocol, - ctx: ctx, - cancel: cancel, - logger: slog.Default(), - windows: make(map[int64]window), - tabs: make(map[int]*tab), - tabsByTarget: make(map[string]int), - frames: make(map[string]*frame), - sessions: make(map[string]*session), - trackingTarget: make(map[string]bool), - relatedTargets: make(map[string]bool), - stateChanged: make(chan struct{}, 1), - subscribers: make(map[int]*subscriber), - closed: make(chan struct{}), + protocol: protocol, + ctx: ctx, + cancel: cancel, + logger: slog.Default(), + windows: make(map[int64]window), + tabs: make(map[int]*tab), + tabsByTarget: make(map[string]int), + frames: make(map[string]*frame), + sessions: make(map[string]*session), + trackingTarget: make(map[string]bool), + trackingFrameTarget: make(map[string]bool), + stateChanged: make(chan struct{}, 1), + subscribers: make(map[int]*subscriber), + closed: make(chan struct{}), } } @@ -82,7 +82,10 @@ func (t *Tracker) Start(ctx context.Context) error { if _, err := t.protocol.Send(ctx, "Target.setDiscoverTargets", map[string]any{ "discover": true, - "filter": []map[string]any{{"type": "page"}}, + "filter": []map[string]any{ + {"type": "page"}, + {"type": "iframe"}, + }, }, ""); err != nil { t.startErr = fmt.Errorf("start browser surface discovery: %w", err) return t.startErr @@ -104,6 +107,11 @@ func (t *Tracker) Start(ctx context.Context) error { t.trackPage(target, false) } } + for _, target := range result.TargetInfos { + if target.Type == "iframe" { + t.trackFrameTarget(target) + } + } return nil } diff --git a/server/lib/browsersurface/tracker_test.go b/server/lib/browsersurface/tracker_test.go index 0c3b539f6..b06cc5576 100644 --- a/server/lib/browsersurface/tracker_test.go +++ b/server/lib/browsersurface/tracker_test.go @@ -57,7 +57,7 @@ func (f *fakeProtocol) Send(_ context.Context, method string, params any, sessio windowID := f.windowIDs[targetID] f.mu.Unlock() return marshal(map[string]any{"windowId": windowID}), nil - case "Target.autoAttachRelated": + case "Target.attachToTarget": targetID := params.(map[string]any)["targetId"].(string) f.mu.Lock() f.attachCalls[targetID]++ @@ -67,15 +67,10 @@ func (f *fakeProtocol) Send(_ context.Context, method string, params any, sessio return nil, errors.New("temporary attach failure") } f.mu.Unlock() - switch targetID { - case "page-a": - f.emitAttached("session-a", "page-a", "Store", "https://store.example/") - case "page-b": - f.emitAttached("session-b", "page-b", "Travel", "https://travel.example/") - case "late-page": - f.emitAttached("late-session", "late-page", "Late", "https://late.example/") - } - return marshal(map[string]any{}), nil + sessionID := map[string]string{ + "page-a": "session-a", "page-b": "session-b", "late-page": "late-session", "oopif": "oopif-session", + }[targetID] + return marshal(map[string]any{"sessionId": sessionID}), nil case "Page.enable": return marshal(map[string]any{}), nil case "Page.getFrameTree": @@ -163,8 +158,7 @@ func TestTrackerMapsBrowserSurfaceAndPublishesLifecycleEvents(t *testing.T) { require.True(t, ok) require.Nil(t, root.Frame) - protocol.emitTarget("Target.attachedToTarget", map[string]any{ - "sessionId": "oopif-session", + protocol.emitTarget("Target.targetCreated", map[string]any{ "targetInfo": map[string]any{ "targetId": "oopif", "type": "iframe", "url": "https://cross-origin.example/", "parentFrameId": "root-a", @@ -260,6 +254,21 @@ func TestTrackerRetriesTabsAfterAttachFailure(t *testing.T) { protocol.mu.Unlock() } +func TestTrackerReattachesPageAfterSessionDetach(t *testing.T) { + protocol := newFakeProtocol() + tracker := New(protocol) + require.NoError(t, tracker.Start(context.Background())) + require.Eventually(t, func() bool { return tracker.SessionExists("session-a") }, time.Second, 10*time.Millisecond) + + protocol.emitTarget("Target.detachedFromTarget", map[string]any{"sessionId": "session-a"}) + require.Eventually(t, func() bool { return !tracker.SessionExists("session-a") }, time.Second, 10*time.Millisecond) + require.NoError(t, tracker.RefreshTargets(context.Background())) + require.Eventually(t, func() bool { return tracker.SessionExists("session-a") }, time.Second, 10*time.Millisecond) + protocol.mu.Lock() + require.Equal(t, 2, protocol.attachCalls["page-a"]) + protocol.mu.Unlock() +} + func TestTrackerDoesNotRetainTabClosedImmediatelyAfterCreation(t *testing.T) { protocol := newFakeProtocol() tracker := New(protocol) diff --git a/server/lib/webmcpclient/client_test.go b/server/lib/webmcpclient/client_test.go index a582de64d..3e265fd7c 100644 --- a/server/lib/webmcpclient/client_test.go +++ b/server/lib/webmcpclient/client_test.go @@ -52,7 +52,7 @@ type fakeCDP struct { mu sync.Mutex connections int - relatedAttaches int + targetAttaches int invocationCount int enabledSessions map[string]int omitResponse bool @@ -155,26 +155,37 @@ func (f *fakeCDP) serve(w http.ResponseWriter, r *http.Request) { windowID = 20 } respond(map[string]any{"windowId": windowID}) - case "Target.autoAttachRelated": + case "Target.attachToTarget": f.mu.Lock() - f.relatedAttaches++ + f.targetAttaches++ f.mu.Unlock() var params struct { TargetID string `json:"targetId"` } _ = json.Unmarshal(request.Params, ¶ms) + sessionID := map[string]string{ + "page-target": "page-session", "iframe-target": "iframe-session", "nested-target": "nested-session", + "other-page": "other-session", "popup-target": "popup-session", + }[params.TargetID] + respond(map[string]any{"sessionId": sessionID}) switch params.TargetID { case "page-target": - writeAttached(write, "", "page-session", "page-target", "page", "Store", "https://merchant.example/", "") - writeAttached(write, "page-session", "iframe-session", "iframe-target", "iframe", "", "https://payments.example/element#private-state", "page-frame") + write(map[string]any{ + "method": "Target.targetCreated", + "params": map[string]any{"targetInfo": map[string]any{ + "targetId": "iframe-target", "type": "iframe", "url": "https://payments.example/element#private-state", + "parentFrameId": "page-frame", + }}, + }) case "iframe-target": - writeAttached(write, "iframe-session", "nested-session", "nested-target", "iframe", "", "https://bank.example/challenge", "iframe-frame") - case "other-page": - writeAttached(write, "", "other-session", "other-page", "page", "Travel", "https://travel.example/", "") - case "popup-target": - writeAttached(write, "", "popup-session", "popup-target", "page", "Popup", "https://popup.example/", "") + write(map[string]any{ + "method": "Target.targetCreated", + "params": map[string]any{"targetInfo": map[string]any{ + "targetId": "nested-target", "type": "iframe", "url": "https://bank.example/challenge", + "parentFrameId": "iframe-frame", + }}, + }) } - respond(map[string]any{}) case "Page.enable": respond(map[string]any{}) case "Page.getFrameTree": @@ -274,23 +285,6 @@ func (f *fakeCDP) serve(w http.ResponseWriter, r *http.Request) { } } -func writeAttached(write func(any), parentSessionID, sessionID, targetID, targetType, title, url, parentFrameID string) { - message := map[string]any{ - "method": "Target.attachedToTarget", - "params": map[string]any{ - "sessionId": sessionID, - "targetInfo": map[string]any{ - "targetId": targetID, "type": targetType, "title": title, "url": url, - "parentFrameId": parentFrameID, - }, - }, - } - if parentSessionID != "" { - message["sessionId"] = parentSessionID - } - write(message) -} - func frameTreeForSession(sessionID string) map[string]any { switch sessionID { case "page-session": @@ -341,7 +335,7 @@ func TestManagerDiscoversToolsAcrossWindowsTabsAndNestedFrames(t *testing.T) { fake.mu.Lock() defer fake.mu.Unlock() require.Equal(t, 1, fake.connections) - require.Equal(t, 4, fake.relatedAttaches) + require.Equal(t, 4, fake.targetAttaches) for _, sessionID := range []string{"page-session", "iframe-session", "nested-session", "other-session"} { require.GreaterOrEqual(t, fake.enabledSessions[sessionID], 1) } @@ -387,7 +381,7 @@ func TestManagerReusesConnectionAndToolReferences(t *testing.T) { fake.mu.Lock() defer fake.mu.Unlock() require.Equal(t, 1, fake.connections) - require.Equal(t, 4, fake.relatedAttaches) + require.Equal(t, 4, fake.targetAttaches) } func paymentToolRef(t *testing.T, manager *Manager) string { From 75925590970b72ed5d827c5ef2c70a32e6733e8f Mon Sep 17 00:00:00 2001 From: rgarcia <72655+rgarcia@users.noreply.github.com> Date: Tue, 1 Sep 2026 11:53:45 +0000 Subject: [PATCH 14/18] Preserve frames across OOPIF process swaps --- server/lib/browsersurface/events.go | 3 +- server/lib/browsersurface/tracker_test.go | 41 +++++++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/server/lib/browsersurface/events.go b/server/lib/browsersurface/events.go index 4866d4944..7363c6446 100644 --- a/server/lib/browsersurface/events.go +++ b/server/lib/browsersurface/events.go @@ -74,8 +74,9 @@ func (t *Tracker) handleProtocolEvent(message cdpconnection.Message) { case "Page.frameDetached": var event struct { FrameID string `json:"frameId"` + Reason string `json:"reason"` } - if json.Unmarshal(message.Params, &event) == nil { + if json.Unmarshal(message.Params, &event) == nil && event.Reason != "swap" { t.removeFrame(event.FrameID) } } diff --git a/server/lib/browsersurface/tracker_test.go b/server/lib/browsersurface/tracker_test.go index b06cc5576..f47537a95 100644 --- a/server/lib/browsersurface/tracker_test.go +++ b/server/lib/browsersurface/tracker_test.go @@ -207,6 +207,47 @@ func TestTrackerMapsBrowserSurfaceAndPublishesLifecycleEvents(t *testing.T) { require.Equal(t, 2, moved.WindowID) } +func TestTrackerPreservesFramesDuringProcessSwap(t *testing.T) { + protocol := newFakeProtocol() + tracker := New(protocol) + events, cancel := tracker.Subscribe() + defer cancel() + require.NoError(t, tracker.Start(context.Background())) + require.Eventually(t, func() bool { + _, ok := tracker.Resolve("session-a", "inner") + return ok + }, time.Second, 10*time.Millisecond) + + waitForDetach := func(reason string) { + require.Eventually(t, func() bool { + select { + case event := <-events: + if event.Kind != EventProtocol || event.Message.Method != "Page.frameDetached" { + return false + } + var params struct { + Reason string `json:"reason"` + } + return json.Unmarshal(event.Message.Params, ¶ms) == nil && params.Reason == reason + default: + return false + } + }, time.Second, 10*time.Millisecond) + } + + protocol.emitTarget("Page.frameDetached", map[string]any{"frameId": "outer", "reason": "swap"}) + waitForDetach("swap") + _, ok := tracker.Resolve("session-a", "inner") + require.True(t, ok) + + protocol.emitTarget("Page.frameDetached", map[string]any{"frameId": "outer", "reason": "remove"}) + waitForDetach("remove") + require.Eventually(t, func() bool { + _, ok := tracker.Resolve("session-a", "inner") + return !ok + }, time.Second, 10*time.Millisecond) +} + func TestTrackerRefreshDoesNotRemoveTabCreatedAfterSnapshot(t *testing.T) { protocol := newFakeProtocol() tracker := New(protocol) From 8d0b963945b6700461d69152fa9e3a8d5a56c62c Mon Sep 17 00:00:00 2001 From: rgarcia <72655+rgarcia@users.noreply.github.com> Date: Tue, 1 Sep 2026 13:00:19 +0000 Subject: [PATCH 15/18] Consolidate CDP client transport --- server/lib/browsersurface/events.go | 4 +- server/lib/browsersurface/tracker_test.go | 16 +- server/lib/browsersurface/types.go | 6 +- server/lib/cdpclient/cdpclient.go | 253 +++++++++++++++++----- server/lib/cdpclient/cdpclient_test.go | 161 +++++++++++++- server/lib/cdpconnection/client.go | 197 ----------------- server/lib/cdpconnection/client_test.go | 97 --------- server/lib/webmcpclient/client.go | 10 +- server/lib/webmcpclient/client_test.go | 4 +- server/lib/webmcpclient/manager.go | 4 +- 10 files changed, 377 insertions(+), 375 deletions(-) delete mode 100644 server/lib/cdpconnection/client.go delete mode 100644 server/lib/cdpconnection/client_test.go diff --git a/server/lib/browsersurface/events.go b/server/lib/browsersurface/events.go index 7363c6446..cdea820c5 100644 --- a/server/lib/browsersurface/events.go +++ b/server/lib/browsersurface/events.go @@ -3,10 +3,10 @@ package browsersurface import ( "encoding/json" - "github.com/kernel/kernel-images/server/lib/cdpconnection" + "github.com/kernel/kernel-images/server/lib/cdpclient" ) -func (t *Tracker) handleProtocolEvent(message cdpconnection.Message) { +func (t *Tracker) handleProtocolEvent(message cdpclient.Message) { switch message.Method { case "Target.targetCreated": var event struct { diff --git a/server/lib/browsersurface/tracker_test.go b/server/lib/browsersurface/tracker_test.go index f47537a95..6164342b9 100644 --- a/server/lib/browsersurface/tracker_test.go +++ b/server/lib/browsersurface/tracker_test.go @@ -8,7 +8,7 @@ import ( "testing" "time" - "github.com/kernel/kernel-images/server/lib/cdpconnection" + "github.com/kernel/kernel-images/server/lib/cdpclient" "github.com/stretchr/testify/require" ) @@ -18,7 +18,7 @@ type fakeProtocol struct { attachFailures int attachCalls map[string]int beforeTargetsResult func() - events chan cdpconnection.Message + events chan cdpclient.Message closed chan struct{} } @@ -26,7 +26,7 @@ func newFakeProtocol() *fakeProtocol { return &fakeProtocol{ windowIDs: map[string]int{"page-a": 10, "page-b": 20}, attachCalls: make(map[string]int), - events: make(chan cdpconnection.Message, 64), + events: make(chan cdpclient.Message, 64), closed: make(chan struct{}), } } @@ -103,9 +103,9 @@ func (f *fakeProtocol) Send(_ context.Context, method string, params any, sessio } } -func (f *fakeProtocol) Events() <-chan cdpconnection.Message { return f.events } -func (f *fakeProtocol) Done() <-chan struct{} { return f.closed } -func (f *fakeProtocol) IsClosed() bool { return false } +func (f *fakeProtocol) Events() <-chan cdpclient.Message { return f.events } +func (f *fakeProtocol) Done() <-chan struct{} { return f.closed } +func (f *fakeProtocol) IsClosed() bool { return false } func (f *fakeProtocol) emitAttached(sessionID, targetID, title, url string) { f.emitAttachedFrom("", sessionID, targetID, title, url) @@ -118,14 +118,14 @@ func (f *fakeProtocol) emitAttachedFrom(parentSessionID, sessionID, targetID, ti "targetId": targetID, "type": "page", "title": title, "url": url, }, }) - f.events <- cdpconnection.Message{ + f.events <- cdpclient.Message{ Method: "Target.attachedToTarget", SessionID: parentSessionID, Params: params, } } func (f *fakeProtocol) emitTarget(method string, params any) { raw, _ := json.Marshal(params) - f.events <- cdpconnection.Message{Method: method, Params: raw} + f.events <- cdpclient.Message{Method: method, Params: raw} } func (f *fakeProtocol) setWindow(targetID string, windowID int) { diff --git a/server/lib/browsersurface/types.go b/server/lib/browsersurface/types.go index 85a505655..5118bd0be 100644 --- a/server/lib/browsersurface/types.go +++ b/server/lib/browsersurface/types.go @@ -4,12 +4,12 @@ import ( "context" "encoding/json" - "github.com/kernel/kernel-images/server/lib/cdpconnection" + "github.com/kernel/kernel-images/server/lib/cdpclient" ) type Protocol interface { Send(ctx context.Context, method string, params any, sessionID string) (json.RawMessage, error) - Events() <-chan cdpconnection.Message + Events() <-chan cdpclient.Message IsClosed() bool } @@ -28,7 +28,7 @@ type Event struct { Kind EventKind SessionID string FrameID string - Message cdpconnection.Message + Message cdpclient.Message } type WindowInfo struct { diff --git a/server/lib/cdpclient/cdpclient.go b/server/lib/cdpclient/cdpclient.go index ac1449d7b..5e1bb6000 100644 --- a/server/lib/cdpclient/cdpclient.go +++ b/server/lib/cdpclient/cdpclient.go @@ -3,10 +3,12 @@ package cdpclient import ( "context" "encoding/json" + "errors" "fmt" "net/http" "net/url" "strings" + "sync" "sync/atomic" "time" @@ -20,26 +22,52 @@ type cdpRequest struct { SessionID string `json:"sessionId,omitempty"` } -type cdpResponse struct { - ID int64 `json:"id"` - Result json.RawMessage `json:"result,omitempty"` - Error *cdpError `json:"error,omitempty"` +// ErrOutcomeUnknown means a CDP connection closed while a command was in +// flight, so the caller cannot know whether Chromium applied it. +var ErrOutcomeUnknown = errors.New("CDP command outcome is unknown") + +// Message is a response or event received from Chromium. +type Message struct { + ID int64 `json:"id,omitempty"` + Method string `json:"method,omitempty"` + Params json.RawMessage `json:"params,omitempty"` + Result json.RawMessage `json:"result,omitempty"` + Error *Error `json:"error,omitempty"` + SessionID string `json:"sessionId,omitempty"` } -type cdpError struct { +// Error is a protocol error returned by Chromium. +type Error struct { Code int `json:"code"` Message string `json:"message"` } -func (e *cdpError) Error() string { +func (e *Error) Error() string { return fmt.Sprintf("CDP error %d: %s", e.Code, e.Message) } -// Client is a minimal CDP client that communicates over a browser-level -// DevTools WebSocket connection. +type commandResult struct { + result json.RawMessage + err error +} + +// Client maintains one browser-level DevTools connection. Commands may be +// sent concurrently. Clients created by DialWithEvents also expose protocol +// events through Events. type Client struct { conn *websocket.Conn - nextID atomic.Int64 + ctx context.Context + cancel context.CancelFunc + + nextID atomic.Int64 + writeMu sync.Mutex + + pendingMu sync.Mutex + pending map[int64]chan commandResult + events chan Message + + closed chan struct{} + closeOnce sync.Once } // BrowserWebSocketURL reads Chrome's browser-level DevTools WebSocket URL. @@ -68,25 +96,60 @@ func BrowserWebSocketURL(ctx context.Context, versionURL string) (string, error) return version.WebSocketDebuggerURL, nil } -// Dial opens a WebSocket connection to the given DevTools URL. +// Dial opens a command-only DevTools connection. Protocol events are +// discarded, preserving the behavior expected by existing short-lived users. func Dial(ctx context.Context, devtoolsURL string) (*Client, error) { + return dial(ctx, devtoolsURL, false) +} + +// DialWithEvents opens a DevTools connection that delivers protocol events in +// receive order through Events. +func DialWithEvents(ctx context.Context, devtoolsURL string) (*Client, error) { + return dial(ctx, devtoolsURL, true) +} + +func dial(ctx context.Context, devtoolsURL string, withEvents bool) (*Client, error) { conn, _, err := websocket.Dial(ctx, devtoolsURL, nil) if err != nil { return nil, fmt.Errorf("dial devtools: %w", err) } - conn.SetReadLimit(4 * 1024 * 1024) - return &Client{conn: conn}, nil + readLimit := int64(4 * 1024 * 1024) + var events chan Message + if withEvents { + readLimit = 8 * 1024 * 1024 + events = make(chan Message, 256) + } + conn.SetReadLimit(readLimit) + clientCtx, cancel := context.WithCancel(context.Background()) + client := &Client{ + conn: conn, + ctx: clientCtx, + cancel: cancel, + pending: make(map[int64]chan commandResult), + events: events, + closed: make(chan struct{}), + } + go client.readLoop() + return client, nil } -// Close shuts down the WebSocket connection. +// Close shuts down the WebSocket connection and unblocks pending commands. func (c *Client) Close() error { - return c.conn.Close(websocket.StatusNormalClosure, "done") + c.shutdown() + c.conn.CloseNow() + return nil +} + +func (c *Client) shutdown() { + c.closeOnce.Do(func() { + c.cancel() + close(c.closed) + c.failPending(ErrOutcomeUnknown) + }) } -// send sends a CDP command and waits for the matching response, discarding -// any intermediate events. This is safe for short-lived connections where the -// caller controls the full message sequence. -func (c *Client) send(ctx context.Context, method string, params any, sessionID string) (json.RawMessage, error) { +// Send sends a CDP command and waits for its matching response. +func (c *Client) Send(ctx context.Context, method string, params any, sessionID string) (json.RawMessage, error) { id := c.nextID.Add(1) var rawParams json.RawMessage @@ -98,33 +161,117 @@ func (c *Client) send(ctx context.Context, method string, params any, sessionID rawParams = b } - req := cdpRequest{ID: id, Method: method, Params: rawParams, SessionID: sessionID} - reqBytes, err := json.Marshal(req) + reqBytes, err := json.Marshal(cdpRequest{ID: id, Method: method, Params: rawParams, SessionID: sessionID}) if err != nil { return nil, fmt.Errorf("marshal request: %w", err) } - if err := c.conn.Write(ctx, websocket.MessageText, reqBytes); err != nil { + responseCh := make(chan commandResult, 1) + c.pendingMu.Lock() + c.pending[id] = responseCh + c.pendingMu.Unlock() + + c.writeMu.Lock() + err = c.conn.Write(ctx, websocket.MessageText, reqBytes) + c.writeMu.Unlock() + if err != nil { + c.pendingMu.Lock() + delete(c.pending, id) + c.pendingMu.Unlock() return nil, fmt.Errorf("write: %w", err) } + select { + case response := <-responseCh: + return response.result, response.err + case <-ctx.Done(): + c.pendingMu.Lock() + delete(c.pending, id) + c.pendingMu.Unlock() + select { + case response := <-responseCh: + return response.result, response.err + default: + return nil, fmt.Errorf("read: %w", ctx.Err()) + } + case <-c.closed: + select { + case response := <-responseCh: + return response.result, response.err + default: + return nil, fmt.Errorf("read: %w", ErrOutcomeUnknown) + } + } +} + +// Events returns the event stream for clients created by DialWithEvents. It +// returns nil for command-only clients. +func (c *Client) Events() <-chan Message { + return c.events +} + +// Done closes when the connection shuts down. +func (c *Client) Done() <-chan struct{} { + return c.closed +} + +// IsClosed reports whether the connection has shut down. +func (c *Client) IsClosed() bool { + select { + case <-c.closed: + return true + default: + return false + } +} + +func (c *Client) readLoop() { + if c.events != nil { + defer close(c.events) + } for { - _, msg, err := c.conn.Read(ctx) + _, payload, err := c.conn.Read(c.ctx) if err != nil { - return nil, fmt.Errorf("read: %w", err) + c.shutdown() + return } - - var resp cdpResponse - if err := json.Unmarshal(msg, &resp); err != nil { - continue // skip malformed messages + var message Message + if json.Unmarshal(payload, &message) != nil { + continue + } + if message.ID == 0 { + if c.events == nil { + continue + } + select { + case c.events <- message: + case <-c.closed: + return + } + continue } - if resp.ID != id { - continue // skip events and responses to other commands + + c.pendingMu.Lock() + responseCh := c.pending[message.ID] + delete(c.pending, message.ID) + c.pendingMu.Unlock() + if responseCh == nil { + continue } - if resp.Error != nil { - return nil, resp.Error + if message.Error != nil { + responseCh <- commandResult{err: message.Error} + } else { + responseCh <- commandResult{result: message.Result} } - return resp.Result, nil + } +} + +func (c *Client) failPending(err error) { + c.pendingMu.Lock() + defer c.pendingMu.Unlock() + for id, responseCh := range c.pending { + responseCh <- commandResult{err: err} + delete(c.pending, id) } } @@ -151,7 +298,7 @@ type BrowserVersion struct { // listener of a Chromium that has not yet wired up its CDP routes. A // Browser.getVersion round-trip rules out both cases. func (c *Client) GetBrowserVersion(ctx context.Context) (*BrowserVersion, error) { - raw, err := c.send(ctx, "Browser.getVersion", nil, "") + raw, err := c.Send(ctx, "Browser.getVersion", nil, "") if err != nil { return nil, fmt.Errorf("Browser.getVersion: %w", err) } @@ -165,7 +312,7 @@ func (c *Client) GetBrowserVersion(ctx context.Context) (*BrowserVersion, error) // LoadUnpackedExtension installs an unpacked extension from an absolute path // visible to Chromium and returns its extension ID. func (c *Client) LoadUnpackedExtension(ctx context.Context, path string) (string, error) { - raw, err := c.send(ctx, "Extensions.loadUnpacked", map[string]string{"path": path}, "") + raw, err := c.Send(ctx, "Extensions.loadUnpacked", map[string]string{"path": path}, "") if err != nil { return "", fmt.Errorf("Extensions.loadUnpacked: %w", err) } @@ -192,7 +339,7 @@ type ExtensionInfo struct { // GetExtensions returns all unpacked extensions known to Chromium. func (c *Client) GetExtensions(ctx context.Context) ([]ExtensionInfo, error) { - raw, err := c.send(ctx, "Extensions.getExtensions", nil, "") + raw, err := c.Send(ctx, "Extensions.getExtensions", nil, "") if err != nil { return nil, fmt.Errorf("Extensions.getExtensions: %w", err) } @@ -227,7 +374,7 @@ type HistogramBucket struct { // reads Chrome's in-memory UMA histograms without attaching to any page. // query is a substring filter on the histogram name; empty returns all. func (c *Client) GetHistograms(ctx context.Context, query string) ([]Histogram, error) { - raw, err := c.send(ctx, "Browser.getHistograms", map[string]any{"query": query}, "") + raw, err := c.Send(ctx, "Browser.getHistograms", map[string]any{"query": query}, "") if err != nil { return nil, fmt.Errorf("Browser.getHistograms: %w", err) } @@ -242,7 +389,7 @@ func (c *Client) GetHistograms(ctx context.Context, query string) ([]Histogram, // CountPageTargets returns the number of open page targets. func (c *Client) CountPageTargets(ctx context.Context) (int, error) { - targetsResult, err := c.send(ctx, "Target.getTargets", nil, "") + targetsResult, err := c.Send(ctx, "Target.getTargets", nil, "") if err != nil { return 0, fmt.Errorf("Target.getTargets: %w", err) } @@ -273,7 +420,7 @@ func DispatchStartURL(ctx context.Context, devtoolsURL, url string) error { } defer c.Close() - targetsResult, err := c.send(ctx, "Target.getTargets", nil, "") + targetsResult, err := c.Send(ctx, "Target.getTargets", nil, "") if err != nil { return fmt.Errorf("Target.getTargets: %w", err) } @@ -297,12 +444,12 @@ func DispatchStartURL(ctx context.Context, devtoolsURL, url string) error { pageTargetID = t.TargetID continue } - _, _ = c.send(ctx, "Target.closeTarget", map[string]any{ + _, _ = c.Send(ctx, "Target.closeTarget", map[string]any{ "targetId": t.TargetID, }, "") } if pageTargetID == "" { - createResult, err := c.send(ctx, "Target.createTarget", map[string]any{ + createResult, err := c.Send(ctx, "Target.createTarget", map[string]any{ "url": "about:blank", }, "") if err != nil { @@ -317,7 +464,7 @@ func DispatchStartURL(ctx context.Context, devtoolsURL, url string) error { pageTargetID = created.TargetID } - attachResult, err := c.send(ctx, "Target.attachToTarget", map[string]any{ + attachResult, err := c.Send(ctx, "Target.attachToTarget", map[string]any{ "targetId": pageTargetID, "flatten": true, }, "") @@ -334,12 +481,12 @@ func DispatchStartURL(ctx context.Context, devtoolsURL, url string) error { defer func() { detachCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - _, _ = c.send(detachCtx, "Target.detachFromTarget", map[string]any{ + _, _ = c.Send(detachCtx, "Target.detachFromTarget", map[string]any{ "sessionId": attach.SessionID, }, "") }() - if _, err := c.send(ctx, "Page.navigate", map[string]any{"url": url}, attach.SessionID); err != nil { + if _, err := c.Send(ctx, "Page.navigate", map[string]any{"url": url}, attach.SessionID); err != nil { return fmt.Errorf("Page.navigate: %w", err) } return nil @@ -366,7 +513,7 @@ func DispatchStartURLAndWait(ctx context.Context, devtoolsURL, navigationURL, de if err != nil { return err } - attachResult, err := c.send(ctx, "Target.attachToTarget", map[string]any{ + attachResult, err := c.Send(ctx, "Target.attachToTarget", map[string]any{ "targetId": targetID, "flatten": true, }, "") @@ -382,7 +529,7 @@ func DispatchStartURLAndWait(ctx context.Context, devtoolsURL, navigationURL, de defer func() { detachCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - _, _ = c.send(detachCtx, "Target.detachFromTarget", map[string]any{ + _, _ = c.Send(detachCtx, "Target.detachFromTarget", map[string]any{ "sessionId": attach.SessionID, }, "") }() @@ -395,7 +542,7 @@ func DispatchStartURLAndWait(ctx context.Context, devtoolsURL, navigationURL, de } lastNavigate := time.Now() for { - raw, evalErr := c.send(ctx, "Runtime.evaluate", map[string]any{ + raw, evalErr := c.Send(ctx, "Runtime.evaluate", map[string]any{ "expression": `JSON.stringify({url: location.href, readyState: document.readyState})`, "returnByValue": true, }, attach.SessionID) @@ -412,7 +559,7 @@ func DispatchStartURLAndWait(ctx context.Context, devtoolsURL, navigationURL, de return nil } if strings.HasPrefix(lastState.URL, "chrome-error://") && time.Since(lastNavigate) >= 250*time.Millisecond { - _, _ = c.send(ctx, "Page.navigate", map[string]any{"url": navigationURL}, attach.SessionID) + _, _ = c.Send(ctx, "Page.navigate", map[string]any{"url": navigationURL}, attach.SessionID) lastNavigate = time.Now() } } @@ -436,7 +583,7 @@ func relatedHosts(a, b string) bool { // by Target.getTargets. Callers that need to operate on the user-facing // browser window (Emulation, Browser.* window bounds) use this to find it. func (c *Client) firstPageTargetID(ctx context.Context) (string, error) { - targetsResult, err := c.send(ctx, "Target.getTargets", nil, "") + targetsResult, err := c.Send(ctx, "Target.getTargets", nil, "") if err != nil { return "", fmt.Errorf("Target.getTargets: %w", err) } @@ -483,7 +630,7 @@ func (c *Client) SetWindowBoundsMaximized(ctx context.Context) error { return nil } - if _, err := c.send(ctx, "Browser.setWindowBounds", map[string]any{ + if _, err := c.Send(ctx, "Browser.setWindowBounds", map[string]any{ "windowId": bounds.WindowID, "bounds": map[string]any{"windowState": "maximized"}, }, ""); err != nil { @@ -512,7 +659,7 @@ func (c *Client) GetWindowBounds(ctx context.Context) (WindowBounds, error) { return WindowBounds{}, err } - winRaw, err := c.send(ctx, "Browser.getWindowForTarget", map[string]any{"targetId": pageTargetID}, "") + winRaw, err := c.Send(ctx, "Browser.getWindowForTarget", map[string]any{"targetId": pageTargetID}, "") if err != nil { return WindowBounds{}, fmt.Errorf("Browser.getWindowForTarget: %w", err) } @@ -544,7 +691,7 @@ func (c *Client) SetDeviceMetricsOverride(ctx context.Context, width, height int return err } - attachResult, err := c.send(ctx, "Target.attachToTarget", map[string]any{ + attachResult, err := c.Send(ctx, "Target.attachToTarget", map[string]any{ "targetId": pageTargetID, "flatten": true, }, "") @@ -559,7 +706,7 @@ func (c *Client) SetDeviceMetricsOverride(ctx context.Context, width, height int return fmt.Errorf("unmarshal attach: %w", err) } - _, err = c.send(ctx, "Emulation.setDeviceMetricsOverride", map[string]any{ + _, err = c.Send(ctx, "Emulation.setDeviceMetricsOverride", map[string]any{ "width": width, "height": height, "deviceScaleFactor": 1, @@ -569,7 +716,7 @@ func (c *Client) SetDeviceMetricsOverride(ctx context.Context, width, height int return fmt.Errorf("Emulation.setDeviceMetricsOverride: %w", err) } - _, _ = c.send(ctx, "Target.detachFromTarget", map[string]any{ + _, _ = c.Send(ctx, "Target.detachFromTarget", map[string]any{ "sessionId": attach.SessionID, }, "") diff --git a/server/lib/cdpclient/cdpclient_test.go b/server/lib/cdpclient/cdpclient_test.go index 5cb9aec66..e7dbe17d1 100644 --- a/server/lib/cdpclient/cdpclient_test.go +++ b/server/lib/cdpclient/cdpclient_test.go @@ -6,6 +6,7 @@ import ( "net/http" "net/http/httptest" "strings" + "sync" "testing" "time" @@ -65,13 +66,13 @@ func (f *fakeCDP) handler(w http.ResponseWriter, r *http.Request) { } var result any - var cdpErr *cdpError + var cdpErr *Error switch req.Method { case "Target.getTargets": f.getTargetsCalled = true if f.failGetTargets { - cdpErr = &cdpError{Code: -1, Message: "mock error"} + cdpErr = &Error{Code: -1, Message: "mock error"} } else { targets := []map[string]string{} if !f.returnNoPageTargets { @@ -88,7 +89,7 @@ func (f *fakeCDP) handler(w http.ResponseWriter, r *http.Request) { case "Emulation.setDeviceMetricsOverride": f.setMetricsCalled = true if f.failSetMetrics { - cdpErr = &cdpError{Code: -2, Message: "metrics error"} + cdpErr = &Error{Code: -2, Message: "metrics error"} } else { var params map[string]any _ = json.Unmarshal(req.Params, ¶ms) @@ -102,7 +103,7 @@ func (f *fakeCDP) handler(w http.ResponseWriter, r *http.Request) { case "Browser.getVersion": f.getVersionCalled = true if f.failGetVersion { - cdpErr = &cdpError{Code: -3, Message: "version error"} + cdpErr = &Error{Code: -3, Message: "version error"} } else { product := f.productResponse if product == "" { @@ -122,14 +123,14 @@ func (f *fakeCDP) handler(w http.ResponseWriter, r *http.Request) { _ = json.Unmarshal(req.Params, ¶ms) f.loadUnpackedPath = params["path"] if f.failLoadUnpacked { - cdpErr = &cdpError{Code: -4, Message: "invalid extension"} + cdpErr = &Error{Code: -4, Message: "invalid extension"} } else { result = map[string]string{"id": f.loadUnpackedID} } case "Extensions.getExtensions": f.getExtensionsCalled = true if f.failGetExtensions { - cdpErr = &cdpError{Code: -5, Message: "extensions unavailable"} + cdpErr = &Error{Code: -5, Message: "extensions unavailable"} } else { result = map[string]any{"extensions": f.extensions} } @@ -449,3 +450,151 @@ func TestLoadUnpackedExtension(t *testing.T) { require.EqualError(t, err, "Extensions.loadUnpacked returned no extension ID") }) } + +func TestClientCorrelatesConcurrentCommandsAndPreservesEvents(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + conn, err := websocket.Accept(w, r, nil) + if err != nil { + return + } + defer conn.CloseNow() + requests := make([]cdpRequest, 0, 2) + for len(requests) < cap(requests) { + _, payload, err := conn.Read(r.Context()) + if err != nil { + return + } + var request cdpRequest + if json.Unmarshal(payload, &request) == nil { + requests = append(requests, request) + } + } + for _, request := range requests { + event, _ := json.Marshal(map[string]any{ + "method": "Test.event", "sessionId": request.SessionID, + "params": map[string]any{"command": request.Method}, + }) + _ = conn.Write(r.Context(), websocket.MessageText, event) + } + for i := len(requests) - 1; i >= 0; i-- { + request := requests[i] + response, _ := json.Marshal(map[string]any{ + "id": request.ID, "result": map[string]any{"command": request.Method}, + }) + _ = conn.Write(r.Context(), websocket.MessageText, response) + } + })) + defer server.Close() + + client, err := DialWithEvents(context.Background(), "ws"+strings.TrimPrefix(server.URL, "http")) + require.NoError(t, err) + defer client.Close() + + commands := []string{"Browser.getVersion", "Target.getTargets"} + type result struct { + command string + err error + } + results := make(chan result, len(commands)) + var wg sync.WaitGroup + for _, command := range commands { + wg.Add(1) + go func() { + defer wg.Done() + raw, sendErr := client.Send(context.Background(), command, nil, "session-1") + if sendErr != nil { + results <- result{err: sendErr} + return + } + var response struct { + Command string `json:"command"` + } + if unmarshalErr := json.Unmarshal(raw, &response); unmarshalErr != nil { + results <- result{err: unmarshalErr} + return + } + results <- result{command: response.Command} + }() + } + wg.Wait() + close(results) + + seenResults := make(map[string]bool) + for result := range results { + require.NoError(t, result.err) + seenResults[result.command] = true + } + for _, command := range commands { + require.True(t, seenResults[command]) + } + + seenEvents := make(map[string]bool) + for range commands { + event := <-client.Events() + require.Equal(t, "Test.event", event.Method) + var params struct { + Command string `json:"command"` + } + require.NoError(t, json.Unmarshal(event.Params, ¶ms)) + seenEvents[params.Command] = true + } + for _, command := range commands { + require.True(t, seenEvents[command]) + } +} + +func TestClientMarksInFlightCommandUnknownOnDisconnect(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + conn, err := websocket.Accept(w, r, nil) + if err != nil { + return + } + defer conn.CloseNow() + _, _, _ = conn.Read(r.Context()) + })) + defer server.Close() + + client, err := Dial(context.Background(), "ws"+strings.TrimPrefix(server.URL, "http")) + require.NoError(t, err) + defer client.Close() + + _, err = client.Send(context.Background(), "Browser.getVersion", nil, "") + require.ErrorIs(t, err, ErrOutcomeUnknown) +} + +func TestCommandOnlyClientDiscardsEvents(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + conn, err := websocket.Accept(w, r, nil) + if err != nil { + return + } + defer conn.CloseNow() + _, payload, err := conn.Read(r.Context()) + if err != nil { + return + } + var request cdpRequest + if json.Unmarshal(payload, &request) != nil { + return + } + event, _ := json.Marshal(map[string]any{"method": "Test.event"}) + for range 300 { + if conn.Write(r.Context(), websocket.MessageText, event) != nil { + return + } + } + response, _ := json.Marshal(map[string]any{"id": request.ID, "result": map[string]any{}}) + _ = conn.Write(r.Context(), websocket.MessageText, response) + })) + defer server.Close() + + client, err := Dial(context.Background(), "ws"+strings.TrimPrefix(server.URL, "http")) + require.NoError(t, err) + defer client.Close() + require.Nil(t, client.Events()) + + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + _, err = client.Send(ctx, "Browser.getVersion", nil, "") + require.NoError(t, err) +} diff --git a/server/lib/cdpconnection/client.go b/server/lib/cdpconnection/client.go deleted file mode 100644 index 36b0e1398..000000000 --- a/server/lib/cdpconnection/client.go +++ /dev/null @@ -1,197 +0,0 @@ -package cdpconnection - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "sync" - "sync/atomic" - - "github.com/coder/websocket" -) - -var ErrOutcomeUnknown = errors.New("CDP command outcome is unknown") - -type Message struct { - ID int64 `json:"id,omitempty"` - Method string `json:"method,omitempty"` - Params json.RawMessage `json:"params,omitempty"` - Result json.RawMessage `json:"result,omitempty"` - Error *Error `json:"error,omitempty"` - SessionID string `json:"sessionId,omitempty"` -} - -type Error struct { - Code int `json:"code"` - Message string `json:"message"` -} - -func (e *Error) Error() string { - return fmt.Sprintf("CDP error %d: %s", e.Code, e.Message) -} - -type request struct { - ID int64 `json:"id"` - Method string `json:"method"` - Params json.RawMessage `json:"params,omitempty"` - SessionID string `json:"sessionId,omitempty"` -} - -type commandResult struct { - result json.RawMessage - err error -} - -// Client maintains one event-aware browser-level CDP connection. Send may be -// called concurrently; Events delivers protocol events in receive order. -type Client struct { - conn *websocket.Conn - ctx context.Context - cancel context.CancelFunc - - nextID atomic.Int64 - writeMu sync.Mutex - - pendingMu sync.Mutex - pending map[int64]chan commandResult - events chan Message - - closed chan struct{} - closeOnce sync.Once -} - -func Dial(ctx context.Context, url string) (*Client, error) { - conn, _, err := websocket.Dial(ctx, url, &websocket.DialOptions{CompressionMode: websocket.CompressionDisabled}) - if err != nil { - return nil, fmt.Errorf("dial Chromium DevTools: %w", err) - } - conn.SetReadLimit(8 * 1024 * 1024) - clientCtx, cancel := context.WithCancel(context.Background()) - client := &Client{ - conn: conn, - ctx: clientCtx, - cancel: cancel, - pending: make(map[int64]chan commandResult), - events: make(chan Message, 256), - closed: make(chan struct{}), - } - go client.readLoop() - return client, nil -} - -func (c *Client) Send(ctx context.Context, method string, params any, sessionID string) (json.RawMessage, error) { - id := c.nextID.Add(1) - var rawParams json.RawMessage - if params != nil { - encoded, err := json.Marshal(params) - if err != nil { - return nil, fmt.Errorf("marshal %s parameters: %w", method, err) - } - rawParams = encoded - } - payload, err := json.Marshal(request{ID: id, Method: method, Params: rawParams, SessionID: sessionID}) - if err != nil { - return nil, fmt.Errorf("marshal %s request: %w", method, err) - } - responseCh := make(chan commandResult, 1) - c.pendingMu.Lock() - c.pending[id] = responseCh - c.pendingMu.Unlock() - - c.writeMu.Lock() - err = c.conn.Write(ctx, websocket.MessageText, payload) - c.writeMu.Unlock() - if err != nil { - c.pendingMu.Lock() - delete(c.pending, id) - c.pendingMu.Unlock() - return nil, fmt.Errorf("write %s: %w", method, err) - } - - select { - case response := <-responseCh: - if response.err != nil { - return nil, fmt.Errorf("%s: %w", method, response.err) - } - return response.result, nil - case <-ctx.Done(): - c.pendingMu.Lock() - delete(c.pending, id) - c.pendingMu.Unlock() - return nil, fmt.Errorf("%s: %w", method, ctx.Err()) - case <-c.closed: - return nil, fmt.Errorf("%s: %w", method, ErrOutcomeUnknown) - } -} - -func (c *Client) Events() <-chan Message { - return c.events -} - -func (c *Client) Done() <-chan struct{} { - return c.closed -} - -func (c *Client) IsClosed() bool { - select { - case <-c.closed: - return true - default: - return false - } -} - -func (c *Client) Close() error { - c.closeOnce.Do(func() { - c.cancel() - c.conn.CloseNow() - close(c.closed) - c.failPending(ErrOutcomeUnknown) - }) - return nil -} - -func (c *Client) readLoop() { - defer close(c.events) - for { - _, payload, err := c.conn.Read(c.ctx) - if err != nil { - _ = c.Close() - return - } - var message Message - if json.Unmarshal(payload, &message) != nil { - continue - } - if message.ID == 0 { - select { - case c.events <- message: - case <-c.closed: - return - } - continue - } - c.pendingMu.Lock() - responseCh := c.pending[message.ID] - delete(c.pending, message.ID) - c.pendingMu.Unlock() - if responseCh == nil { - continue - } - if message.Error != nil { - responseCh <- commandResult{err: message.Error} - } else { - responseCh <- commandResult{result: message.Result} - } - } -} - -func (c *Client) failPending(err error) { - c.pendingMu.Lock() - defer c.pendingMu.Unlock() - for id, responseCh := range c.pending { - responseCh <- commandResult{err: err} - delete(c.pending, id) - } -} diff --git a/server/lib/cdpconnection/client_test.go b/server/lib/cdpconnection/client_test.go deleted file mode 100644 index faa3f1e53..000000000 --- a/server/lib/cdpconnection/client_test.go +++ /dev/null @@ -1,97 +0,0 @@ -package cdpconnection - -import ( - "context" - "encoding/json" - "net/http" - "net/http/httptest" - "strings" - "sync" - "testing" - - "github.com/coder/websocket" - "github.com/stretchr/testify/require" -) - -func TestClientCorrelatesConcurrentCommandsAndPreservesEvents(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - conn, err := websocket.Accept(w, r, nil) - if err != nil { - return - } - defer conn.CloseNow() - for { - _, payload, err := conn.Read(r.Context()) - if err != nil { - return - } - var request request - if json.Unmarshal(payload, &request) != nil { - continue - } - event, _ := json.Marshal(map[string]any{ - "method": "Test.event", "sessionId": request.SessionID, - "params": map[string]any{"command": request.Method}, - }) - _ = conn.Write(r.Context(), websocket.MessageText, event) - response, _ := json.Marshal(map[string]any{ - "id": request.ID, "result": map[string]any{"command": request.Method}, - }) - _ = conn.Write(r.Context(), websocket.MessageText, response) - } - })) - defer server.Close() - - client, err := Dial(context.Background(), "ws"+strings.TrimPrefix(server.URL, "http")) - require.NoError(t, err) - defer client.Close() - - commands := []string{"Browser.getVersion", "Target.getTargets"} - type commandResult struct { - command string - err error - } - results := make(chan commandResult, len(commands)) - var wg sync.WaitGroup - for _, command := range commands { - wg.Add(1) - go func() { - defer wg.Done() - raw, sendErr := client.Send(context.Background(), command, nil, "session-1") - if sendErr != nil { - results <- commandResult{err: sendErr} - return - } - var result struct { - Command string `json:"command"` - } - if unmarshalErr := json.Unmarshal(raw, &result); unmarshalErr != nil { - results <- commandResult{err: unmarshalErr} - return - } - results <- commandResult{command: result.Command} - }() - } - wg.Wait() - close(results) - seenResults := make(map[string]bool) - for result := range results { - require.NoError(t, result.err) - seenResults[result.command] = true - } - require.True(t, seenResults[commands[0]]) - require.True(t, seenResults[commands[1]]) - - seenEvents := make(map[string]bool) - for range commands { - event := <-client.Events() - require.Equal(t, "Test.event", event.Method) - var params struct { - Command string `json:"command"` - } - require.NoError(t, json.Unmarshal(event.Params, ¶ms)) - seenEvents[params.Command] = true - } - require.True(t, seenEvents[commands[0]]) - require.True(t, seenEvents[commands[1]]) -} diff --git a/server/lib/webmcpclient/client.go b/server/lib/webmcpclient/client.go index 8fa916d17..91e81612c 100644 --- a/server/lib/webmcpclient/client.go +++ b/server/lib/webmcpclient/client.go @@ -11,7 +11,7 @@ import ( "time" "github.com/kernel/kernel-images/server/lib/browsersurface" - "github.com/kernel/kernel-images/server/lib/cdpconnection" + "github.com/kernel/kernel-images/server/lib/cdpclient" "github.com/nrednav/cuid2" ) @@ -25,7 +25,7 @@ const ( ) type connection struct { - protocol *cdpconnection.Client + protocol *cdpclient.Client surface *browsersurface.Tracker startMu sync.Mutex @@ -50,7 +50,7 @@ type connection struct { closeOnce sync.Once } -func newConnection(protocol *cdpconnection.Client) *connection { +func newConnection(protocol *cdpclient.Client) *connection { surface := browsersurface.New(protocol) events, cancel := surface.Subscribe() client := &connection{ @@ -164,7 +164,7 @@ func (c *connection) enableSession(sessionID string) { c.signalStateChanged() } -func (c *connection) handleProtocolEvent(message cdpconnection.Message) { +func (c *connection) handleProtocolEvent(message cdpclient.Message) { switch message.Method { case "WebMCP.toolsAdded": var event struct { @@ -353,7 +353,7 @@ func (c *connection) invoke(ctx context.Context, toolRef string, input map[strin if err != nil { unknownOutcome := errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) || - errors.Is(err, cdpconnection.ErrOutcomeUnknown) || + errors.Is(err, cdpclient.ErrOutcomeUnknown) || !c.sessionExists(sessionID) if unknownOutcome { return InvocationResult{}, ErrOutcomeUnknown diff --git a/server/lib/webmcpclient/client_test.go b/server/lib/webmcpclient/client_test.go index 3e265fd7c..6d1389d49 100644 --- a/server/lib/webmcpclient/client_test.go +++ b/server/lib/webmcpclient/client_test.go @@ -12,7 +12,7 @@ import ( "time" "github.com/coder/websocket" - "github.com/kernel/kernel-images/server/lib/cdpconnection" + "github.com/kernel/kernel-images/server/lib/cdpclient" "github.com/stretchr/testify/require" ) @@ -520,7 +520,7 @@ func TestToolResponsesAreScopedBySession(t *testing.T) { for _, sessionID := range []string{"page-session", "iframe-session"} { params, err := json.Marshal(invocationResponse{InvocationID: "invocation-1", Status: "Completed", Output: sessionID}) require.NoError(t, err) - client.handleProtocolEvent(cdpconnection.Message{Method: "WebMCP.toolResponded", SessionID: sessionID, Params: params}) + client.handleProtocolEvent(cdpclient.Message{Method: "WebMCP.toolResponded", SessionID: sessionID, Params: params}) } require.Len(t, client.invocations, 2) diff --git a/server/lib/webmcpclient/manager.go b/server/lib/webmcpclient/manager.go index 77388ef2e..dcdb362a3 100644 --- a/server/lib/webmcpclient/manager.go +++ b/server/lib/webmcpclient/manager.go @@ -5,7 +5,7 @@ import ( "fmt" "sync" - "github.com/kernel/kernel-images/server/lib/cdpconnection" + "github.com/kernel/kernel-images/server/lib/cdpclient" ) type UpstreamManager interface { @@ -88,7 +88,7 @@ func (m *Manager) getConnection(ctx context.Context) (*connection, error) { if m.connection != nil { _ = m.connection.close() } - protocol, err := cdpconnection.Dial(ctx, url) + protocol, err := cdpclient.DialWithEvents(ctx, url) if err != nil { return nil, fmt.Errorf("WebMCP: %w", err) } From ffa3dd04c6ede9d755bc058e7907b298a4240e19 Mon Sep 17 00:00:00 2001 From: rgarcia <72655+rgarcia@users.noreply.github.com> Date: Tue, 1 Sep 2026 13:16:45 +0000 Subject: [PATCH 16/18] Harden shared CDP client lifecycle --- server/lib/browsersurface/tracker.go | 4 ++ server/lib/browsersurface/tracker_test.go | 9 +++ server/lib/cdpclient/cdpclient.go | 21 ++++--- server/lib/cdpclient/cdpclient_test.go | 67 +++++++++++++++++++++++ 4 files changed, 92 insertions(+), 9 deletions(-) diff --git a/server/lib/browsersurface/tracker.go b/server/lib/browsersurface/tracker.go index 98a0b50f5..b6741d62f 100644 --- a/server/lib/browsersurface/tracker.go +++ b/server/lib/browsersurface/tracker.go @@ -78,6 +78,10 @@ func (t *Tracker) Start(ctx context.Context) error { return t.startErr } t.started = true + if t.protocol.Events() == nil { + t.startErr = fmt.Errorf("start browser surface discovery: protocol events are unavailable") + return t.startErr + } go t.eventLoop() if _, err := t.protocol.Send(ctx, "Target.setDiscoverTargets", map[string]any{ diff --git a/server/lib/browsersurface/tracker_test.go b/server/lib/browsersurface/tracker_test.go index 6164342b9..c9217a523 100644 --- a/server/lib/browsersurface/tracker_test.go +++ b/server/lib/browsersurface/tracker_test.go @@ -134,6 +134,15 @@ func (f *fakeProtocol) setWindow(targetID string, windowID int) { f.mu.Unlock() } +func TestTrackerRejectsProtocolWithoutEvents(t *testing.T) { + protocol := newFakeProtocol() + protocol.events = nil + tracker := New(protocol) + + err := tracker.Start(context.Background()) + require.EqualError(t, err, "start browser surface discovery: protocol events are unavailable") +} + func TestTrackerMapsBrowserSurfaceAndPublishesLifecycleEvents(t *testing.T) { protocol := newFakeProtocol() tracker := New(protocol) diff --git a/server/lib/cdpclient/cdpclient.go b/server/lib/cdpclient/cdpclient.go index 5e1bb6000..825b3f36b 100644 --- a/server/lib/cdpclient/cdpclient.go +++ b/server/lib/cdpclient/cdpclient.go @@ -168,6 +168,10 @@ func (c *Client) Send(ctx context.Context, method string, params any, sessionID responseCh := make(chan commandResult, 1) c.pendingMu.Lock() + if c.IsClosed() { + c.pendingMu.Unlock() + return nil, fmt.Errorf("read: %w", ErrOutcomeUnknown) + } c.pending[id] = responseCh c.pendingMu.Unlock() @@ -253,16 +257,15 @@ func (c *Client) readLoop() { c.pendingMu.Lock() responseCh := c.pending[message.ID] - delete(c.pending, message.ID) - c.pendingMu.Unlock() - if responseCh == nil { - continue - } - if message.Error != nil { - responseCh <- commandResult{err: message.Error} - } else { - responseCh <- commandResult{result: message.Result} + if responseCh != nil { + if message.Error != nil { + responseCh <- commandResult{err: message.Error} + } else { + responseCh <- commandResult{result: message.Result} + } + delete(c.pending, message.ID) } + c.pendingMu.Unlock() } } diff --git a/server/lib/cdpclient/cdpclient_test.go b/server/lib/cdpclient/cdpclient_test.go index e7dbe17d1..6e23390d8 100644 --- a/server/lib/cdpclient/cdpclient_test.go +++ b/server/lib/cdpclient/cdpclient_test.go @@ -543,6 +543,73 @@ func TestClientCorrelatesConcurrentCommandsAndPreservesEvents(t *testing.T) { } } +func TestClientPreservesResponseBeforeDisconnect(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + conn, err := websocket.Accept(w, r, nil) + if err != nil { + return + } + defer conn.CloseNow() + _, payload, err := conn.Read(r.Context()) + if err != nil { + return + } + var request cdpRequest + if json.Unmarshal(payload, &request) != nil { + return + } + response, _ := json.Marshal(map[string]any{ + "id": request.ID, "result": map[string]any{"completed": true}, + }) + _ = conn.Write(r.Context(), websocket.MessageText, response) + })) + defer server.Close() + + client, err := Dial(context.Background(), "ws"+strings.TrimPrefix(server.URL, "http")) + require.NoError(t, err) + defer client.Close() + + raw, err := client.Send(context.Background(), "Test.complete", nil, "") + require.NoError(t, err) + require.JSONEq(t, `{"completed":true}`, string(raw)) +} + +func TestEventClientCloseUnblocksFullEventStream(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + conn, err := websocket.Accept(w, r, nil) + if err != nil { + return + } + defer conn.CloseNow() + event, _ := json.Marshal(map[string]any{"method": "Test.event"}) + for { + if conn.Write(r.Context(), websocket.MessageText, event) != nil { + return + } + } + })) + defer server.Close() + + client, err := DialWithEvents(context.Background(), "ws"+strings.TrimPrefix(server.URL, "http")) + require.NoError(t, err) + require.Eventually(t, func() bool { + return len(client.Events()) == cap(client.events) + }, time.Second, 10*time.Millisecond) + require.NoError(t, client.Close()) + + drained := make(chan struct{}) + go func() { + for range client.Events() { + } + close(drained) + }() + select { + case <-drained: + case <-time.After(time.Second): + t.Fatal("event stream did not close") + } +} + func TestClientMarksInFlightCommandUnknownOnDisconnect(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { conn, err := websocket.Accept(w, r, nil) From 4e866b6b03ebabb3e243deb9013e924f9cc8c2a3 Mon Sep 17 00:00:00 2001 From: rgarcia <72655+rgarcia@users.noreply.github.com> Date: Tue, 1 Sep 2026 14:38:38 +0000 Subject: [PATCH 17/18] Address WebMCP review feedback --- server/cmd/api/api/middleware.go | 38 +++++++++++++++--- server/cmd/api/api/middleware_test.go | 47 +++++++++++++++++++++++ server/cmd/api/api/webmcp.go | 1 + server/cmd/api/main.go | 1 + server/e2e/e2e_playwright_test.go | 13 +++++++ server/lib/browsersurface/frames.go | 38 ++++++++++++------ server/lib/browsersurface/tracker.go | 5 ++- server/lib/browsersurface/tracker_test.go | 43 +++++++++++++++++++++ server/runtime/playwright-daemon.ts | 8 +++- 9 files changed, 173 insertions(+), 21 deletions(-) diff --git a/server/cmd/api/api/middleware.go b/server/cmd/api/api/middleware.go index e2b6294cb..2436caef7 100644 --- a/server/cmd/api/api/middleware.go +++ b/server/cmd/api/api/middleware.go @@ -121,14 +121,38 @@ func apiCallEvent(operationID string) (string, oapi.TelemetryEventCategory) { return "platform_api_call", events.Platform } -// TelemetryStrictMiddleware records the matched OpenAPI operationId onto the -// per-request scratch so TelemetryHTTPMiddleware can include it in the event. -func StrictRequestErrorHandler(w http.ResponseWriter, _ *http.Request, err error) { - writeStrictError(w, http.StatusBadRequest, err.Error()) +// WebMCPRequestSizeMiddleware bounds invoke bodies before the generated JSON +// decoder materializes them. The allowance above the input limit covers the +// request envelope while keeping memory use bounded. +func WebMCPRequestSizeMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost && r.URL.Path == "/webmcp/invoke" { + r.Body = http.MaxBytesReader(w, r.Body, maxWebMCPRequestBytes) + } + next.ServeHTTP(w, r) + }) } -func StrictResponseErrorHandler(w http.ResponseWriter, _ *http.Request, err error) { - writeStrictError(w, http.StatusInternalServerError, err.Error()) +// StrictRequestErrorHandler preserves the existing plaintext contract outside +// WebMCP, whose documented errors are JSON. +func StrictRequestErrorHandler(w http.ResponseWriter, r *http.Request, err error) { + if isWebMCPRequest(r) { + writeStrictError(w, http.StatusBadRequest, err.Error()) + return + } + http.Error(w, err.Error(), http.StatusBadRequest) +} + +func StrictResponseErrorHandler(w http.ResponseWriter, r *http.Request, err error) { + if isWebMCPRequest(r) { + writeStrictError(w, http.StatusInternalServerError, err.Error()) + return + } + http.Error(w, err.Error(), http.StatusInternalServerError) +} + +func isWebMCPRequest(r *http.Request) bool { + return r.URL.Path == "/webmcp/tools" || r.URL.Path == "/webmcp/invoke" } func writeStrictError(w http.ResponseWriter, status int, message string) { @@ -137,6 +161,8 @@ func writeStrictError(w http.ResponseWriter, status int, message string) { _ = json.NewEncoder(w).Encode(map[string]string{"message": message}) } +// TelemetryStrictMiddleware records the matched OpenAPI operationId onto the +// per-request scratch so TelemetryHTTPMiddleware can include it in the event. func TelemetryStrictMiddleware() oapi.StrictMiddlewareFunc { return func(next oapi.StrictHandlerFunc, operationID string) oapi.StrictHandlerFunc { return func(ctx context.Context, w http.ResponseWriter, r *http.Request, request any) (any, error) { diff --git a/server/cmd/api/api/middleware_test.go b/server/cmd/api/api/middleware_test.go index 8ec667c7b..7e1f40636 100644 --- a/server/cmd/api/api/middleware_test.go +++ b/server/cmd/api/api/middleware_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "errors" + "io" "net/http" "net/http/httptest" "strings" @@ -72,6 +73,33 @@ func withTelemetryMiddlewareEnabled(t *testing.T) { }) } +func TestWebMCPRequestSizeMiddlewareRejectsOversizedInvokeBody(t *testing.T) { + body := strings.NewReader(strings.Repeat("x", maxWebMCPRequestBytes+1)) + request := httptest.NewRequest(http.MethodPost, "/webmcp/invoke", body) + recorder := httptest.NewRecorder() + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, err := io.ReadAll(r.Body) + var tooLarge *http.MaxBytesError + require.ErrorAs(t, err, &tooLarge) + w.WriteHeader(http.StatusBadRequest) + }) + + WebMCPRequestSizeMiddleware(next).ServeHTTP(recorder, request) + require.Equal(t, http.StatusBadRequest, recorder.Code) +} + +func TestWebMCPRequestSizeMiddlewareDoesNotLimitOtherRoutes(t *testing.T) { + body := strings.NewReader(strings.Repeat("x", maxWebMCPRequestBytes+1)) + request := httptest.NewRequest(http.MethodPost, "/playwright/execute", body) + next := http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { + payload, err := io.ReadAll(r.Body) + require.NoError(t, err) + require.Len(t, payload, maxWebMCPRequestBytes+1) + }) + + WebMCPRequestSizeMiddleware(next).ServeHTTP(httptest.NewRecorder(), request) +} + func TestStrictErrorHandlersReturnJSON(t *testing.T) { for _, test := range []struct { name string @@ -91,6 +119,25 @@ func TestStrictErrorHandlersReturnJSON(t *testing.T) { } } +func TestStrictErrorHandlersPreservePlaintextOutsideWebMCP(t *testing.T) { + for _, test := range []struct { + name string + handler func(http.ResponseWriter, *http.Request, error) + status int + }{ + {name: "request", handler: StrictRequestErrorHandler, status: http.StatusBadRequest}, + {name: "response", handler: StrictResponseErrorHandler, status: http.StatusInternalServerError}, + } { + t.Run(test.name, func(t *testing.T) { + recorder := httptest.NewRecorder() + test.handler(recorder, httptest.NewRequest(http.MethodPost, "/playwright/execute", nil), errors.New("invalid request")) + require.Equal(t, test.status, recorder.Code) + require.Equal(t, "text/plain; charset=utf-8", recorder.Header().Get("Content-Type")) + require.Equal(t, "invalid request\n", recorder.Body.String()) + }) + } +} + func TestTelemetryMiddleware_EmitsApiCallEventOnDocumentedRoute(t *testing.T) { withTelemetryMiddlewareEnabled(t) rp := &recordingPublisher{} diff --git a/server/cmd/api/api/webmcp.go b/server/cmd/api/api/webmcp.go index 1e1c072a2..88fefe170 100644 --- a/server/cmd/api/api/webmcp.go +++ b/server/cmd/api/api/webmcp.go @@ -17,6 +17,7 @@ const ( defaultWebMCPInvocationTimeout = 60 * time.Second maxWebMCPInvocationTimeoutSec = 120 maxWebMCPInputBytes = 1 << 20 + maxWebMCPRequestBytes = maxWebMCPInputBytes + (4 << 10) ) func (s *ApiService) GetWebMCPTools(ctx context.Context, _ oapi.GetWebMCPToolsRequestObject) (oapi.GetWebMCPToolsResponseObject, error) { diff --git a/server/cmd/api/main.go b/server/cmd/api/main.go index 962fec0d2..b3384c6f5 100644 --- a/server/cmd/api/main.go +++ b/server/cmd/api/main.go @@ -250,6 +250,7 @@ func main() { // api_call event emission. Off until the telemetry handlers flip it on. r.Use(api.TelemetryHTTPMiddleware(telemetrySession.Publish)) + r.Use(api.WebMCPRequestSizeMiddleware) strictHandler := oapi.NewStrictHandlerWithOptions(apiService, []oapi.StrictMiddlewareFunc{ api.TelemetryStrictMiddleware(), }, oapi.StrictHTTPServerOptions{ diff --git a/server/e2e/e2e_playwright_test.go b/server/e2e/e2e_playwright_test.go index d455d7147..225e25ad7 100644 --- a/server/e2e/e2e_playwright_test.go +++ b/server/e2e/e2e_playwright_test.go @@ -125,6 +125,19 @@ func TestPlaywrightExecuteAPI(t *testing.T) { require.Nil(t, helperResult.Failure.InvocationID) require.NotEmpty(t, helperResult.Failure.Message) + t.Log("verifying existing code may declare its own webmcp binding") + shadowedWebMCPRsp, err := client.ExecutePlaywrightCodeWithResponse(ctx, instanceoapi.ExecutePlaywrightCodeJSONRequestBody{ + Code: ` + const webmcp = 'user-defined'; + return webmcp; + `, + }) + require.NoError(t, err, "shadowed WebMCP request error: %v", err) + require.Equal(t, http.StatusOK, shadowedWebMCPRsp.StatusCode(), "unexpected status: %s body=%s", shadowedWebMCPRsp.Status(), string(shadowedWebMCPRsp.Body)) + require.NotNil(t, shadowedWebMCPRsp.JSON200) + require.True(t, shadowedWebMCPRsp.JSON200.Success, "existing webmcp binding should remain valid") + require.Equal(t, "user-defined", shadowedWebMCPRsp.JSON200.Result) + // Reuse the same container/warm daemon connection to verify tab-binding // behavior: `page` must bind to the browser's actual foreground tab, not // just the most recently opened one (resolveActivePage in diff --git a/server/lib/browsersurface/frames.go b/server/lib/browsersurface/frames.go index c3852fe78..0423e51f4 100644 --- a/server/lib/browsersurface/frames.go +++ b/server/lib/browsersurface/frames.go @@ -77,21 +77,35 @@ func (t *Tracker) initializeSession(sessionID string) { ctx, cancel := context.WithTimeout(t.ctx, sessionInitTimeout) defer cancel() - if _, err := t.protocol.Send(ctx, "Page.enable", nil, sessionID); err != nil { - t.failSessionInitialization(sessionID, err) - return - } - raw, err := t.protocol.Send(ctx, "Page.getFrameTree", nil, sessionID) - if err != nil { - t.failSessionInitialization(sessionID, err) - return - } var result struct { FrameTree frameTree `json:"frameTree"` } - if err := json.Unmarshal(raw, &result); err != nil { - t.failSessionInitialization(sessionID, err) - return + var initErr error + for { + result.FrameTree = frameTree{} + if _, initErr = t.protocol.Send(ctx, "Page.enable", nil, sessionID); initErr == nil { + var raw json.RawMessage + raw, initErr = t.protocol.Send(ctx, "Page.getFrameTree", nil, sessionID) + if initErr == nil { + initErr = json.Unmarshal(raw, &result) + } + } + if initErr == nil { + break + } + + timer := time.NewTimer(sessionInitRetryDelay) + select { + case <-timer.C: + case <-ctx.Done(): + timer.Stop() + t.failSessionInitialization(sessionID, initErr) + return + case <-t.closed: + timer.Stop() + t.failSessionInitialization(sessionID, initErr) + return + } } t.stateMu.Lock() diff --git a/server/lib/browsersurface/tracker.go b/server/lib/browsersurface/tracker.go index b6741d62f..a9f1b8927 100644 --- a/server/lib/browsersurface/tracker.go +++ b/server/lib/browsersurface/tracker.go @@ -11,7 +11,10 @@ import ( "time" ) -const sessionInitTimeout = 5 * time.Second +const ( + sessionInitTimeout = 5 * time.Second + sessionInitRetryDelay = 50 * time.Millisecond +) type subscriber struct { events chan Event diff --git a/server/lib/browsersurface/tracker_test.go b/server/lib/browsersurface/tracker_test.go index c9217a523..e59b60329 100644 --- a/server/lib/browsersurface/tracker_test.go +++ b/server/lib/browsersurface/tracker_test.go @@ -17,6 +17,9 @@ type fakeProtocol struct { windowIDs map[string]int attachFailures int attachCalls map[string]int + pageEnableFailures int + pageEnableCalls int + frameTreeFailures int beforeTargetsResult func() events chan cdpclient.Message closed chan struct{} @@ -72,8 +75,23 @@ func (f *fakeProtocol) Send(_ context.Context, method string, params any, sessio }[targetID] return marshal(map[string]any{"sessionId": sessionID}), nil case "Page.enable": + f.mu.Lock() + f.pageEnableCalls++ + if f.pageEnableFailures > 0 { + f.pageEnableFailures-- + f.mu.Unlock() + return nil, errors.New("temporary Page.enable failure") + } + f.mu.Unlock() return marshal(map[string]any{}), nil case "Page.getFrameTree": + f.mu.Lock() + if f.frameTreeFailures > 0 { + f.frameTreeFailures-- + f.mu.Unlock() + return nil, errors.New("temporary Page.getFrameTree failure") + } + f.mu.Unlock() if sessionID == "oopif-session" { return marshal(map[string]any{"frameTree": map[string]any{ "frame": map[string]any{"id": "oopif", "url": "https://cross-origin.example/"}, @@ -143,6 +161,31 @@ func TestTrackerRejectsProtocolWithoutEvents(t *testing.T) { require.EqualError(t, err, "start browser surface discovery: protocol events are unavailable") } +func TestTrackerRetriesTransientSessionInitializationFailure(t *testing.T) { + for _, test := range []struct { + name string + configure func(*fakeProtocol) + }{ + {name: "enable", configure: func(protocol *fakeProtocol) { protocol.pageEnableFailures = 1 }}, + {name: "frame tree", configure: func(protocol *fakeProtocol) { protocol.frameTreeFailures = 1 }}, + } { + t.Run(test.name, func(t *testing.T) { + protocol := newFakeProtocol() + test.configure(protocol) + tracker := New(protocol) + require.NoError(t, tracker.Start(context.Background())) + + require.Eventually(t, func() bool { + protocol.mu.Lock() + calls := protocol.pageEnableCalls + protocol.mu.Unlock() + _, resolved := tracker.Resolve("session-a", "root-a") + return calls >= 2 && resolved + }, time.Second, 10*time.Millisecond) + }) + } +} + func TestTrackerMapsBrowserSurfaceAndPublishesLifecycleEvents(t *testing.T) { protocol := newFakeProtocol() tracker := New(protocol) diff --git a/server/runtime/playwright-daemon.ts b/server/runtime/playwright-daemon.ts index a95e69cb7..b46d28e7c 100644 --- a/server/runtime/playwright-daemon.ts +++ b/server/runtime/playwright-daemon.ts @@ -308,10 +308,14 @@ async function executeCode(request: ExecuteRequest, signal: AbortSignal): Promis const webmcp = createWebMCPClient({apiBaseUrl: KERNEL_API_ENDPOINT, signal}); const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor; - const userFunction = new AsyncFunction('page', 'context', 'browser', 'webmcp', jsCode); + const createUserFunction = new AsyncFunction( + 'webmcp', + `return async function(page, context, browser) {\n${jsCode}\n};`, + ); + const userFunction = await createUserFunction(webmcp); signal.throwIfAborted(); - const result = await userFunction(page, context, browserInstance, webmcp); + const result = await userFunction(page, context, browserInstance); return { id, From 5140d71ba1f7959237edfb9503198f87f7354def Mon Sep 17 00:00:00 2001 From: rgarcia <72655+rgarcia@users.noreply.github.com> Date: Tue, 1 Sep 2026 14:47:40 +0000 Subject: [PATCH 18/18] Tighten WebMCP retry and size contracts --- server/lib/browsersurface/frames.go | 6 +++ server/lib/browsersurface/tracker_test.go | 57 +++++++++++++++++------ server/openapi.yaml | 1 + 3 files changed, 49 insertions(+), 15 deletions(-) diff --git a/server/lib/browsersurface/frames.go b/server/lib/browsersurface/frames.go index 0423e51f4..8867c26a8 100644 --- a/server/lib/browsersurface/frames.go +++ b/server/lib/browsersurface/frames.go @@ -82,6 +82,9 @@ func (t *Tracker) initializeSession(sessionID string) { } var initErr error for { + if !t.SessionExists(sessionID) { + return + } result.FrameTree = frameTree{} if _, initErr = t.protocol.Send(ctx, "Page.enable", nil, sessionID); initErr == nil { var raw json.RawMessage @@ -93,6 +96,9 @@ func (t *Tracker) initializeSession(sessionID string) { if initErr == nil { break } + if !t.SessionExists(sessionID) { + return + } timer := time.NewTimer(sessionInitRetryDelay) select { diff --git a/server/lib/browsersurface/tracker_test.go b/server/lib/browsersurface/tracker_test.go index e59b60329..2a8689a6a 100644 --- a/server/lib/browsersurface/tracker_test.go +++ b/server/lib/browsersurface/tracker_test.go @@ -17,9 +17,9 @@ type fakeProtocol struct { windowIDs map[string]int attachFailures int attachCalls map[string]int - pageEnableFailures int - pageEnableCalls int - frameTreeFailures int + pageEnableFailures map[string]int + pageEnableCalls map[string]int + frameTreeFailures map[string]int beforeTargetsResult func() events chan cdpclient.Message closed chan struct{} @@ -27,10 +27,13 @@ type fakeProtocol struct { func newFakeProtocol() *fakeProtocol { return &fakeProtocol{ - windowIDs: map[string]int{"page-a": 10, "page-b": 20}, - attachCalls: make(map[string]int), - events: make(chan cdpclient.Message, 64), - closed: make(chan struct{}), + windowIDs: map[string]int{"page-a": 10, "page-b": 20}, + attachCalls: make(map[string]int), + pageEnableFailures: make(map[string]int), + pageEnableCalls: make(map[string]int), + frameTreeFailures: make(map[string]int), + events: make(chan cdpclient.Message, 64), + closed: make(chan struct{}), } } @@ -76,9 +79,9 @@ func (f *fakeProtocol) Send(_ context.Context, method string, params any, sessio return marshal(map[string]any{"sessionId": sessionID}), nil case "Page.enable": f.mu.Lock() - f.pageEnableCalls++ - if f.pageEnableFailures > 0 { - f.pageEnableFailures-- + f.pageEnableCalls[sessionID]++ + if f.pageEnableFailures[sessionID] > 0 { + f.pageEnableFailures[sessionID]-- f.mu.Unlock() return nil, errors.New("temporary Page.enable failure") } @@ -86,8 +89,8 @@ func (f *fakeProtocol) Send(_ context.Context, method string, params any, sessio return marshal(map[string]any{}), nil case "Page.getFrameTree": f.mu.Lock() - if f.frameTreeFailures > 0 { - f.frameTreeFailures-- + if f.frameTreeFailures[sessionID] > 0 { + f.frameTreeFailures[sessionID]-- f.mu.Unlock() return nil, errors.New("temporary Page.getFrameTree failure") } @@ -166,8 +169,8 @@ func TestTrackerRetriesTransientSessionInitializationFailure(t *testing.T) { name string configure func(*fakeProtocol) }{ - {name: "enable", configure: func(protocol *fakeProtocol) { protocol.pageEnableFailures = 1 }}, - {name: "frame tree", configure: func(protocol *fakeProtocol) { protocol.frameTreeFailures = 1 }}, + {name: "enable", configure: func(protocol *fakeProtocol) { protocol.pageEnableFailures["session-a"] = 1 }}, + {name: "frame tree", configure: func(protocol *fakeProtocol) { protocol.frameTreeFailures["session-a"] = 1 }}, } { t.Run(test.name, func(t *testing.T) { protocol := newFakeProtocol() @@ -177,7 +180,7 @@ func TestTrackerRetriesTransientSessionInitializationFailure(t *testing.T) { require.Eventually(t, func() bool { protocol.mu.Lock() - calls := protocol.pageEnableCalls + calls := protocol.pageEnableCalls["session-a"] protocol.mu.Unlock() _, resolved := tracker.Resolve("session-a", "root-a") return calls >= 2 && resolved @@ -186,6 +189,30 @@ func TestTrackerRetriesTransientSessionInitializationFailure(t *testing.T) { } } +func TestTrackerStopsInitializationRetriesAfterSessionRemoval(t *testing.T) { + protocol := newFakeProtocol() + protocol.pageEnableFailures["session-a"] = 1000 + tracker := New(protocol) + require.NoError(t, tracker.Start(context.Background())) + require.Eventually(t, func() bool { + protocol.mu.Lock() + defer protocol.mu.Unlock() + return protocol.pageEnableCalls["session-a"] > 0 + }, time.Second, 10*time.Millisecond) + + protocol.emitTarget("Target.detachedFromTarget", map[string]any{"sessionId": "session-a"}) + require.Eventually(t, func() bool { + return !tracker.SessionExists("session-a") + }, time.Second, 10*time.Millisecond) + protocol.mu.Lock() + callsAfterRemoval := protocol.pageEnableCalls["session-a"] + protocol.mu.Unlock() + time.Sleep(4 * sessionInitRetryDelay) + protocol.mu.Lock() + defer protocol.mu.Unlock() + require.Equal(t, callsAfterRemoval, protocol.pageEnableCalls["session-a"]) +} + func TestTrackerMapsBrowserSurfaceAndPublishesLifecycleEvents(t *testing.T) { protocol := newFakeProtocol() tracker := New(protocol) diff --git a/server/openapi.yaml b/server/openapi.yaml index da3dbae7d..9eb936a8a 100644 --- a/server/openapi.yaml +++ b/server/openapi.yaml @@ -1396,6 +1396,7 @@ paths: operationId: invokeWebMCPTool x-telemetry-category: control requestBody: + description: The raw JSON request body is limited to 1 MiB plus 4 KiB of envelope overhead before decoding. required: true content: application/json: