diff --git a/server/e2e/e2e_playwright_test.go b/server/e2e/e2e_playwright_test.go index a1fb61bd..cbe1f7f9 100644 --- a/server/e2e/e2e_playwright_test.go +++ b/server/e2e/e2e_playwright_test.go @@ -265,6 +265,9 @@ func TestPlaywrightExecuteAPI(t *testing.T) { t.Run("WebMCPCustomNavigation", func(t *testing.T) { testCustomWebMCPInvokesAcrossNavigation(t, ctx, client) }) + t.Run("WebMCPPolyfill", func(t *testing.T) { + testWebMCPPolyfill(t, ctx, client) + }) } func TestPlaywrightExecuteTimeoutReturnsPromptlyAndRecovers(t *testing.T) { diff --git a/server/e2e/e2e_webmcp_polyfill_test.go b/server/e2e/e2e_webmcp_polyfill_test.go new file mode 100644 index 00000000..db7c6797 --- /dev/null +++ b/server/e2e/e2e_webmcp_polyfill_test.go @@ -0,0 +1,157 @@ +package e2e + +import ( + "bytes" + "context" + "net/http" + "os" + "testing" + "time" + + instanceoapi "github.com/kernel/kernel-images/server/lib/oapi" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// testWebMCPPolyfill covers tools that a page registers through its own +// navigator.modelContext polyfill instead of the native document.modelContext +// registry: late installation, same-origin embedded frames, native precedence +// for a shared name, invocation results, unregistration, and navigation. +func testWebMCPPolyfill(t *testing.T, ctx context.Context, client *instanceoapi.ClientWithResponses) { + t.Helper() + + fixture, err := os.ReadFile("testdata/webmcp/polyfill.html") + require.NoError(t, err) + written, err := client.WriteFileWithBodyWithResponse(ctx, + &instanceoapi.WriteFileParams{Path: "/tmp/polyfill.html"}, "text/html", bytes.NewReader(fixture)) + require.NoError(t, err) + require.Equal(t, http.StatusCreated, written.StatusCode(), "%s", written.Body) + + const pageURL = "file:///tmp/polyfill.html" + var readyState string + executeWebMCPPlaywright(t, ctx, client, ` + await page.goto('file:///tmp/polyfill.html', { waitUntil: 'load' }); + return page.evaluate(() => document.readyState); + `, &readyState) + require.Equal(t, "complete", readyState) + + toolsByName := func(collect *assert.CollectT) map[string]instanceoapi.WebMCPTool { + rsp, err := client.GetWebMCPToolsWithResponse(ctx, &instanceoapi.GetWebMCPToolsParams{}) + if !assert.NoError(collect, err) || !assert.Equal(collect, http.StatusOK, rsp.StatusCode(), "%s", rsp.Body) || rsp.JSON200 == nil { + return nil + } + tools := make(map[string]instanceoapi.WebMCPTool) + for _, tool := range rsp.JSON200.Tools { + if tool.Source.PageUrl == pageURL { + tools[tool.Tool.Name] = tool + } + } + return tools + } + + // The polyfill installs 1.5s after load; discovery picks it up on a later listing. + var tools map[string]instanceoapi.WebMCPTool + require.EventuallyWithT(t, func(collect *assert.CollectT) { + tools = toolsByName(collect) + for _, name := range []string{"search_items", "shared_name", "failing_tool", "navigate_away", "frame_tool"} { + assert.Contains(collect, tools, name) + } + }, 15*time.Second, 250*time.Millisecond) + t.Logf("GET /webmcp/tools: %d tools on %s", len(tools), pageURL) + + search := tools["search_items"] + require.Nil(t, search.Source.Frame) + require.Nil(t, search.Source.Custom) + require.Equal(t, "Search the catalog.", search.Tool.Description) + require.Equal(t, []any{"query"}, search.Tool.InputSchema["required"]) + + // The same name registered natively lists once, as the native tool. + shared := tools["shared_name"] + require.Equal(t, "Native copy.", shared.Tool.Description) + + frameTool := tools["frame_tool"] + require.NotNil(t, frameTool.Source.Frame) + require.Equal(t, "about:srcdoc", frameTool.Source.Frame.Url) + + timeout := 10 + invoke := func(ref string, input map[string]any) *instanceoapi.InvokeWebMCPToolResponse { + rsp, err := client.InvokeWebMCPToolWithResponse(ctx, instanceoapi.WebMCPInvokeRequest{ + ToolRef: ref, Input: input, TimeoutSec: &timeout, + }) + require.NoError(t, err) + t.Logf("POST /webmcp/invoke %s: %s", ref, rsp.Body) + return rsp + } + + searched := invoke(search.ToolRef, map[string]any{"query": "lamp"}) + require.Equal(t, http.StatusOK, searched.StatusCode(), "%s", searched.Body) + require.NotNil(t, searched.JSON200) + require.Equal(t, instanceoapi.WebMCPInvocationResultStatusCompleted, searched.JSON200.Status) + require.NotEmpty(t, searched.JSON200.InvocationId) + require.Equal(t, map[string]any{"results": []any{"match for lamp"}, "page": "/tmp/polyfill.html"}, searched.JSON200.Output) + + var logText string + executeWebMCPPlaywright(t, ctx, client, `return page.evaluate(() => document.querySelector('#log').textContent);`, &logText) + require.Equal(t, "searched lamp", logText) + + framed := invoke(frameTool.ToolRef, map[string]any{"item": 1}) + require.Equal(t, http.StatusOK, framed.StatusCode(), "%s", framed.Body) + require.Equal(t, map[string]any{"frame": "Embedded polyfill catalog", "input": map[string]any{"item": float64(1)}}, framed.JSON200.Output) + + native := invoke(shared.ToolRef, map[string]any{}) + require.Equal(t, http.StatusOK, native.StatusCode(), "%s", native.Body) + require.Equal(t, map[string]any{"source": "native"}, native.JSON200.Output) + + failed := invoke(tools["failing_tool"].ToolRef, map[string]any{}) + require.Equal(t, http.StatusOK, failed.StatusCode(), "%s", failed.Body) + require.Equal(t, instanceoapi.WebMCPInvocationResultStatusError, failed.JSON200.Status) + require.NotNil(t, failed.JSON200.ErrorText) + require.Equal(t, "Error: nothing to do", *failed.JSON200.ErrorText) + + // Unregistration through the polyfill drops the tool on the next listing, + // and the surviving tool keeps its reference. + var clicked bool + executeWebMCPPlaywright(t, ctx, client, `await page.click('#unregister'); return true;`, &clicked) + require.True(t, clicked) + require.EventuallyWithT(t, func(collect *assert.CollectT) { + tools = toolsByName(collect) + assert.NotContains(collect, tools, "failing_tool") + assert.Equal(collect, search.ToolRef, tools["search_items"].ToolRef) + }, 10*time.Second, 250*time.Millisecond) + + // The page can still register a bridged name natively; the native tool is + // then listed and invoked in place of the bridged copy. + var nativeResult string + executeWebMCPPlaywright(t, ctx, client, ` + await page.click('#register-native'); + await page.locator('#native-result').filter({hasText: /./}).waitFor(); + return page.locator('#native-result').textContent(); + `, &nativeResult) + require.Equal(t, "registered", nativeResult) + require.EventuallyWithT(t, func(collect *assert.CollectT) { + tools = toolsByName(collect) + assert.Equal(collect, "Native search.", tools["search_items"].Tool.Description) + }, 10*time.Second, 250*time.Millisecond) + nativeSearch := invoke(tools["search_items"].ToolRef, map[string]any{}) + require.Equal(t, http.StatusOK, nativeSearch.StatusCode(), "%s", nativeSearch.Body) + require.Equal(t, map[string]any{"source": "native"}, nativeSearch.JSON200.Output) + + // A tool that navigates its document completes like a native one and the + // old registrations disappear with the document. + tabID := search.Source.TabId + navigated := invoke(tools["navigate_away"].ToolRef, map[string]any{}) + require.Equal(t, http.StatusOK, navigated.StatusCode(), "%s", navigated.Body) + require.Equal(t, instanceoapi.WebMCPInvocationResultStatusCompleted, navigated.JSON200.Status) + require.Equal(t, []any{}, navigated.JSON200.Output) + require.EventuallyWithT(t, func(collect *assert.CollectT) { + rsp, err := client.GetWebMCPToolsWithResponse(ctx, &instanceoapi.GetWebMCPToolsParams{}) + if !assert.NoError(collect, err) || !assert.Equal(collect, http.StatusOK, rsp.StatusCode(), "%s", rsp.Body) || rsp.JSON200 == nil { + return + } + for _, tool := range rsp.JSON200.Tools { + assert.NotEqual(collect, tabID, tool.Source.TabId, "%s survived navigation", tool.Tool.Name) + } + }, 10*time.Second, 250*time.Millisecond) + stale := invoke(tools["search_items"].ToolRef, map[string]any{"query": "x"}) + require.Equal(t, http.StatusNotFound, stale.StatusCode(), "%s", stale.Body) +} diff --git a/server/e2e/testdata/webmcp/README.md b/server/e2e/testdata/webmcp/README.md index 1350b240..abf75398 100644 --- a/server/e2e/testdata/webmcp/README.md +++ b/server/e2e/testdata/webmcp/README.md @@ -14,3 +14,15 @@ E2E_CHROMIUM_HEADLESS_IMAGE=kernel-headless-test \ ``` Chromium 152.0.7977.42 with the image's default WebMCP flags exposes `reserve_table` with string name/date fields, a numeric party size with bounds, and a seating enum. It adds a date format hint to the field description. Both top-level and embedded invocations return `completed` and the submitted values; the tests also read the DOM through `/playwright/execute` to verify native agent submission occurred exactly once. Discovery and invocation responses are logged by the test. Missing declarative tools fail the test rather than silently skipping supported behavior. + +# Polyfill WebMCP fixture + +`polyfill.html` mirrors sites that ship their own tools through a `navigator.modelContext` +polyfill (a registry object plus `registerTool`, `unregisterTool`, `listTools`, and +`callTool`). The polyfill installs 1.5s after load, the page also registers `shared_name` +natively so native precedence is covered, and a `srcdoc` child frame carries a Map-based +registry of its own. Run with: + +```bash +GOFLAGS='-run=TestPlaywrightExecuteAPI/WebMCPPolyfill -count=1' make test-e2e +``` diff --git a/server/e2e/testdata/webmcp/polyfill.html b/server/e2e/testdata/webmcp/polyfill.html new file mode 100644 index 00000000..71cfe7a0 --- /dev/null +++ b/server/e2e/testdata/webmcp/polyfill.html @@ -0,0 +1,135 @@ + + + + + Polyfill catalog + + +

Catalog

+

+ + +

+ + + + + + diff --git a/server/lib/browsersurface/frames.go b/server/lib/browsersurface/frames.go index e8b4a770..233fe931 100644 --- a/server/lib/browsersurface/frames.go +++ b/server/lib/browsersurface/frames.go @@ -165,7 +165,7 @@ func (t *Tracker) initializeSession(sessionID string) { result.FrameTree.Frame.ParentID = existing.parentID } } - t.addFrameTreeLocked(sess.tabID, result.FrameTree) + t.addFrameTreeLocked(sess.tabID, sessionID, result.FrameTree) if sess.target.Type == "page" { if trackedTab := t.tabs[sess.tabID]; trackedTab != nil { trackedTab.rootFrameID = result.FrameTree.Frame.ID @@ -189,14 +189,14 @@ func (t *Tracker) failSessionInitialization(sessionID string, err error) { } } -func (t *Tracker) addFrameTreeLocked(tabID int, tree frameTree) { - t.upsertFrameLocked(tabID, tree.Frame) +func (t *Tracker) addFrameTreeLocked(tabID int, sessionID string, tree frameTree) { + t.upsertFrameLocked(tabID, sessionID, tree.Frame) for _, child := range tree.ChildFrames { - t.addFrameTreeLocked(tabID, child) + t.addFrameTreeLocked(tabID, sessionID, child) } } -func (t *Tracker) upsertFrameLocked(tabID int, info frameInfo) { +func (t *Tracker) upsertFrameLocked(tabID int, sessionID string, info frameInfo) { tracked := t.frames[info.ID] if tracked == nil { publicID := 0 @@ -212,6 +212,7 @@ func (t *Tracker) upsertFrameLocked(tabID int, info frameInfo) { } tracked.parentID = info.ParentID tracked.tabID = tabID + tracked.sessionID = sessionID tracked.url = info.URL } @@ -255,7 +256,7 @@ func (t *Tracker) bindSessionsLocked() { 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.upsertFrameLocked(sess.tabID, sessionID, frameInfo{ID: frameID, ParentID: parentFrameID}) t.bindSessionsLocked() } t.stateMu.Unlock() @@ -272,7 +273,7 @@ func (t *Tracker) navigateFrame(sessionID string, info frameInfo) { info.ParentID = existing.parentID } } - t.upsertFrameLocked(sess.tabID, info) + t.upsertFrameLocked(sess.tabID, sessionID, info) if trackedTab := t.tabs[sess.tabID]; trackedTab != nil && trackedTab.rootFrameID == info.ID { trackedTab.url = info.URL } @@ -348,6 +349,11 @@ func (t *Tracker) removeSessionLocked(sessionID string) []string { removed = append(removed, id) } } + for _, tracked := range t.frames { + if toRemove[tracked.sessionID] { + tracked.sessionID = "" + } + } sort.Strings(removed) return removed } diff --git a/server/lib/browsersurface/iframe_initialization_test.go b/server/lib/browsersurface/iframe_initialization_test.go index 6f2c2e90..bf2717cd 100644 --- a/server/lib/browsersurface/iframe_initialization_test.go +++ b/server/lib/browsersurface/iframe_initialization_test.go @@ -15,7 +15,7 @@ func TestExplicitIframeInitializationDoesNotWaitForParent(t *testing.T) { tracker.stateMu.Lock() // The page's frame is known, but its session initialization is still pending. tracker.sessions["session-a"] = &session{id: "session-a", target: page, tabID: tabID, initializing: true} - tracker.upsertFrameLocked(tabID, frameInfo{ID: "page-a"}) + tracker.upsertFrameLocked(tabID, "session-a", frameInfo{ID: "page-a"}) tracker.stateMu.Unlock() tracker.addSession("oopif-session", "", targetInfo{ TargetID: "oopif", Type: "iframe", ParentFrameID: "page-a", diff --git a/server/lib/browsersurface/tracker.go b/server/lib/browsersurface/tracker.go index 2e7e6502..f55d2a89 100644 --- a/server/lib/browsersurface/tracker.go +++ b/server/lib/browsersurface/tracker.go @@ -296,6 +296,55 @@ func (t *Tracker) Snapshot() Snapshot { return snapshot } +// SessionFrames lists every frame of an initialized page or iframe session, +// roots first, so callers can evaluate in each document through the session +// that owns it. +func (t *Tracker) SessionFrames() []SessionFrame { + t.stateMu.RLock() + defer t.stateMu.RUnlock() + // An out-of-process iframe session owns its own frame regardless of which + // session reported the frame last. + iframeSessions := make(map[string]*session) + for _, sess := range t.sessions { + if sess.target.Type == "iframe" { + iframeSessions[sess.target.TargetID] = sess + } + } + owner := func(tracked *frame) *session { + if sess := iframeSessions[tracked.rawID]; sess != nil { + return sess + } + return t.sessions[tracked.sessionID] + } + frames := make([]SessionFrame, 0, len(t.frames)) + for _, tracked := range t.frames { + sess := owner(tracked) + if sess == nil || !sess.initialized || sess.tabID == 0 || sess.tabID != tracked.tabID { + continue + } + root := true + if parent := t.frames[tracked.parentID]; parent != nil { + root = owner(parent) != sess + } + frames = append(frames, SessionFrame{ + SessionID: sess.id, + FrameID: tracked.rawID, + Root: root, + URL: tracked.url, + }) + } + sort.Slice(frames, func(i, j int) bool { + if frames[i].SessionID != frames[j].SessionID { + return frames[i].SessionID < frames[j].SessionID + } + if frames[i].Root != frames[j].Root { + return frames[i].Root + } + return frames[i].FrameID < frames[j].FrameID + }) + return frames +} + func (t *Tracker) SessionExists(sessionID string) bool { t.stateMu.RLock() defer t.stateMu.RUnlock() diff --git a/server/lib/browsersurface/tracker_test.go b/server/lib/browsersurface/tracker_test.go index a1af6806..ca2cf582 100644 --- a/server/lib/browsersurface/tracker_test.go +++ b/server/lib/browsersurface/tracker_test.go @@ -341,6 +341,31 @@ func TestTrackerMapsBrowserSurfaceAndPublishesLifecycleEvents(t *testing.T) { require.Equal(t, 2, moved.WindowID) } +func TestSessionFramesReportOwnersAndRoots(t *testing.T) { + protocol := newFakeProtocol() + tracker := New(protocol) + require.NoError(t, tracker.Start(context.Background())) + protocol.emitTarget("Target.targetCreated", map[string]any{ + "targetInfo": map[string]any{ + "targetId": "oopif", "type": "iframe", "url": "https://cross-origin.example/", + "parentFrameId": "root-a", + }, + }) + + var frames []SessionFrame + require.Eventually(t, func() bool { + frames = tracker.SessionFrames() + return len(frames) == 5 + }, time.Second, 10*time.Millisecond) + require.Equal(t, []SessionFrame{ + {SessionID: "oopif-session", FrameID: "oopif", Root: true, URL: "https://cross-origin.example/"}, + {SessionID: "session-a", FrameID: "root-a", Root: true, URL: "https://store.example/"}, + {SessionID: "session-a", FrameID: "inner", URL: "https://bank.example/"}, + {SessionID: "session-a", FrameID: "outer", URL: "https://payments.example/"}, + {SessionID: "session-b", FrameID: "root-b", Root: true, URL: "https://travel.example/"}, + }, frames) +} + func TestTrackerPreservesFramesDuringProcessSwap(t *testing.T) { protocol := newFakeProtocol() tracker := New(protocol) diff --git a/server/lib/browsersurface/types.go b/server/lib/browsersurface/types.go index 1684632e..ccf36c45 100644 --- a/server/lib/browsersurface/types.go +++ b/server/lib/browsersurface/types.go @@ -76,6 +76,17 @@ type FrameLocation struct { URL string } +// SessionFrame is a frame whose document is reachable through the session +// that reported it. Root frames are the main frame of a page target or the +// frame of an out-of-process iframe target; other frames are same-process +// children of the session's root. +type SessionFrame struct { + SessionID string + FrameID string + Root bool + URL string +} + type Location struct { WindowID int TabID int @@ -132,9 +143,10 @@ type tab struct { } type frame struct { - id int - rawID string - parentID string - tabID int - url string + id int + rawID string + parentID string + tabID int + sessionID string + url string } diff --git a/server/lib/webmcpclient/client.go b/server/lib/webmcpclient/client.go index 01425e88..10dc02ae 100644 --- a/server/lib/webmcpclient/client.go +++ b/server/lib/webmcpclient/client.go @@ -24,6 +24,7 @@ const ( maxToolsPerSession = 256 maxCompletedInvocations = 256 maxAbandonedInvocations = 256 + maxExceptionTextBytes = 64 << 10 ) type connection struct { @@ -41,6 +42,8 @@ type connection struct { invocations map[invocationKey]invocationResponse waitingInvocations map[invocationKey]string abandonedInvocations map[invocationKey]time.Time + polyfillBridges map[string]*polyfillBridge + polyfillSyncMu sync.Mutex stateChangedCh chan struct{} logger *slog.Logger @@ -63,6 +66,7 @@ func newConnection(protocol *cdpclient.Client) *connection { invocations: make(map[invocationKey]invocationResponse), waitingInvocations: make(map[invocationKey]string), abandonedInvocations: make(map[invocationKey]time.Time), + polyfillBridges: make(map[string]*polyfillBridge), stateChangedCh: make(chan struct{}, 1), logger: slog.Default(), eventsCancel: cancel, @@ -184,6 +188,9 @@ func (c *connection) handleProtocolEvent(message cdpclient.Message) { case "WebMCP.toolResponded": var response invocationResponse if json.Unmarshal(message.Params, &response) == nil { + if response.ErrorText == "" && response.Exception != nil { + response.ErrorText = exceptionText(response.Exception) + } key := invocationKey{sessionID: message.SessionID, invocationID: response.InvocationID} c.stateMu.Lock() c.pruneAbandonedInvocationsLocked() @@ -204,6 +211,26 @@ func (c *connection) handleProtocolEvent(message cdpclient.Message) { } } +// exceptionText reports what a page tool threw, bounded to +// maxExceptionTextBytes. Chromium leaves errorText empty and describes Error +// objects with their stack, so only the message line is kept. +func exceptionText(exception *exceptionDetails) string { + var text string + if exception.Description != "" { + text, _, _ = strings.Cut(exception.Description, "\n at ") + } else if value, ok := exception.Value.(string); ok { + text = value + } else if exception.Value != nil { + if encoded, err := json.Marshal(exception.Value); err == nil { + text = string(encoded) + } + } + if len(text) > maxExceptionTextBytes { + text = strings.ToValidUTF8(text[:maxExceptionTextBytes], "") + } + return text +} + // customToolIdentity decodes the hidden name used for a custom registration. // The browser registers custom.. to avoid collisions with page // tools, while discovery exposes the original name and the generated ID separately. @@ -258,7 +285,11 @@ func (c *connection) addTools(sessionID string, tools []toolEvent) { c.toolRefs[key] = ref tracked++ } - customID, name := customToolIdentity(tool.Name) + var customID string + name, bridged := strings.CutPrefix(tool.Name, polyfillToolNamePrefix) + if !bridged { + customID, name = customToolIdentity(tool.Name) + } c.tools[ref] = ®isteredTool{ ref: ref, sessionID: sessionID, @@ -270,6 +301,7 @@ func (c *connection) addTools(sessionID string, tools []toolEvent) { customID: customID, frameID: tool.FrameID, declarative: tool.BackendNodeID != nil, + bridged: bridged, } } c.signalStateChanged() @@ -278,8 +310,19 @@ func (c *connection) addTools(sessionID string, tools []toolEvent) { func (c *connection) toolsSnapshot() []Tool { c.stateMu.RLock() defer c.stateMu.RUnlock() + // A page that registers a name natively while its polyfill still lists it + // keeps the native tool. + native := make(map[string]bool) + for _, tool := range c.tools { + if !tool.bridged && tool.customID == "" { + native[toolKey(tool.sessionID, tool.frameID, tool.name)] = true + } + } result := make([]Tool, 0, len(c.tools)) for _, tool := range c.tools { + if tool.bridged && native[toolKey(tool.sessionID, tool.frameID, tool.name)] { + continue + } location, ok := c.surface.Resolve(tool.sessionID, tool.frameID) if !ok { continue @@ -498,7 +541,7 @@ func (c *connection) removeSession(sessionID string) { } for ref, tool := range c.tools { if tool.sessionID == sessionID { - delete(c.toolRefs, toolKey(tool.sessionID, tool.frameID, tool.name)) + delete(c.toolRefs, toolKey(tool.sessionID, tool.frameID, tool.registeredName)) delete(c.tools, ref) } } @@ -516,7 +559,7 @@ func (c *connection) abandonFrameInvocationsAcrossSessionsLocked(frameID string) 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.toolRefs, toolKey(tool.sessionID, tool.frameID, tool.registeredName)) delete(c.tools, ref) } } @@ -525,7 +568,7 @@ func (c *connection) removeFrameToolsLocked(sessionID, frameID string) { 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.toolRefs, toolKey(tool.sessionID, tool.frameID, tool.registeredName)) delete(c.tools, ref) } } diff --git a/server/lib/webmcpclient/client_test.go b/server/lib/webmcpclient/client_test.go index 70cca9a6..ecc0df15 100644 --- a/server/lib/webmcpclient/client_test.go +++ b/server/lib/webmcpclient/client_test.go @@ -67,12 +67,22 @@ type fakeCDP struct { popupOpen bool iframeOpen bool nestedFrameOpen bool + childFrameOpen bool + polyfillTools map[string][]string + bridgedTools map[string]map[string]bool + staleBridges map[string]bool + bridgesCreated int + lastInvokedName string + methods map[string]int 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 := &fakeCDP{ + enabledSessions: make(map[string]int), methods: make(map[string]int), omitResponse: omitResponse, toolCount: 1, + bridgedTools: make(map[string]map[string]bool), staleBridges: make(map[string]bool), + } fake.server = httptest.NewServer(http.HandlerFunc(fake.serve)) fake.url = "ws" + strings.TrimPrefix(fake.server.URL, "http") t.Cleanup(fake.server.Close) @@ -135,6 +145,9 @@ func (f *fakeCDP) serve(w http.ResponseWriter, r *http.Request) { respond := func(result any) { write(map[string]any{"id": request.ID, "result": result}) } + f.mu.Lock() + f.methods[request.Method]++ + f.mu.Unlock() switch request.Method { case "Target.setDiscoverTargets": respond(map[string]any{}) @@ -211,7 +224,18 @@ func (f *fakeCDP) serve(w http.ResponseWriter, r *http.Request) { case "Page.enable": respond(map[string]any{}) case "Page.getFrameTree": - respond(map[string]any{"frameTree": frameTreeForSession(request.SessionID)}) + tree := frameTreeForSession(request.SessionID) + f.mu.Lock() + childFrameOpen := f.childFrameOpen + f.mu.Unlock() + if childFrameOpen && request.SessionID == "page-session" { + tree["childFrames"] = []map[string]any{{"frame": map[string]any{ + "id": "page-child", "parentId": "page-frame", "loaderId": "child-loader", "url": "https://merchant.example/child", + }}} + } + respond(map[string]any{"frameTree": tree}) + case "Runtime.evaluate", "Runtime.callFunctionOn", "Runtime.releaseObjectGroup", "DOM.getFrameOwner", "DOM.resolveNode": + f.servePolyfill(request, respond, write) case "WebMCP.enable": f.mu.Lock() f.enabledSessions[request.SessionID]++ @@ -248,7 +272,12 @@ func (f *fakeCDP) serve(w http.ResponseWriter, r *http.Request) { "params": map[string]any{"tools": tools}, }) case "WebMCP.invokeTool": + var invokeParams struct { + ToolName string `json:"toolName"` + } + _ = json.Unmarshal(request.Params, &invokeParams) f.mu.Lock() + f.lastInvokedName = invokeParams.ToolName f.invocationCount++ invocationID := fmt.Sprintf("invocation-%d", f.invocationCount) closeOnInvoke := f.closeOnInvoke diff --git a/server/lib/webmcpclient/manager.go b/server/lib/webmcpclient/manager.go index 93d2495f..4941a907 100644 --- a/server/lib/webmcpclient/manager.go +++ b/server/lib/webmcpclient/manager.go @@ -51,6 +51,9 @@ func (m *Manager) Tools(ctx context.Context) ([]Tool, error) { if !conn.surface.HasTabs() { return nil, ErrNoPageTarget } + if conn.syncPolyfillTools(ctx) { + conn.waitForSettled(ctx) + } return conn.toolsSnapshot(), nil } diff --git a/server/lib/webmcpclient/polyfill.go b/server/lib/webmcpclient/polyfill.go new file mode 100644 index 00000000..e06db528 --- /dev/null +++ b/server/lib/webmcpclient/polyfill.go @@ -0,0 +1,261 @@ +package webmcpclient + +import ( + "context" + _ "embed" + "encoding/json" + "errors" + "fmt" + "strings" + "sync" + "time" + + "github.com/kernel/kernel-images/server/lib/browsersurface" + "github.com/kernel/kernel-images/server/lib/cdpclient" + "github.com/nrednav/cuid2" +) + +// Sites that ship their own WebMCP tools before the browser exposes the +// native registry install a polyfill on navigator.modelContext. Chromium +// dropped that alias in favor of document.modelContext, so nothing such a +// polyfill registers reaches the CDP WebMCP domain. polyfill_bridge.js copies +// those tools into the frame's native registry, after which they are listed, +// invoked, and removed like any other page tool. +// +//go:embed polyfill_bridge.js +var polyfillBridgeSource string + +// polyfillToolNamePrefix must match BRIDGED_PREFIX in polyfill_bridge.js. +// Bridged tools register under this prefix so the page can still register +// the original name natively; discovery strips it. +const polyfillToolNamePrefix = "polyfill." + +const ( + polyfillSyncTimeout = 2 * time.Second + polyfillSyncConcurrency = 8 + polyfillReleaseTimeout = time.Second +) + +// polyfillBridge is a remote object handle for one frame's bridge. The handle +// lives in the frame's execution context and becomes invalid when it goes away. +type polyfillBridge struct { + objectID string + group string +} + +func polyfillBridgeKey(sessionID, frameID string) string { + return sessionID + "\x00" + frameID +} + +// polyfillFrameURL excludes browser-internal documents; every web document, +// including about:blank and srcdoc frames that inherit their parent's origin, +// can carry a polyfill. +func polyfillFrameURL(url string) bool { + return !strings.HasPrefix(url, "chrome") && !strings.HasPrefix(url, "devtools://") +} + +// syncPolyfillTools brings every tracked frame's bridged registrations up to +// date with its polyfill and reports whether any native registry changed. +func (c *connection) syncPolyfillTools(ctx context.Context) bool { + // One bridge per document: concurrent listings would otherwise create two + // and leave the first one's registrations unmanaged. + c.polyfillSyncMu.Lock() + defer c.polyfillSyncMu.Unlock() + frames := c.surface.SessionFrames() + live := make(map[string]bool, len(frames)) + for _, frame := range frames { + live[polyfillBridgeKey(frame.SessionID, frame.FrameID)] = true + } + c.stateMu.Lock() + for key := range c.polyfillBridges { + if !live[key] { + delete(c.polyfillBridges, key) + } + } + c.stateMu.Unlock() + + ctx, cancel := context.WithTimeout(ctx, polyfillSyncTimeout) + defer cancel() + var ( + wg sync.WaitGroup + mu sync.Mutex + changed bool + ) + semaphore := make(chan struct{}, polyfillSyncConcurrency) + for _, frame := range frames { + if !polyfillFrameURL(frame.URL) { + continue + } + wg.Add(1) + go func(frame browsersurface.SessionFrame) { + defer wg.Done() + semaphore <- struct{}{} + defer func() { <-semaphore }() + if c.syncPolyfillFrame(ctx, frame) { + mu.Lock() + changed = true + mu.Unlock() + } + }(frame) + } + wg.Wait() + return changed +} + +func (c *connection) syncPolyfillFrame(ctx context.Context, frame browsersurface.SessionFrame) bool { + // A handle from a replaced document fails once; the retry bridges the + // frame's current document in the same listing. + for attempt := 0; attempt < 2; attempt++ { + changed, stale := c.syncPolyfillBridge(ctx, frame) + if !stale { + return changed + } + } + return false +} + +// syncPolyfillBridge syncs the frame's bridge, creating it when needed, and +// reports whether the bridge's handle was stale. +func (c *connection) syncPolyfillBridge(ctx context.Context, frame browsersurface.SessionFrame) (changed, stale bool) { + key := polyfillBridgeKey(frame.SessionID, frame.FrameID) + c.stateMu.RLock() + bridge := c.polyfillBridges[key] + c.stateMu.RUnlock() + if bridge == nil { + var err error + if bridge, err = c.createPolyfillBridge(ctx, frame); err != nil { + return false, false + } + c.stateMu.Lock() + c.polyfillBridges[key] = bridge + c.stateMu.Unlock() + } + + raw, err := c.surface.Send(ctx, "Runtime.callFunctionOn", map[string]any{ + "objectId": bridge.objectID, + "functionDeclaration": "function() { return this.sync(); }", + "awaitPromise": true, + "returnByValue": true, + }, frame.SessionID) + if err == nil { + var result evaluationResult + if err = json.Unmarshal(raw, &result); err == nil && result.ExceptionDetails != nil { + err = errors.New("WebMCP: polyfill bridge threw") + } + if err == nil { + return string(result.Result.Value) == "true", false + } + } + // A protocol error means the handle's document is gone. Timeouts keep the + // handle, which may still own registrations. + var protocolErr *cdpclient.Error + if !errors.As(err, &protocolErr) { + return false, false + } + c.stateMu.Lock() + if c.polyfillBridges[key] == bridge { + delete(c.polyfillBridges, key) + } + c.stateMu.Unlock() + go c.releaseObjectGroup(frame.SessionID, bridge.group) + return false, true +} + +type evaluationResult struct { + Result struct { + ObjectID string `json:"objectId"` + Value json.RawMessage `json:"value"` + } `json:"result"` + ExceptionDetails *json.RawMessage `json:"exceptionDetails"` +} + +func (c *connection) createPolyfillBridge(ctx context.Context, frame browsersurface.SessionFrame) (*polyfillBridge, error) { + group := "kernel-webmcp-polyfill-" + cuid2.Generate() + windowID, err := c.frameWindow(ctx, frame, group) + if err == nil { + var raw json.RawMessage + raw, err = c.surface.Send(ctx, "Runtime.callFunctionOn", map[string]any{ + "objectId": windowID, + "functionDeclaration": polyfillBridgeSource, + "objectGroup": group, + }, frame.SessionID) + if err == nil { + var result evaluationResult + if err = json.Unmarshal(raw, &result); err == nil { + if result.ExceptionDetails != nil || result.Result.ObjectID == "" { + err = errors.New("WebMCP: polyfill bridge is unavailable") + } else { + return &polyfillBridge{objectID: result.Result.ObjectID, group: group}, nil + } + } + } + } + go c.releaseObjectGroup(frame.SessionID, group) + return nil, err +} + +func (c *connection) releaseObjectGroup(sessionID, group string) { + ctx, cancel := context.WithTimeout(context.Background(), polyfillReleaseTimeout) + defer cancel() + _, _ = c.surface.Send(ctx, "Runtime.releaseObjectGroup", map[string]any{"objectGroup": group}, sessionID) +} + +// frameWindow returns a remote object handle for the frame's Window. Root +// frames evaluate directly in their session; same-process child frames are +// reached through their owner element's contentWindow, which only succeeds +// for same-origin documents. +func (c *connection) frameWindow(ctx context.Context, frame browsersurface.SessionFrame, group string) (string, error) { + if frame.Root { + raw, err := c.surface.Send(ctx, "Runtime.evaluate", map[string]any{ + "expression": "window", + "objectGroup": group, + }, frame.SessionID) + if err != nil { + return "", err + } + var result evaluationResult + if err := json.Unmarshal(raw, &result); err != nil || result.Result.ObjectID == "" { + return "", fmt.Errorf("WebMCP: frame window is unavailable") + } + return result.Result.ObjectID, nil + } + + raw, err := c.surface.Send(ctx, "DOM.getFrameOwner", map[string]any{"frameId": frame.FrameID}, frame.SessionID) + if err != nil { + return "", err + } + var owner struct { + BackendNodeID int `json:"backendNodeId"` + } + if err := json.Unmarshal(raw, &owner); err != nil || owner.BackendNodeID == 0 { + return "", fmt.Errorf("WebMCP: frame owner is unavailable") + } + raw, err = c.surface.Send(ctx, "DOM.resolveNode", map[string]any{ + "backendNodeId": owner.BackendNodeID, + "objectGroup": group, + }, frame.SessionID) + if err != nil { + return "", err + } + var node struct { + Object struct { + ObjectID string `json:"objectId"` + } `json:"object"` + } + if err := json.Unmarshal(raw, &node); err != nil || node.Object.ObjectID == "" { + return "", fmt.Errorf("WebMCP: frame owner is unavailable") + } + raw, err = c.surface.Send(ctx, "Runtime.callFunctionOn", map[string]any{ + "objectId": node.Object.ObjectID, + "functionDeclaration": "function() { return this.contentWindow; }", + "objectGroup": group, + }, frame.SessionID) + if err != nil { + return "", err + } + var window evaluationResult + if err := json.Unmarshal(raw, &window); err != nil || window.Result.ObjectID == "" { + return "", fmt.Errorf("WebMCP: frame window is unavailable") + } + return window.Result.ObjectID, nil +} diff --git a/server/lib/webmcpclient/polyfill_bridge.js b/server/lib/webmcpclient/polyfill_bridge.js new file mode 100644 index 00000000..9e2af9e5 --- /dev/null +++ b/server/lib/webmcpclient/polyfill_bridge.js @@ -0,0 +1,327 @@ +// Copies tools that a page registers through a JavaScript +// navigator.modelContext polyfill into the frame's native document.modelContext +// registry, where the CDP WebMCP domain lists and invokes them like any other +// page tool. Called with a frame's Window as `this`; the returned bridge is +// reachable only through the caller's remote object handle, so nothing is +// stored on the page. +// +// Bridged tools register under BRIDGED_PREFIX, never under the page's own +// names, so the page can still register any name natively later. Once it +// does, the bridge withdraws its copy. +function () { + const BRIDGED_PREFIX = 'polyfill.'; + const MAX_TOOLS = 256; + const MAX_TEXT = 64 * 1024; + const MAX_TOOL_BYTES = 256 * 1024; + const MAX_LIST_BYTES = 1024 * 1024; + const MAX_OUTPUT_BYTES = 1024 * 1024; + const window = this; + + function isObject(value) { + return typeof value === 'object' && value !== null; + } + + function isFunction(value) { + return typeof value === 'function'; + } + + function isNative(context) { + try { + return Object.prototype.toString.call(context) === '[object ModelContext]'; + } catch { + return false; + } + } + + function nativeContext() { + try { + const context = window.document.modelContext; + return isNative(context) ? context : null; + } catch { + return null; + } + } + + // The page-created object on navigator.modelContext, or null when it is + // absent or is the native registry itself. + function polyfill() { + let context; + try { + context = window.navigator.modelContext; + } catch { + return null; + } + if (!isObject(context) && !isFunction(context)) return null; + if (isNative(context) || context === nativeContext()) return null; + return context; + } + + function currentDocument() { + try { + return window.document; + } catch { + return null; + } + } + + // Tool registries that polyfills keep next to their public methods. + function registryEntries(context) { + const candidates = []; + for (const key of ['_registeredTools', '_tools', 'tools']) { + try { + candidates.push(context[key]); + } catch { + // A throwing accessor is not a registry. + } + } + try { + candidates.push(window.__webmcp && window.__webmcp.tools); + } catch { + // Not a registry either. + } + for (const registry of candidates) { + try { + if (isObject(registry) && isFunction(registry.values) && isFunction(registry.get)) { + return Array.from(registry.values()); + } + if (Array.isArray(registry)) return registry; + if (isObject(registry)) return Object.values(registry); + } catch { + continue; + } + } + return null; + } + + async function listedTools(context) { + for (const method of ['listTools', 'getTools']) { + let fn; + try { + fn = context[method]; + } catch { + continue; + } + if (!isFunction(fn)) continue; + try { + const result = await fn.call(context); + if (Array.isArray(result)) return result; + if (isObject(result) && Array.isArray(result.tools)) return result.tools; + } catch { + // Fall through to the next source. + } + } + return registryEntries(context); + } + + function text(value) { + if (typeof value !== 'string') return undefined; + return value.length > MAX_TEXT ? value.slice(0, MAX_TEXT) : value; + } + + function schema(value) { + if (typeof value === 'string') { + try { + value = JSON.parse(value); + } catch { + return undefined; + } + } + return isObject(value) && !Array.isArray(value) ? value : undefined; + } + + // A JSON copy of the tool's metadata, so nothing page-owned reaches the + // native registry except the data itself. + function metadata(tool, registered) { + try { + if (!isObject(tool)) return null; + const name = tool.name; + if (typeof name !== 'string' || name === '' || name.length > 256 - BRIDGED_PREFIX.length) return null; + const entry = { + name, + // listTools() often drops the title that the registry entry keeps. + title: text(tool.title) ?? text(registered?.title), + description: text(tool.description) ?? '', + // Some polyfills accept the pre-standard `parameters` alias for inputSchema. + inputSchema: schema(tool.inputSchema) ?? schema(tool.parameters) ?? {type: 'object'}, + outputSchema: schema(tool.outputSchema), + annotations: isObject(tool.annotations) ? tool.annotations : undefined, + }; + const serialized = JSON.stringify(entry); + if (typeof serialized !== 'string' || serialized.length > MAX_TOOL_BYTES) return null; + return {entry: JSON.parse(serialized), serialized}; + } catch { + return null; + } + } + + function registeredByName(context) { + const byName = new Map(); + for (const entry of registryEntries(context) ?? []) { + try { + if (isObject(entry) && typeof entry.name === 'string') byName.set(entry.name, entry); + } catch { + continue; + } + } + return byName; + } + + async function desiredTools(context) { + const tools = await listedTools(context); + const desired = new Map(); + if (!Array.isArray(tools)) return desired; + const registered = registeredByName(context); + let bytes = 0; + for (const tool of tools) { + if (desired.size >= MAX_TOOLS) break; + let name; + try { + name = tool.name; + } catch { + continue; + } + const item = metadata(tool, registered.get(name)); + if (!item || desired.has(item.entry.name)) continue; + if (bytes + item.serialized.length > MAX_LIST_BYTES) break; + bytes += item.serialized.length; + desired.set(item.entry.name, item); + } + return desired; + } + + function registryEntry(context, name) { + const entries = registryEntries(context); + if (!entries) return null; + for (const entry of entries) { + try { + if (isObject(entry) && entry.name === name && isFunction(entry.execute)) return entry; + } catch { + continue; + } + } + return null; + } + + async function run(context, name, input) { + // The registry entry's own execute is unambiguous; callTool's shape is not. + const entry = registryEntry(context, name); + if (entry) return entry.execute(input); + let callTool; + try { + callTool = context.callTool; + } catch { + callTool = undefined; + } + if (isFunction(callTool)) { + // Site polyfills take (name, input); MCP-style polyfills take ({name, arguments}). + // Arity cannot tell defaulted or rest parameters apart, which is why the + // registry entry is tried first. + if (callTool.length >= 2) return callTool.call(context, name, input); + return callTool.call(context, {name, arguments: input}); + } + let executeTool; + try { + executeTool = context.executeTool; + } catch { + executeTool = undefined; + } + if (isFunction(executeTool)) { + const output = await executeTool.call(context, name, JSON.stringify(input)); + if (typeof output !== 'string') return output; + try { + return JSON.parse(output); + } catch { + return output; + } + } + throw new Error('the modelContext polyfill does not expose a way to execute tools'); + } + + function plain(output) { + if (output === undefined) return undefined; + let serialized; + try { + serialized = JSON.stringify(output); + } catch { + throw new Error('tool output is not JSON-serializable'); + } + if (serialized === undefined) return undefined; + if (typeof serialized !== 'string') throw new Error('tool output is not JSON-serializable'); + if (serialized.length > MAX_OUTPUT_BYTES) throw new Error('tool output exceeds 1 MiB'); + return JSON.parse(serialized); + } + + function execute(name) { + return async (input) => { + const context = polyfill(); + if (!context) throw new Error('the page no longer exposes a modelContext polyfill'); + return plain(await run(context, name, input)); + }; + } + + // Names the page registered in the native registry itself. + async function nativeNames(native) { + const names = new Set(); + try { + for (const tool of await native.getTools()) { + if (typeof tool.name === 'string' && !tool.name.startsWith(BRIDGED_PREFIX)) names.add(tool.name); + } + } catch { + // Without a readable registry, discovery still hides shadowed tools. + } + return names; + } + + // name -> {controller, serialized} for tools registered in `bridgedDocument`. + const bridged = new Map(); + let bridgedDocument = null; + + function unregister(name) { + bridged.get(name).controller.abort(); + bridged.delete(name); + } + + return { + // Registers new polyfill tools, unregisters removed or changed ones, and + // reports whether the native registry changed. + async sync() { + const document = currentDocument(); + if (document !== bridgedDocument) { + // A same-origin child frame navigated; its registrations went with it. + bridged.clear(); + bridgedDocument = document; + } + const native = nativeContext(); + const context = polyfill(); + const desired = native && context ? await desiredTools(context) : new Map(); + if (desired.size > 0) { + for (const name of await nativeNames(native)) desired.delete(name); + } + let changed = false; + for (const [name, registration] of bridged) { + if (desired.get(name)?.serialized !== registration.serialized) { + unregister(name); + changed = true; + } + } + for (const [name, item] of desired) { + if (bridged.has(name)) continue; + const controller = new AbortController(); + try { + await native.registerTool( + {...item.entry, name: BRIDGED_PREFIX + name, execute: execute(name)}, + {signal: controller.signal}, + ); + } catch { + // The registry rejected the tool (for example its schema); the next + // sync tries again. + controller.abort(); + continue; + } + bridged.set(name, {controller, serialized: item.serialized}); + changed = true; + } + return changed; + }, + }; +} diff --git a/server/lib/webmcpclient/polyfill_test.go b/server/lib/webmcpclient/polyfill_test.go new file mode 100644 index 00000000..9dc4fb44 --- /dev/null +++ b/server/lib/webmcpclient/polyfill_test.go @@ -0,0 +1,259 @@ +package webmcpclient + +import ( + "context" + "encoding/json" + "sort" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +// Window handles are "window:" for roots and +// "window::page-child" for the same-process child frame; bridge +// handles are "bridge|". +var polyfillWindowFrames = map[string]string{ + "window:page-session": "page-frame", + "window:page-session:page-child": "page-child", + "window:iframe-session": "iframe-frame", +} + +// servePolyfill answers the Runtime and DOM commands used to bridge a page's +// navigator.modelContext polyfill. A bridge sync emits WebMCP.toolsAdded and +// toolsRemoved for the difference, the way Chromium reports registrations in +// the native registry. +func (f *fakeCDP) servePolyfill(request wireRequest, respond func(any), write func(any)) { + fail := func(message string) { + write(map[string]any{"id": request.ID, "error": map[string]any{"code": -32000, "message": message}}) + } + var params struct { + ObjectID string `json:"objectId"` + FunctionDeclaration string `json:"functionDeclaration"` + FrameID string `json:"frameId"` + } + _ = json.Unmarshal(request.Params, ¶ms) + + switch request.Method { + case "Runtime.evaluate": + respond(map[string]any{"result": map[string]any{"type": "object", "className": "Window", "objectId": "window:" + request.SessionID}}) + case "DOM.getFrameOwner": + if params.FrameID != "page-child" { + fail("Frame with the given id was not found.") + return + } + respond(map[string]any{"backendNodeId": 7}) + case "DOM.resolveNode": + respond(map[string]any{"object": map[string]any{"type": "object", "subtype": "node", "objectId": "iframe:" + request.SessionID}}) + case "Runtime.releaseObjectGroup": + respond(map[string]any{}) + case "Runtime.callFunctionOn": + switch { + case strings.Contains(params.FunctionDeclaration, "contentWindow"): + respond(map[string]any{"result": map[string]any{"type": "object", "className": "Window", "objectId": "window:" + request.SessionID + ":page-child"}}) + case params.FunctionDeclaration == polyfillBridgeSource: + f.mu.Lock() + f.bridgesCreated++ + f.mu.Unlock() + respond(map[string]any{"result": map[string]any{"type": "object", "objectId": "bridge|" + params.ObjectID}}) + case strings.Contains(params.FunctionDeclaration, "this.sync()"): + windowID := strings.TrimPrefix(params.ObjectID, "bridge|") + f.mu.Lock() + if f.staleBridges[windowID] { + delete(f.staleBridges, windowID) + f.mu.Unlock() + fail("Could not find object with given id") + return + } + desired := make(map[string]bool) + for _, name := range f.polyfillTools[windowID] { + desired[name] = true + } + bridged := f.bridgedTools[windowID] + var added, removed []map[string]any + for name := range bridged { + if !desired[name] { + removed = append(removed, map[string]any{"name": polyfillToolNamePrefix + name, "frameId": polyfillWindowFrames[windowID]}) + } + } + for name := range desired { + if !bridged[name] { + added = append(added, map[string]any{ + "name": polyfillToolNamePrefix + name, "description": name + " description", "frameId": polyfillWindowFrames[windowID], + "inputSchema": map[string]any{"type": "object"}, + }) + } + } + f.bridgedTools[windowID] = desired + f.mu.Unlock() + if len(removed) > 0 { + write(map[string]any{"method": "WebMCP.toolsRemoved", "sessionId": request.SessionID, "params": map[string]any{"tools": removed}}) + } + if len(added) > 0 { + write(map[string]any{"method": "WebMCP.toolsAdded", "sessionId": request.SessionID, "params": map[string]any{"tools": added}}) + } + respond(map[string]any{"result": map[string]any{"type": "boolean", "value": len(added)+len(removed) > 0}}) + default: + fail("unexpected function") + } + } +} + +func newPolyfillFakeCDP(t *testing.T) *fakeCDP { + t.Helper() + fake := newFakeCDP(t, false) + fake.childFrameOpen = true + fake.polyfillTools = map[string][]string{ + // merchant_tool is also registered natively in the same frame. + "window:page-session": {"poly_search", "merchant_tool"}, + "window:page-session:page-child": {"child_poly"}, + "window:iframe-session": {"payment_poly"}, + } + return fake +} + +func toolsByName(tools []Tool) map[string]Tool { + byName := make(map[string]Tool, len(tools)) + for _, tool := range tools { + byName[tool.Name] = tool + } + return byName +} + +func toolNames(tools []Tool) []string { + names := make([]string, 0, len(tools)) + for _, tool := range tools { + names = append(names, tool.Name) + } + sort.Strings(names) + return names +} + +func TestPolyfillToolsAreBridgedIntoTheNativeRegistry(t *testing.T) { + fake := newPolyfillFakeCDP(t) + manager := NewManager(staticUpstream{url: fake.url}) + t.Cleanup(func() { _ = manager.Close() }) + + tools, err := manager.Tools(context.Background()) + require.NoError(t, err) + require.Equal(t, []string{ + "bank_tool", "child_poly", "merchant_tool", "payment_poly", "payment_tool", "poly_search", "search_flights", + }, toolNames(tools)) + byName := toolsByName(tools) + require.Nil(t, byName["poly_search"].Source.Frame) + // The page's native registration is listed, not the bridged copy. + require.Equal(t, "merchant_tool description", byName["merchant_tool"].Description) + require.Equal(t, "https://merchant.example/child", byName["child_poly"].Source.Frame.URL) + // An out-of-process iframe is bridged through its own session. + require.Equal(t, "https://payments.example/element", byName["payment_poly"].Source.Frame.URL) + + // Bridging never enables the Runtime or DOM domains, which pages can detect. + fake.mu.Lock() + require.Zero(t, fake.methods["Runtime.enable"]) + require.Zero(t, fake.methods["DOM.enable"]) + // Every web frame gets a bridge, including the two without a polyfill. + require.Equal(t, 5, fake.bridgesCreated) + fake.mu.Unlock() + + // Bridges and references are reused while the documents live. + again, err := manager.Tools(context.Background()) + require.NoError(t, err) + require.Equal(t, byName["poly_search"].Ref, toolsByName(again)["poly_search"].Ref) + fake.mu.Lock() + require.Equal(t, 5, fake.bridgesCreated) + fake.mu.Unlock() + + // Bridged tools invoke through the native WebMCP domain under their + // registered name. + result, err := manager.Invoke(context.Background(), byName["poly_search"].Ref, map[string]any{"query": "lamp"}) + require.NoError(t, err) + require.Equal(t, "Completed", result.Status) + fake.mu.Lock() + require.Equal(t, "polyfill.poly_search", fake.lastInvokedName) + fake.mu.Unlock() +} + +func TestPolyfillUnregistrationRemovesBridgedTools(t *testing.T) { + fake := newPolyfillFakeCDP(t) + manager := NewManager(staticUpstream{url: fake.url}) + t.Cleanup(func() { _ = manager.Close() }) + tools, err := manager.Tools(context.Background()) + require.NoError(t, err) + childRef := toolsByName(tools)["child_poly"].Ref + + fake.mu.Lock() + fake.polyfillTools["window:page-session"] = nil + fake.mu.Unlock() + after, err := manager.Tools(context.Background()) + require.NoError(t, err) + require.NotContains(t, toolsByName(after), "poly_search") + require.Equal(t, childRef, toolsByName(after)["child_poly"].Ref) +} + +func TestPolyfillBridgeIsRecreatedForANewDocument(t *testing.T) { + fake := newPolyfillFakeCDP(t) + manager := NewManager(staticUpstream{url: fake.url}) + t.Cleanup(func() { _ = manager.Close() }) + tools, err := manager.Tools(context.Background()) + require.NoError(t, err) + oldRef := toolsByName(tools)["poly_search"].Ref + + // Navigation drops the document's registrations and invalidates the + // bridge handle; the next listing bridges the new document. + fake.mu.Lock() + fake.staleBridges["window:page-session"] = true + delete(fake.bridgedTools, "window:page-session") + fake.mu.Unlock() + fake.emit(map[string]any{ + "method": "Page.frameNavigated", "sessionId": "page-session", + "params": map[string]any{"frame": map[string]any{"id": "page-frame", "loaderId": "next-loader", "url": "https://merchant.example/next"}}, + }) + require.Eventually(t, func() bool { + for _, tab := range manager.connection.surface.Snapshot().Tabs { + if tab.PageURL == "https://merchant.example/next" { + return true + } + } + return false + }, 3*time.Second, 10*time.Millisecond) + + // The stale handle is replaced within the same listing. + tools, err = manager.Tools(context.Background()) + require.NoError(t, err) + require.Contains(t, toolsByName(tools), "poly_search") + require.NotEqual(t, oldRef, toolsByName(tools)["poly_search"].Ref) + fake.mu.Lock() + defer fake.mu.Unlock() + require.Equal(t, 6, fake.bridgesCreated) +} + +func TestBridgedToolIsListedWhenTheNativeToolGoesAway(t *testing.T) { + fake := newPolyfillFakeCDP(t) + manager := NewManager(staticUpstream{url: fake.url}) + t.Cleanup(func() { _ = manager.Close() }) + tools, err := manager.Tools(context.Background()) + require.NoError(t, err) + nativeRef := toolsByName(tools)["merchant_tool"].Ref + + fake.emit(map[string]any{ + "method": "WebMCP.toolsRemoved", "sessionId": "page-session", + "params": map[string]any{"tools": []map[string]any{{"name": "merchant_tool", "frameId": "page-frame"}}}, + }) + require.Eventually(t, func() bool { + tools, err := manager.Tools(context.Background()) + require.NoError(t, err) + tool, ok := toolsByName(tools)["merchant_tool"] + return ok && tool.Ref != nativeRef && tool.Description == "merchant_tool description" + }, 3*time.Second, 20*time.Millisecond) +} + +func TestExceptionTextKeepsTheThrownMessage(t *testing.T) { + require.Equal(t, "Error: page says no", exceptionText(&exceptionDetails{ + Type: "object", Description: "Error: page says no\n at execute (:1:147)", + })) + require.Equal(t, "plain string", exceptionText(&exceptionDetails{Type: "string", Value: "plain string"})) + require.Equal(t, `{"code":7}`, exceptionText(&exceptionDetails{Type: "object", Value: map[string]any{"code": 7}})) + require.Empty(t, exceptionText(&exceptionDetails{Type: "undefined"})) + require.Len(t, exceptionText(&exceptionDetails{Type: "string", Value: strings.Repeat("x", 100<<10)}), maxExceptionTextBytes) +} diff --git a/server/lib/webmcpclient/types.go b/server/lib/webmcpclient/types.go index 9f2bcebe..ff6d5743 100644 --- a/server/lib/webmcpclient/types.go +++ b/server/lib/webmcpclient/types.go @@ -59,6 +59,7 @@ type registeredTool struct { customID string frameID string declarative bool + bridged bool } type toolEvent struct { @@ -71,10 +72,19 @@ type toolEvent struct { } type invocationResponse struct { - InvocationID string `json:"invocationId"` - Status string `json:"status"` - Output any `json:"output,omitempty"` - ErrorText string `json:"errorText,omitempty"` + InvocationID string `json:"invocationId"` + Status string `json:"status"` + Output any `json:"output,omitempty"` + ErrorText string `json:"errorText,omitempty"` + Exception *exceptionDetails `json:"exception,omitempty"` +} + +// exceptionDetails is the Runtime.RemoteObject Chromium reports when a page +// tool's execute throws or rejects. +type exceptionDetails struct { + Type string `json:"type"` + Value any `json:"value"` + Description string `json:"description"` } type invocationKey struct { diff --git a/server/runtime/webmcp-polyfill.test.ts b/server/runtime/webmcp-polyfill.test.ts new file mode 100644 index 00000000..1788c2a5 --- /dev/null +++ b/server/runtime/webmcp-polyfill.test.ts @@ -0,0 +1,236 @@ +import assert from 'node:assert/strict'; +import {readFileSync} from 'node:fs'; +import test from 'node:test'; +import vm from 'node:vm'; + +// The page-side bridge that lib/webmcpclient calls on a frame's Window. +const source = readFileSync(new URL('../lib/webmcpclient/polyfill_bridge.js', import.meta.url), 'utf8'); + +interface Bridge { + sync(): Promise; +} + +function bridge(window: unknown): Bridge { + const create = vm.runInNewContext(`(${source})`, {AbortController}) as (this: unknown) => Bridge; + return create.call(window); +} + +type Tool = {name: string; execute?: (input: unknown) => unknown} & Record; + +// Stands in for Chromium's document.modelContext: duplicate names reject and +// aborting the registration signal removes the tool. +class ModelContext { + readonly tools = new Map(); + + get [Symbol.toStringTag]() { + return 'ModelContext'; + } + + async registerTool(tool: Tool, options: {signal?: AbortSignal} = {}) { + if (this.tools.has(tool.name)) throw new Error(`Duplicate tool name: ${tool.name}`); + this.tools.set(tool.name, tool); + options.signal?.addEventListener('abort', () => this.tools.delete(tool.name), {once: true}); + } + + async getTools() { + return [...this.tools.values()].map(({execute: _execute, ...metadata}) => metadata); + } + + // Bridged tools register under the reserved prefix; helpers take the page's name. + bridged() { + return [...this.tools.keys()].filter((name) => name.startsWith(PREFIX)).map((name) => name.slice(PREFIX.length)); + } + + metadata(name: string) { + const {execute: _execute, ...metadata} = this.tools.get(PREFIX + name)!; + return JSON.parse(JSON.stringify(metadata)); + } + + async invoke(name: string, input: unknown) { + return JSON.parse(JSON.stringify(await this.tools.get(PREFIX + name)!.execute!(input) ?? null)); + } +} + +const PREFIX = 'polyfill.'; + +// Mirrors the polyfill shape that sites ship before Chromium exposed +// document.modelContext: a registry object plus listTools/callTool helpers. +function sitePolyfill() { + const registry: Record = {}; + return { + _registeredTools: registry, + registerTool(tool: Tool) { + registry[tool.name] = { + name: tool.name, + title: tool.title, + description: tool.description, + inputSchema: tool.inputSchema ?? tool.parameters, + execute: tool.execute, + }; + }, + unregisterTool(name: string) { + delete registry[name]; + }, + async callTool(name: string, input: unknown) { + const tool = registry[name]; + if (!tool) throw new Error(`Tool not found: ${name}`); + return tool.execute?.(input); + }, + listTools: () => Object.values(registry).map((tool) => ({name: tool.name, description: tool.description, inputSchema: tool.inputSchema})), + }; +} + +function fakeWindow(modelContext: unknown, native = new ModelContext()) { + return {navigator: {modelContext}, document: {modelContext: native}, native}; +} + +test('bridges a site polyfill into the native registry and follows its changes', async () => { + const polyfill = sitePolyfill(); + polyfill.registerTool({ + name: 'search_items', + title: 'Search', + description: 'Search the catalog.', + inputSchema: {type: 'object', properties: {query: {type: 'string'}}, required: ['query']}, + execute: async (input) => ({results: [`match for ${(input as {query: string}).query}`]}), + }); + polyfill.registerTool({ + name: 'legacy_params', + description: 'Uses the pre-standard parameters alias.', + parameters: {type: 'object', properties: {}}, + execute: async () => undefined, + }); + polyfill.registerTool({name: 'broken', description: 'Throws.', inputSchema: {type: 'object'}, execute: async () => { + throw new Error('page rejected the call'); + }}); + const window = fakeWindow(polyfill); + const {native} = window; + const b = bridge(window); + + assert.equal(await b.sync(), true); + assert.deepEqual(native.bridged(), ['search_items', 'legacy_params', 'broken']); + assert.deepEqual(native.metadata('search_items'), { + name: 'polyfill.search_items', + title: 'Search', + description: 'Search the catalog.', + inputSchema: {type: 'object', properties: {query: {type: 'string'}}, required: ['query']}, + }); + assert.deepEqual(native.metadata('legacy_params').inputSchema, {type: 'object', properties: {}}); + assert.deepEqual(await native.invoke('search_items', {query: 'lamp'}), {results: ['match for lamp']}); + assert.equal(await native.invoke('legacy_params', {}), null); + await assert.rejects(native.invoke('broken', {}), /page rejected the call/); + + assert.equal(await b.sync(), false); + + polyfill.unregisterTool('search_items'); + polyfill.registerTool({name: 'broken', description: 'Fixed.', inputSchema: {type: 'object'}, execute: async () => 'ok'}); + assert.equal(await b.sync(), true); + assert.deepEqual(native.bridged().sort(), ['broken', 'legacy_params']); + assert.equal(native.metadata('broken').description, 'Fixed.'); + assert.equal(await native.invoke('broken', {}), 'ok'); +}); + +test('supports MCP-style polyfills that take callTool({name, arguments})', async () => { + const tools = new Map(); + tools.set('add_to_cart', { + name: 'add_to_cart', + title: 'Add to cart', + description: 'Add an item.', + inputSchema: {type: 'object', properties: {sku: {type: 'string'}}}, + outputSchema: {type: 'object'}, + annotations: {readOnlyHint: false}, + execute: async () => ({content: [{type: 'text', text: 'added'}]}), + }); + const polyfill = { + listTools: () => ({tools: [...tools.values()].map(({execute: _execute, ...metadata}) => metadata)}), + async callTool(params: {name: string; arguments: unknown}) { + return tools.get(params.name)?.execute?.(params.arguments); + }, + }; + const window = fakeWindow(polyfill); + await bridge(window).sync(); + + assert.deepEqual(window.native.metadata('add_to_cart'), { + name: 'polyfill.add_to_cart', + title: 'Add to cart', + description: 'Add an item.', + inputSchema: {type: 'object', properties: {sku: {type: 'string'}}}, + outputSchema: {type: 'object'}, + annotations: {readOnlyHint: false}, + }); + assert.deepEqual(await window.native.invoke('add_to_cart', {sku: '1'}), {content: [{type: 'text', text: 'added'}]}); +}); + +test('falls back to the registry when the polyfill has no list or call helpers', async () => { + const registry = new Map(); + registry.set('get_context', {name: 'get_context', description: 'Context.', inputSchema: {type: 'object'}, execute: async () => 'ctx'}); + const polyfill = {registerTool() {}, provideContext() {}}; + const window = {...fakeWindow(polyfill), __webmcp: {tools: registry}}; + await bridge(window).sync(); + + assert.deepEqual(window.native.bridged(), ['get_context']); + assert.equal(await window.native.invoke('get_context', {}), 'ctx'); +}); + +test('never takes a name the page registers natively, before or after bridging', async () => { + const native = new ModelContext(); + await native.registerTool({name: 'shared', description: 'Native copy.', execute: async () => 'native'}); + const polyfill = sitePolyfill(); + polyfill.registerTool({name: 'shared', description: 'Polyfill copy.', inputSchema: {type: 'object'}, execute: async () => 'polyfill'}); + polyfill.registerTool({name: 'later', description: 'Polyfill copy.', inputSchema: {type: 'object'}, execute: async () => 'polyfill'}); + const window = fakeWindow(polyfill, native); + const b = bridge(window); + assert.equal(await b.sync(), true); + assert.deepEqual(native.bridged(), ['later']); + assert.equal(await native.tools.get('shared')!.execute!({}), 'native'); + + // The page registers a bridged name natively afterwards: it succeeds, and + // the next sync withdraws the bridged copy. + await native.registerTool({name: 'later', description: 'Native copy.', execute: async () => 'native'}); + assert.equal(await b.sync(), true); + assert.deepEqual(native.bridged(), []); + assert.deepEqual([...native.tools.keys()].sort(), ['later', 'shared']); + + // navigator.modelContext that is the native registry, or no polyfill at all. + const self = new ModelContext(); + assert.equal(await bridge(fakeWindow(self, self)).sync(), false); + assert.equal(await bridge(fakeWindow(undefined)).sync(), false); + assert.equal(await bridge({navigator: {get modelContext() { + throw new Error('blocked'); + }}, document: {modelContext: new ModelContext()}}).sync(), false); + // A document whose modelContext is not the native registry. + assert.equal(await bridge({navigator: {modelContext: sitePolyfill()}, document: {modelContext: {}}}).sync(), false); +}); + +test('drops malformed entries, bounds output, and resets for a new document', async () => { + const polyfill = { + listTools: () => [ + null, + {name: 42}, + {name: '', description: 'empty'}, + {name: 'dup', description: 'first', inputSchema: '{"type":"object"}'}, + {name: 'dup', description: 'second'}, + {name: 'bad_schema', inputSchema: 'not json', description: 7}, + {name: 'huge', description: 'x', inputSchema: {type: 'object', enum: ['x'.repeat(300 * 1024)]}}, + ], + async callTool(name: string, _input: unknown) { + if (name === 'dup') return {payload: 'x'.repeat(1024 * 1024)}; + const value: Record = {}; + value.self = value; + return value; + }, + }; + const window = fakeWindow(polyfill); + const b = bridge(window); + await b.sync(); + + assert.deepEqual(window.native.bridged(), ['dup', 'bad_schema']); + assert.deepEqual(window.native.metadata('dup'), {name: 'polyfill.dup', description: 'first', inputSchema: {type: 'object'}}); + assert.deepEqual(window.native.metadata('bad_schema'), {name: 'polyfill.bad_schema', description: '', inputSchema: {type: 'object'}}); + await assert.rejects(window.native.invoke('dup', {}), /exceeds 1 MiB/); + await assert.rejects(window.native.invoke('bad_schema', {}), /not JSON-serializable/); + + // A same-origin child frame navigated: its registry is new and empty. + window.document = {modelContext: new ModelContext()}; + assert.equal(await b.sync(), true); + assert.deepEqual((window.document.modelContext as ModelContext).bridged(), ['dup', 'bad_schema']); +});