From 06a183830ebf49ec4b8196b1915e9bab108b5c3a Mon Sep 17 00:00:00 2001 From: lodar Date: Thu, 17 Sep 2026 14:24:47 +0000 Subject: [PATCH] =?UTF-8?q?feat(browser):=20add=20`adblock=20on|off|status?= =?UTF-8?q?`=20=E2=80=94=20the=20per-site=20off=20switch=20for=20pinned=20?= =?UTF-8?q?uBlock=20Origin=20Lite=20(DIVE-4516)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The stack half of this row installs ONE extension into agent Chrome profiles by managed policy and blocks every other. Some sites break under filtering, and uBOL's own per-site switch lives in its popup, which is not scriptable from a seat. The one that is, is Chrome policy's `ExtensionSettings..runtime_blocked_hosts`. That key is documented against content-script injection and extension API access, and uBOL Lite filters through declarativeNetRequest in the network stack — a different mechanism — so it was MEASURED before the verb was built on it, on exact-swallow at Chrome 153 (receipt on DIVE-4516): with the key set, the DNR block disappeared and the content script stopped injecting; removing it restored both. - Root, like `setup`, because chrome policy on Linux is machine-level only. - `/var/lib/5dive/browser/ubol/adblock-off` is the source of truth, so the nightly root converge that re-renders the policy file preserves a site a seat turned off. - Both host patterns are written: `*://*.example.com` does not match `example.com`, and a wildcard-only off switch would report success while leaving the apex filtered. - The verb says a running `serve` may need a restart rather than promising live pickup: only fresh launches were measured. 18 new arms in tests/browser_plugin_unit.sh (531 pass, 0 fail). Mutation checked: a wildcard-only off switch reds 3 arms; leaving an empty array instead of deleting the key reds 1. README and the connect-site skill say when to reach for it. Co-Authored-By: Claude Opus 5 --- CHANGES.md | 25 ++++ plugins/browser/.claude-plugin/plugin.json | 2 +- plugins/browser/README.md | 34 +++++ plugins/browser/bin/browser | 142 ++++++++++++++++++- plugins/browser/skills/connect-site/SKILL.md | 20 +++ tests/browser_plugin_unit.sh | 95 ++++++++++++- 6 files changed, 315 insertions(+), 3 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index befb266..4d1173b 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,5 +1,30 @@ ## Unreleased +### Added — `5dive browser adblock`: turn ad filtering off for one site (DIVE-4516), browser 1.6.0 + +Agent Chrome profiles on a managed box now carry exactly one extension — uBlock +Origin Lite, pinned and force-installed by Chrome managed policy — and that same +policy blocks every other extension from being added, including by a human at the +one-time viewer. Some sites break under filtering, so there is a way to turn it off +for one host without turning it off everywhere. + +- `5dive browser adblock status` / `sudo 5dive browser adblock off|on `. +- Root, and not by preference: Chrome policy on Linux is machine-level only, with + no per-user path, so the file belongs to root the way the profile store does. + `adblock` joins `setup` as the second verb a root caller is NOT dropped out of. +- The mechanism is `ExtensionSettings..runtime_blocked_hosts`, measured on + Chrome 153 before it was built on: uBOL Lite filters through declarativeNetRequest + (the network stack), not through the content-script path that key is documented + against, and it turned out to stop BOTH. It is total for that host. +- Both `*://host` and `*://*.host` are written. `*://*.example.com` does not match + `example.com`, so a wildcard-only off switch reports success and leaves the apex — + the host the seat actually typed — still filtered. +- `/var/lib/5dive/browser/ubol/adblock-off` is the source of truth, not the policy + file: the nightly root converge re-renders that file, and a host living only there + would be silently re-filtered at 03:00. +- Only fresh launches were measured, so `shot`/`read` pick a change up on their next + render and the verb SAYS a live `serve` may need a restart rather than promising it. + ### Fixed — the browser connect-site runbook now follows the shipped bound viewer flow (DIVE-4523), browser 1.5.3 The Claude skill and harness-neutral AGENTS block now carry one byte-identical fenced workflow. It diff --git a/plugins/browser/.claude-plugin/plugin.json b/plugins/browser/.claude-plugin/plugin.json index 57556c8..cb10ade 100644 --- a/plugins/browser/.claude-plugin/plugin.json +++ b/plugins/browser/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "browser", - "version": "1.5.3", + "version": "1.6.0", "description": "Persistent human-authenticated browser sessions \u2014 log into a site once by hand, then let deterministic adapters operate it. Never solves a security challenge; it stops and asks you.", "author": { "name": "5dive", diff --git a/plugins/browser/README.md b/plugins/browser/README.md index 596fa15..52ba354 100644 --- a/plugins/browser/README.md +++ b/plugins/browser/README.md @@ -414,6 +414,40 @@ a library nobody chose. The driver looks in exactly two places, in order: the di names, if any, then `plugins/browser/node_modules`. There is no ancestor walk, so "not installed" is a fact about those two places rather than about where the plugin was unpacked. +## Ad filtering, and the one site where you turn it off + +Agent Chrome profiles on a managed box carry exactly ONE extension — uBlock Origin +Lite, pinned to a version we pack and host ourselves — and the same Chrome managed +policy that installs it blocks every other extension from being added, including by +a human sitting at the one-time viewer. Cookie walls, ad iframes and consent +overlays are what an agent clicks by accident, and what makes a `shot`/`read` DOM +several times larger than the article it was asked to read. + +Some sites break under filtering. That is what this is for: + +``` +5dive browser adblock status # is it on, and which sites is it off for +sudo 5dive browser adblock off example.com # this site breaks — stop filtering it +sudo 5dive browser adblock on example.com # filter it again +``` + +Three things worth knowing before you use it: + +- **It is root, and not by preference.** Chrome policy on Linux is machine-level + only — `/etc/opt/chrome/policies/managed` — and there is no per-user policy path, + so the file belongs to root the way the profile store's parent does. +- **It is total for that host.** The mechanism is + `ExtensionSettings..runtime_blocked_hosts`, measured on Chrome 153: it stops + the network-level filtering AND the extension's content script on that host. + There is no "filter a bit less" setting. +- **`shot` and `read` pick it up on their next render; a browser already running + under `serve` may not.** Only fresh launches were measured. If you need it to + take effect inside a live session, `serve --stop` first. + +The off list lives at `/var/lib/5dive/browser/ubol/adblock-off` and IS the source of +truth: the nightly root converge re-renders the policy file from it, so a host you +remove from that list comes back filtered. + ## Shipped, and what is still named so nobody assumes it - **The customer-facing FLOW is live.** The dashboard's Connected sites tile went to production on diff --git a/plugins/browser/bin/browser b/plugins/browser/bin/browser index 4ea1898..0866e0c 100755 --- a/plugins/browser/bin/browser +++ b/plugins/browser/bin/browser @@ -79,6 +79,10 @@ usage() { # the relay's gate: consumes the link, prints the loopback target 5dive browser viewer-revoke # kill the view; the browser stays logged in + 5dive browser adblock status # is ad filtering on, and which sites is it off for + sudo 5dive browser adblock off # this site breaks under filtering — stop filtering it + sudo 5dive browser adblock on # filter it again + 5dive browser doc [--append=] # print the agent-facing workflow doc (any harness), # or install it into a seat's AGENTS.md / CLAUDE.md @@ -1486,6 +1490,139 @@ _doc_fence_or_die() { return 0 } +# --- adblock: the per-site OFF switch (DIVE-4516) ----------------------------- +# +# ONE extension is allowed in an agent Chrome profile — uBlock Origin Lite, pinned +# — because cookie walls, ad iframes and consent overlays are what an agent clicks +# by accident and what makes a `shot`/`read` DOM several times larger than the +# article. Some sites break under filtering, so lodar's direction asks for a way to +# turn it off for one site without turning it off everywhere. +# +# uBOL's OWN per-site switch lives in its popup and is not scriptable from a seat. +# The one that IS scriptable is Chrome managed policy: +# `ExtensionSettings..runtime_blocked_hosts`. That is documented against +# content-script injection and extension API access, and uBOL Lite filters through +# declarativeNetRequest — a different mechanism in the network stack — so this was +# MEASURED before it was built on, on exact-swallow at Chrome 153 (2026-09-14, +# receipt on DIVE-4516): with the key set for the host, the DNR block disappeared +# (0 -> 3000 divs, 149 B -> 65002 B) AND the content script stopped injecting; +# removing the key restored both. It is a real off switch, and it is total. +# +# WHY THIS VERB IS ROOT. Chrome policy on Linux is machine-level only — there is +# no per-user policy path — so the file belongs to root and this is the same shape +# as `setup`. The off list, not the policy file, is the SOURCE OF TRUTH: the +# nightly root converge (scripts/inc/browser-stack.sh) re-renders the whole policy +# file, and a site turned off at 14:00 that lived only in that file would be +# silently re-filtered at 03:00 with nobody to notice. +_ADBLOCK_STATE_DIR="$STATE_DIR/browser/ubol" +_ADBLOCK_OFF_FILE="$_ADBLOCK_STATE_DIR/adblock-off" +# Same rule as the converger's _bs_policy_dir, and for the same reason: google-chrome +# and chromium read different paths, and writing the wrong one is a file nobody +# loads — which is indistinguishable from success unless you look for it. +_adblock_policy_file() { + local dir="${CHROME_POLICY_DIR:-}" + if [[ -z "$dir" ]]; then + case "$(_chrome 2>/dev/null || true)" in + *chromium*) dir=/etc/chromium/policies/managed ;; + *) dir=/etc/opt/chrome/policies/managed ;; + esac + fi + printf '%s/5dive-browser.json\n' "$dir" +} +_adblock_id() { + local f="$_ADBLOCK_STATE_DIR/extension-id" + [[ -f "$f" ]] && { tr -d '[:space:]' < "$f"; return 0; } + printf 'bjnapnkpiihibhjaehecmpbpeejnloib\n' +} +_adblock_hosts() { + [[ -f "$_ADBLOCK_OFF_FILE" ]] || return 0 + sed -e 's/#.*//' -e 's/[[:space:]]//g' "$_ADBLOCK_OFF_FILE" | grep -v '^$' | sort -u +} +# BOTH patterns per host, and this is not belt-and-braces: `*://*.example.com` does +# NOT match `example.com`, so a wildcard-only off switch reports success and leaves +# the apex — the host the seat actually typed — still filtered. +_adblock_patterns() { + local h; for h in $(_adblock_hosts); do printf '*://%s\n*://*.%s\n' "$h" "$h"; done +} +# Patch the LIVE policy file so the change does not wait for 03:00. Only the one +# key: everything else in that file is the converger's and must not be re-derived +# here, or two writers would drift and the file would flip every night. +_adblock_apply() { + local f; f="$(_adblock_policy_file)" + [[ -f "$f" ]] || return 2 + local id; id="$(_adblock_id)" + local tmp="$f.5dive.tmp" + jq --arg id "$id" --arg hosts "$(_adblock_patterns)" ' + ($hosts | split("\n") | map(select(length > 0))) as $b + | if (.ExtensionSettings[$id] | type) == "object" then . else .ExtensionSettings[$id] = {} end + | if ($b | length) == 0 + then .ExtensionSettings[$id] |= del(.runtime_blocked_hosts) + else .ExtensionSettings[$id].runtime_blocked_hosts = $b end' "$f" > "$tmp" 2>/dev/null \ + && mv -f "$tmp" "$f" || { rm -f "$tmp"; return 1; } + chmod 644 "$f" 2>/dev/null || true + return 0 +} +_adblock_write_list() { + mkdir -p "$_ADBLOCK_STATE_DIR" || die "$E_UNAVAILABLE" "cannot create $_ADBLOCK_STATE_DIR" + local tmp="$_ADBLOCK_OFF_FILE.tmp" + { printf '# 5dive browser adblock — hosts uBlock Origin Lite is turned OFF for.\n' + printf '# The SOURCE OF TRUTH: the nightly root converge re-renders the chrome\n' + printf '# policy file from this list, so a host removed here comes back filtered.\n' + printf '%s\n' "$@"; } > "$tmp" && mv -f "$tmp" "$_ADBLOCK_OFF_FILE" \ + || { rm -f "$tmp"; die "$E_UNAVAILABLE" "cannot write $_ADBLOCK_OFF_FILE"; } + chmod 644 "$_ADBLOCK_OFF_FILE" 2>/dev/null || true +} +cmd_adblock() { + local sub="${1:-status}"; shift 2>/dev/null || true + local site="${1:-}" + case "$sub" in + status|"") + local f; f="$(_adblock_policy_file)" + printf 'ad filtering: uBlock Origin Lite %s\n' "$(_adblock_id)" + if [[ -f "$f" ]]; then + printf 'policy: %s\n' "$f" + else + printf 'policy: %s (ABSENT — this box has no pinned uBlock Origin Lite yet, so nothing is being filtered and nothing is being blocked)\n' "$f" + fi + # NOT `paste -sd', '`: -d takes a LIST of delimiters and CYCLES them, so + # three hosts come out "a,b c,d". One delimiter, then space it out. + local off; off="$(_adblock_hosts | paste -sd, - | sed 's/,/, /g')" + printf 'filtering is OFF for: %s\n' "${off:-nothing — every site is filtered}" + return 0 + ;; + on|off) ;; + *) die "$E_USAGE" "unknown adblock subcommand '$sub' — expected on, off or status" ;; + esac + + [[ -n "$site" ]] || die "$E_USAGE" "$sub which site? e.g. \`sudo 5dive browser adblock $sub example.com\`" + _valid_site "$site" || die "$E_USAGE" "'$site' is not a host — IS THE HOST (example.com), the same name the profile takes" + [[ "$(id -u)" == 0 ]] || die "$E_PERM" "chrome policy on Linux is machine-level and root-owned — there is no per-user policy path. Run: sudo 5dive browser adblock $sub $site" + + local -a hosts=(); local h + while read -r h; do [[ -n "$h" && "$h" != "$site" ]] && hosts+=("$h"); done < <(_adblock_hosts) + local was_off=no; _adblock_hosts | grep -qx "$site" && was_off=yes + [[ "$sub" == off ]] && hosts+=("$site") + _adblock_write_list "${hosts[@]+"${hosts[@]}"}" + + local rc=0; _adblock_apply || rc=$? + if [[ "$sub" == off ]]; then + printf '%s: ad filtering OFF for %s%s\n' "$PROG" "$site" "$([[ $was_off == yes ]] && printf ' (it already was)')" + else + printf '%s: ad filtering ON for %s%s\n' "$PROG" "$site" "$([[ $was_off == no ]] && printf ' (it already was)')" + fi + case "$rc" in + 0) # Measured across FRESH launches only — `shot`/`read` start a new headless + # chrome each time and pick it up, an already-running `serve` was never + # measured and must not be promised. + printf '%s: applied to %s. `shot`/`read` pick this up on their next render; a browser already running under `serve` may need `serve %s --stop` first.\n' \ + "$PROG" "$(_adblock_policy_file)" "$site" ;; + 2) printf '%s: recorded. This box has no chrome policy file yet (%s), so nothing is filtered here and there is nothing to turn off — the setting applies the moment the pinned uBlock Origin Lite reaches this box.\n' \ + "$PROG" "$(_adblock_policy_file)" ;; + *) die "$E_UNAVAILABLE" "recorded in $_ADBLOCK_OFF_FILE, but $(_adblock_policy_file) could not be rewritten — the change takes effect at the next nightly converge, not now" ;; + esac + return 0 +} + cmd_doc() { local target="" for a in "$@"; do @@ -1549,7 +1686,9 @@ ok_doc() { printf '%s: %s\n' "$PROG" "$1"; } # to the seat's, which is what Chrome and the adapters need. if [[ $EUID -eq 0 && -n "${SUDO_USER:-}" && "${SUDO_USER}" != root ]]; then case "${1:-}" in - setup|-h|--help|help|"") ;; + # `adblock` joins `setup` here: it writes the MACHINE-WIDE chrome policy + # file, which is root's. Dropping to the seat would turn it into a refusal. + setup|adblock|-h|--help|help|"") ;; *) _drop="${SUDO_USER}" id -u "$_drop" >/dev/null 2>&1 || die "$E_PERM" "SUDO_USER=$_drop is not a user on this box — refusing to operate a profile store as root" @@ -1567,6 +1706,7 @@ case "${1:-}" in probe-all) shift; cmd_probe_all "$@" ;; ls) shift; cmd_ls "$@" ;; doc) shift; cmd_doc "$@" ;; + adblock) shift; cmd_adblock "$@" ;; run) shift; cmd_run "$@" ;; shot) shift; cmd_shot "$@" ;; read) shift; cmd_read "$@" ;; diff --git a/plugins/browser/skills/connect-site/SKILL.md b/plugins/browser/skills/connect-site/SKILL.md index 7eb096a..f67c0ec 100644 --- a/plugins/browser/skills/connect-site/SKILL.md +++ b/plugins/browser/skills/connect-site/SKILL.md @@ -127,3 +127,23 @@ anti-bot bypassing. A CAPTCHA, a 2FA prompt or an "unusual activity" interstitia **hard stop that asks for a person**: surface it, do not attempt it, do not look for a way around it. Never ask the human for a password, never accept one, never write one down, and never export cookies out of a profile. + +## When a page looks broken or half-loaded + +The agent profile filters ads and cookie walls (uBlock Origin Lite, installed by +Chrome policy and pinned — it is the only extension allowed, and nothing else can +be added). A small number of sites break under that filtering: the page renders +empty, a player never starts, a login form does not submit. + +Turn it off for that one site and re-render: + +``` +sudo 5dive browser adblock off example.com +5dive browser shot example.com https://example.com/... +``` + +`sudo 5dive browser adblock on example.com` puts it back. `5dive browser adblock +status` says which sites are currently unfiltered. It is off for the whole host +(both `example.com` and its subdomains) — there is no partial setting — and a +browser already running under `serve` may need `serve example.com --stop` before it +picks the change up. diff --git a/tests/browser_plugin_unit.sh b/tests/browser_plugin_unit.sh index 68b8f2c..cdf3db1 100755 --- a/tests/browser_plugin_unit.sh +++ b/tests/browser_plugin_unit.sh @@ -231,7 +231,11 @@ t 'T2c5 ...and the store root too' 'yes' "$(grep -q 'chmod 00711 "\$PROFILE_ROO # DIVE-4348: the dashboard's only path is shelld -> `sudo -n 5dive browser …` (root, # SUDO_USER=claude); as root every verb but setup refused. Root drops to the seat. t 'T2c6 a root caller with SUDO_USER re-executes as the seat before touching a store' 'yes' "$(grep -q 'exec runuser -u "\$_drop" -- "\$0" "\$@"' "$ROOT/plugins/browser/bin/browser" && echo yes || echo no)" -t 'T2c7 ...but setup stays root'"'"'s' 'yes' "$(grep -A2 'if \[\[ \$EUID -eq 0 && -n "\${SUDO_USER:-}"' "$ROOT/plugins/browser/bin/browser" | grep -q 'setup|-h|--help|help|"") ;;' && echo yes || echo no)" +# DIVE-4516 added `adblock` to this set for the same reason `setup` is in it: it +# writes the MACHINE-WIDE chrome policy file, which has no per-user path on Linux, +# so dropping to the seat would turn the verb into a permission refusal. +t 'T2c7 ...but setup and adblock stay root'"'"'s' 'yes' "$(grep -A6 'if \[\[ \$EUID -eq 0 && -n "\${SUDO_USER:-}"' "$ROOT/plugins/browser/bin/browser" | grep -q 'setup|adblock|-h|--help|help|"") ;;' && echo yes || echo no)" +t 'T2c8 ...and no OTHER verb joined them' '2' "$(grep -A6 'if \[\[ \$EUID -eq 0 && -n "\${SUDO_USER:-}"' "$ROOT/plugins/browser/bin/browser" | grep -oP '^\s+\K[a-z|]+(?=\|-h\|--help)' | tr '|' '\n' | grep -c .)" # DIVE-4519 iteration 2 — setup owns the schedule, and these arms DRIVE setup. # @@ -2250,5 +2254,94 @@ t 'T21d it does NOT match the logged-in settings page' 'miss' \ "$(grep -qiE "$GMARK" <<<'Your profile' && echo match || echo miss)" tc 'T21e the file records the measurement' 'MEASURED, NOT GUESSED' "$(cat "$GADP")" +# --- T9x: the per-site adblock off switch (DIVE-4516) ------------------------- +# +# ONE extension is allowed in an agent profile (uBlock Origin Lite, pinned by +# managed policy) and some sites break under filtering. These arms grade the verb +# that turns it off for one site. The policy dir and the state dir are redirected +# into $TMP; the root check is satisfied by a fake `id` on PATH, because the real +# property under test is WHAT GETS WRITTEN, and a suite that needed root to grade +# it would be a suite nobody runs. +echo "== T9x adblock (DIVE-4516)" +ADB="$TMP/adb"; mkdir -p "$ADB/state" "$ADB/policies" "$ADB/bin" +cat > "$ADB/bin/id" <<'SH' +#!/bin/sh +[ "$1" = -u ] && [ $# -eq 1 ] && { echo 0; exit 0; } +exec /usr/bin/id "$@" +SH +chmod +x "$ADB/bin/id" +POLF="$ADB/policies/5dive-browser.json" +UBOL=bjnapnkpiihibhjaehecmpbpeejnloib +adb() { run env STATE_DIR="$ADB/state" CHROME_POLICY_DIR="$ADB/policies" bash "$BROWSER" adblock "$@"; } +adb_root(){ run env PATH="$ADB/bin:$PATH" STATE_DIR="$ADB/state" CHROME_POLICY_DIR="$ADB/policies" bash "$BROWSER" adblock "$@"; } +# What the nightly converger renders (scripts/inc/browser-stack.sh). Seeded here so +# the arms grade the PATCH, not a file this verb invented. +seed_policy() { + jq -n --arg id "$UBOL" '{ "ExtensionInstallBlocklist": ["*"], "ExtensionInstallAllowlist": [$id], + "ExtensionInstallForcelist": [($id + ";https://api.5dive.com/ext/ubol/updates.xml")], + "ExtensionSettings": { ($id): {"installation_mode":"force_installed","update_url":"https://api.5dive.com/ext/ubol/updates.xml"} } }' > "$POLF" +} + +# A box with no pinned uBOL must SAY there is nothing being filtered. "adblock is +# off for nothing" on a box with no extension is the reassuring half of a lie. +adb status +tc 'T90 status on a box with no policy file says the policy is ABSENT' 'ABSENT' "$OUT" +tc 'T90b ...and spells out that nothing is being filtered or blocked' 'nothing is being filtered' "$OUT" + +# The file is machine-wide and root-owned; a seat that could edit it could turn +# filtering off for a site and then be shown a page it was never meant to trust. +adb off example.com +t 'T91 a non-root caller is refused' '77' "$RC" +tc 'T91b ...and is told the one command that works' 'sudo 5dive browser adblock off example.com' "$ERR" +adb_root off 'not a host/../..' +t 'T92 a site name that is not a host is refused' '64' "$RC" +adb_root frobnicate example.com +t 'T92b an unknown subcommand is refused' '64' "$RC" +adb_root off +t 'T92c `off` with no site is refused rather than applied to everything' '64' "$RC" + +# The off list is recorded even with no policy file to patch — the box may get the +# extension tonight, and a setting that silently evaporated would come back ON. +rm -f "$POLF" +adb_root off news.example.com +t 'T93 with no policy file on the box the verb still succeeds' '0' "$RC" +tc 'T93b ...and says the setting applies when the extension arrives' 'applies the moment' "$OUT" +t 'T93c ...and the off list records it' 'news.example.com' "$(grep -v '^#' "$ADB/state/browser/ubol/adblock-off" | tr -d '[:space:]')" + +# The patch: ONE key, and both host patterns. `*://*.example.com` does not match +# `example.com`, so a wildcard-only off switch reports success and leaves the apex +# — the host the seat typed — still filtered. +seed_policy +adb_root off news.example.com +t 'T94 the live policy file is patched with BOTH the apex and the wildcard' '*://news.example.com,*://*.news.example.com' \ + "$(jq -r --arg id "$UBOL" '.ExtensionSettings[$id].runtime_blocked_hosts | join(",")' "$POLF")" +t 'T94b ...and the blocklist that keeps every other extension out is untouched' '["*"]' "$(jq -c '.ExtensionInstallBlocklist' "$POLF")" +t 'T94c ...and so is the force-install entry' "$UBOL;https://api.5dive.com/ext/ubol/updates.xml" "$(jq -r '.ExtensionInstallForcelist[0]' "$POLF")" +tc 'T94d ...and the caller is told a running `serve` may not pick it up (only fresh launches were measured)' 'may need `serve news.example.com --stop`' "$OUT" + +adb_root off shop.example.org +t 'T95 a second site is added, not replaced' '*://news.example.com,*://*.news.example.com,*://shop.example.org,*://*.shop.example.org' \ + "$(jq -r --arg id "$UBOL" '.ExtensionSettings[$id].runtime_blocked_hosts | join(",")' "$POLF")" +adb status +tc 'T95b ...and status lists both' 'news.example.com, shop.example.org' "$OUT" + +adb_root on news.example.com +t 'T96 `on` removes just that site' '*://shop.example.org,*://*.shop.example.org' \ + "$(jq -r --arg id "$UBOL" '.ExtensionSettings[$id].runtime_blocked_hosts | join(",")' "$POLF")" +adb_root on shop.example.org +t 'T96b ...and the last one removed DELETES the key rather than leaving an empty array' 'false' \ + "$(jq -r --arg id "$UBOL" '.ExtensionSettings[$id] | has("runtime_blocked_hosts")' "$POLF")" +t 'T96c ...and the extension is still force-installed (turning filtering back on is not uninstalling it)' 'force_installed' \ + "$(jq -r --arg id "$UBOL" '.ExtensionSettings[$id].installation_mode' "$POLF")" + +# The list, not the policy file, is the source of truth: the root converge +# re-renders that file nightly, and a site turned off at 14:00 that lived only +# there would be silently re-filtered at 03:00. +adb_root off apex.example.net +grep -q 'SOURCE OF TRUTH' "$ADB/state/browser/ubol/adblock-off" \ + && { PASS=$((PASS+1)); } || { FAIL=$((FAIL+1)); printf 'FAIL: T97 the off list does not say it is the source of truth\n'; } +t 'T97b the off list survives as the record the nightly converge re-renders from' 'apex.example.net' \ + "$(grep -v '^#' "$ADB/state/browser/ubol/adblock-off" | tr -d '[:space:]')" + printf '\n%s passed, %s failed\n' "$PASS" "$FAIL" [[ "$FAIL" -eq 0 ]]