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/cmd/api/api/api.go b/server/cmd/api/api/api.go index 2fd6580b8..222ac9a50 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) ([]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/middleware.go b/server/cmd/api/api/middleware.go index fe8ec194a..2436caef7 100644 --- a/server/cmd/api/api/middleware.go +++ b/server/cmd/api/api/middleware.go @@ -121,6 +121,46 @@ func apiCallEvent(operationID string) (string, oapi.TelemetryEventCategory) { return "platform_api_call", events.Platform } +// 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) + }) +} + +// 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) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = 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 { diff --git a/server/cmd/api/api/middleware_test.go b/server/cmd/api/api/middleware_test.go index e1d568e12..7e1f40636 100644 --- a/server/cmd/api/api/middleware_test.go +++ b/server/cmd/api/api/middleware_test.go @@ -3,6 +3,8 @@ package api import ( "context" "encoding/json" + "errors" + "io" "net/http" "net/http/httptest" "strings" @@ -71,6 +73,71 @@ 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 + 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 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 new file mode 100644 index 000000000..88fefe170 --- /dev/null +++ b/server/cmd/api/api/webmcp.go @@ -0,0 +1,132 @@ +package api + +import ( + "context" + "encoding/json" + "errors" + "strings" + "time" + "unicode/utf8" + + "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 + maxWebMCPInvocationTimeoutSec = 120 + maxWebMCPInputBytes = 1 << 20 + maxWebMCPRequestBytes = maxWebMCPInputBytes + (4 << 10) +) + +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 + } + 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, + 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{ + ReadOnly: tool.Annotations.ReadOnly, + UntrustedContent: tool.Annotations.UntrustedContent, + Consequential: tool.Annotations.Consequential, + Autosubmit: tool.Annotations.Autosubmit, + } + } + 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 + } + 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 + } + 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) + 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..3745e86ec --- /dev/null +++ b/server/cmd/api/api/webmcp_test.go @@ -0,0 +1,190 @@ +package api + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "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 + toolRef string + input map[string]any +} + +func (f *fakeWebMCPClient) Tools(_ context.Context) ([]webmcpclient.Tool, error) { + 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}, + 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} + + response, err := service.GetWebMCPTools(context.Background(), oapi.GetWebMCPToolsRequestObject{}) + require.NoError(t, err) + body := response.(oapi.GetWebMCPTools200JSONResponse) + require.Len(t, body.Tools, 1) + tool := body.Tools[0] + require.Equal(t, "wmcp_test", tool.ToolRef) + 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", + 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 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) { + 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} + 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/cmd/api/main.go b/server/cmd/api/main.go index 30155ede4..b3384c6f5 100644 --- a/server/cmd/api/main.go +++ b/server/cmd/api/main.go @@ -250,8 +250,12 @@ 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{ + r.Use(api.WebMCPRequestSizeMiddleware) + strictHandler := oapi.NewStrictHandlerWithOptions(apiService, []oapi.StrictMiddlewareFunc{ api.TelemetryStrictMiddleware(), + }, oapi.StrictHTTPServerOptions{ + RequestErrorHandlerFunc: api.StrictRequestErrorHandler, + ResponseErrorHandlerFunc: api.StrictResponseErrorHandler, }) oapi.HandlerFromMux(strictHandler, r) diff --git a/server/e2e/e2e_playwright_test.go b/server/e2e/e2e_playwright_test.go index bd10cfca0..225e25ad7 100644 --- a/server/e2e/e2e_playwright_test.go +++ b/server/e2e/e2e_playwright_test.go @@ -80,6 +80,64 @@ 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) + + 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/events.go b/server/lib/browsersurface/events.go new file mode 100644 index 000000000..cdea820c5 --- /dev/null +++ b/server/lib/browsersurface/events.go @@ -0,0 +1,84 @@ +package browsersurface + +import ( + "encoding/json" + + "github.com/kernel/kernel-images/server/lib/cdpclient" +) + +func (t *Tracker) handleProtocolEvent(message cdpclient.Message) { + switch message.Method { + case "Target.targetCreated": + var event struct { + TargetInfo targetInfo `json:"targetInfo"` + } + 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 { + 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.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"` + } + if json.Unmarshal(message.Params, &event) == nil { + t.navigateFrame(message.SessionID, event.Frame) + } + case "Page.frameDetached": + var event struct { + FrameID string `json:"frameId"` + Reason string `json:"reason"` + } + if json.Unmarshal(message.Params, &event) == nil && event.Reason != "swap" { + 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..8867c26a8 --- /dev/null +++ b/server/lib/browsersurface/frames.go @@ -0,0 +1,309 @@ +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 && target.Type == "iframe" && parentSessionID != "" { + if parent := t.sessions[parentSessionID]; parent != nil { + tabID = parent.tabID + } + } + if tabID == 0 && target.Type == "iframe" && 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 + } + } + 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() + go t.initializeSession(sessionID) +} + +func (t *Tracker) initializeSession(sessionID string) { + deadline := time.Now().Add(sessionInitTimeout) + var sess session + for { + t.stateMu.Lock() + t.bindSessionsLocked() + 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.removeSession(sessionID) + return + } + if t.protocol.IsClosed() { + return + } + time.Sleep(10 * time.Millisecond) + } + + ctx, cancel := context.WithTimeout(t.ctx, sessionInitTimeout) + defer cancel() + var result struct { + FrameTree frameTree `json:"frameTree"` + } + 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 + raw, initErr = t.protocol.Send(ctx, "Page.getFrameTree", nil, sessionID) + if initErr == nil { + initErr = json.Unmarshal(raw, &result) + } + } + if initErr == nil { + break + } + if !t.SessionExists(sessionID) { + return + } + + 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() + 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 tabID := t.tabsByTarget[sess.target.TargetID]; tabID != 0 { + sess.tabID = 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 + } + } + } +} + +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 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) + } + } + 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..41b56c6bf --- /dev/null +++ b/server/lib/browsersurface/targets.go @@ -0,0 +1,308 @@ +package browsersurface + +import ( + "context" + "encoding/json" + "fmt" + "time" +) + +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 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() + t.signalChanged() + return tabID, attach + } + t.nextTabID++ + 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 +} + +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 + 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.assignFallbackWindow(target.TargetID, tabID) + } + 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 { + t.trackingTarget[target.TargetID] = false + } + t.stateMu.Unlock() + t.signalChanged() +} + +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) trackFrameTarget(target targetInfo) { + t.stateMu.Lock() + if t.trackingFrameTarget[target.TargetID] { + t.stateMu.Unlock() + return + } + t.trackingFrameTarget[target.TargetID] = true + t.stateMu.Unlock() + + go func() { + 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.trackingFrameTarget, target.TargetID) + t.stateMu.Unlock() + t.signalChanged() + }() +} + +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) + } + knownFrames := make([]string, 0, len(t.trackingFrameTarget)) + for targetID := range t.trackingFrameTarget { + knownFrames = append(knownFrames, targetID) + } + t.stateMu.RUnlock() + + 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) + 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": + activeFrames[target.TargetID] = true + t.trackFrameTarget(target) + } + } + for _, targetID := range known { + if active[targetID] { + continue + } + t.removeTarget(targetID) + } + t.stateMu.Lock() + for _, targetID := range knownFrames { + if !activeFrames[targetID] { + delete(t.trackingFrameTarget, targetID) + } + } + t.stateMu.Unlock() + 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)) + } +} + +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 == "iframe" { + t.trackFrameTarget(target) + return + } + if target.Type == "page" && !knownTab { + t.trackPage(target, true) + 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() + delete(t.trackingFrameTarget, targetID) + 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..a9f1b8927 --- /dev/null +++ b/server/lib/browsersurface/tracker.go @@ -0,0 +1,334 @@ +package browsersurface + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "sort" + "strings" + "sync" + "time" +) + +const ( + sessionInitTimeout = 5 * time.Second + sessionInitRetryDelay = 50 * time.Millisecond +) + +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 + trackingFrameTarget 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), + trackingFrameTarget: 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 + 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{ + "discover": true, + "filter": []map[string]any{ + {"type": "page"}, + {"type": "iframe"}, + }, + }, ""); 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(target, false) + } + } + for _, target := range result.TargetInfos { + if target.Type == "iframe" { + t.trackFrameTarget(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..2a8689a6a --- /dev/null +++ b/server/lib/browsersurface/tracker_test.go @@ -0,0 +1,462 @@ +package browsersurface + +import ( + "context" + "encoding/json" + "errors" + "sync" + "testing" + "time" + + "github.com/kernel/kernel-images/server/lib/cdpclient" + "github.com/stretchr/testify/require" +) + +type fakeProtocol struct { + mu sync.Mutex + windowIDs map[string]int + attachFailures int + attachCalls map[string]int + pageEnableFailures map[string]int + pageEnableCalls map[string]int + frameTreeFailures map[string]int + beforeTargetsResult func() + events chan cdpclient.Message + closed chan struct{} +} + +func newFakeProtocol() *fakeProtocol { + return &fakeProtocol{ + 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{}), + } +} + +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": + 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/"}, + }}), 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.attachToTarget": + targetID := params.(map[string]any)["targetId"].(string) + 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() + 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": + f.mu.Lock() + f.pageEnableCalls[sessionID]++ + if f.pageEnableFailures[sessionID] > 0 { + f.pageEnableFailures[sessionID]-- + 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[sessionID] > 0 { + f.frameTreeFailures[sessionID]-- + 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/"}, + }}), 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" { + 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 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) +} + +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 <- cdpclient.Message{ + Method: "Target.attachedToTarget", SessionID: parentSessionID, Params: params, + } +} + +func (f *fakeProtocol) emitTarget(method string, params any) { + raw, _ := json.Marshal(params) + f.events <- cdpclient.Message{Method: method, Params: raw} +} + +func (f *fakeProtocol) setWindow(targetID string, windowID int) { + f.mu.Lock() + f.windowIDs[targetID] = windowID + 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 TestTrackerRetriesTransientSessionInitializationFailure(t *testing.T) { + for _, test := range []struct { + name string + configure func(*fakeProtocol) + }{ + {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() + test.configure(protocol) + tracker := New(protocol) + require.NoError(t, tracker.Start(context.Background())) + + require.Eventually(t, func() bool { + protocol.mu.Lock() + calls := protocol.pageEnableCalls["session-a"] + protocol.mu.Unlock() + _, resolved := tracker.Resolve("session-a", "root-a") + return calls >= 2 && resolved + }, time.Second, 10*time.Millisecond) + }) + } +} + +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) + 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.targetCreated", map[string]any{ + "targetInfo": map[string]any{ + "targetId": "oopif", "type": "iframe", "url": "https://cross-origin.example/", + "parentFrameId": "root-a", + }, + }) + require.Eventually(t, func() bool { + 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}}, + 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/"}, + {ID: 3, TabID: 1, URL: "https://cross-origin.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 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) + 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 + 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 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) + 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.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 { + 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) { + 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..5118bd0be --- /dev/null +++ b/server/lib/browsersurface/types.go @@ -0,0 +1,121 @@ +package browsersurface + +import ( + "context" + "encoding/json" + + "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 cdpclient.Message + IsClosed() bool +} + +type EventKind int + +const ( + EventProtocol EventKind = iota + EventSessionReady + EventSessionRemoved + EventDocumentInvalidated + EventDocumentChanged + EventFrameRemoved +) + +type Event struct { + Kind EventKind + SessionID string + FrameID string + Message cdpclient.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/cdpclient/cdpclient.go b/server/lib/cdpclient/cdpclient.go index ac1449d7b..825b3f36b 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,120 @@ 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() + if c.IsClosed() { + c.pendingMu.Unlock() + return nil, fmt.Errorf("read: %w", ErrOutcomeUnknown) + } + 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 resp.ID != id { - continue // skip events and responses to other commands + if message.ID == 0 { + if c.events == nil { + continue + } + select { + case c.events <- message: + case <-c.closed: + return + } + continue } - if resp.Error != nil { - return nil, resp.Error + + c.pendingMu.Lock() + responseCh := c.pending[message.ID] + if responseCh != nil { + if message.Error != nil { + responseCh <- commandResult{err: message.Error} + } else { + responseCh <- commandResult{result: message.Result} + } + delete(c.pending, message.ID) } - return resp.Result, nil + c.pendingMu.Unlock() + } +} + +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 +301,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 +315,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 +342,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 +377,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 +392,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 +423,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 +447,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 +467,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 +484,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 +516,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 +532,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 +545,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 +562,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 +586,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 +633,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 +662,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 +694,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 +709,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 +719,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..6e23390d8 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,218 @@ 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 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) + 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/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..78fe009cb 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,90 @@ 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"` + InputSchema map[string]interface{} `json:"input_schema"` + Name string `json:"name"` + Source WebMCPToolSource `json:"source"` + + // 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"` +} + +// 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"` +} + +// 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"` +} + // WriteClipboardRequest defines model for WriteClipboardRequest. type WriteClipboardRequest struct { // Text Text to write to the system clipboard @@ -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, 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, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewGetWebMCPToolsRequest(c.Server) + 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,73 @@ 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) (*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 + } + + 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 +13417,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, reqEditors ...RequestEditorFn) (*GetWebMCPToolsResponse, error) } type PatchChromiumFlagsResponse struct { @@ -14540,6 +14782,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 +15601,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, reqEditors ...RequestEditorFn) (*GetWebMCPToolsResponse, error) { + rsp, err := c.GetWebMCPTools(ctx, 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 +17792,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 +18059,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 across the browser + // (GET /webmcp/tools) + GetWebMCPTools(w http.ResponseWriter, r *http.Request) } // Unimplemented server implementation that returns http.StatusNotImplemented for each endpoint. @@ -17995,6 +18413,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 across the browser +// (GET /webmcp/tools) +func (_ Unimplemented) GetWebMCPTools(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotImplemented) +} + // ServerInterfaceWrapper converts contexts to parameters. type ServerInterfaceWrapper struct { Handler ServerInterface @@ -19119,6 +19549,34 @@ 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) { + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.GetWebMCPTools(w, r) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + type UnescapedCookieParamError struct { ParamName string Err error @@ -19403,6 +19861,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 +22269,93 @@ 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 { +} + +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 +22529,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 across the browser + // (GET /webmcp/tools) + GetWebMCPTools(ctx context.Context, request GetWebMCPToolsRequestObject) (GetWebMCPToolsResponseObject, error) } type StrictHandlerFunc = strictnethttp.StrictHTTPHandlerFunc @@ -23694,6 +24251,61 @@ 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) { + var request GetWebMCPToolsRequestObject + + 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 +24709,199 @@ var swaggerSpec = []string{ "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", + "AAD//4st449OcwMA", } // GetSwagger returns the content of the embedded swagger specification file 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 new file mode 100644 index 000000000..91e81612c --- /dev/null +++ b/server/lib/webmcpclient/client.go @@ -0,0 +1,575 @@ +package webmcpclient + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log/slog" + "sort" + "sync" + "time" + + "github.com/kernel/kernel-images/server/lib/browsersurface" + "github.com/kernel/kernel-images/server/lib/cdpclient" + "github.com/nrednav/cuid2" +) + +const ( + settleDelay = 200 * time.Millisecond + settleLimit = 2 * time.Second + maxToolsPerSession = 256 + maxCompletedInvocations = 256 + maxAbandonedInvocations = 256 + maxDocumentChanges = 1024 +) + +type connection struct { + protocol *cdpclient.Client + surface *browsersurface.Tracker + + startMu sync.Mutex + started bool + + stateMu sync.RWMutex + enabledSessions map[string]bool + tools map[string]*registeredTool + toolRefs map[string]string + toolLimitWarned map[string]bool + invocations map[invocationKey]invocationResponse + waitingInvocations map[invocationKey]string + abandonedInvocations map[invocationKey]time.Time + documentChanges map[documentKey]uint64 + eventSequence uint64 + stateChangedCh chan struct{} + logger *slog.Logger + + eventsCancel func() + eventsDone chan struct{} + closed chan struct{} + closeOnce sync.Once +} + +func newConnection(protocol *cdpclient.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), + invocations: make(map[invocationKey]invocationResponse), + 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, + eventsDone: make(chan struct{}), + closed: make(chan struct{}), + } + 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.eventsCancel() + _ = c.protocol.Close() + close(c.closed) + }) + return nil +} + +func (c *connection) isClosed() bool { + select { + case <-c.closed: + return true + default: + return c.protocol.IsClosed() + } +} + +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.EventDocumentInvalidated: + c.stateMu.Lock() + c.markDocumentChangedLocked(documentKey{sessionID: event.SessionID, frameID: event.FrameID}) + c.abandonFrameInvocationsLocked(event.SessionID, event.FrameID) + c.stateMu.Unlock() + c.signalStateChanged() + case browsersurface.EventDocumentChanged: + c.stateMu.Lock() + c.removeFrameToolsLocked(event.SessionID, event.FrameID) + c.stateMu.Unlock() + 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() + case browsersurface.EventProtocol: + c.handleProtocolEvent(event.Message) + } + } +} + +func (c *connection) enableSession(sessionID string) { + c.stateMu.Lock() + if c.enabledSessions[sessionID] { + c.stateMu.Unlock() + return + } + c.enabledSessions[sessionID] = true + c.stateMu.Unlock() + + 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) + } + return + } + c.signalStateChanged() +} + +func (c *connection) handleProtocolEvent(message cdpclient.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.eventSequence++ + response.observedAt = c.eventSequence + c.pruneAbandonedInvocationsLocked() + if _, abandoned := c.abandonedInvocations[key]; !abandoned { + 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) addTools(sessionID string, tools []toolEvent) { + if !c.surface.SessionExists(sessionID) { + return + } + c.stateMu.Lock() + defer c.stateMu.Unlock() + 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 { + 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, + }) + } + 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) { + 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 + document := documentKey{sessionID: sessionID, frameID: frameID} + startedAfterChange := c.documentChanges[document] + c.stateMu.RUnlock() + if !c.sessionExists(sessionID) { + return InvocationResult{}, ErrToolNotFound + } + + raw, err := c.surface.Send(ctx, "WebMCP.invokeTool", map[string]any{ + "frameId": frameID, + "toolName": name, + "input": input, + }, sessionID) + if err != nil { + unknownOutcome := errors.Is(err, context.Canceled) || + errors.Is(err, context.DeadlineExceeded) || + errors.Is(err, cdpclient.ErrOutcomeUnknown) || + !c.sessionExists(sessionID) + if unknownOutcome { + 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") + } + + key := invocationKey{sessionID: sessionID, invocationID: started.InvocationID} + c.stateMu.Lock() + 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() + for { + c.stateMu.Lock() + if response, ok := c.invocations[key]; ok { + delete(c.invocations, key) + delete(c.waitingInvocations, key) + c.stateMu.Unlock() + return InvocationResult{ + InvocationID: response.InvocationID, + Status: response.Status, + Output: response.Output, + ErrorText: response.ErrorText, + }, nil + } + if _, abandoned := c.abandonedInvocations[key]; abandoned { + c.stateMu.Unlock() + return InvocationResult{InvocationID: started.InvocationID}, ErrOutcomeUnknown + } + c.stateMu.Unlock() + if !c.sessionExists(sessionID) || c.executionClosed() { + c.stateMu.Lock() + abandoned := c.abandonInvocationLocked(key) + c.stateMu.Unlock() + if !abandoned { + continue + } + return InvocationResult{InvocationID: started.InvocationID}, ErrOutcomeUnknown + } + select { + case <-c.stateChangedCh: + case <-ticker.C: + case <-ctx.Done(): + c.stateMu.Lock() + abandoned := c.abandonInvocationLocked(key) + c.stateMu.Unlock() + if !abandoned { + continue + } + return InvocationResult{InvocationID: started.InvocationID}, ErrOutcomeUnknown + case <-c.closed: + c.stateMu.Lock() + abandoned := c.abandonInvocationLocked(key) + c.stateMu.Unlock() + if !abandoned { + continue + } + return InvocationResult{InvocationID: started.InvocationID}, ErrOutcomeUnknown + } + } +} + +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() + 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 document := range c.documentChanges { + if document.sessionID == sessionID { + delete(c.documentChanges, document) + } + } + 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) 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 { + 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 + } + c.forceAbandonInvocationLocked(key) + return true +} + +func (c *connection) forceAbandonInvocationLocked(key invocationKey) { + 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 { + 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 new file mode 100644 index 000000000..6d1389d49 --- /dev/null +++ b/server/lib/webmcpclient/client_test.go @@ -0,0 +1,555 @@ +package webmcpclient + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "github.com/coder/websocket" + "github.com/kernel/kernel-images/server/lib/cdpclient" + "github.com/stretchr/testify/require" +) + +type staticUpstream struct { + url string +} + +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 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 + + mu sync.Mutex + connections int + targetAttaches 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 { + t.Helper() + 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.mu.Lock() + f.popupOpen = true + f.mu.Unlock() + 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 { + 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) + } + f.mu.Lock() + f.write = write + f.mu.Unlock() + for { + _, payload, err := conn.Read(r.Context()) + if err != nil { + return + } + var request wireRequest + 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.setDiscoverTargets": + respond(map[string]any{}) + case "Target.getTargets": + 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"` + } + _ = json.Unmarshal(request.Params, ¶ms) + windowID := 10 + if params.TargetID == "other-page" { + windowID = 20 + } + respond(map[string]any{"windowId": windowID}) + case "Target.attachToTarget": + f.mu.Lock() + 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": + 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": + 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", + }}, + }) + } + case "Page.enable": + respond(map[string]any{}) + case "Page.getFrameTree": + 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" + 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" + } + 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": + 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 responseDelay > 0 { + time.Sleep(responseDelay) + } + 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 navigateBeforeResult { + output = []any{} + } + write(map[string]any{ + "method": "WebMCP.toolResponded", "sessionId": request.SessionID, + "params": map[string]any{ + "invocationId": invocationID, "status": "Completed", "output": output, + }, + }) + if !navigateBeforeResult { + write(map[string]any{ + "method": "Page.frameStartedLoading", "sessionId": request.SessionID, + "params": map[string]any{"frameId": "iframe-frame"}, + }) + } + 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}, + }) + } + } + default: + write(map[string]any{"id": request.ID, "error": map[string]any{"code": -32601, "message": "unknown method"}}) + } + } +} + +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/"}, + } + 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"}, + } + 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()) + require.NoError(t, err) + require.Len(t, tools, 4) + + byName := make(map[string]Tool, len(tools)) + for _, tool := range tools { + byName[tool.Name] = tool + } + 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, 4, fake.targetAttaches) + 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) { + 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, 4, fake.targetAttaches) +} + +func paymentToolRef(t *testing.T, manager *Manager) string { + t.Helper() + tools, err := manager.Tools(context.Background()) + require.NoError(t, err) + for _, tool := range tools { + if tool.Name == "payment_tool" { + return tool.Ref + } + } + t.Fatal("payment tool not found") + return "" +} + +func TestInvocationPreservesResponseObservedBeforeFrameNavigation(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(), 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"]) + + 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 + fake.navigationDoesNotCommit = true + manager := NewManager(staticUpstream{url: fake.url}) + t.Cleanup(func() { _ = manager.Close() }) + toolRef := paymentToolRef(t, manager) + + 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) { + fake := newFakeCDP(t, false) + fake.detachAfterResponse = 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.NoError(t, err) + require.Equal(t, "Completed", result.Status) +} + +func TestInvocationTimeoutHasUnknownOutcome(t *testing.T) { + fake := newFakeCDP(t, true) + manager := NewManager(staticUpstream{url: fake.url}) + t.Cleanup(func() { _ = manager.Close() }) + toolRef := paymentToolRef(t, manager) + + 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) +} + +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() }) + toolRef := paymentToolRef(t, manager) + + 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() }) + + _, err := manager.Invoke(context.Background(), paymentToolRef(t, manager), map[string]any{}) + require.ErrorIs(t, err, ErrOutcomeUnknown) +} + +func TestToolResponsesAreScopedBySession(t *testing.T) { + client := &connection{ + invocations: make(map[invocationKey]invocationResponse), + waitingInvocations: make(map[invocationKey]string), + 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.handleProtocolEvent(cdpclient.Message{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 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) +} + +func TestToolRegistryIsBoundedPerSession(t *testing.T) { + fake := newFakeCDP(t, false) + fake.toolCount = maxToolsPerSession + 10 + 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, maxToolsPerSession*4) +} diff --git a/server/lib/webmcpclient/manager.go b/server/lib/webmcpclient/manager.go new file mode 100644 index 000000000..dcdb362a3 --- /dev/null +++ b/server/lib/webmcpclient/manager.go @@ -0,0 +1,108 @@ +package webmcpclient + +import ( + "context" + "fmt" + "sync" + + "github.com/kernel/kernel-images/server/lib/cdpclient" +) + +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) ([]Tool, error) { + conn, err := m.getConnection(ctx) + if err != nil { + 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.start(ctx); err != nil { + _ = conn.close() + m.discardConnection(conn) + 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) + if !conn.surface.HasTabs() { + return nil, ErrNoPageTarget + } + 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() + } + protocol, err := cdpclient.DialWithEvents(ctx, url) + if err != nil { + return nil, fmt.Errorf("WebMCP: %w", err) + } + conn := newConnection(protocol) + 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..71c22a46a --- /dev/null +++ b/server/lib/webmcpclient/types.go @@ -0,0 +1,83 @@ +package webmcpclient + +import ( + "errors" +) + +var ( + 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 + 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 { + 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 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"` + observedAt uint64 +} + +type invocationKey struct { + sessionID string + invocationID string +} + +type documentKey struct { + sessionID string + frameID string +} diff --git a/server/openapi.yaml b/server/openapi.yaml index 495ee52dd..9eb936a8a 100644 --- a/server/openapi.yaml +++ b/server/openapi.yaml @@ -1356,13 +1356,83 @@ paths: schema: $ref: "#/components/schemas/ChromiumConfigureError" + /webmcp/tools: + get: + summary: Discover WebMCP tools across the browser + description: | + 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 + 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 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: + description: The raw JSON request body is limited to 1 MiB plus 4 KiB of envelope overhead before decoding. + 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 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 @@ -6170,6 +6240,128 @@ 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 + 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, source] + properties: + tool_ref: + type: string + description: Opaque reference for invoking this exact live registration. It becomes invalid when its document or browser process is replaced. + name: + type: string + description: + type: string + input_schema: + type: object + additionalProperties: true + annotations: + $ref: "#/components/schemas/WebMCPToolAnnotations" + source: + $ref: "#/components/schemas/WebMCPToolSource" + 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: diff --git a/server/runtime/playwright-daemon.ts b/server/runtime/playwright-daemon.ts index 9823f350a..b46d28e7c 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,8 +306,13 @@ 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 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); 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); +}