blob: b424361a7bc75e027ad855ccc8dc2938001d6775 [file]
// Copyright 2026 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
#include "content/browser/renderer_host/model_context_user_data.h"
#include <algorithm>
#include "content/browser/bad_message.h"
#include "content/browser/renderer_host/frame_tree_node.h"
#include "content/browser/renderer_host/page_impl.h"
#include "content/browser/renderer_host/render_frame_host_impl.h"
#include "content/browser/renderer_host/render_frame_host_manager.h"
#include "content/browser/site_instance_impl.h"
#include "content/public/browser/browser_thread.h"
#include "third_party/blink/public/common/features.h"
#include "url/origin.h"
namespace content {
namespace {
bool IsScriptToolExposedToOrigin(
const url::Origin& tool_owner_origin,
const std::vector<url::Origin>& exposed_origins,
const url::Origin& accessing_origin) {
if (accessing_origin.IsSameOriginWith(tool_owner_origin)) {
return true;
}
return std::ranges::contains(exposed_origins, accessing_origin);
}
bool IsScriptToolOwnerRequestedByOrigin(
const url::Origin& tool_owner_origin,
const url::Origin& caller_origin,
const std::vector<url::Origin>& from_origins) {
if (caller_origin.IsSameOriginWith(tool_owner_origin)) {
return true;
}
return std::ranges::contains(from_origins, tool_owner_origin);
}
bool IsWebMCPEnabled(RenderFrameHost& rfh) {
return base::FeatureList::IsEnabled(blink::features::kWebMCP) &&
rfh.IsFeatureEnabled(network::mojom::PermissionsPolicyFeature::kTools);
}
} // namespace
DOCUMENT_USER_DATA_KEY_IMPL(ModelContextUserData);
ModelContextUserData::ModelContextUserData(RenderFrameHost* rfh)
: DocumentUserData<ModelContextUserData>(rfh) {}
// TODO(https://crbug.com/508285989): In the destructor, implement the implicit
// unregistering of all `script_tools_` and invoking `NotifyToolChange()` on any
// relevant documents. Right now, the destructor only supports the cancelling of
// pending tool execution in the calling renderer process, when the tool host's
// document is destroyed.
ModelContextUserData::~ModelContextUserData() {
auto& page = render_frame_host().GetPage();
auto* page_data = ModelContextPageUserData::GetForPage(page);
if (page_data) {
auto& rfh_impl = static_cast<RenderFrameHostImpl&>(render_frame_host());
page_data->CancelPendingScriptToolExecutionsForDocument(
rfh_impl.GetDocumentToken());
}
}
// static
void ModelContextUserData::Bind(
RenderFrameHost* rfh,
mojo::PendingReceiver<blink::mojom::ModelContextHost> receiver) {
auto* user_data = ModelContextUserData::GetForCurrentDocument(rfh);
if (user_data) {
bad_message::ReceivedBadMessage(rfh->GetProcess(),
bad_message::RFHI_WEBMCP_DUPLICATE_BIND);
return;
}
user_data = ModelContextUserData::GetOrCreateForCurrentDocument(rfh);
user_data->receiver_.Bind(std::move(receiver));
}
void ModelContextUserData::BindModelContext(
mojo::PendingRemote<blink::mojom::ModelContext> model_context) {
if (model_context_remote_.is_bound()) {
bad_message::ReceivedBadMessage(
render_frame_host().GetProcess(),
bad_message::RFHI_WEBMCP_DUPLICATE_SET_RECEIVER);
return;
}
model_context_remote_.Bind(std::move(model_context));
}
void ModelContextUserData::RegisterScriptTool(
blink::mojom::ScriptToolPtr tool,
RegisterScriptToolCallback callback) {
if (!IsWebMCPEnabled(render_frame_host())) {
bad_message::ReceivedBadMessage(render_frame_host().GetProcess(),
bad_message::RFHI_WEBMCP_NOT_ENABLED);
std::move(callback).Run();
return;
}
// Kill the renderer if it tries to register a duplicate tool name, because
// the renderer should prevent this.
auto it = std::find_if(script_tools_.begin(), script_tools_.end(),
[&](const blink::mojom::ScriptToolPtr& t) {
return t->name == tool->name;
});
if (it != script_tools_.end()) {
bad_message::ReceivedBadMessage(
render_frame_host().GetProcess(),
bad_message::RFHI_WEBMCP_REGISTER_DUPLICATE_TOOL_NAME);
std::move(callback).Run();
return;
}
for (const auto& origin : tool->exposed_origins) {
if (!network::IsOriginPotentiallyTrustworthy(origin)) {
bad_message::ReceivedBadMessage(
render_frame_host().GetProcess(),
bad_message::RFHI_WEBMCP_EXPOSED_UNTRUSTWORTHY_ORIGIN);
std::move(callback).Run();
return;
}
}
// TOOD(https://crbug.com/509568047): Stop passing in a frame token and origin
// during tool registration. These values are obvious from context, and the
// renderer shouldn't need to pass them in and have the browser verify them.
// Instead, the browser should be setting them for the first time here, with
// no input from the renderer.
if (tool->tool_owner_frame_token != render_frame_host().GetFrameToken() ||
tool->origin != render_frame_host().GetLastCommittedOrigin()) {
bad_message::ReceivedBadMessage(
render_frame_host().GetProcess(),
bad_message::RFHI_WEBMCP_INVALID_TOOL_OWNER);
std::move(callback).Run();
return;
}
std::vector<url::Origin> exposed_origins = tool->exposed_origins;
script_tools_.push_back(std::move(tool));
NotifyToolChange(exposed_origins);
std::move(callback).Run();
}
void ModelContextUserData::UnregisterScriptTool(const std::string& name) {
if (!IsWebMCPEnabled(render_frame_host())) {
bad_message::ReceivedBadMessage(render_frame_host().GetProcess(),
bad_message::RFHI_WEBMCP_NOT_ENABLED);
return;
}
auto it = std::find_if(
script_tools_.begin(), script_tools_.end(),
[&](const blink::mojom::ScriptToolPtr& t) { return t->name == name; });
if (it == script_tools_.end()) {
bad_message::ReceivedBadMessage(render_frame_host().GetProcess(),
bad_message::RFHI_WEBMCP_UNKNOWN_TOOL_NAME);
return;
}
std::vector<url::Origin> exposed_origins = (*it)->exposed_origins;
script_tools_.erase(it);
NotifyToolChange(exposed_origins);
}
void ModelContextUserData::GetScriptTools(
const std::vector<url::Origin>& from_origins,
GetScriptToolsCallback callback) {
if (!IsWebMCPEnabled(render_frame_host())) {
bad_message::ReceivedBadMessage(render_frame_host().GetProcess(),
bad_message::RFHI_WEBMCP_NOT_ENABLED);
std::move(callback).Run({});
return;
}
const url::Origin& caller_origin =
render_frame_host().GetLastCommittedOrigin();
std::vector<blink::mojom::ScriptToolPtr> all_tools;
RenderFrameHost* main_frame = render_frame_host().GetMainFrame();
SiteInstanceGroup* site_instance_group =
static_cast<SiteInstanceImpl*>(render_frame_host().GetSiteInstance())
->group();
main_frame->ForEachRenderFrameHostWithAction([&](RenderFrameHost* rfh) {
if (rfh->GetMainFrame() != main_frame) {
return RenderFrameHost::FrameIterationAction::kSkipChildren;
}
if (!rfh->IsFeatureEnabled(
network::mojom::PermissionsPolicyFeature::kTools)) {
return RenderFrameHost::FrameIterationAction::kContinue;
}
const url::Origin& tool_owner_origin = rfh->GetLastCommittedOrigin();
if (!IsScriptToolOwnerRequestedByOrigin(tool_owner_origin, caller_origin,
from_origins)) {
return RenderFrameHost::FrameIterationAction::kContinue;
}
auto* data = ModelContextUserData::GetForCurrentDocument(rfh);
if (!data) {
return RenderFrameHost::FrameIterationAction::kContinue;
}
const auto& local_tools = data->script_tools();
for (const auto& t : local_tools) {
if (!IsScriptToolExposedToOrigin(tool_owner_origin, t->exposed_origins,
/*accessing_origin=*/caller_origin)) {
continue;
}
blink::mojom::ScriptToolPtr cloned_tool = t.Clone();
// Find the frame (it could be local or remote) that the caller can use
// to reference the `Window` hosting the tool.
//
// `token` will never be `nullopt`.
blink::FrameToken token =
*static_cast<RenderFrameHostImpl*>(rfh)
->frame_tree_node()
->render_manager()
->GetFrameTokenForSiteInstanceGroup(site_instance_group);
cloned_tool->tool_owner_frame_token = token;
// Clear `exposed_origins` to prevent leaking the full list of
// authorized origins to the calling renderer process.
cloned_tool->exposed_origins.clear();
all_tools.push_back(std::move(cloned_tool));
}
return RenderFrameHost::FrameIterationAction::kContinue;
});
std::ranges::sort(all_tools, [](const blink::mojom::ScriptToolPtr& a,
const blink::mojom::ScriptToolPtr& b) {
return a->name < b->name;
});
std::move(callback).Run(std::move(all_tools));
}
void ModelContextUserData::ExecuteRemoteScriptTool(
const base::UnguessableToken& invocation_id,
const blink::FrameToken& tool_owner_frame_token,
const url::Origin& expected_target_origin,
const std::string& name,
const std::string& input_arguments,
ExecuteRemoteScriptToolCallback callback) {
if (!IsWebMCPEnabled(render_frame_host())) {
bad_message::ReceivedBadMessage(render_frame_host().GetProcess(),
bad_message::RFHI_WEBMCP_NOT_ENABLED);
std::move(callback).Run(std::nullopt, false);
return;
}
RenderFrameHostImpl* target_rfh = nullptr;
if (tool_owner_frame_token.Is<blink::LocalFrameToken>()) {
target_rfh = static_cast<RenderFrameHostImpl*>(
RenderFrameHost::FromFrameToken(GlobalRenderFrameHostToken(
render_frame_host().GetProcess()->GetDeprecatedID(),
tool_owner_frame_token.GetAs<blink::LocalFrameToken>())));
} else {
target_rfh =
static_cast<RenderFrameHostImpl*>(RenderFrameHost::FromPlaceholderToken(
render_frame_host().GetProcess()->GetDeprecatedID(),
tool_owner_frame_token.GetAs<blink::RemoteFrameToken>()));
}
// Don't kill the renderer, since legitimate script can target a document that
// no longer exists, or simply target the *wrong* document. It can also target
// a document in another frame tree, but at the moment we do not support
// cross-frame-tree, same-browsing-context-group tool execution, hence the
// main-frame check below.
RenderFrameHost* main_frame = render_frame_host().GetMainFrame();
if (!target_rfh || main_frame != target_rfh->GetMainFrame()) {
std::move(callback).Run(std::nullopt, false);
return;
}
// It is not possible to target the execution of tools that live in a document
// with an opaque origin; therefore, `expected_target_origin` will never be
// opaque.
if (expected_target_origin.opaque()) {
bad_message::ReceivedBadMessage(
render_frame_host().GetProcess(),
bad_message::RFHI_WEBMCP_OPAQUE_TARGET_ORIGIN);
std::move(callback).Run(std::nullopt, false);
return;
}
// Verify that `target_rfh`'s origin matches the origin that the tool invoker
// expects the tool to run in. This is necessary because it is possible that
// the `tool_owner_frame_token` points to a `RenderFrameProxyHost` whose frame
// tree node's `current_frame_host()` has changed since tool registration.
if (!target_rfh->GetLastCommittedOrigin().IsSameOriginWith(
expected_target_origin)) {
std::move(callback).Run(std::nullopt, false);
return;
}
auto* target_data = ModelContextUserData::GetForCurrentDocument(target_rfh);
// Don't kill the renderer, since legitimate script can target the wrong
// document, including one that has never touched its `modelContext` accessor,
// and therefore does not have `target_data`. However, if our API ever moves
// to opaque "Tool" interface that internally encapsulates a tool host's frame
// token instead of making JavaScript pass it in via a `Window` argument, then
// we'd be able to terminate the renderer here, since it would not be possible
// for a legitimate renderer to supply a frame token targeting a frame host
// that exists, but does not have `target_data`.
if (!target_data) {
std::move(callback).Run(std::nullopt, false);
return;
}
auto& tools = target_data->script_tools();
auto it = std::find_if(
tools.begin(), tools.end(),
[&](const blink::mojom::ScriptToolPtr& t) { return t->name == name; });
// Don't kill the renderer, since legitimate script can provide the name of a
// tool that doesn't exist (including one that did exist, but got unregistered
// by the time the call to invoke it here).
if (it == tools.end()) {
std::move(callback).Run(std::nullopt, false);
return;
}
// Don't kill the renderer here, since legitimate script can target a tool in
// another document that it might have been told about, but technically cannot
// access.
if (!IsScriptToolExposedToOrigin(
/*tool_owner_origin=*/target_rfh->GetLastCommittedOrigin(),
(*it)->exposed_origins,
/*accessing_origin=*/render_frame_host().GetLastCommittedOrigin())) {
std::move(callback).Run(std::nullopt, false);
return;
}
// At this point, it is safe to invoke the tool in the target renderer pointed
// to by `target_data`.
ModelContextPageUserData* page_data =
ModelContextPageUserData::GetOrCreateForPage(target_rfh->GetPage());
ModelContextPageUserData::PendingScriptToolExecution execution;
execution.caller_token =
static_cast<RenderFrameHostImpl&>(render_frame_host()).GetDocumentToken();
execution.target_token = target_rfh->GetDocumentToken();
execution.target_document = target_rfh->GetWeakDocumentPtr();
execution.tool_name = name;
execution.callback = std::move(callback);
page_data->AddPendingScriptToolExecution(invocation_id, std::move(execution));
target_data->model_context_remote_->ExecuteScriptTool(
invocation_id, name, input_arguments,
base::BindOnce(
[](base::WeakPtr<ModelContextPageUserData> page_data,
base::UnguessableToken invocation_id,
const std::optional<std::string>& result, bool success) {
if (page_data) {
page_data->CompletePendingScriptToolExecution(invocation_id,
result, success);
}
},
page_data->GetWeakPtr(), invocation_id));
}
void ModelContextUserData::CancelRemoteScriptTool(
const base::UnguessableToken& invocation_id) {
if (ModelContextPageUserData* page_data =
ModelContextPageUserData::GetOrCreateForPage(
render_frame_host().GetPage())) {
page_data->CancelPendingScriptToolExecution(invocation_id);
}
}
void ModelContextUserData::NotifyToolChange(
const std::vector<url::Origin>& exposed_origins) {
RenderFrameHost& rfh = render_frame_host();
url::Origin tool_owner_origin = rfh.GetLastCommittedOrigin();
RenderFrameHost* main_frame = rfh.GetMainFrame();
main_frame->ForEachRenderFrameHostWithAction([&](RenderFrameHost* frame) {
if (frame->GetMainFrame() != main_frame) {
return RenderFrameHost::FrameIterationAction::kSkipChildren;
}
if (!frame->IsFeatureEnabled(
network::mojom::PermissionsPolicyFeature::kTools)) {
return RenderFrameHost::FrameIterationAction::kContinue;
}
if (IsScriptToolExposedToOrigin(
tool_owner_origin, exposed_origins,
/*accessing_origin=*/frame->GetLastCommittedOrigin())) {
auto* data = ModelContextUserData::GetForCurrentDocument(frame);
if (!data) {
return RenderFrameHost::FrameIterationAction::kContinue;
}
if (data->model_context_remote_.is_bound()) {
data->model_context_remote_->NotifyToolChange();
}
}
return RenderFrameHost::FrameIterationAction::kContinue;
});
}
PAGE_USER_DATA_KEY_IMPL(ModelContextPageUserData);
ModelContextPageUserData::PendingScriptToolExecution::
PendingScriptToolExecution() = default;
ModelContextPageUserData::PendingScriptToolExecution::
PendingScriptToolExecution(PendingScriptToolExecution&&) = default;
ModelContextPageUserData::PendingScriptToolExecution&
ModelContextPageUserData::PendingScriptToolExecution::operator=(
PendingScriptToolExecution&&) = default;
ModelContextPageUserData::PendingScriptToolExecution::
~PendingScriptToolExecution() = default;
ModelContextPageUserData::ModelContextPageUserData(Page& page)
: PageUserData<ModelContextPageUserData>(page) {}
ModelContextPageUserData::~ModelContextPageUserData() = default;
void ModelContextPageUserData::AddPendingScriptToolExecution(
const base::UnguessableToken& invocation_id,
PendingScriptToolExecution execution) {
pending_script_tool_executions_.emplace(invocation_id, std::move(execution));
}
void ModelContextPageUserData::CompletePendingScriptToolExecution(
const base::UnguessableToken& invocation_id,
const std::optional<std::string>& result,
bool success) {
// We are here because a tool just returned a result, and we should forward
// the result to the tool's invoker. But it's possible that no pending
// execution exists for this tool anymore. This can happen if the invoker
// document is destroyed before the response from the renderer hosting the
// tool comes back.
//
// Conversely, if the document *hosting* a tool gets destroyed before it
// responds with the result of the tool the associated pending execution is
// *also* removed from `this`, but the mojo receiver is terminated before this
// `CompletePendingScriptToolExecution()` callback is ever run, so we'll never
// end up here.
auto it = pending_script_tool_executions_.find(invocation_id);
if (it == pending_script_tool_executions_.end()) {
return;
}
std::move(it->second.callback).Run(result, success);
pending_script_tool_executions_.erase(it);
}
void ModelContextPageUserData::SendCancelScriptToolToTarget(
WeakDocumentPtr target_document,
const base::UnguessableToken& invocation_id) {
RenderFrameHost* target_rfh = target_document.AsRenderFrameHostIfValid();
// `target_rfh` and `target_data` are always non-null, because they are only
// cleared or invalidated during destruction when the tool owner is destroyed,
// and in that case `CancelPendingScriptToolExecutionsForDocument()` will not
// invoke this method.
CHECK(target_rfh);
auto* target_data = ModelContextUserData::GetForCurrentDocument(target_rfh);
CHECK(target_data);
target_data->model_context_remote_->CancelScriptTool(invocation_id);
}
void ModelContextPageUserData::CancelPendingScriptToolExecution(
const base::UnguessableToken& invocation_id) {
// We are here because a caller document aborted its execution signal for a
// tool. However, it is possible that no pending execution exists for this
// `invocation_id` anymore. This can happen in legitimate, non-compromised
// renderer races:
// 1. The tool execution completed and sent its response IPC
// (`CompletePendingScriptToolExecution()`) before this cancellation
// IPC arrived.
// 2. The document hosting the tool was destroyed
// (`CancelPendingScriptToolExecutionsForDocument()`) before this
// cancellation IPC arrived.
auto it = pending_script_tool_executions_.find(invocation_id);
if (it == pending_script_tool_executions_.end()) {
return;
}
SendCancelScriptToolToTarget(it->second.target_document, invocation_id);
std::move(it->second.callback).Run(std::nullopt, false);
pending_script_tool_executions_.erase(it);
}
void ModelContextPageUserData::CancelPendingScriptToolExecutionsForDocument(
const blink::DocumentToken& document_token) {
for (auto it = pending_script_tool_executions_.begin();
it != pending_script_tool_executions_.end();) {
const bool is_caller = it->second.caller_token == document_token;
const bool is_target = it->second.target_token == document_token;
if (!is_caller && !is_target) {
++it;
continue;
}
// If the now-destructing document represented by `document_token` owns the
// tool, then it's too late to send a cancellation IPC to it. During
// document destruction, document weak pointers like `target_document` below
// are still valid, but they point to a `RenderFrameHostImpl` whose user
// data (thus `ModelContextUserData`) has already been cleared. So it's
// impossible to grab a remote to the renderer and send the IPC.
if (!is_target) {
SendCancelScriptToolToTarget(it->second.target_document, it->first);
}
std::move(it->second.callback).Run(std::nullopt, false);
it = pending_script_tool_executions_.erase(it);
}
}
base::WeakPtr<ModelContextPageUserData> ModelContextPageUserData::GetWeakPtr() {
return weak_factory_.GetWeakPtr();
}
} // namespace content