Thanks to visit codestin.com
Credit goes to github.com

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
7 changes: 7 additions & 0 deletions docs/src/content/docs/install.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,13 @@ set of reserved keys is also accepted for non-rule build-time toggles:
target for browser-use agents. Enable for human-facing deployments where
on-page access to options is useful.

- `runOnInactiveTabs` (boolean, default **off**) — keep the shared subtree
watcher observing while the tab is hidden. Off by default because a hidden tab
gets no observer callbacks, which avoids work the user can't see. Enable when
something else reads the page while it's in the background (chat copilots,
accessibility-tree agents, sidebar extensions) — without this, a page that
mutates while hidden could reach the consumer unredacted.

The file may be partial; rules not listed keep the committed default. Unknown
keys (neither a registered rule id nor a reserved key) and non-boolean values
fail the build with a message naming them.
Expand Down
8 changes: 7 additions & 1 deletion extension/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,8 @@ async function build(): Promise<void> {
if (defaultsPath) {
const changed =
Object.keys(overrides.rules).length +
(overrides.optionsButton === undefined ? 0 : 1);
(overrides.optionsButton === undefined ? 0 : 1) +
(overrides.runOnInactiveTabs === undefined ? 0 : 1);
console.log(
`Applying ${changed} build-time default override(s) from ${defaultsPath}.`,
);
Expand Down Expand Up @@ -152,6 +153,11 @@ async function build(): Promise<void> {
? ""
: String(overrides.optionsButton),
),
"process.env.EXTENSION_RUN_ON_INACTIVE_TABS_DEFAULT": JSON.stringify(
overrides.runOnInactiveTabs === undefined
? ""
: String(overrides.runOnInactiveTabs),
),
Comment thread
twschiller marked this conversation as resolved.
},
});

Expand Down
23 changes: 23 additions & 0 deletions extension/scripts/__tests__/load-default-overrides.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,29 @@ describe("loadDefaultOverrides", () => {
).toThrow(/non-boolean values for: optionsButton/);
});

it("extracts the runOnInactiveTabs reserved key alongside rules", () => {
const file = writeFile(
"with-run-on-inactive.json",
JSON.stringify({ "pii-redact": true, runOnInactiveTabs: true }),
);
expect(
loadDefaultOverrides({ path: file, knownRuleIds: KNOWN_IDS }),
).toEqual({
rules: { "pii-redact": true },
runOnInactiveTabs: true,
});
});

it("rejects a non-boolean runOnInactiveTabs value", () => {
const file = writeFile(
"bad-run-on-inactive.json",
JSON.stringify({ runOnInactiveTabs: "always" }),
);
expect(() =>
loadDefaultOverrides({ path: file, knownRuleIds: KNOWN_IDS }),
).toThrow(/non-boolean values for: runOnInactiveTabs/);
});

