Skip to content
Merged
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
38 changes: 38 additions & 0 deletions agent/apps/server/src/lib/redrob-bridge/browser-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -231,5 +231,43 @@ function buildTools(browser: Browser, session: BrowserSession): ToolDef[] {
return path
},
},
{
name: 'login',
description:
"Sign in on a tab with a login the user saved in the browser's password manager, under the user's agent sign-in settings. The browser fills the password itself; you never see it. Answers what happened (filled as which account, several accounts, no saved login, locked, refused). Then press the site's own sign-in button with click.",
input: z.object({
tab_id: tabId,
username_hint: z
.string()
.optional()
.describe(
'Which saved account to use when several match. Omitted: the only one.',
),
}),
handler: async ({ tab_id, username_hint }) => {
const pageId = await resolveTab(tab_id as number | undefined)
const info =
(await session.pages.refresh(pageId)) ??
session.pages.getInfo(pageId)
if (!info) throw new Error(`No open tab with id ${pageId}.`)
const bridgeToken = process.env.REDROB_APP_BRIDGE_TOKEN
if (!bridgeToken) {
throw new Error(
'Agent sign-in needs the browser that launched this server (no app-bridge secret).',
)
}
// Browser.redrobLogin runs in the browser process (TaskStore::LoginFromBridge): the same policy,
// per-site list, unlock window and audit log as the in-browser agent's login tool. The browser
// refuses any caller without the per-run secret it put in this process's environment.
const result = (await session.cdp('Browser.redrobLogin', {
targetId: info.targetId,
bridgeToken,
...(typeof username_hint === 'string' && username_hint
? { usernameHint: username_hint }
: {}),
})) as { text?: string }
return `tab ${pageId}: ${result.text ?? '(no answer)'}`
},
},
]
}
7 changes: 4 additions & 3 deletions docs/CREDENTIALS.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,9 @@ The agent signs in with logins saved in the profile's own password manager. Ther
3. **Which site:** the agent tab's committed origin. The model never names a URL to match, so a phishing page cannot borrow a bank's login.
4. **Default policy: while unlocked.** The agent fills only within 15 minutes (adjustable) of an OS re-authentication (Touch ID, Windows Hello; on Linux, which has no device authenticator, the user's own click in Settings). Other options: always, never. The password manager's own "ask before filling" setting still applies.
5. **Per site:** block refuses a site under every policy; allow skips the unlock for it. Never overrides both.
6. **Locked, or a code / passkey / security key / CAPTCHA:** the agent does not try. The task waits in `kBlockedOnPolicy` until the user unlocks or says they finished, from Settings → Passwords and the agent.
6. **Locked, or a code / passkey / security key / CAPTCHA:** the agent does not try. The task waits in `kBlockedOnPolicy` until the user unlocks or says they finished, from Settings → Passwords and the agent. A locked wait also raises a "Task needs you" notification whose Unlock button runs the same OS re-authentication over the task's tab and, on success, retries the fill.
7. **Never:** in a private (off-the-record) profile, in a read-only task, or when the policy is never; the tool is not even offered.
8. **Audit:** every sign-in attempt is a `kUsedCredential` step in the task trace and a record (time, site, username, task, outcome) in the AI sign-in history, the last 500 kept. No quality log is uploaded.
9. **Not built:** passkeys, TOTP, cards, notes, SSH keys and API credentials as agent-usable types; sign-in from the engine's app-bridge tools (they drive tabs over CDP and cannot reach the browser-process password manager).
10. **Code:** `sources/chrome/browser/redrob/credential_tools.{h,cc}`, the `login` / `ask_user_to_verify` dispatch in `task_store.cc`, prefs `redrob.agent_credentials.*`, the settings page `agent/apps/app/screens/agent-passwords/`.
9. **App bridge:** the engine's app-bridge `login` tool (`browser-bridge.ts`) calls the CDP command `Browser.redrobLogin`, which runs `TaskStore::LoginFromBridge` in the browser process: same gate, same fill path, audit records under task `app-bridge`. Only the agent server the browser itself launched may call it: the browser makes a per-run secret, hands it to that child in its environment (`REDROB_APP_BRIDGE_TOKEN`, never argv), and refuses `Browser.redrobLogin` without it, so another local program on the debugging port cannot trigger a sign-in. A locked store answers "ask the user to unlock" instead of parking, because there is no task to park.
10. **Not built:** passkeys, TOTP, cards, notes, SSH keys and API credentials as agent-usable types.
11. **Code:** `sources/chrome/browser/redrob/credential_tools.{h,cc}`, the `login` / `ask_user_to_verify` dispatch in `task_store.cc`, prefs `redrob.agent_credentials.*`, the settings page `agent/apps/app/screens/agent-passwords/`.
1 change: 1 addition & 0 deletions l10n/20-newtab.tsv
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ taskNotifyNeedsYou Task needs you: $1 작업 확인 필요: $1 OS notification t
taskNotifyNeedsYouBody The agent wants to press "$1". Allow it? 에이전트가 "$1"을(를) 누르려고 합니다. 허용할까요? OS notification body for a guarded click; $1 is the control's text
taskNotifyAllow Allow 허용 Notification button that lets the guarded click run
taskNotifyDecline Decline 거절 Notification button that refuses the guarded click
taskNotifyUnlock Unlock 잠금 해제 Notification button on a task waiting for agent sign-in to be unlocked; opens the OS re-authentication
taskNotifyDone Task done: $1 작업 완료: $1 OS notification title when a task finishes with no browser window open; $1 is the task's prompt
taskNotifyFailed Task stopped: $1 작업 중단: $1 OS notification title when a task fails with no browser window open; $1 is the task's prompt
taskNotifyOpen Click to see what it did. 눌러서 작업 내용을 확인하세요. OS notification body for a finished task
Expand Down
8 changes: 8 additions & 0 deletions sources/chrome/browser/redrob/BUILD.gn
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,14 @@
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.

# The per-run secret that marks the agent server as the only caller of Browser.redrobLogin. Its own
# target with nothing but //base, so the devtools handler and the server launcher can both depend on it
# without pulling in the sidecar.
source_set("app_bridge_token") {
sources = [ "app_bridge_token.h" ]
deps = [ "//base" ]
}

source_set("sidecar") {
sources = [
"agent_service_factories.cc",
Expand Down
44 changes: 44 additions & 0 deletions sources/chrome/browser/redrob/app_bridge_token.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
// Copyright 2026 Redrob. Portions copyright The Chromium Authors.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

#ifndef CHROME_BROWSER_REDROB_APP_BRIDGE_TOKEN_H_
#define CHROME_BROWSER_REDROB_APP_BRIDGE_TOKEN_H_

#include <string>
#include <string_view>

#include "base/no_destructor.h"
#include "base/unguessable_token.h"

namespace redrob {

// The environment variable the browser sets for the agent server it launches, and nobody else.
inline constexpr char kAppBridgeTokenEnv[] = "REDROB_APP_BRIDGE_TOKEN";

// A secret made once per browser run. Only the agent server the browser starts gets it, in that
// child's environment (never on its command line, where `ps` would show it). Browser.redrobLogin
// refuses a caller that cannot present it, so another local program on the debugging port cannot
// ask the agent to sign in.
inline const std::string& AppBridgeToken() {
static const base::NoDestructor<std::string> token(
base::UnguessableToken::Create().ToString());
return *token;
}

// Constant-time, so the answer time says nothing about how much of a guess was right.
inline bool IsAppBridgeToken(std::string_view candidate) {
const std::string& token = AppBridgeToken();
if (candidate.size() != token.size()) {
return false;
}
unsigned char diff = 0;
for (size_t i = 0; i < token.size(); ++i) {
diff |= static_cast<unsigned char>(candidate[i] ^ token[i]);
}
return diff == 0;
}

} // namespace redrob

#endif // CHROME_BROWSER_REDROB_APP_BRIDGE_TOKEN_H_
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ inline constexpr webui::LocalizedString kRedrobLocalizedStrings[] = {
{"taskNotifyNeedsYouBody", IDS_REDROB_TASK_NOTIFY_NEEDS_YOU_BODY},
{"taskNotifyAllow", IDS_REDROB_TASK_NOTIFY_ALLOW},
{"taskNotifyDecline", IDS_REDROB_TASK_NOTIFY_DECLINE},
{"taskNotifyUnlock", IDS_REDROB_TASK_NOTIFY_UNLOCK},
{"taskNotifyDone", IDS_REDROB_TASK_NOTIFY_DONE},
{"taskNotifyFailed", IDS_REDROB_TASK_NOTIFY_FAILED},
{"taskNotifyOpen", IDS_REDROB_TASK_NOTIFY_OPEN},
Expand Down
1 change: 1 addition & 0 deletions sources/chrome/browser/redrob/resources/redrob_strings.grd
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,7 @@
<message name="IDS_REDROB_TASK_NOTIFY_NEEDS_YOU_BODY" desc="OS notification body for a guarded click; $1 is the control's text">The agent wants to press "<ph name="ARG1">$1<ex>1</ex></ph>". Allow it?</message>
<message name="IDS_REDROB_TASK_NOTIFY_ALLOW" desc="Notification button that lets the guarded click run">Allow</message>
<message name="IDS_REDROB_TASK_NOTIFY_DECLINE" desc="Notification button that refuses the guarded click">Decline</message>
<message name="IDS_REDROB_TASK_NOTIFY_UNLOCK" desc="Notification button on a task waiting for agent sign-in to be unlocked; opens the OS re-authentication">Unlock</message>
<message name="IDS_REDROB_TASK_NOTIFY_DONE" desc="OS notification title when a task finishes with no browser window open; $1 is the task's prompt">Task done: <ph name="ARG1">$1<ex>1</ex></ph></message>
<message name="IDS_REDROB_TASK_NOTIFY_FAILED" desc="OS notification title when a task fails with no browser window open; $1 is the task's prompt">Task stopped: <ph name="ARG1">$1<ex>1</ex></ph></message>
<message name="IDS_REDROB_TASK_NOTIFY_OPEN" desc="OS notification body for a finished task">Click to see what it did.</message>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@
<translation id="5910612823086285890">에이전트가 "<ph name="ARG1"/>"을(를) 누르려고 합니다. 허용할까요?</translation>
<translation id="5039804452771397117">허용</translation>
<translation id="951991426597076286">거절</translation>
<translation id="9111102763498581341">잠금 해제</translation>
<translation id="5482866180639445146">작업 완료: <ph name="ARG1"/></translation>
<translation id="8557495873765456461">작업 중단: <ph name="ARG1"/></translation>
<translation id="7471762478073426373">눌러서 작업 내용을 확인하세요.</translation>
Expand Down
80 changes: 77 additions & 3 deletions sources/chrome/browser/redrob/task_store.cc
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
#include <utility>

#include "base/functional/bind.h"
#include "base/functional/callback_helpers.h"
#include "base/json/json_reader.h"
#include "base/strings/string_util.h"
#include "base/json/json_writer.h"
Expand Down Expand Up @@ -1990,6 +1991,12 @@ void TaskStore::AnnounceTask(const std::string& task_id) {
const bool failed = task.state == mojom::TaskState::kFailed;
const bool needs_you = task.state == mojom::TaskState::kBlockedOnPolicy &&
task.awaiting_confirmation.has_value();
// A sign-in parked on a locked store (While unlocked policy) also needs the user, and the notification
// carries the unlock itself so they do not have to find Settings first. See OnTaskNotificationClicked.
const auto credential_wait = credential_waits_.find(task_id);
const bool needs_unlock = task.state == mojom::TaskState::kBlockedOnPolicy && !needs_you &&
credential_wait != credential_waits_.end() &&
credential_wait->second.needs_unlock;
NotificationDisplayService* display = NotificationDisplayServiceFactory::GetForProfile(profile_);
if (!display) {
return;
Expand All @@ -1998,7 +2005,7 @@ void TaskStore::AnnounceTask(const std::string& task_id) {

auto announced = announced_.find(task_id);
const bool already = announced != announced_.end() && announced->second == task.state;
if (!done && !failed && !needs_you) {
if (!done && !failed && !needs_you && !needs_unlock) {
// Running again, or cancelled: an earlier "needs you" no longer describes the task.
if (announced != announced_.end()) {
display->Close(NotificationHandler::Type::TRANSIENT, notification_id);
Expand All @@ -2012,15 +2019,23 @@ void TaskStore::AnnounceTask(const std::string& task_id) {
// "Needs you" always: the task cannot move until the user answers, and the browser's own surface may
// not be on screen. "Done" and "failed" only when no window of the user's is open -- someone at the
// browser is already watching the task.
if (!needs_you && AgentTabs::HasUserWindow(profile_)) {
if (!needs_you && !needs_unlock && AgentTabs::HasUserWindow(profile_)) {
return;
}
announced_[task_id] = task.state;

message_center::RichNotificationData rich;
std::u16string title;
std::u16string message;
if (needs_you) {
if (needs_unlock) {
const url::Origin& origin = credential_wait->second.origin;
title = l10n_util::GetStringFUTF16(IDS_REDROB_TASK_NOTIFY_NEEDS_YOU, TaskTitleFor(task));
message = l10n_util::GetStringFUTF16(
IDS_REDROB_TASK_CREDENTIAL_WAITING_UNLOCK,
base::UTF8ToUTF16(origin.opaque() ? std::string("this page") : origin.Serialize()));
rich.buttons.emplace_back(l10n_util::GetStringUTF16(IDS_REDROB_TASK_NOTIFY_UNLOCK));
rich.never_timeout = true;
} else if (needs_you) {
title = l10n_util::GetStringFUTF16(IDS_REDROB_TASK_NOTIFY_NEEDS_YOU, TaskTitleFor(task));
message = l10n_util::GetStringFUTF16(IDS_REDROB_TASK_NOTIFY_NEEDS_YOU_BODY,
base::UTF8ToUTF16(*task.awaiting_confirmation));
Expand Down Expand Up @@ -2057,6 +2072,20 @@ void TaskStore::OnTaskNotificationClicked(const std::string& task_id, std::optio
AnswerConfirmation(task_id, button.value() == 0);
return;
}
// The one button on a "locked" notification: the same OS re-authentication Settings uses, anchored to the
// task's own tab so the prompt has a window to sit over. A successful unlock resumes every task waiting
// for it and retries the fill (OnCredentialsUnlocked). Never with no anchor: CredentialTools::Unlock
// treats a missing anchor as "no device authenticator", which would skip the prompt on macOS/Windows.
if (auto wait = credential_waits_.find(task_id);
button.has_value() && wait != credential_waits_.end() && wait->second.needs_unlock) {
auto conversation = conversations_.find(task_id);
content::WebContents* anchor =
conversation != conversations_.end() ? conversation->second.page.get() : nullptr;
if (anchor && AgentTabs::Reveal(anchor)) {
UnlockCredentials(anchor, base::DoNothing());
return;
}
}
if (NotificationDisplayService* display =
profile_ ? NotificationDisplayServiceFactory::GetForProfile(profile_) : nullptr) {
display->Close(NotificationHandler::Type::TRANSIENT, NotificationIdFor(task_id));
Expand Down Expand Up @@ -3027,6 +3056,51 @@ void TaskStore::ParkForUser(const std::string& task_id,
Notify(task_id);
}

void TaskStore::LoginFromBridge(content::WebContents* page,
const std::string& username_hint,
base::OnceCallback<void(std::string)> callback) {
if (!page || !page->GetPrimaryMainFrame()) {
std::move(callback).Run("There is no such tab. Open the site's sign-in page first.");
return;
}
// The tab's committed origin, never a URL from the caller, exactly as for a task.
const url::Origin origin = page->GetPrimaryMainFrame()->GetLastCommittedOrigin();
switch (credentials()->Check(origin)) {
case CredentialTools::Gate::kRefused: {
credentials()->RecordUse(kBridgeAuditId, origin, std::string(),
CredentialTools::Outcome::kPolicyRefused);
CredentialTools::Result refused;
refused.outcome = CredentialTools::Outcome::kPolicyRefused;
refused.origin = origin;
std::move(callback).Run(CredentialTools::ToolResultText(refused));
return;
}
case CredentialTools::Gate::kLocked:
credentials()->RecordUse(kBridgeAuditId, origin, std::string(), CredentialTools::Outcome::kLocked);
std::move(callback).Run(
"Signing in needs the user to unlock the browser's passwords first, in the browser itself. Ask "
"them to unlock, then call login again.");
return;
case CredentialTools::Gate::kAllowed:
break;
}
credentials()->Login(page, username_hint,
base::BindOnce(&TaskStore::OnBridgeLoginResult, weak_factory_.GetWeakPtr(),
std::move(callback)));
}

void TaskStore::OnBridgeLoginResult(base::OnceCallback<void(std::string)> callback,
CredentialTools::Result result) {
credentials()->RecordUse(kBridgeAuditId, result.origin, result.username, result.outcome);
if (result.outcome == CredentialTools::Outcome::kLocked) {
std::move(callback).Run(
"Signing in needs the user to unlock the browser's passwords first, in the browser itself. Ask "
"them to unlock, then call login again.");
return;
}
std::move(callback).Run(CredentialTools::ToolResultText(result));
}

void TaskStore::UnlockCredentials(content::WebContents* anchor,
base::OnceCallback<void(bool)> callback) {
credentials()->Unlock(anchor, base::BindOnce(&TaskStore::OnCredentialsUnlocked,
Expand Down
12 changes: 12 additions & 0 deletions sources/chrome/browser/redrob/task_store.h
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,16 @@ class TaskStore : public KeyedService, public SidecarClient::CompletionDelegate
// The user says they finished the verification a task was waiting on. False when it was not waiting.
bool ResumeAfterVerification(const std::string& task_id);

// The app bridge's `login` tool (agent/apps/server/src/lib/redrob-bridge/browser-bridge.ts), reached over
// CDP as Browser.redrobLogin. Same gate, same fill path and same audit log as the task tool; the audit
// record carries kBridgeAuditId as its task id. The bridge has no task to park, so a locked store
// answers at once and the caller is told to ask the user to unlock. `callback` gets the model-facing text,
// which, like every other sign-in answer, cannot carry a password.
static constexpr char kBridgeAuditId[] = "app-bridge";
void LoginFromBridge(content::WebContents* page,
const std::string& username_hint,
base::OnceCallback<void(std::string)> callback);

private:
// What the engine proposed, held until the turn ends. Tools are run only after a turn completes: a
// tool call whose arguments arrived in a stream that then failed is one the browser must not run.
Expand Down Expand Up @@ -461,6 +471,8 @@ class TaskStore : public KeyedService, public SidecarClient::CompletionDelegate
const url::Origin& origin,
bool needs_unlock);
void OnCredentialsUnlocked(base::OnceCallback<void(bool)> callback, bool unlocked);
void OnBridgeLoginResult(base::OnceCallback<void(std::string)> callback,
CredentialTools::Result result);
std::map<std::string, PendingCredentialCall> credential_waits_;
std::unique_ptr<CredentialTools> credentials_;
};
Expand Down
Loading
Loading