fix: support ptrace-restricted browser containers (#1242)

## ℹ️ Description

- Link to the related issue(s): Fixes #1240
- Allow ptrace-restricted Docker and LXC users to omit Chromium's
`--test-type` startup flag while preserving the existing default for
other users.

## 📋 Changes Summary

- Add `browser.suppress_unsupported_flag_warning`, defaulting to `true`.
- Warn in recognized Linux containers without `CAP_SYS_PTRACE` when the
flag remains enabled.
- Document the container configuration, regenerate config artifacts, and
cover default, opt-out, runtime propagation, and Linux-specific
behavior.

### ⚙️ Type of Change

- [x] 🐞 Bug fix (non-breaking change which fixes an issue)
- [ ]  New feature (adds new functionality without breaking existing
usage)
- [ ] 💥 Breaking change (changes that might break existing user setups,
scripts, or configurations)

##  Checklist

- [x] I have reviewed my changes to ensure they meet the project's
standards.
- [x] I have tested my changes and ensured that all tests pass (`pdm run
test`).
- [x] I have formatted the code (`pdm run format`).
- [x] I have verified that linting passes (`pdm run lint`).
- [x] I have updated documentation where necessary.


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

- **New Features**
- Added a browser setting to control Chromium warning suppression,
enabled by default.
- Added support for disabling the setting in ptrace-restricted Docker or
LXC containers to help browser startup succeed.
- Added diagnostics and warnings for environments missing the required
system capability.

- **Documentation**
- Documented the new setting, container limitations, and configuration
guidance.

- **Tests**
- Added coverage for configuration validation, container detection,
capability handling, and browser launch warnings.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Jens
2026-08-14 14:49:49 +02:00
committed by GitHub
parent c966252da5
commit 92da5f7c70
14 changed files with 135 additions and 4 deletions

View File

@@ -44,6 +44,7 @@ reviews:
# Root config files
- "pyproject.toml"
- "*.yaml"
- "**/*.yaml"
- "*.yml"
- "**/*.md"

View File

@@ -465,6 +465,15 @@ browser:
- **Root user**: Never run as root, use regular user
- **Display**: Ensure X11 or Wayland is properly configured
#### ptrace-restricted containers
Chrome and Brave can exit before startup in Docker or LXC containers without `CAP_SYS_PTRACE` when launched with Chromium's internal `--test-type` flag. Keep the normal default for desktop use, but disable only this warning-suppression flag in affected containers:
```yaml
browser:
suppress_unsupported_flag_warning: false
```
## Configuration Examples
### Basic working configuration

View File

@@ -299,6 +299,8 @@ browser:
- --no-sandbox
# --headless
# --start-maximized
# Set false in ptrace-restricted containers if --test-type prevents browser startup.
suppress_unsupported_flag_warning: true
binary_location: # path to custom browser executable, if not specified will be looked up on PATH
extensions: [] # a list of .crx extension files to be loaded
use_private_window: true
@@ -313,6 +315,8 @@ browser:
- `--headless` - Run browser in headless mode (no GUI)
- `--start-maximized` - Start browser maximized
`suppress_unsupported_flag_warning` keeps Chromium's `--test-type` flag enabled by default to hide unsupported-command-line warnings. Set it to `false` in Docker or LXC environments without `CAP_SYS_PTRACE` if Chrome or Brave exits before the DevTools endpoint becomes available.
For detailed browser connection troubleshooting, including Chrome 136+ security requirements and remote debugging setup, see [Browser Troubleshooting](./BROWSER_TROUBLESHOOTING.md).
### update_check

View File

@@ -206,6 +206,9 @@ browser:
# - "--user-data-dir=/path/to/profile"
arguments: []
# add Chromium's --test-type switch to suppress unsupported command-line flag warnings. Set to false in ptrace-restricted containers where this switch can prevent the browser from starting
suppress_unsupported_flag_warning: true
# path to custom browser executable (optional). Leave empty to use system default
binary_location: ''

View File

@@ -243,6 +243,12 @@
"title": "Arguments",
"type": "array"
},
"suppress_unsupported_flag_warning": {
"default": true,
"description": "add Chromium's --test-type switch to suppress unsupported command-line flag warnings. Set to false in ptrace-restricted containers where this switch can prevent the browser from starting",
"title": "Suppress Unsupported Flag Warning",
"type": "boolean"
},
"binary_location": {
"anyOf": [
{

View File

@@ -223,6 +223,13 @@ class BrowserConfig(ContextualModel):
),
examples = ['"--headless"', '"--disable-dev-shm-usage"', '"--user-data-dir=/path/to/profile"'],
)
suppress_unsupported_flag_warning:bool = Field(
default = True,
description=(
"add Chromium's --test-type switch to suppress unsupported command-line flag warnings. "
"Set to false in ptrace-restricted containers where this switch can prevent the browser from starting"
),
)
binary_location:str | None = Field(default = "", description = "path to custom browser executable (optional). Leave empty to use system default")
extensions:list[str] = Field(
default_factory = list,

View File

@@ -849,6 +849,8 @@ kleinanzeigen_bot/utils/web_scraping_mixin.py:
"4. Check if any antivirus or security software is blocking the connection": "4. Überprüfen Sie, ob Antiviren- oder Sicherheitssoftware die Verbindung blockiert"
_build_new_browser_launch_args:
? "Container without CAP_SYS_PTRACE detected. If the browser fails to start, set browser.suppress_unsupported_flag_warning: false to omit --test-type."
: "Container ohne CAP_SYS_PTRACE erkannt. Falls der Browser nicht startet, setzen Sie browser.suppress_unsupported_flag_warning: false, um --test-type wegzulassen."
" -> Browser profile name: %s": " -> Browser-Profilname: %s"
" -> Custom Browser argument: %s": " -> Benutzerdefiniertes Browser-Argument: %s"
"Ignoring empty --user-data-dir= argument; falling back to configured user_data_dir.": "Ignoriere leeres --user-data-dir= Argument; verwende konfiguriertes user_data_dir."

View File

@@ -148,6 +148,7 @@ def load_config(config_file_path:str, workspace:_xdg_paths.Workspace | None, com
def apply_browser_config(browser_config:Any, config:Config, workspace:_xdg_paths.Workspace | None, config_file_path:str) -> None:
browser_config.arguments = config.browser.arguments
browser_config.suppress_unsupported_flag_warning = config.browser.suppress_unsupported_flag_warning
browser_config.binary_location = config.browser.binary_location
browser_config.extensions = [abspath(item, relative_to = config_file_path) for item in config.browser.extensions]
browser_config.use_private_window = config.browser.use_private_window

View File

@@ -28,6 +28,8 @@ from .chrome_version_detector import (
from .net import is_port_open
LOG:Final[loggers.Logger] = loggers.get_logger(__name__)
_CAP_SYS_PTRACE:Final[int] = 19
_CONTAINER_CGROUP_MARKERS:Final[tuple[str, ...]] = ("docker", "containerd", "kubepods", "libpod", "lxc")
def _format_url_host(host:str) -> str:
@@ -58,6 +60,36 @@ def _is_admin() -> bool:
return False
def _is_linux_container() -> bool:
"""Return whether common Linux container markers are present."""
if os.path.exists("/.dockerenv") or os.path.exists("/run/.containerenv") or any(key.lower() == "container" for key in os.environ):
return True
try:
with open("/proc/1/cgroup", encoding = "UTF-8") as fd: # noqa: PTH123
return any(marker in fd.read().lower() for marker in _CONTAINER_CGROUP_MARKERS)
except OSError:
return False
def _has_linux_capability(capability:int) -> bool:
"""Return whether the current Linux process has *capability*."""
try:
with open("/proc/self/status", encoding = "UTF-8") as fd: # noqa: PTH123
for line in fd:
if line.startswith("CapEff:"):
effective_capabilities = int(line.split(maxsplit = 1)[1], 16)
return bool(effective_capabilities & (1 << capability))
except (OSError, ValueError, IndexError):
# procfs can be unavailable in restricted containers; treat the capability as absent.
return False
return False
def _is_linux_container_without_sys_ptrace() -> bool:
"""Return whether a recognized Linux container lacks CAP_SYS_PTRACE."""
return platform.system() == "Linux" and _is_linux_container() and not _has_linux_capability(_CAP_SYS_PTRACE)
def _remote_debugging_api_browser(remote_host:str, remote_port:int, probe_timeout:float) -> tuple[str | None, Exception | None]:
"""Probe the remote debugging API.

View File

@@ -14,6 +14,7 @@ class BrowserConfig:
Attributes:
arguments: Additional browser command-line arguments.
suppress_unsupported_flag_warning: Whether to add --test-type to suppress browser warnings.
binary_location: Path to the browser executable, or None for auto-detection.
extensions: List of extension paths to load.
use_private_window: Whether to start in incognito/private mode.
@@ -23,6 +24,7 @@ class BrowserConfig:
def __init__(self) -> None:
self.arguments:list[str] = []
self.suppress_unsupported_flag_warning:bool = True
self.binary_location:str | None = None
self.extensions:list[str] = []
self.use_private_window:bool = True

View File

@@ -22,7 +22,7 @@ from kleinanzeigen_bot.model.config_model import Config as BotConfig
from kleinanzeigen_bot.model.config_model import HumanizationConfig, TimeoutConfig
from . import files, loggers, net, xdg_paths
from .browser_diagnostics import _run_browser_diagnostics
from .browser_diagnostics import _is_linux_container_without_sys_ptrace, _run_browser_diagnostics
from .browser_runtime_config import BrowserConfig
from .chrome_version_detector import (
ChromeVersionInfo,
@@ -788,11 +788,18 @@ class WebScrapingMixin: # noqa: PLR0904
"--disable-search-engine-choice-screen",
"--disable-features=MediaRouter",
"--use-mock-keychain",
"--test-type", # https://stackoverflow.com/a/36746675/5116073
# https://chromium.googlesource.com/chromium/src/+/master/net/dns/README.md#request-remapping
'--host-resolver-rules="MAP connect.facebook.net 127.0.0.1, MAP securepubads.g.doubleclick.net 127.0.0.1, MAP www.googletagmanager.com 127.0.0.1"',
]
if self.browser_config.suppress_unsupported_flag_warning:
browser_args.append("--test-type") # https://stackoverflow.com/a/36746675/5116073
if _is_linux_container_without_sys_ptrace():
LOG.warning(
"Container without CAP_SYS_PTRACE detected. If the browser fails to start, "
"set browser.suppress_unsupported_flag_warning: false to omit --test-type."
)
is_edge = "edge" in (self.browser_config.binary_location or "").lower()
if is_edge:

View File

@@ -57,6 +57,15 @@ def test_minimal_config_validation() -> None:
config = Config.model_validate(minimal_cfg)
assert config.login.username == "dummy"
assert config.login.password == "dummy" # noqa: S105
assert config.browser.suppress_unsupported_flag_warning is True
def test_browser_config_allows_unsupported_flag_warning_to_be_shown() -> None:
config = Config.model_validate({
"login": {"username": "dummy", "password": "dummy"}, # noqa: S106
"browser": {"suppress_unsupported_flag_warning": False},
})
assert config.browser.suppress_unsupported_flag_warning is False
def test_publishing_local_path_renaming_defaults_to_off() -> None:

View File

@@ -220,6 +220,7 @@ publishing:
{
"login": {"username": "user", "password": "pass"},
"ad_defaults": {"contact": {"name": "Test User", "zipcode": "12345"}},
"browser": {"suppress_unsupported_flag_warning": False},
"publishing": {"delete_old_ads": "BEFORE_PUBLISH", "delete_old_ads_by_title": False},
}
)
@@ -228,6 +229,7 @@ publishing:
assert browser_config.user_data_dir == str(workspace.browser_profile_dir)
assert browser_config.profile_name == config.browser.profile_name
assert browser_config.suppress_unsupported_flag_warning is False
def test_apply_browser_config_uses_custom_profile_dir(self, tmp_path:Path) -> None:
config_path = tmp_path / "config.yaml"

View File

@@ -24,8 +24,8 @@ from nodriver.core.element import Element
from nodriver.core.tab import Tab as Page
from kleinanzeigen_bot.model.config_model import Config
from kleinanzeigen_bot.utils import files, loggers
from kleinanzeigen_bot.utils.browser_diagnostics import _format_url_host, _is_admin # noqa: PLC2701
from kleinanzeigen_bot.utils import browser_diagnostics, files, loggers
from kleinanzeigen_bot.utils.browser_diagnostics import _format_url_host, _is_admin, _is_linux_container_without_sys_ptrace # noqa: PLC2701
from kleinanzeigen_bot.utils.web_scraping_mixin import By, Is, WebScrapingMixin, _allocate_selector_group_budgets # noqa: PLC2701
@@ -1443,6 +1443,52 @@ class TestWebScrolling:
class TestWebScrapingBrowserConfiguration:
"""Test browser configuration in WebScrapingMixin."""
def test_browser_args_suppress_unsupported_flag_warning_by_default(self) -> None:
scraper = WebScrapingMixin()
args, _ = scraper._build_new_browser_launch_args()
assert "--test-type" in args
def test_browser_args_can_show_unsupported_flag_warning(self) -> None:
scraper = WebScrapingMixin()
scraper.browser_config.arguments = ["--custom-arg=value"]
scraper.browser_config.suppress_unsupported_flag_warning = False
args, _ = scraper._build_new_browser_launch_args()
assert "--test-type" not in args
assert "--custom-arg=value" in args
assert "--incognito" in args
def test_linux_container_detection_uses_cgroup_marker(self, monkeypatch:pytest.MonkeyPatch) -> None:
monkeypatch.setattr(os.path, "exists", lambda _: False)
with patch("builtins.open", mock_open(read_data = "0::/docker/test-container\n")):
assert browser_diagnostics._is_linux_container() is True # noqa: SLF001
def test_linux_capability_detection_handles_procfs_errors(self) -> None:
with patch("builtins.open", side_effect = OSError("procfs unavailable")):
assert browser_diagnostics._has_linux_capability(19) is False # noqa: SLF001
def test_linux_capability_detection_reads_effective_capabilities(self) -> None:
with patch("builtins.open", mock_open(read_data = "CapEff:\t0000000000080000\n")):
assert browser_diagnostics._has_linux_capability(19) is True # noqa: SLF001
@pytest.mark.skipif(platform.system() != "Linux", reason = "Linux-specific container capability test")
def test_linux_container_without_sys_ptrace_is_detected(self, monkeypatch:pytest.MonkeyPatch) -> None:
monkeypatch.setattr(browser_diagnostics, "_is_linux_container", lambda: True)
monkeypatch.setattr(browser_diagnostics, "_has_linux_capability", lambda capability: capability != 19)
assert _is_linux_container_without_sys_ptrace() is True
def test_ptrace_restricted_container_warns_about_test_type(self, monkeypatch:pytest.MonkeyPatch, caplog:pytest.LogCaptureFixture) -> None:
scraper = WebScrapingMixin()
monkeypatch.setattr("kleinanzeigen_bot.utils.web_scraping_mixin._is_linux_container_without_sys_ptrace", lambda: True)
scraper._build_new_browser_launch_args()
assert "Container without CAP_SYS_PTRACE detected" in caplog.text
@pytest.mark.asyncio
async def test_browser_binary_location_detection(self, tmp_path:Path, monkeypatch:pytest.MonkeyPatch) -> None:
"""Test browser binary location detection on different platforms."""