it("throws when the file does not exist", () => {
expect(() =>
loadDefaultOverrides({
Expand Down
5 changes: 4 additions & 1 deletion extension/scripts/load-default-overrides.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,13 @@ export interface LoadOverridesOptions {
export interface DefaultOverrides {
rules: Record<string, boolean>;
optionsButton?: boolean;
runOnInactiveTabs?: boolean;
}

// Reserved top-level keys are not rule ids; the loader maps each one to a
// typed field on `DefaultOverrides`. Add new build-time toggles here as they
// appear.
const RESERVED_KEYS = new Set<string>(["optionsButton"]);
const RESERVED_KEYS = new Set<string>(["optionsButton", "runOnInactiveTabs"]);

export function loadDefaultOverrides(
options: LoadOverridesOptions,
Expand Down Expand Up @@ -78,6 +79,8 @@ export function loadDefaultOverrides(
}
if (key === "optionsButton") {
result.optionsButton = value;
} else if (key === "runOnInactiveTabs") {
result.runOnInactiveTabs = value;
}
continue;
}
Expand Down
190 changes: 181 additions & 9 deletions extension/src/lib/__tests__/subtree-watcher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

import { PLACEHOLDER_CLASS } from "../placeholder";
import { __resetRouteChangeForTesting } from "../route-change";
import { runOnInactiveTabsStorage } from "../run-on-inactive-tabs";
import {
__resetShadowRootsForTesting,
installShadowRootHook,
Expand All @@ -25,7 +26,15 @@ async function flushMutations(): Promise<void> {
await Promise.resolve();
}

beforeEach(() => {
// The watcher reads runOnInactiveTabsStorage asynchronously on start(). Tests
// that depend on the resolved value (or that flipped it) need to drain the
// chained microtasks before observing behavior.
async function flushStorageReads(): Promise<void> {
await Promise.resolve();
await Promise.resolve();
}

beforeEach(async () => {
document.body.innerHTML = "";
jest.useFakeTimers();
__resetRouteChangeForTesting();
Expand All @@ -36,6 +45,9 @@ beforeEach(() => {
// behavior run regardless of whether content.ts has been imported.
installShadowRootHook();
history.replaceState(null, "", "/initial");
// The in-memory webext-storage mock persists across tests; reset to the
// committed default so each test starts from a known state.
await runOnInactiveTabsStorage.set(false);
});

afterEach(() => {
Expand Down Expand Up @@ -737,14 +749,6 @@ describe("createSubtreeWatcher", () => {
// per-subscriber options and that router lifecycle tracks the last
// subscriber.

function setHidden(hidden: boolean): void {
Object.defineProperty(document, "hidden", {
configurable: true,
value: hidden,
});
document.dispatchEvent(new Event("visibilitychange"));
}

it("fans out a single mutation to every watcher on the same root", async () => {
const a = jest.fn();
const b = jest.fn();
Expand Down Expand Up @@ -970,6 +974,14 @@ describe("createSubtreeWatcher", () => {
});

it("flushes every subscriber's pending on visibilitychange to hidden", async () => {
function setHidden(hidden: boolean): void {
Object.defineProperty(document, "hidden", {
configurable: true,
value: hidden,
});
document.dispatchEvent(new Event("visibilitychange"));
}

const a = jest.fn();
const b = jest.fn();
const watcherA = createSubtreeWatcher({ onSubtrees: a });
Expand Down Expand Up @@ -1578,4 +1590,164 @@ describe("createSubtreeWatcher", () => {
watcher.stop();
});
});

describe("runOnInactiveTabs option", () => {
function setHidden(hidden: boolean): void {
Object.defineProperty(document, "hidden", {
configurable: true,
value: hidden,
});
document.dispatchEvent(new Event("visibilitychange"));
}

function fireRouteChange(toUrl: string): void {
history.replaceState(null, "", toUrl);
globalThis.dispatchEvent(new Event("popstate"));
}

it("keeps delivering callbacks while hidden when enabled at start", async () => {
await runOnInactiveTabsStorage.set(true);

const onSubtrees = jest.fn();
const watcher = createSubtreeWatcher({ onSubtrees });
watcher.start(document.body);
// Wait out the storage read so the watcher latches the stored value
// before we hide the tab.
await flushStorageReads();

setHidden(true);

document.body.append(document.createElement("div"));
await flushMutations();
jest.advanceTimersByTime(THROTTLE_MS);

expect(onSubtrees).toHaveBeenCalledTimes(1);
watcher.stop();
});

it("reattaches when the option flips on while the tab is hidden", async () => {
const onSubtrees = jest.fn();
const watcher = createSubtreeWatcher({ onSubtrees });
watcher.start(document.body);
await flushStorageReads();

setHidden(true);
// Default-off: a mid-hidden mutation is ignored.
document.body.append(document.createElement("div"));
await flushMutations();
jest.advanceTimersByTime(THROTTLE_MS);
expect(onSubtrees).not.toHaveBeenCalled();

await runOnInactiveTabsStorage.set(true);
await flushMutations();

document.body.append(document.createElement("section"));
await flushMutations();
jest.advanceTimersByTime(THROTTLE_MS);

expect(onSubtrees).toHaveBeenCalledTimes(1);
watcher.stop();
});

it("detaches when the option flips off while the tab is hidden", async () => {
await runOnInactiveTabsStorage.set(true);

const onSubtrees = jest.fn();
const watcher = createSubtreeWatcher({ onSubtrees });
watcher.start(document.body);
await flushStorageReads();

setHidden(true);
// Sanity: with the option on, a hidden mutation surfaces.
document.body.append(document.createElement("div"));
await flushMutations();
jest.advanceTimersByTime(THROTTLE_MS);
expect(onSubtrees).toHaveBeenCalledTimes(1);

await runOnInactiveTabsStorage.set(false);
await flushMutations();

document.body.append(document.createElement("section"));
await flushMutations();
jest.advanceTimersByTime(THROTTLE_MS);

// No additional call: the watcher detached when the option flipped off.
expect(onSubtrees).toHaveBeenCalledTimes(1);
watcher.stop();
});

it("does not change behavior while the tab is visible", async () => {
const onSubtrees = jest.fn();
const watcher = createSubtreeWatcher({ onSubtrees });
watcher.start(document.body);
await flushStorageReads();

// Visible the whole time; flipping the option must not double-attach
// or otherwise duplicate callbacks.
await runOnInactiveTabsStorage.set(true);
await flushMutations();
await runOnInactiveTabsStorage.set(false);
await flushMutations();

document.body.append(document.createElement("div"));
await flushMutations();
jest.advanceTimersByTime(THROTTLE_MS);

expect(onSubtrees).toHaveBeenCalledTimes(1);
watcher.stop();
});

it("sweeps document.body on route change while hidden when enabled", async () => {
await runOnInactiveTabsStorage.set(true);

const onSubtrees = jest.fn();
const watcher = createSubtreeWatcher({ onSubtrees });
watcher.start(document.body);
await flushStorageReads();

setHidden(true);
fireRouteChange("/route-b");
jest.advanceTimersToNextFrame();

expect(onSubtrees).toHaveBeenCalledTimes(1);
const [roots] = onSubtrees.mock.calls[0] as [Element[]];
expect(roots).toEqual([document.body]);
watcher.stop();
});

it("uses setTimeout (not rAF) for the route sweep while hidden", async () => {
// Chrome documents rAF as paused in background tabs (see
// developer.chrome.com/blog/background_tabs). Documented exemptions
// (audible audio, WebRTC) and devtools attachment can keep it firing,
// so the safety-net sweep can't rely on either outcome — fall back to
// setTimeout, which is clamped to ~1s in background tabs but does
// fire. Spy on both schedulers since Jest's fake timers don't
// simulate the visibility-based pause.
await runOnInactiveTabsStorage.set(true);

const onSubtrees = jest.fn();
const watcher = createSubtreeWatcher({ onSubtrees });
watcher.start(document.body);
await flushStorageReads();

setHidden(true);

const rafSpy = jest.spyOn(globalThis, "requestAnimationFrame");
const setTimeoutSpy = jest.spyOn(globalThis, "setTimeout");

fireRouteChange("/route-b");

expect(rafSpy).not.toHaveBeenCalled();
expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 0);

// Drain the setTimeout — advance by 0ms is sufficient since the
// handler was scheduled with delay=0.
jest.advanceTimersByTime(0);
expect(onSubtrees).toHaveBeenCalledTimes(1);

rafSpy.mockRestore();
setTimeoutSpy.mockRestore();
watcher.stop();
});
});
});
35 changes: 35 additions & 0 deletions extension/src/lib/run-on-inactive-tabs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
// Copyright (c) 2026 PixieBrix, Inc.
// Licensed under PolyForm Shield 1.0.0 — see LICENSE.

// Controls whether the shared subtree watcher keeps observing while the tab
// is hidden. Default off: a hidden tab gets no observer callbacks, which
// avoids work the user can't see. Operators flip it on when something else
// reads the page while it's in the background — a chat copilot, an
// accessibility-tree agent, or a sidebar extension can still consume the
// page's content after the user navigates away, and a page that mutates
// while hidden (lazy widgets finishing load, periodic refreshes, late
// prompt-injection payloads) would otherwise reach the consumer unredacted.

import { createChromeStorageValue } from "./chrome-storage-value";

export const RUN_ON_INACTIVE_TABS_DEFAULT = false;

// `process.env.EXTENSION_RUN_ON_INACTIVE_TABS_DEFAULT` is substituted by
// build.ts when the operator passes a defaults file with a
// `runOnInactiveTabs` field. Literal `"true"` / `"false"` forces a value;
// empty string falls back to the committed default above.
function resolveDefault(): boolean {
const raw = process.env.EXTENSION_RUN_ON_INACTIVE_TABS_DEFAULT;
if (raw === "true") {
return true;
}
if (raw === "false") {
return false;
}
return RUN_ON_INACTIVE_TABS_DEFAULT;
}

export const runOnInactiveTabsStorage = createChromeStorageValue<boolean>({
key: "agent-browser-shield.run-on-inactive-tabs",
defaultValue: resolveDefault(),
});
Loading
Loading