Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions server/e2e/e2e_playwright_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
138 changes: 138 additions & 0 deletions server/e2e/e2e_webmcp_polyfill_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

the errorText == "nothing to do" assertion was dropped in the rewrite. does the page's thrown message still reach the caller through WebMCP.toolResponded, or does the native domain replace it? if callers now get a generic error, a note in the PR body would help, since the previous revision returned the page's message.


// 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)

// 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(search.ToolRef, map[string]any{"query": "x"})
require.Equal(t, http.StatusNotFound, stale.StatusCode(), "%s", stale.Body)
}
12 changes: 12 additions & 0 deletions server/e2e/testdata/webmcp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
120 changes: 120 additions & 0 deletions server/e2e/testdata/webmcp/polyfill.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Polyfill catalog</title>
</head>
<body>
<h1>Catalog</h1>
<p id="log" role="status"></p>
<button id="unregister" type="button">Remove failing tool</button>
<!-- A same-origin child document with its own registry-style polyfill. -->
<iframe title="Embedded catalog" width="600" height="300" srcdoc="
<title>Embedded polyfill catalog</title>
<script>
const registry = new Map();
navigator.modelContext = {
_registeredTools: registry,
registerTool(tool) { registry.set(tool.name, tool); },
unregisterTool(name) { registry.delete(name); },
listTools: () => [...registry.values()].map(({execute, ...metadata}) => metadata),
async callTool(name, input) { return registry.get(name).execute(input); },
};
navigator.modelContext.registerTool({
name: 'frame_tool',
description: 'Registered by the embedded document.',
inputSchema: {type: 'object'},
execute: async (input) => ({frame: document.title, input}),
});
</script>
"></iframe>
<!-- Mirrors sites that ship WebMCP tools through their own navigator.modelContext
polyfill: a registry object plus registerTool/unregisterTool/listTools/callTool.
The polyfill installs after a delay, like a lazily loaded site bundle. -->
<script>
function installPolyfill() {
if ('modelContext' in navigator) return;
const registry = {};
navigator.modelContext = {
_registeredTools: registry,
registerTool(tool, options) {
registry[tool.name] = {
name: tool.name,
title: tool.title,
description: tool.description,
inputSchema: tool.inputSchema ?? tool.parameters,
execute: tool.execute,
};
const signal = options?.signal ?? tool.signal;
signal?.addEventListener('abort', () => delete registry[tool.name], {once: true});
},
unregisterTool(name) {
delete registry[name];
},
async callTool(name, input) {
const tool = registry[name];
if (!tool) throw new Error(`Tool not found: ${name}`);
return tool.execute(input, undefined);
},
listTools: () => Object.values(registry).map((tool) => ({
name: tool.name,
description: tool.description,
inputSchema: tool.inputSchema,
})),
};
}

function registerTools() {
navigator.modelContext.registerTool({
name: 'search_items',
description: 'Search the catalog.',
inputSchema: {type: 'object', properties: {query: {type: 'string'}}, required: ['query']},
execute: async (input) => {
document.querySelector('#log').textContent = `searched ${input.query}`;
return {results: [`match for ${input.query}`], page: location.pathname};
},
});
navigator.modelContext.registerTool({
name: 'shared_name',
description: 'Polyfill copy.',
inputSchema: {type: 'object'},
execute: async () => ({source: 'polyfill'}),
});
navigator.modelContext.registerTool({
name: 'failing_tool',
description: 'Always throws.',
inputSchema: {type: 'object'},
execute: async () => {
throw new Error('nothing to do');
},
});
navigator.modelContext.registerTool({
name: 'navigate_away',
description: 'Navigates the page and never resolves.',
inputSchema: {type: 'object'},
execute: async () => {
location.href = 'about:blank';
return new Promise(() => {});
},
});
}

// The same name registered natively must win over the polyfill copy.
document.modelContext.registerTool({
name: 'shared_name',
description: 'Native copy.',
inputSchema: {type: 'object'},
execute: async () => ({source: 'native'}),
});

document.querySelector('#unregister').addEventListener('click', () => {
navigator.modelContext.unregisterTool('failing_tool');
});

setTimeout(() => {
installPolyfill();
registerTools();
}, 1500);
</script>
</body>
</html>
20 changes: 13 additions & 7 deletions server/lib/browsersurface/frames.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -212,6 +212,7 @@ func (t *Tracker) upsertFrameLocked(tabID int, info frameInfo) {
}
tracked.parentID = info.ParentID
tracked.tabID = tabID
tracked.sessionID = sessionID
Comment thread
cursor[bot] marked this conversation as resolved.
tracked.url = info.URL
}

Expand Down Expand Up @@ -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()
Expand All @@ -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
}
Expand Down Expand Up @@ -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
}
Expand Down
2 changes: 1 addition & 1 deletion server/lib/browsersurface/iframe_initialization_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading
Loading