blob: 458c853f6f1173aabdc9910d8d09624ab281d737 [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 "chrome/browser/actor/tools/script_tool_host.h"
#include "base/feature_list.h"
#include "base/strings/strcat.h"
#include "chrome/browser/actor/actor_features.h"
#include "chrome/browser/actor/actor_metrics.h"
#include "chrome/browser/actor/actor_proto_conversion.h"
#include "chrome/browser/actor/actor_task.h"
#include "chrome/common/actor/action_result.h"
#include "chrome/common/actor/actor_constants.h"
#include "chrome/common/actor/journal_details_builder.h"
#include "components/optimization_guide/content/browser/page_content_proto_provider.h"
#include "content/public/browser/page.h"
#include "content/public/browser/render_frame_host.h"
#include "content/public/browser/web_contents.h"
#include "third_party/blink/public/common/associated_interfaces/associated_interface_provider.h"
namespace actor {
ScriptToolHost::ScriptToolHost(TaskId task_id,
ToolDelegate& tool_delegate,
tabs::TabHandle target_tab,
const base::UnguessableToken& target_document_id,
mojom::ToolActionPtr action)
: Tool(task_id, tool_delegate),
target_tab_(target_tab),
target_document_id_(target_document_id),
action_(std::move(action)) {}
ScriptToolHost::~ScriptToolHost() = default;
void ScriptToolHost::Validate(ToolCallback callback) {
// No browser-side validation yet.
base::SequencedTaskRunner::GetCurrentDefault()->PostTask(
FROM_HERE, base::BindOnce(std::move(callback), MakeOkResult()));
}
mojom::ActionResultPtr ScriptToolHost::TimeOfUseValidation(
const optimization_guide::proto::AnnotatedPageContent* last_observation) {
tabs::TabInterface* tab = target_tab_.Get();
if (!tab || !tab->GetContents()) {
return MakeResult(mojom::ActionResultCode::kTabWentAway);
}
// Check that the target Document is associated with the target tab. Only
// main frames are supported.
// TODO(khushalsagar): Add support for subframes.
auto primary_document_id = optimization_guide::DocumentIdentifierUserData::
GetOrCreateForCurrentDocument(
tab->GetContents()->GetPrimaryMainFrame())
->token();
if (primary_document_id != target_document_id_) {
return MakeResult(mojom::ActionResultCode::kTabWentAway);
}
target_document_ =
tab->GetContents()->GetPrimaryMainFrame()->GetWeakDocumentPtr();
return MakeOkResult();
}
std::unique_ptr<ObservationDelayController>
ScriptToolHost::GetObservationDelayer(
ObservationDelayController::PageStabilityConfig page_stability_config) {
if (!base::FeatureList::IsEnabled(kActorScriptToolDelayObservation)) {
return nullptr;
}
content::RenderFrameHost* frame = new_document_.AsRenderFrameHostIfValid();
if (!frame) {
frame = target_document_.AsRenderFrameHostIfValid();
}
if (!frame) {
return nullptr;
}
return std::make_unique<ObservationDelayController>(
*frame, task_id(), journal(), page_stability_config);
}
void ScriptToolHost::Invoke(ToolCallback callback) {
auto* frame = target_document_.AsRenderFrameHostIfValid();
CHECK(frame);
journal().EnsureJournalBound(*frame);
tool_done_callback_ = std::move(callback);
const auto& script_tool = action_->get_script_tool();
RecordScriptToolInputSizeBytes(script_tool->input_arguments.size());
auto invocation = actor::mojom::ToolInvocation::New();
invocation->action = action_->Clone();
invocation->task_id = task_id();
invocation->target =
actor::mojom::ToolTarget::NewDomNodeId(kRootElementDomNodeId);
frame->GetRemoteAssociatedInterfaces()->GetInterface(
&target_document_render_frame_);
target_document_origin_ = frame->GetLastCommittedOrigin();
lifecycle_ = Lifecycle::kInvokeSent;
target_document_render_frame_->InvokeTool(
std::move(invocation),
base::BindOnce(&ScriptToolHost::OnToolInvokedInOldDocument,
weak_ptr_factory_.GetWeakPtr()));
Observe(target_tab_.Get()->GetContents());
// TODO(khushalsagar): We likely need a timeout here if script hangs
// indefinitely but will need to reconcile this with the user interaction
// flow.
}
void ScriptToolHost::Cancel() {
switch (lifecycle_) {
case Lifecycle::kInitial:
case Lifecycle::kDone:
break;
case Lifecycle::kInvokeSent:
case Lifecycle::kWaitingForNavigation:
case Lifecycle::kPendingResultFromNewDocument:
journal().Log(
JournalURL(), task_id(), "ScriptToolHost::Cancel",
JournalDetailsBuilder().Add("tab_handle", target_tab_).Build());
if (target_document_render_frame_) {
target_document_render_frame_->CancelTool(task_id());
}
// TODO(khushalsagar): Should we cancel the ongoing navigation here? See
// crbug.com/478276089.
PostErrorResult(std::move(tool_done_callback_),
mojom::ActionResultCode::kInvokeCanceled);
break;
}
}
std::string ScriptToolHost::DebugString() const {
const auto& script_tool = action_->get_script_tool();
return absl::StrFormat("ScriptToolHost: name:%s\n input:%s",
script_tool->name, script_tool->input_arguments);
}
GURL ScriptToolHost::JournalURL() const {
// TODO(khushalsagar): Use the new document's URL for the navigation case?
auto* frame = target_document_.AsRenderFrameHostIfValid();
return frame ? frame->GetLastCommittedURL() : GURL();
}
std::string ScriptToolHost::JournalEvent() const {
return DebugString();
}
void ScriptToolHost::UpdateTaskBeforeInvoke(ActorTask& task,
ToolCallback callback) const {
task.AddTab(target_tab_, std::move(callback));
}
tabs::TabHandle ScriptToolHost::GetTargetTab() const {
return target_tab_;
}
void ScriptToolHost::OnToolInvokedInOldDocument(mojom::ActionResultPtr result) {
TearDown();
if (result && result->code == mojom::ActionResultCode::kOk) {
result->requires_page_stabilization =
base::FeatureList::IsEnabled(kActorScriptToolDelayObservation);
}
const bool result_on_new_document = result && result->script_tool_response &&
!result->script_tool_response->result;
if (result_on_new_document) {
auto* contents = target_tab_.Get()->GetContents();
CHECK(contents);
lifecycle_ = Lifecycle::kWaitingForNavigation;
pending_result_ = std::move(result);
Observe(contents);
return;
}
lifecycle_ = Lifecycle::kDone;
RecordMetrics(*result);
std::move(tool_done_callback_).Run(std::move(result));
}
void ScriptToolHost::OnResultReceivedFromNewDocument(
const std::string& result) {
CHECK_EQ(lifecycle_, Lifecycle::kPendingResultFromNewDocument);
CHECK(pending_result_);
lifecycle_ = Lifecycle::kDone;
pending_result_->script_tool_response->result = result;
RecordMetrics(*pending_result_);
std::move(tool_done_callback_).Run(std::move(pending_result_));
}
void ScriptToolHost::RecordMetrics(const mojom::ActionResult& result) {
RecordScriptToolActionResultCode(result.code);
if (result.code == mojom::ActionResultCode::kOk) {
RecordScriptToolOutputSizeBytes(
result.script_tool_response->result->size());
}
}
void ScriptToolHost::RenderFrameHostChanged(
content::RenderFrameHost* old_host,
content::RenderFrameHost* new_host) {
switch (lifecycle_) {
case Lifecycle::kInitial:
case Lifecycle::kDone:
NOTREACHED();
case Lifecycle::kInvokeSent:
// If the old_host is destroyed before we get a result from the renderer,
// we have to process this as a failure since we can't provide the
// invocation result.
if (old_host && old_host == target_document_.AsRenderFrameHostIfValid()) {
PostErrorResult(std::move(tool_done_callback_),
mojom::ActionResultCode::kFrameWentAway);
}
break;
case Lifecycle::kPendingResultFromNewDocument:
if (old_host && old_host == new_document_.AsRenderFrameHostIfValid()) {
PostErrorResult(std::move(tool_done_callback_),
mojom::ActionResultCode::kFrameWentAway);
}
break;
case Lifecycle::kWaitingForNavigation:
// RFH swap is too early and doesn't provide the committed origin. We use
// PrimaryPageChanged which is dispatched after the committed origin is
// available.
break;
}
}
// TODO(crbug.com/496250244): Should we only listen to DidFinishNavigation as is
// suggested by the documentation for PrimaryPageChanged?
void ScriptToolHost::PrimaryPageChanged(content::Page& page) {
if (lifecycle_ != Lifecycle::kWaitingForNavigation) {
return;
}
auto& new_host = page.GetMainDocument();
// If it's an error page, let DidFinishNavigation handle this failure.
if (new_host.IsErrorDocument()) {
return;
}
if (!new_host.GetLastCommittedOrigin().IsSameOriginWith(
target_document_origin_)) {
// If we end with a cross-origin navigation, assume execution
// failure.
PostErrorResult(std::move(tool_done_callback_),
mojom::ActionResultCode::kScriptToolCrossOriginNavigation,
base::StrCat({"Cross-origin navigation to: ",
new_host.GetLastCommittedURL().spec()}));
return;
}
// The new navigation has committed. Send a request to the renderer to
// pull the result.
lifecycle_ = Lifecycle::kPendingResultFromNewDocument;
new_document_ = new_host.GetWeakDocumentPtr();
new_host.GetRemoteAssociatedInterfaces()->GetInterface(
&new_document_render_frame_);
new_document_render_frame_->GetCrossDocumentScriptToolResult(
base::BindOnce(&ScriptToolHost::OnResultReceivedFromNewDocument,
weak_ptr_factory_.GetWeakPtr()));
// TODO(khushalsagar): We need to address the case where this navigation never
// commits in which case PrimaryPageChanged won't be dispatched. See
// crbug.com/478063859.
}
void ScriptToolHost::DidFinishNavigation(
content::NavigationHandle* navigation_handle) {
if (lifecycle_ != Lifecycle::kWaitingForNavigation &&
lifecycle_ != Lifecycle::kPendingResultFromNewDocument) {
return;
}
if (!navigation_handle->IsInPrimaryMainFrame() ||
navigation_handle->IsSameDocument()) {
return;
}
// If we have invoked a script tool on the target document but failed to
// receive a result, we are expecting to receive it after a successful
// navigation to a new document. Return an error from the tool if that
// navigation failed to commit.
if (!navigation_handle->HasCommitted()) {
if (lifecycle_ == Lifecycle::kWaitingForNavigation) {
PostErrorResult(
std::move(tool_done_callback_),
mojom::ActionResultCode::kScriptToolNavigationDidNotCommit,
base::StrCat(
{"Navigation failed: ", navigation_handle->GetURL().spec()}));
}
return;
}
if (navigation_handle->IsErrorPage()) {
PostErrorResult(
std::move(tool_done_callback_),
mojom::ActionResultCode::kScriptToolNavigationCommittedErrorPage,
base::StrCat({"Navigation committed error page: ",
navigation_handle->GetURL().spec()}));
return;
}
}
void ScriptToolHost::DidFailLoad(content::RenderFrameHost* render_frame_host,
const GURL& validated_url,
int error_code) {
// We care about loading when we are expecting a script tool result after
// navigation to a new document.
if (lifecycle_ != Lifecycle::kPendingResultFromNewDocument) {
return;
}
if (render_frame_host == new_document_.AsRenderFrameHostIfValid()) {
PostErrorResult(
std::move(tool_done_callback_),
mojom::ActionResultCode::kScriptToolNavigationFailedLoad,
base::StrCat({"Navigation failed load: ", validated_url.spec()}));
}
}
void ScriptToolHost::RenderFrameDeleted(content::RenderFrameHost* rfh) {
bool terminate_with_error = false;
switch (lifecycle_) {
case Lifecycle::kInitial:
case Lifecycle::kDone:
NOTREACHED();
case Lifecycle::kInvokeSent:
case Lifecycle::kWaitingForNavigation:
// Note: If a new navigation is committed, OnRenderFrameHostChanged will
// be dispatched before RenderFrameDeleted. If we're receiving the
// RenderFrameDeleted notification in this state, it's safe to assume
// there was an error/crash in the old frame or the tab was closed.
terminate_with_error =
(rfh == target_document_.AsRenderFrameHostIfValid());
break;
case Lifecycle::kPendingResultFromNewDocument:
terminate_with_error = (rfh == new_document_.AsRenderFrameHostIfValid());
break;
}
if (terminate_with_error) {
PostErrorResult(std::move(tool_done_callback_),
MaybeGetErrorCodeForTab(target_tab_.Get())
.value_or(mojom::ActionResultCode::kFrameWentAway));
}
}
void ScriptToolHost::PostErrorResult(ToolCallback tool_callback,
mojom::ActionResultCode code,
const std::string& message) {
lifecycle_ = Lifecycle::kDone;
TearDown();
auto result =
MakeResult(code, /*requires_page_stabilization=*/false, message);
RecordMetrics(*result);
base::SequencedTaskRunner::GetCurrentDefault()->PostTask(
FROM_HERE, base::BindOnce(std::move(tool_callback), std::move(result)));
}
void ScriptToolHost::TearDown() {
Observe(nullptr);
target_document_render_frame_.reset();
new_document_render_frame_.reset();
weak_ptr_factory_.InvalidateWeakPtrs();
}
} // namespace actor