The Wipe That Ignored My Scope
This is the story behind VKT CleanMate, a preview-first browser cleaner I ship as an extension. It is about one specific API fact — chrome.browsingData.remove() applies its origins filter to only some data types — and about what you build around a fact like that.
Here is the sentence that burned me: chrome.browsingData.remove() takes an origins array, and if you pass origins, it deletes browsing data for those origins. That is true. It is also only true for some of the types. The other types are stored profile-wide, with no origin column at all, and the API does not tell you so when you call it.
The concrete case, from early CleanMate testing: I was in targeted mode with exactly one domain on the clean list, all nine data type switches on, retention 7 days. I expected one site's footprint to vanish. What actually vanished: every download entry in the profile, every saved form autofill, every saved password. Cache and cookies behaved per-site, as advertised. The other three types looked at my carefully scoped origin list, shrugged, and cleared the whole profile. No exception, no warning, no partial-failure flag in the callback. Just a smaller profile than I left.
Saved passwords. That is the kind of bug that ends your reputation as a "cleaner" tool. And the API surface makes it easy to hit, because nothing about the call signature distinguishes the two groups.
Where the Docs Hide It
The behavior is not exactly secret, but it is distributed. The browsingData reference documents origins as a field on RemovalOptions — one field for all the boolean type flags — and the per-type notes are where you can piece together which stores are actually origin-keyed. There is no red box saying "these four types will ignore the list you are holding". You assemble the truth by reading type by type, or, like me, by watching downloads disappear on a one-site test.
The honest summary, which is what ended up encoded in the extension:
- Origin-aware:
cache,cookies,localStorage,indexedDB,serviceWorkers,fileSystems(plus legacycacheStorage/webSQL). - Whole-profile only:
downloads,formData,passwords— andhistory, which gets a special workaround discussed below.
Four of the nine types in the product's type table do not scope. And passing them alongside origins is a silent, immediate, profile-wide delete. The origins option was bolted onto an API whose underlying stores were never origin-keyed for the "account-level" data, and Chrome preserves backward compatibility by ignoring the filter for those types rather than rejecting the call.
The Inverted Whitelist: WHOLE_ONLY_TYPES
Once the fact is known, the first job is to make it structural so no code path can forget it. In vkt-clean-mate/common/store.js, the nine types are split into two lists that are checked against each other at plan-build time:
// common/store.js
/** browsingData 支持 origins 过滤的类型 */
export const ORIGIN_TYPES = [
'cache', 'cookies', 'localStorage', 'indexedDB',
'serviceWorkers', 'cacheStorage', 'fileSystems', 'webSQL'
];
/** 只能整体清理、无法按站点区分的类型 */
export const WHOLE_ONLY_TYPES = ['history', 'downloads', 'formData', 'passwords'];
The interesting one is WHOLE_ONLY_TYPES — a whitelist used backwards. Instead of asking "does browsingData respect origins here?" at every call site (and getting it wrong eventually), the extension enumerates the types that cannot be scoped, and every plan must route those through a different execution path. The plan object in background.js literally carries the split:
// background.js — buildPlan()
const originTypes = types.filter((t) => ORIGIN_TYPES.includes(t));
const wholeTypes = types.filter((t) => WHOLE_ONLY_TYPES.includes(t));
Downstream, nothing ever asks browsingData to scope a whole-only type, because the executor never puts one in an origins call. This is the kind of decision a type system would have made for you, but a named constant checked in three places does almost as well and cost ten minutes.
An Origin Is Not a Host
Second trap, smaller but sharper: even for the origin-aware types, what you pass in origins is not a domain. example.com is rejected — the list wants full origin patterns like https://example.com, with scheme. It will not expand a bare host, and it will not expand subdomains: https://example.com covers exactly that origin, not cdn.example.com.
Users think in domains — the clean list stores example.com rules that implicitly include subdomains. So there is an expansion layer between the rule lists and the API:
// common/domain.js
/** 同一 host 的两种 scheme 都算(browsingData 的 origins 不吃通配子域) */
export function originsOf(host) {
if (!host) return [];
return [`http://${host}`, `https://${host}`];
}
/** 一批 host 展开成 origins */
export function expandOrigins(hosts) {
const out = [];
for (const h of hosts || []) out.push(...originsOf(h));
return out;
}
Every enumerated host becomes two origins, http and https — you cannot know which scheme stored the data, so you delete both. That is also why the preview reports origins and sites as different numbers: an origin count is roughly double the site count, and anyone reading it as "sites" would be alarmed for no reason.
Subdomain coverage comes from the enumeration, not from the API: the plan first expands user rules against a list of hosts it can actually find (open tabs, cookie domains, history hosts), then feeds the matched hosts into expandOrigins. There are two failure modes the comments in buildPlan() call out by name, and both bit me during development:
- The closed-tab problem. A host only appears in the enumeration if something currently references it. Add
example.comto the clean list while its tab is open, close the tab, run the clean — and the enumerated pool contains zero origins for it, so the clean silently does nothing. The fix: always union the rule's apex domain into the origin set, even when enumeration finds nothing (originsFilter = expandOrigins([...hostPool, ...cleanable])). - The protection backdoor. Cookies for
secure.example.comcan live on a.example.comcookie domain. If the user protectssecure.example.comand the plan still includeshttps://example.comas an origin because it matched the clean list, deleting that origin's cookie storage nukes the protected subdomain's cookies too. Protecting a subdomain therefore vetoes the apex origin from the list — you can see the double filter in the code:cleanList.filter(r => !hostMatchesAny(r, protectList) && !protectList.some(p => hostMatches(p, r))).
Chunks of 50, and Why
The executor does not send one remove() call with the whole origin array. It slices:
// background.js
const BATCH = 50;
...
} else if (plan.originsFilter.length > 0) {
for (const chunk of chunks(plan.originsFilter, BATCH)) {
await chrome.browsingData
.remove({ since: plan.since, origins: chunk, ...typeObj })
.catch((e) => result.errors.push(e.message));
}
}
Am I hiding a documented limit behind that 50? No, and I want to be honest about the provenance: there is no citation in the code comment because there is no doc to cite. The number is a debugging-era heuristic that survived because the chunking itself earns its keep for a different reason than size limits:
- Error isolation. Each chunk has its own
.catch. One malformed origin pattern or one transient failure kills that chunk, not the run, and the surviving chunks still delete. The errors array accumulates what was not removed — which the UI can then show instead of a fake success. - Progressivity. A
remove()call is a promise that resolves when the wipe is done. Hundreds of origins in one call means one long, uninterruptible operation in a service worker that Chrome would rather kill. Fifty origins per call keeps each operation short enough that recycling is survivable.
The same instinct appears in the history path, where per-URL deletes go out in concurrent batches of 40 (chunks(urls, 40) with Promise.all). Nobody documented 40 or 50. They are numbers I chose, watched work, and left alone — which is sometimes what engineering budgeting actually is.
History: The One Whole-Only Type With a Side Door
Of the four whole-profile types, three are genuinely hopeless: downloads, form data and passwords have no per-origin API anywhere in the extensions surface. History is different, because it has its own API. chrome.history.deleteUrl({ url }) deletes one URL's entry, so a per-site history clean is buildable by enumerating and filtering yourself:
// background.js — deleteHistoryFiltered()
const items = await chrome.history.search({
text: '', startTime: plan.since || 0, maxResults: 20000
});
// keep URLs whose host matches the clean list and not the protect list,
// then deleteUrl() them in batches of 40.
Two compromises fall out of this, and both are visible to the user:
- It costs the optional
historypermission. The manifest listshistoryandcookiesunderoptional_permissions, so a fresh install never shows the scary "read your browsing history" warning. The extension requests the permission at the moment you first preview or clean history per site — and only from a user gesture, in the popup or the options checkbox handler, becausechrome.permissions.request()fails silently outside one. - There is no per-site and per-date history delete.
deleteUrlremoves the entry as a whole. Filtering the enumeration bystartTimedecides which URLs to consider, but once a URL qualifies, every visit to it goes. The settings page says exactly that: "Per-site history cleaning removes each matched URL as a whole, so older visits of the same sites go too."
When the plan runs in full mode instead, history goes through browsingData.remove({ since, history: true }) with no origins at all, and the result records historyDeleted = -1 — a sentinel meaning "cleared profile-wide, count unknowable after the fact". The run log renders -1 as just "history", no number. A count you cannot verify is a count you should not display.
Counting the Damage Before It Happens
Deleting the wrong scope is bad; deleting the right scope without telling anyone how much it is, is worse. So buildPreview() assembles live numbers straight from the browser before anything is removed:
// background.js — buildPreview() (essentials)
return {
mode: plan.mode,
retention: plan.retention,
types: plan.types,
hosts: plan.hostPool.length, // sites matched
originsUnrestricted: plan.originsFilter === null,
historyCount: historyInfo ? historyInfo.count : null,
historyTruncated: !!(historyInfo && historyInfo.truncated),
cookieCount, // per-host cookie tally
warnings: plan.warnings,
needHistory: plan.historyMode === 'skip' && plan.wholeTypes.includes('history'),
needCookies: cookieCount === null && plan.types.includes('cookies')
};
The cookie count comes from chrome.cookies.getAll({}) with the dot-prefix stripped from domains and subdomain suffix matching done by hand. The history count comes from the same 20,000-entry search the deleter will use, and if the result is saturated, the preview shows "20000+" with a plus sign rather than a confident wrong number. Missing permissions surface as an inline "Grant" row in the preview sheet itself, not as a failed delete three steps later. Retention — one of all / 1d / 3d / 7d / 30d, converted to a since timestamp by retentionSince() — is always on the sheet, because "keep last 7 days" changes the meaning of every number above it.
One boundary matters, and I have been sloppy about how I describe it in the past: this preview sheet appears on manual runs only. Clicking Clean in the popup — or using the right-click "Preview and clean this site" menu, which opens the same popup locked to a host — builds the plan, shows the sheet, and waits for a confirm button. The scheduled triggers (interval, daily, idle, onStartup, onClose) never show a preview: they run the plan your rules describe and send a notification afterwards with the result summary. That is not an oversight; it is the product contract. A preview you must click while you're asleep is not automation. Auto-runs commit to the rules, and the safety for rules is that the rules are conservative — free tier is targeted mode only, targeted mode never passes whole-only types to browsingData without your explicit acknowledgment, and runClean() demotes any unlicensed full-mode schedule to targeted before executing. Manual runs commit to a counted snapshot, so they get the sheet. Two different promises, two different UIs.
Saying "We Cannot Do Per-Site" in the Interface
The product decision that this whole article argues for: when an API cannot do what users assume it does, the UI states the limitation next to the control that triggers it — instead of silently widening scope, or silently skipping the type and making the tool look broken.
Concretely, three strings shipped in _locales/en/messages.json:
"warnWholeOnly": { "message": "Downloads / form data / passwords cannot target a single site" }
"warnWholeOnlyProtect": { "message": "Downloads / form data / passwords will be cleared for all sites" }
"typeNote": { "message": "The browser cannot limit history, downloads, form data or passwords to a single site — those are only filtered in Full mode." }
The first appears in the preview warnings when targeted mode has those types checked (in which case the executor skips them — refusing beats nuking the profile). The second fires in full mode when a protect list exists, because a protected github.com does not protect your download history entries from github.com — nothing does, there is no origin to match on. The third sits permanently under the data-type checkbox grid on the options page. That note is the most valuable line of copy in the extension, and it exists only because WHOLE_ONLY_TYPES is true and I am not allowed to pretend otherwise.
The store listing repeats it: "Limit history, downloads, form data or passwords to a single site: the browser does not expose that; those four are filtered only in Full mode, and the interface says so next to the checkbox." Users who read that before installing are exactly the users who would have filed the "extension deleted all my passwords" report if I had hidden it.
The Same Honesty Applies to What We Collect
CleanMate also tracks per-site focus time ("Footprints"), and since the theme of this article is scope-honesty, it is worth recording the constraints that live in the code next to the cleaner's:
// background.js
/** 单块计时上限:休眠/挂起醒来后心跳间隔会很长,不能把整夜空档算成专注 */
const MAX_FOCUS_BLOCK_SEC = 300;
/** 连续这么久没有任何输入视为"人不在",停止累计;看视频时不会误判成离开 */
const FOCUS_IDLE_SEC = 600;
A timing block counts only when the tab is active and its window is focused and not minimized, checked through chrome.tabs.query({ active: true, lastFocusedWindow: true }) plus chrome.idle.queryState(FOCUS_IDLE_SEC). Ten minutes without input means you left; each committed block is capped at five minutes so a resumed-from-hibernation heartbeat cannot bank your whole night as "focus"; the one-minute alarm flushes with keep: true so a killed service worker loses at most a minute of accounting. Storage per host: visits, seconds, a 24-bucket hour histogram, last visit. No URLs, no titles. A 30-day rolling window, an 800-host cap, and the whole store is one chrome.storage.local key you can wipe with one button. And there is no server: the only host in host_permissions is api.annmax1983.com, used for optional license checks.
Origin scoping and footprint scoping are the same discipline: know precisely what the API can and cannot bound, keep only what your feature actually needs, and let the observable surface — counts, warnings, retention notes — match the internals exactly.
Checklist
- Assume
originsis ignored unless a type is on your origin-aware list: cache, cookies, localStorage, indexedDB, serviceWorkers, fileSystems. Everything else is profile-wide. - Encode the exclusion as data (
WHOLE_ONLY_TYPES) and route on it, so no call site can forget. - Never pass a bare host to
origins— expand rules tohttp://hostandhttps://host, and enumerate subdomains yourself. - Union user rules into the origin set even when live enumeration finds nothing; closed tabs are not evidence of no data.
- Veto apex origins when a subdomain is protected — cookie domains reach upward.
- Chunk origin deletes; catch per chunk so one bad pattern cannot abort the run.
- History is the only whole-only type with a per-URL side door — budget for the optional
historypermission and request it from a user gesture. - Count live before deleting (sites, history entries, cookies, types, retention), show saturation as "20000+", and use an explicit sentinel for "wiped profile-wide, count unknowable".
- Preview is the contract of manual runs; scheduled runs promise their rules and notify after. Never describe automation as "always previewed".
- Put "the browser cannot do per-site here" next to the checkbox. Refusing or warning beats silently deleting the profile.
References
- chrome.browsingData API Reference — Chrome Docs
- RemovalOptions (origins field) — Chrome Docs
- chrome.history API Reference — Chrome Docs
- Declare permissions — Chrome Docs
- chrome.idle API Reference — Chrome Docs