mirror of
https://github.com/Tencent/BrowserSkill.git
synced 2026-09-10 13:01:23 +08:00
fix(extension): fail closed on borrow confirmation
This commit is contained in:
@@ -67,8 +67,8 @@ function BorrowRequestItem({ request, isModal }: { request: BorrowRequestData; i
|
||||
const totalSeconds = Math.ceil(request.timeoutMs / 1000);
|
||||
const [secondsLeft, setSecondsLeft] = useState(totalSeconds);
|
||||
const [exiting, setExiting] = useState(false);
|
||||
const [awaitingAutoAllow, setAwaitingAutoAllow] = useState(false);
|
||||
const allowedRef = useRef(false);
|
||||
const [awaitingAutoDeny, setAwaitingAutoDeny] = useState(false);
|
||||
const settledRef = useRef(false);
|
||||
const onAllowRef = useRef(request.onAllow);
|
||||
const onDenyRef = useRef(request.onDeny);
|
||||
onAllowRef.current = request.onAllow;
|
||||
@@ -76,8 +76,8 @@ function BorrowRequestItem({ request, isModal }: { request: BorrowRequestData; i
|
||||
|
||||
useEffect(() => {
|
||||
if (secondsLeft <= 0) {
|
||||
if (!allowedRef.current) {
|
||||
setAwaitingAutoAllow(true);
|
||||
if (!settledRef.current) {
|
||||
setAwaitingAutoDeny(true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -86,9 +86,9 @@ function BorrowRequestItem({ request, isModal }: { request: BorrowRequestData; i
|
||||
}, [secondsLeft]);
|
||||
|
||||
function triggerAllow() {
|
||||
if (allowedRef.current) return;
|
||||
allowedRef.current = true;
|
||||
setAwaitingAutoAllow(false);
|
||||
if (settledRef.current) return;
|
||||
settledRef.current = true;
|
||||
setAwaitingAutoDeny(false);
|
||||
setExiting(true);
|
||||
setTimeout(() => onAllowRef.current(), EXIT_ANIMATION_MS);
|
||||
}
|
||||
@@ -98,9 +98,9 @@ function BorrowRequestItem({ request, isModal }: { request: BorrowRequestData; i
|
||||
}
|
||||
|
||||
function handleDeny() {
|
||||
if (allowedRef.current) return;
|
||||
allowedRef.current = true;
|
||||
setAwaitingAutoAllow(false);
|
||||
if (settledRef.current) return;
|
||||
settledRef.current = true;
|
||||
setAwaitingAutoDeny(false);
|
||||
setExiting(true);
|
||||
setTimeout(() => onDenyRef.current(), EXIT_ANIMATION_MS);
|
||||
}
|
||||
@@ -128,13 +128,13 @@ function BorrowRequestItem({ request, isModal }: { request: BorrowRequestData; i
|
||||
progress={progress}
|
||||
seconds={secondsLeft}
|
||||
onProgressTransitionEnd={(propertyName) => {
|
||||
if (!awaitingAutoAllow || secondsLeft !== 0) return;
|
||||
if (!awaitingAutoDeny || secondsLeft !== 0) return;
|
||||
if (propertyName !== "stroke-dashoffset") return;
|
||||
triggerAllow();
|
||||
handleDeny();
|
||||
}}
|
||||
/>
|
||||
<span className="text-[13px] text-gray-500">
|
||||
{t("borrowConfirmation.autoAllow", { count: secondsLeft })}
|
||||
{t("borrowConfirmation.autoDeny", { count: secondsLeft })}
|
||||
</span>
|
||||
</div>
|
||||
<ActionButtons onDeny={handleDeny} onAllow={handleAllow} />
|
||||
@@ -156,9 +156,9 @@ function BorrowRequestItem({ request, isModal }: { request: BorrowRequestData; i
|
||||
<BorrowProgressBar
|
||||
progress={progress}
|
||||
onProgressTransitionEnd={(event) => {
|
||||
if (!awaitingAutoAllow || secondsLeft !== 0) return;
|
||||
if (!awaitingAutoDeny || secondsLeft !== 0) return;
|
||||
if (event.propertyName !== "width") return;
|
||||
triggerAllow();
|
||||
handleDeny();
|
||||
}}
|
||||
/>
|
||||
<ActionButtons onDeny={handleDeny} onAllow={handleAllow} gapClass="gap-2" />
|
||||
|
||||
@@ -12,7 +12,7 @@ describe("BorrowConfirmationOverlay", () => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("auto-allows after countdown reaches zero and progress transition ends", async () => {
|
||||
it("auto-denies after countdown reaches zero and progress transition ends", async () => {
|
||||
const onAllow = vi.fn();
|
||||
const onDeny = vi.fn();
|
||||
|
||||
@@ -37,7 +37,7 @@ describe("BorrowConfirmationOverlay", () => {
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
});
|
||||
}
|
||||
expect(screen.getByText("0 秒后自动允许")).toBeTruthy();
|
||||
expect(screen.getByText("0 秒后自动拒绝")).toBeTruthy();
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
});
|
||||
@@ -54,8 +54,8 @@ describe("BorrowConfirmationOverlay", () => {
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(150);
|
||||
});
|
||||
expect(onAllow).toHaveBeenCalledTimes(1);
|
||||
expect(onDeny).not.toHaveBeenCalled();
|
||||
expect(onDeny).toHaveBeenCalledTimes(1);
|
||||
expect(onAllow).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("only invokes onDeny once when deny is clicked repeatedly", async () => {
|
||||
|
||||
@@ -162,6 +162,22 @@ describe("requestBorrowConfirmation", () => {
|
||||
expect(await pending).toBe(false);
|
||||
});
|
||||
|
||||
it("denies malformed or missing confirmation responses", async () => {
|
||||
tabs.get.mockResolvedValue({ id: 42, title: "Tab" });
|
||||
windows.getLastFocused.mockResolvedValue(
|
||||
userWindowWithActiveTab({ windowId: 11, tabId: 42, url: "https://app.example/" }),
|
||||
);
|
||||
|
||||
for (const response of [undefined, {}, { allowed: true }, { type: "borrow-response" }]) {
|
||||
tabs.sendMessage.mockResolvedValueOnce(response);
|
||||
const pending = requestBorrowConfirmation(42, {
|
||||
deps: { tabs, windows, notifications },
|
||||
});
|
||||
await vi.runAllTimersAsync();
|
||||
expect(await pending).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("skips the Agent Window when it is lastFocusedWindow and falls back to a real user window", async () => {
|
||||
tabs.get.mockResolvedValueOnce({
|
||||
id: 42,
|
||||
@@ -250,7 +266,7 @@ describe("requestBorrowConfirmation", () => {
|
||||
expect(tabs.sendMessage.mock.calls[1]?.[0]).toBe(301);
|
||||
});
|
||||
|
||||
it("does NOT fail-open immediately when every candidate's sendMessage fails — waits for notification button or timeout", async () => {
|
||||
it("waits for a notification decision and denies when every UI path times out", async () => {
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
||||
tabs.get.mockResolvedValueOnce({ id: 42, title: "Tab" });
|
||||
windows.getLastFocused.mockResolvedValueOnce(
|
||||
@@ -282,13 +298,13 @@ describe("requestBorrowConfirmation", () => {
|
||||
|
||||
expect(tabs.sendMessage).toHaveBeenCalledTimes(2);
|
||||
// CRITICAL: previously this would already be settled(true). Now we
|
||||
// require an explicit user choice (notification button) or timeout.
|
||||
// require an explicit user choice (notification button) or safe timeout.
|
||||
expect(resolved).toBe(false);
|
||||
|
||||
// Allow the BACKGROUND_TIMEOUT_MS fail-open to fire so the test resolves.
|
||||
// The background timeout must resolve the request without authorizing it.
|
||||
await vi.advanceTimersByTimeAsync(BACKGROUND_TIMEOUT_MS);
|
||||
expect(await pending).toBe(true);
|
||||
expect(resolvedValue).toBe(true);
|
||||
expect(await pending).toBe(false);
|
||||
expect(resolvedValue).toBe(false);
|
||||
|
||||
const exhaustedWarn = warnSpy.mock.calls.find(
|
||||
(call) =>
|
||||
@@ -375,7 +391,7 @@ describe("requestBorrowConfirmation", () => {
|
||||
expect(await pending).toBe(false);
|
||||
});
|
||||
|
||||
it("fail-opens when no injectable user window exists at all", async () => {
|
||||
it("denies when no injectable user window exists at all", async () => {
|
||||
tabs.get.mockResolvedValueOnce({ id: 42, title: "Stranded Tab" });
|
||||
windows.getLastFocused.mockResolvedValueOnce(
|
||||
userWindowWithActiveTab({ windowId: 500, tabId: 5001, url: "about:blank" }),
|
||||
@@ -394,7 +410,7 @@ describe("requestBorrowConfirmation", () => {
|
||||
});
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
expect(await pending).toBe(true);
|
||||
expect(await pending).toBe(false);
|
||||
expect(tabs.sendMessage).not.toHaveBeenCalled();
|
||||
// No notification either — there's no actionable target window.
|
||||
expect(notifications.create).not.toHaveBeenCalled();
|
||||
@@ -469,7 +485,7 @@ describe("requestBorrowConfirmation", () => {
|
||||
await pending;
|
||||
});
|
||||
|
||||
it("fail-opens after background timeout when content script never responds", async () => {
|
||||
it("denies after background timeout when content script never responds", async () => {
|
||||
tabs.get.mockResolvedValueOnce({ id: 42, title: "Tab" });
|
||||
windows.getLastFocused.mockResolvedValueOnce(
|
||||
userWindowWithActiveTab({ windowId: 11, tabId: 42, url: "https://app.example/" }),
|
||||
@@ -480,10 +496,10 @@ describe("requestBorrowConfirmation", () => {
|
||||
deps: { tabs, windows, notifications },
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(BACKGROUND_TIMEOUT_MS);
|
||||
expect(await pending).toBe(true);
|
||||
expect(await pending).toBe(false);
|
||||
});
|
||||
|
||||
it("dismisses pending overlay and fail-opens when aborted", async () => {
|
||||
it("dismisses the pending overlay and denies when aborted", async () => {
|
||||
tabs.get.mockResolvedValueOnce({ id: 42, title: "Borrow Target" });
|
||||
windows.getLastFocused.mockResolvedValueOnce(
|
||||
userWindowWithActiveTab({ windowId: 11, tabId: 7, url: "https://app.example/" }),
|
||||
@@ -500,7 +516,7 @@ describe("requestBorrowConfirmation", () => {
|
||||
controller.abort();
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
expect(await pending).toBe(true);
|
||||
expect(await pending).toBe(false);
|
||||
expect(tabs.sendMessage).toHaveBeenCalledTimes(2);
|
||||
const requestMessage = tabs.sendMessage.mock.calls[0][1] as { requestId: string };
|
||||
const cancelMessage = tabs.sendMessage.mock.calls[1][1] as {
|
||||
|
||||
@@ -114,9 +114,8 @@ const pendingBorrowNotifications = new Map<string, PendingBorrowNotification>();
|
||||
*
|
||||
* This is the *fallback* authorization path that fires when no candidate
|
||||
* user window could host the in-page overlay (every `chrome.tabs.sendMessage`
|
||||
* was rejected because the content script was not present). Without it the
|
||||
* background-task fail-open at the end of `tryCandidate` would silently
|
||||
* allow the borrow without any user-visible authorization step.
|
||||
* was rejected because the content script was not present). It preserves an
|
||||
* explicit authorization path without weakening the fail-closed timeout.
|
||||
*/
|
||||
const pendingBorrowDecisions = new Map<string, (allowed: boolean) => void>();
|
||||
|
||||
@@ -170,7 +169,7 @@ export function attachBorrowNotificationClickHandler(deps: {
|
||||
* Allow / Deny buttons on the OS notification can resolve the matching
|
||||
* pending `requestBorrowConfirmation`. This is the explicit-authorization
|
||||
* fallback path used when every candidate user window's content script was
|
||||
* missing — without it we'd be back to silently fail-opening the borrow.
|
||||
* missing.
|
||||
*
|
||||
* Button index convention (matches `BorrowNotificationCopy`):
|
||||
* - 0 → Allow → `resolve(true)`
|
||||
@@ -326,8 +325,7 @@ async function listConfirmationCandidates(
|
||||
if (typeof w.id !== "number") continue;
|
||||
// Never bounce the overlay back into a session's Agent Window — those
|
||||
// pages can't host a content script when they're on the about:blank
|
||||
// bootstrap URL, so sendMessage would fail-open and silently allow the
|
||||
// borrow with no UI shown.
|
||||
// bootstrap URL, so they cannot surface an authorization decision.
|
||||
if (isAgentWindowId(w.id)) continue;
|
||||
const activeTab = w.tabs?.find((t) => t.active === true) ?? null;
|
||||
if (!activeTab || typeof activeTab.id !== "number") continue;
|
||||
@@ -360,16 +358,14 @@ async function listConfirmationCandidates(
|
||||
* *explicit-authorization fallback* when every candidate fails sendMessage:
|
||||
* we no longer silently allow the borrow in that case, the promise stays
|
||||
* pending until the user clicks a notification button (resolves true/false)
|
||||
* or `BACKGROUND_TIMEOUT_MS` fires (last-resort fail-open so the agent
|
||||
* doesn't hang forever when even the notification path is broken).
|
||||
* or `BACKGROUND_TIMEOUT_MS` fires and safely denies the request.
|
||||
*
|
||||
* Clicking the notification body (vs. its buttons) focuses the chosen user
|
||||
* window so the overlay becomes visible — the previous behaviour.
|
||||
*
|
||||
* Returns `true` on user-allow (overlay or notification) OR last-resort
|
||||
* fail-open (no candidate window at all; abort signal; timeout). Returns
|
||||
* `false` only on explicit user-deny (overlay button or notification Deny
|
||||
* button).
|
||||
* Returns `true` only on an explicit user allow from the overlay or
|
||||
* notification. Missing UI, aborts, malformed responses, and timeouts all
|
||||
* fail closed and return `false`.
|
||||
*/
|
||||
export async function requestBorrowConfirmation(
|
||||
tabId: number,
|
||||
@@ -385,12 +381,9 @@ export async function requestBorrowConfirmation(
|
||||
|
||||
// The borrow pipeline already fetched this tab moments earlier
|
||||
// (validateBorrowTarget in tabs.ts), so a failure here is a transient
|
||||
// SW/page hiccup, not a missing tab. Do NOT fail-open: silently allowing
|
||||
// a borrow with no confirmation UI contradicts the gate's purpose and is
|
||||
// not one of the documented fail-open cases (no candidate window; abort;
|
||||
// timeout — see this function's doc comment). Fall back to a generic
|
||||
// title and still surface the overlay + notification so the user gets a
|
||||
// real chance to approve or deny.
|
||||
// SW/page hiccup, not a missing tab. Fall back to a generic title and still
|
||||
// surface the overlay + notification so the user gets a real chance to
|
||||
// approve or deny.
|
||||
let tabTitle: string;
|
||||
try {
|
||||
const tab = await tabsApi.get(tabId);
|
||||
@@ -405,10 +398,10 @@ export async function requestBorrowConfirmation(
|
||||
|
||||
const candidates = await listConfirmationCandidates(windowsApi, isAgentWindowId);
|
||||
if (candidates.length === 0) {
|
||||
console.warn("[bsk borrow] no injectable user window available — proceeding without overlay", {
|
||||
console.warn("[bsk borrow] no injectable user window available — denying borrow", {
|
||||
tabId,
|
||||
});
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
const requestId = createBorrowRequestId(tabId);
|
||||
@@ -419,7 +412,7 @@ export async function requestBorrowConfirmation(
|
||||
// overlay immediately.
|
||||
const notificationAnchor = candidates[0];
|
||||
if (!notificationAnchor) {
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
return new Promise<boolean>((resolve) => {
|
||||
@@ -463,7 +456,7 @@ export async function requestBorrowConfirmation(
|
||||
});
|
||||
});
|
||||
}
|
||||
settle(true);
|
||||
settle(false);
|
||||
};
|
||||
|
||||
if (signal?.aborted) {
|
||||
@@ -473,19 +466,17 @@ export async function requestBorrowConfirmation(
|
||||
signal?.addEventListener("abort", onAbort, { once: true });
|
||||
|
||||
timeout = setTimeout(() => {
|
||||
console.info("[bsk borrow] confirmation timed out — proceeding", {
|
||||
console.info("[bsk borrow] confirmation timed out — denying borrow", {
|
||||
tabId,
|
||||
messageTabId: activeMessageTabId,
|
||||
});
|
||||
settle(true);
|
||||
settle(false);
|
||||
}, BACKGROUND_TIMEOUT_MS);
|
||||
|
||||
// Surface the OS notification *before* messaging any candidate so the
|
||||
// user has a parallel signal even if every content script is missing.
|
||||
// The Allow / Deny buttons let the user authorize explicitly when the
|
||||
// in-page overlay never reached them — without those buttons we'd have
|
||||
// to fail-open after `tryCandidate` exhausts its candidates, which is
|
||||
// exactly the silent-allow bug we're fixing here.
|
||||
// in-page overlay never reached them.
|
||||
if (notificationsApi) {
|
||||
pendingBorrowNotifications.set(notificationId, {
|
||||
windowId: notificationAnchor.windowId,
|
||||
@@ -499,7 +490,7 @@ export async function requestBorrowConfirmation(
|
||||
title: copy.title,
|
||||
message: copy.body(tabTitle),
|
||||
priority: 2,
|
||||
requireInteraction: false,
|
||||
requireInteraction: true,
|
||||
silent: false,
|
||||
buttons: [{ title: copy.allowButton }, { title: copy.denyButton }],
|
||||
})
|
||||
@@ -523,15 +514,12 @@ export async function requestBorrowConfirmation(
|
||||
): void => {
|
||||
if (settled) return;
|
||||
if (index >= candidates.length) {
|
||||
// Every candidate rejected sendMessage. We deliberately do NOT
|
||||
// fail-open here anymore: silently allowing a borrow with zero
|
||||
// user-visible authorization UI is the bug. Instead we keep the
|
||||
// promise pending and rely on:
|
||||
// Every candidate rejected sendMessage. Keep the promise pending and
|
||||
// rely on:
|
||||
// • the OS notification's Allow / Deny buttons for an explicit
|
||||
// user choice (preferred), or
|
||||
// • the BACKGROUND_TIMEOUT_MS fail-open as the last-resort
|
||||
// soft-fail so the agent doesn't hang forever when even the
|
||||
// notification API is unavailable.
|
||||
// • the BACKGROUND_TIMEOUT_MS fail-closed result so the agent does
|
||||
// not hang forever when even the notification API is unavailable.
|
||||
// Keep the diagnostics so real-world frequency is still observable.
|
||||
console.warn(
|
||||
"[bsk borrow] every candidate user window failed sendMessage — awaiting notification button or timeout",
|
||||
@@ -560,7 +548,9 @@ export async function requestBorrowConfirmation(
|
||||
.sendMessage(candidate.tabId, message)
|
||||
.then((response) => {
|
||||
if (settled) return;
|
||||
const allowed = (response as BorrowResponseMessage | undefined)?.allowed !== false;
|
||||
const candidateResponse = response as BorrowResponseMessage | undefined;
|
||||
const allowed =
|
||||
candidateResponse?.type === "borrow-response" && candidateResponse.allowed === true;
|
||||
console.info("[bsk borrow] confirmation response", { tabId, allowed });
|
||||
settle(allowed);
|
||||
})
|
||||
|
||||
@@ -52,7 +52,7 @@
|
||||
"titleInactive": "Agent wants to borrow a tab",
|
||||
"body": "An AI agent wants to move this tab into its workspace. You can deny or allow the borrow.",
|
||||
"targetTab": "Target tab:",
|
||||
"autoAllow": "Auto-allowing in {{count}}s",
|
||||
"autoDeny": "Request expires in {{count}}s",
|
||||
"deny": "Deny",
|
||||
"allow": "Allow",
|
||||
"notificationTitle": "BrowserSkill: Agent wants to borrow a tab",
|
||||
|
||||
@@ -52,7 +52,7 @@
|
||||
"titleInactive": "Agent 请求借用标签页",
|
||||
"body": "AI Agent 希望将此标签页移入工作区。您可以拒绝或允许借用。",
|
||||
"targetTab": "目标标签页:",
|
||||
"autoAllow": "{{count}} 秒后自动允许",
|
||||
"autoDeny": "{{count}} 秒后自动拒绝",
|
||||
"deny": "拒绝",
|
||||
"allow": "允许",
|
||||
"notificationTitle": "BrowserSkill:Agent 请求借用标签页",
|
||||
|
||||
Reference in New Issue
Block a user