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
30 changes: 15 additions & 15 deletions apps/extension/src/content/BorrowConfirmationOverlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,17 +67,17 @@ 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;
onDenyRef.current = request.onDeny;

useEffect(() => {
if (secondsLeft <= 0) {
if (!allowedRef.current) {
setAwaitingAutoAllow(true);
if (!settledRef.current) {
setAwaitingAutoDeny(true);
}
return;
}
Expand All @@ -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);
}
Expand All @@ -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);
}
Expand Down Expand Up @@ -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} />
Expand All @@ -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" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand All @@ -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);
});
Expand All @@ -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 () => {
Expand Down
38 changes: 27 additions & 11 deletions apps/extension/src/tools/__tests__/borrow-confirmation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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) =>
Expand Down Expand Up @@ -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" }),
Expand All @@ -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();
Expand Down Expand Up @@ -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/" }),
Expand All @@ -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/" }),
Expand All @@ -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 {
Expand Down
62 changes: 26 additions & 36 deletions apps/extension/src/tools/borrow-confirmation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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>();

Expand Down Expand Up @@ -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)`
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand All @@ -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);
Expand All @@ -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);
Expand All @@ -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) => {
Expand Down Expand Up @@ -463,7 +456,7 @@ export async function requestBorrowConfirmation(
});
});
}
settle(true);
settle(false);
};

if (signal?.aborted) {
Expand All @@ -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,
Expand All @@ -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 }],
})
Expand All @@ -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",
Expand Down Expand Up @@ -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);
})
Expand Down
4 changes: 1 addition & 3 deletions apps/extension/vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,7 @@ export default defineConfig({
},
define: {
__BSK_EXT_VERSION__: JSON.stringify(pkg.version),
__BSK_DAEMON_WS_URL__: JSON.stringify(
process.env.BSK_DAEMON_WS_URL ?? "ws://127.0.0.1:52800",
),
__BSK_DAEMON_WS_URL__: JSON.stringify(process.env.BSK_DAEMON_WS_URL ?? "ws://127.0.0.1:52800"),
},
resolve: {
alias: {
Expand Down
5 changes: 1 addition & 4 deletions crates/bsk-cli/tests/browser_list_ordering.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ use bsk_protocol::system::BrowserStatusEntry;
use bsk_protocol::{ErrorCode, Method};
use rand::Rng;
use serde::Deserialize;
use tokio::net::TcpListener;
use tokio::sync::mpsc;

fn tempfile_path(prefix: &str) -> PathBuf {
Expand All @@ -30,9 +29,7 @@ fn tempfile_path(prefix: &str) -> PathBuf {
}

async fn spawn_daemon() -> (daemon::DaemonHandle, PathBuf) {
let probe = TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = probe.local_addr().unwrap().port();
drop(probe);
let port = 0;

let config = DaemonConfig::new(port);
let sock = tempfile_path("bsk-test-browser-list");
Expand Down
Loading