comodor 0.8.2__tar.gz → 0.8.3__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {comodor-0.8.2 → comodor-0.8.3}/PKG-INFO +1 -1
- {comodor-0.8.2 → comodor-0.8.3}/src/comodor/_version.py +2 -2
- {comodor-0.8.2 → comodor-0.8.3}/src/comodor/browser/launch.py +80 -5
- {comodor-0.8.2 → comodor-0.8.3}/src/comodor/web/commands.py +16 -0
- {comodor-0.8.2 → comodor-0.8.3}/src/comodor/web/server.py +33 -0
- {comodor-0.8.2 → comodor-0.8.3}/tests/test_real_browser.py +80 -0
- {comodor-0.8.2 → comodor-0.8.3}/tests/test_web.py +70 -0
- {comodor-0.8.2 → comodor-0.8.3}/.gitignore +0 -0
- {comodor-0.8.2 → comodor-0.8.3}/LICENSE +0 -0
- {comodor-0.8.2 → comodor-0.8.3}/README.md +0 -0
- {comodor-0.8.2 → comodor-0.8.3}/pyproject.toml +0 -0
- {comodor-0.8.2 → comodor-0.8.3}/src/comodor/__init__.py +0 -0
- {comodor-0.8.2 → comodor-0.8.3}/src/comodor/__main__.py +0 -0
- {comodor-0.8.2 → comodor-0.8.3}/src/comodor/agent/__init__.py +0 -0
- {comodor-0.8.2 → comodor-0.8.3}/src/comodor/agent/context.py +0 -0
- {comodor-0.8.2 → comodor-0.8.3}/src/comodor/agent/loop.py +0 -0
- {comodor-0.8.2 → comodor-0.8.3}/src/comodor/agent/prompts.py +0 -0
- {comodor-0.8.2 → comodor-0.8.3}/src/comodor/agent/spawn.py +0 -0
- {comodor-0.8.2 → comodor-0.8.3}/src/comodor/agent/tokens.py +0 -0
- {comodor-0.8.2 → comodor-0.8.3}/src/comodor/browser/__init__.py +0 -0
- {comodor-0.8.2 → comodor-0.8.3}/src/comodor/browser/cdp.py +0 -0
- {comodor-0.8.2 → comodor-0.8.3}/src/comodor/browser/page.py +0 -0
- {comodor-0.8.2 → comodor-0.8.3}/src/comodor/catalogue.py +0 -0
- {comodor-0.8.2 → comodor-0.8.3}/src/comodor/cli.py +0 -0
- {comodor-0.8.2 → comodor-0.8.3}/src/comodor/config.py +0 -0
- {comodor-0.8.2 → comodor-0.8.3}/src/comodor/doctor.py +0 -0
- {comodor-0.8.2 → comodor-0.8.3}/src/comodor/events.py +0 -0
- {comodor-0.8.2 → comodor-0.8.3}/src/comodor/learning/__init__.py +0 -0
- {comodor-0.8.2 → comodor-0.8.3}/src/comodor/learning/associations.py +0 -0
- {comodor-0.8.2 → comodor-0.8.3}/src/comodor/learning/bm25.py +0 -0
- {comodor-0.8.2 → comodor-0.8.3}/src/comodor/learning/hotindex.py +0 -0
- {comodor-0.8.2 → comodor-0.8.3}/src/comodor/learning/memory.py +0 -0
- {comodor-0.8.2 → comodor-0.8.3}/src/comodor/learning/progress.py +0 -0
- {comodor-0.8.2 → comodor-0.8.3}/src/comodor/learning/reflect.py +0 -0
- {comodor-0.8.2 → comodor-0.8.3}/src/comodor/learning/rules.py +0 -0
- {comodor-0.8.2 → comodor-0.8.3}/src/comodor/learning/signals.py +0 -0
- {comodor-0.8.2 → comodor-0.8.3}/src/comodor/learning/store.py +0 -0
- {comodor-0.8.2 → comodor-0.8.3}/src/comodor/learning/writer.py +0 -0
- {comodor-0.8.2 → comodor-0.8.3}/src/comodor/mcp/__init__.py +0 -0
- {comodor-0.8.2 → comodor-0.8.3}/src/comodor/mcp/catalogue.py +0 -0
- {comodor-0.8.2 → comodor-0.8.3}/src/comodor/mcp/commands.py +0 -0
- {comodor-0.8.2 → comodor-0.8.3}/src/comodor/mcp/http.py +0 -0
- {comodor-0.8.2 → comodor-0.8.3}/src/comodor/mcp/manager.py +0 -0
- {comodor-0.8.2 → comodor-0.8.3}/src/comodor/mcp/protocol.py +0 -0
- {comodor-0.8.2 → comodor-0.8.3}/src/comodor/net/__init__.py +0 -0
- {comodor-0.8.2 → comodor-0.8.3}/src/comodor/net/http.py +0 -0
- {comodor-0.8.2 → comodor-0.8.3}/src/comodor/net/sse.py +0 -0
- {comodor-0.8.2 → comodor-0.8.3}/src/comodor/paths.py +0 -0
- {comodor-0.8.2 → comodor-0.8.3}/src/comodor/providers/__init__.py +0 -0
- {comodor-0.8.2 → comodor-0.8.3}/src/comodor/providers/anthropic.py +0 -0
- {comodor-0.8.2 → comodor-0.8.3}/src/comodor/providers/base.py +0 -0
- {comodor-0.8.2 → comodor-0.8.3}/src/comodor/providers/caching.py +0 -0
- {comodor-0.8.2 → comodor-0.8.3}/src/comodor/providers/fake.py +0 -0
- {comodor-0.8.2 → comodor-0.8.3}/src/comodor/providers/gateway.py +0 -0
- {comodor-0.8.2 → comodor-0.8.3}/src/comodor/providers/openai_compat.py +0 -0
- {comodor-0.8.2 → comodor-0.8.3}/src/comodor/providers/registry.py +0 -0
- {comodor-0.8.2 → comodor-0.8.3}/src/comodor/safety/__init__.py +0 -0
- {comodor-0.8.2 → comodor-0.8.3}/src/comodor/safety/checkpoints.py +0 -0
- {comodor-0.8.2 → comodor-0.8.3}/src/comodor/safety/permissions.py +0 -0
- {comodor-0.8.2 → comodor-0.8.3}/src/comodor/safety/redact.py +0 -0
- {comodor-0.8.2 → comodor-0.8.3}/src/comodor/session/__init__.py +0 -0
- {comodor-0.8.2 → comodor-0.8.3}/src/comodor/session/search.py +0 -0
- {comodor-0.8.2 → comodor-0.8.3}/src/comodor/session/store.py +0 -0
- {comodor-0.8.2 → comodor-0.8.3}/src/comodor/setup.py +0 -0
- {comodor-0.8.2 → comodor-0.8.3}/src/comodor/skills/__init__.py +0 -0
- {comodor-0.8.2 → comodor-0.8.3}/src/comodor/skills/catalogue.py +0 -0
- {comodor-0.8.2 → comodor-0.8.3}/src/comodor/skills/commands.py +0 -0
- {comodor-0.8.2 → comodor-0.8.3}/src/comodor/skills/examples.py +0 -0
- {comodor-0.8.2 → comodor-0.8.3}/src/comodor/skills/loader.py +0 -0
- {comodor-0.8.2 → comodor-0.8.3}/src/comodor/skills/propose.py +0 -0
- {comodor-0.8.2 → comodor-0.8.3}/src/comodor/skills/registry.py +0 -0
- {comodor-0.8.2 → comodor-0.8.3}/src/comodor/tools/__init__.py +0 -0
- {comodor-0.8.2 → comodor-0.8.3}/src/comodor/tools/base.py +0 -0
- {comodor-0.8.2 → comodor-0.8.3}/src/comodor/tools/browse.py +0 -0
- {comodor-0.8.2 → comodor-0.8.3}/src/comodor/tools/browser.py +0 -0
- {comodor-0.8.2 → comodor-0.8.3}/src/comodor/tools/delegate.py +0 -0
- {comodor-0.8.2 → comodor-0.8.3}/src/comodor/tools/fs.py +0 -0
- {comodor-0.8.2 → comodor-0.8.3}/src/comodor/tools/history.py +0 -0
- {comodor-0.8.2 → comodor-0.8.3}/src/comodor/tools/mcp.py +0 -0
- {comodor-0.8.2 → comodor-0.8.3}/src/comodor/tools/overflow.py +0 -0
- {comodor-0.8.2 → comodor-0.8.3}/src/comodor/tools/registry.py +0 -0
- {comodor-0.8.2 → comodor-0.8.3}/src/comodor/tools/search.py +0 -0
- {comodor-0.8.2 → comodor-0.8.3}/src/comodor/tools/shell.py +0 -0
- {comodor-0.8.2 → comodor-0.8.3}/src/comodor/tools/skills.py +0 -0
- {comodor-0.8.2 → comodor-0.8.3}/src/comodor/tools/todo.py +0 -0
- {comodor-0.8.2 → comodor-0.8.3}/src/comodor/tools/web.py +0 -0
- {comodor-0.8.2 → comodor-0.8.3}/src/comodor/ui/__init__.py +0 -0
- {comodor-0.8.2 → comodor-0.8.3}/src/comodor/ui/app.py +0 -0
- {comodor-0.8.2 → comodor-0.8.3}/src/comodor/ui/bidi.py +0 -0
- {comodor-0.8.2 → comodor-0.8.3}/src/comodor/ui/chooser.py +0 -0
- {comodor-0.8.2 → comodor-0.8.3}/src/comodor/ui/console.py +0 -0
- {comodor-0.8.2 → comodor-0.8.3}/src/comodor/ui/input/__init__.py +0 -0
- {comodor-0.8.2 → comodor-0.8.3}/src/comodor/ui/input/keys.py +0 -0
- {comodor-0.8.2 → comodor-0.8.3}/src/comodor/ui/input/reader.py +0 -0
- {comodor-0.8.2 → comodor-0.8.3}/src/comodor/ui/layout.py +0 -0
- {comodor-0.8.2 → comodor-0.8.3}/src/comodor/ui/markdown.py +0 -0
- {comodor-0.8.2 → comodor-0.8.3}/src/comodor/ui/screen.py +0 -0
- {comodor-0.8.2 → comodor-0.8.3}/src/comodor/ui/theme.py +0 -0
- {comodor-0.8.2 → comodor-0.8.3}/src/comodor/ui/widgets/__init__.py +0 -0
- {comodor-0.8.2 → comodor-0.8.3}/src/comodor/ui/widgets/buttons.py +0 -0
- {comodor-0.8.2 → comodor-0.8.3}/src/comodor/ui/widgets/chat.py +0 -0
- {comodor-0.8.2 → comodor-0.8.3}/src/comodor/ui/widgets/history.py +0 -0
- {comodor-0.8.2 → comodor-0.8.3}/src/comodor/ui/widgets/overlay.py +0 -0
- {comodor-0.8.2 → comodor-0.8.3}/src/comodor/ui/widgets/panel.py +0 -0
- {comodor-0.8.2 → comodor-0.8.3}/src/comodor/ui/widgets/progress.py +0 -0
- {comodor-0.8.2 → comodor-0.8.3}/src/comodor/ui/widgets/prompt.py +0 -0
- {comodor-0.8.2 → comodor-0.8.3}/src/comodor/ui/widgets/statusbar.py +0 -0
- {comodor-0.8.2 → comodor-0.8.3}/src/comodor/ui/widgets/toast.py +0 -0
- {comodor-0.8.2 → comodor-0.8.3}/src/comodor/uninstall.py +0 -0
- {comodor-0.8.2 → comodor-0.8.3}/src/comodor/update.py +0 -0
- {comodor-0.8.2 → comodor-0.8.3}/src/comodor/web/__init__.py +0 -0
- {comodor-0.8.2 → comodor-0.8.3}/src/comodor/web/index.html +0 -0
- {comodor-0.8.2 → comodor-0.8.3}/src/comodor/web/session.py +0 -0
- {comodor-0.8.2 → comodor-0.8.3}/src/comodor/workspace.py +0 -0
- {comodor-0.8.2 → comodor-0.8.3}/tests/conftest.py +0 -0
- {comodor-0.8.2 → comodor-0.8.3}/tests/support/fake_mcp_server.py +0 -0
- {comodor-0.8.2 → comodor-0.8.3}/tests/test_agent_loop.py +0 -0
- {comodor-0.8.2 → comodor-0.8.3}/tests/test_app.py +0 -0
- {comodor-0.8.2 → comodor-0.8.3}/tests/test_associations.py +0 -0
- {comodor-0.8.2 → comodor-0.8.3}/tests/test_bidi.py +0 -0
- {comodor-0.8.2 → comodor-0.8.3}/tests/test_browser.py +0 -0
- {comodor-0.8.2 → comodor-0.8.3}/tests/test_caching.py +0 -0
- {comodor-0.8.2 → comodor-0.8.3}/tests/test_chooser.py +0 -0
- {comodor-0.8.2 → comodor-0.8.3}/tests/test_delegate.py +0 -0
- {comodor-0.8.2 → comodor-0.8.3}/tests/test_doctor.py +0 -0
- {comodor-0.8.2 → comodor-0.8.3}/tests/test_history.py +0 -0
- {comodor-0.8.2 → comodor-0.8.3}/tests/test_input.py +0 -0
- {comodor-0.8.2 → comodor-0.8.3}/tests/test_layout.py +0 -0
- {comodor-0.8.2 → comodor-0.8.3}/tests/test_learning.py +0 -0
- {comodor-0.8.2 → comodor-0.8.3}/tests/test_markdown.py +0 -0
- {comodor-0.8.2 → comodor-0.8.3}/tests/test_mcp.py +0 -0
- {comodor-0.8.2 → comodor-0.8.3}/tests/test_mcp_http.py +0 -0
- {comodor-0.8.2 → comodor-0.8.3}/tests/test_overflow.py +0 -0
- {comodor-0.8.2 → comodor-0.8.3}/tests/test_performance.py +0 -0
- {comodor-0.8.2 → comodor-0.8.3}/tests/test_prefix_stability.py +0 -0
- {comodor-0.8.2 → comodor-0.8.3}/tests/test_progress.py +0 -0
- {comodor-0.8.2 → comodor-0.8.3}/tests/test_propose.py +0 -0
- {comodor-0.8.2 → comodor-0.8.3}/tests/test_providers.py +0 -0
- {comodor-0.8.2 → comodor-0.8.3}/tests/test_reflex.py +0 -0
- {comodor-0.8.2 → comodor-0.8.3}/tests/test_run_loop.py +0 -0
- {comodor-0.8.2 → comodor-0.8.3}/tests/test_setup.py +0 -0
- {comodor-0.8.2 → comodor-0.8.3}/tests/test_skill_budget.py +0 -0
- {comodor-0.8.2 → comodor-0.8.3}/tests/test_skill_catalogue.py +0 -0
- {comodor-0.8.2 → comodor-0.8.3}/tests/test_skills.py +0 -0
- {comodor-0.8.2 → comodor-0.8.3}/tests/test_tools.py +0 -0
- {comodor-0.8.2 → comodor-0.8.3}/tests/test_uninstall.py +0 -0
- {comodor-0.8.2 → comodor-0.8.3}/tests/test_update.py +0 -0
|
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
|
|
|
18
18
|
commit_id: str | None
|
|
19
19
|
__commit_id__: str | None
|
|
20
20
|
|
|
21
|
-
__version__ = version = '0.8.
|
|
22
|
-
__version_tuple__ = version_tuple = (0, 8,
|
|
21
|
+
__version__ = version = '0.8.3'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 8, 3)
|
|
23
23
|
|
|
24
24
|
__commit_id__ = commit_id = None
|
|
@@ -21,6 +21,7 @@ import shutil
|
|
|
21
21
|
import socket
|
|
22
22
|
import subprocess
|
|
23
23
|
import sys
|
|
24
|
+
import tempfile
|
|
24
25
|
import time
|
|
25
26
|
from pathlib import Path
|
|
26
27
|
|
|
@@ -94,12 +95,42 @@ def free_port() -> int:
|
|
|
94
95
|
return probe.getsockname()[1]
|
|
95
96
|
|
|
96
97
|
|
|
98
|
+
#: What Chromium says when it will not run unprotected and cannot protect
|
|
99
|
+
#: itself. Matched on the specifics, so a browser that failed for some other
|
|
100
|
+
#: reason is not silently restarted with a weaker configuration.
|
|
101
|
+
_NO_SANDBOX_SIGNS = (
|
|
102
|
+
"no usable sandbox",
|
|
103
|
+
"failed to move to new namespace",
|
|
104
|
+
"operation not permitted",
|
|
105
|
+
"sandboxhelper",
|
|
106
|
+
"chrome-sandbox",
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _refused_for_want_of_a_sandbox(message: str) -> bool:
|
|
111
|
+
text = message.lower()
|
|
112
|
+
return any(sign in text for sign in _NO_SANDBOX_SIGNS)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _said(handle) -> str:
|
|
116
|
+
"""Whatever the browser printed before giving up, trimmed to one line."""
|
|
117
|
+
try:
|
|
118
|
+
handle.seek(0)
|
|
119
|
+
raw = handle.read(4000).decode("utf-8", "replace").strip()
|
|
120
|
+
except (OSError, ValueError):
|
|
121
|
+
return ""
|
|
122
|
+
if not raw:
|
|
123
|
+
return ""
|
|
124
|
+
first = next((line for line in raw.splitlines() if line.strip()), "")
|
|
125
|
+
return f" It said: {first.strip()[:300]}"
|
|
126
|
+
|
|
127
|
+
|
|
97
128
|
class Browser:
|
|
98
129
|
"""A browser process we started, and are responsible for stopping."""
|
|
99
130
|
|
|
100
131
|
def __init__(self, executable: str, profile: Path, port: int,
|
|
101
132
|
headless: bool = True, process: subprocess.Popen | None = None,
|
|
102
|
-
ours: bool = True) -> None:
|
|
133
|
+
ours: bool = True, sandboxed: bool = True) -> None:
|
|
103
134
|
self.executable = executable
|
|
104
135
|
self.profile = profile
|
|
105
136
|
self.port = port
|
|
@@ -108,6 +139,11 @@ class Browser:
|
|
|
108
139
|
#: False when we attached to something already running, in which case
|
|
109
140
|
#: stopping it is not ours to do.
|
|
110
141
|
self.ours = ours
|
|
142
|
+
#: False when the renderer sandbox had to be given up to start at all.
|
|
143
|
+
#: Worth knowing rather than assuming: it is the difference between a
|
|
144
|
+
#: bad page being contained by Chromium and being contained only by
|
|
145
|
+
#: whatever is around Chromium.
|
|
146
|
+
self.sandboxed = sandboxed
|
|
111
147
|
|
|
112
148
|
@classmethod
|
|
113
149
|
def start(cls, profile: Path, executable: str = "", headless: bool = True,
|
|
@@ -116,9 +152,25 @@ class Browser:
|
|
|
116
152
|
profile.mkdir(parents=True, exist_ok=True)
|
|
117
153
|
chosen = port or free_port()
|
|
118
154
|
|
|
155
|
+
try:
|
|
156
|
+
return cls._spawn(binary, profile, chosen, headless, window,
|
|
157
|
+
sandboxed=True)
|
|
158
|
+
except BrowserError as error:
|
|
159
|
+
if not _refused_for_want_of_a_sandbox(str(error)):
|
|
160
|
+
raise
|
|
161
|
+
# It will not run its renderers in a user namespace here — almost
|
|
162
|
+
# always a container, whose own confinement is the stronger boundary
|
|
163
|
+
# anyway. Give up the inner one rather than weaken the outer one, and
|
|
164
|
+
# remember that this is what happened.
|
|
165
|
+
return cls._spawn(binary, profile, chosen, headless, window,
|
|
166
|
+
sandboxed=False)
|
|
167
|
+
|
|
168
|
+
@classmethod
|
|
169
|
+
def _spawn(cls, binary: str, profile: Path, port: int, headless: bool,
|
|
170
|
+
window: tuple[int, int], sandboxed: bool) -> "Browser":
|
|
119
171
|
arguments = [
|
|
120
172
|
binary,
|
|
121
|
-
f"--remote-debugging-port={
|
|
173
|
+
f"--remote-debugging-port={port}",
|
|
122
174
|
# Loopback only. Without this Chrome will accept a debugging
|
|
123
175
|
# connection from anything that can route to the port, which is a
|
|
124
176
|
# remote code execution hole with a browser attached.
|
|
@@ -129,20 +181,33 @@ class Browser:
|
|
|
129
181
|
]
|
|
130
182
|
if headless:
|
|
131
183
|
arguments.append("--headless=new")
|
|
184
|
+
if not sandboxed:
|
|
185
|
+
arguments.append("--no-sandbox")
|
|
132
186
|
arguments.append("about:blank")
|
|
133
187
|
|
|
188
|
+
# Kept, not discarded: when it exits at once the reason is in there,
|
|
189
|
+
# and the difference between "no sandbox available" and anything else
|
|
190
|
+
# decides whether retrying is sensible or dishonest.
|
|
191
|
+
complaints = tempfile.TemporaryFile()
|
|
134
192
|
try:
|
|
135
193
|
process = subprocess.Popen(
|
|
136
|
-
arguments, stdout=subprocess.DEVNULL, stderr=
|
|
194
|
+
arguments, stdout=subprocess.DEVNULL, stderr=complaints,
|
|
137
195
|
stdin=subprocess.DEVNULL,
|
|
138
196
|
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0)
|
|
139
197
|
if os.name == "nt" else 0,
|
|
140
198
|
)
|
|
141
199
|
except OSError as error:
|
|
200
|
+
complaints.close()
|
|
142
201
|
raise BrowserError(f"could not start {binary}: {error}") from error
|
|
143
202
|
|
|
144
|
-
browser = cls(binary, profile,
|
|
145
|
-
|
|
203
|
+
browser = cls(binary, profile, port, headless, process,
|
|
204
|
+
sandboxed=sandboxed)
|
|
205
|
+
try:
|
|
206
|
+
browser._await_port()
|
|
207
|
+
except BrowserError as error:
|
|
208
|
+
raise BrowserError(f"{error}{_said(complaints)}") from None
|
|
209
|
+
finally:
|
|
210
|
+
complaints.close()
|
|
146
211
|
return browser
|
|
147
212
|
|
|
148
213
|
def _await_port(self) -> None:
|
|
@@ -171,6 +236,16 @@ class Browser:
|
|
|
171
236
|
raise BrowserError(f"nothing is listening for DevTools on port {port}")
|
|
172
237
|
return cls("", Path(), port, headless=False, process=None, ours=False)
|
|
173
238
|
|
|
239
|
+
@property
|
|
240
|
+
def note(self) -> str:
|
|
241
|
+
"""What is worth saying about how it started, if anything."""
|
|
242
|
+
if self.sandboxed:
|
|
243
|
+
return ""
|
|
244
|
+
return ("Chromium is running without its own renderer sandbox: it "
|
|
245
|
+
"could not create a user namespace here, which is normal "
|
|
246
|
+
"inside a container. The container's confinement is what is "
|
|
247
|
+
"holding a bad page, rather than the browser's.")
|
|
248
|
+
|
|
174
249
|
@property
|
|
175
250
|
def label(self) -> str:
|
|
176
251
|
info = version(self.port)
|
|
@@ -70,6 +70,22 @@ def _announce(server: Server) -> None:
|
|
|
70
70
|
|
|
71
71
|
if server.local:
|
|
72
72
|
print(" Only this machine can reach it. Ctrl-C to stop.")
|
|
73
|
+
elif server.contained:
|
|
74
|
+
# Correct, and the warning above would be both alarming and wrong. The
|
|
75
|
+
# real boundary is one layer out, so that is what gets explained.
|
|
76
|
+
print(" Listening on every address inside this container, which is")
|
|
77
|
+
print(" the only way the machine running it can reach the port.")
|
|
78
|
+
print()
|
|
79
|
+
print(" What decides who else can is how the port was published:")
|
|
80
|
+
print(f" -p 127.0.0.1:{server.port}:{server.port} only this machine")
|
|
81
|
+
print(f" -p {server.port}:{server.port} anyone who can route to it")
|
|
82
|
+
print()
|
|
83
|
+
print(" The second form puts a shell on the network, and there is no")
|
|
84
|
+
print(" TLS here — the token and everything the agent reads would")
|
|
85
|
+
print(" cross in the clear. Use the first, and an SSH tunnel to reach")
|
|
86
|
+
print(" a remote host.")
|
|
87
|
+
print()
|
|
88
|
+
print(" Ctrl-C to stop.")
|
|
73
89
|
else:
|
|
74
90
|
print(" ⚠ This is bound to a public address.")
|
|
75
91
|
print()
|
|
@@ -32,6 +32,7 @@ from __future__ import annotations
|
|
|
32
32
|
|
|
33
33
|
import hmac
|
|
34
34
|
import json
|
|
35
|
+
import os
|
|
35
36
|
import secrets
|
|
36
37
|
import threading
|
|
37
38
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
@@ -51,6 +52,25 @@ GUARD = "X-Comodor"
|
|
|
51
52
|
MAX_BODY = 1_000_000
|
|
52
53
|
|
|
53
54
|
|
|
55
|
+
def in_a_container() -> bool:
|
|
56
|
+
"""Docker, Podman or a Kubernetes pod, as best as can be told from inside.
|
|
57
|
+
|
|
58
|
+
Docker leaves a marker file; Podman sets an environment variable; both, and
|
|
59
|
+
Kubernetes, show it in the process control groups. Any one of them is
|
|
60
|
+
enough, and being wrong in either direction only changes the wording of a
|
|
61
|
+
warning, never what is bound or who can reach it.
|
|
62
|
+
"""
|
|
63
|
+
if Path("/.dockerenv").exists() or Path("/run/.containerenv").exists():
|
|
64
|
+
return True
|
|
65
|
+
if os.environ.get("container"):
|
|
66
|
+
return True
|
|
67
|
+
try:
|
|
68
|
+
groups = Path("/proc/self/cgroup").read_text(encoding="utf-8", errors="ignore")
|
|
69
|
+
except OSError:
|
|
70
|
+
return False
|
|
71
|
+
return any(marker in groups for marker in ("docker", "kubepods", "containerd"))
|
|
72
|
+
|
|
73
|
+
|
|
54
74
|
def page() -> str:
|
|
55
75
|
return (Path(__file__).parent / "index.html").read_text(encoding="utf-8")
|
|
56
76
|
|
|
@@ -71,6 +91,19 @@ class Server:
|
|
|
71
91
|
def local(self) -> bool:
|
|
72
92
|
return self.host in LOCAL
|
|
73
93
|
|
|
94
|
+
@property
|
|
95
|
+
def contained(self) -> bool:
|
|
96
|
+
"""Whether this is running inside a container.
|
|
97
|
+
|
|
98
|
+
It changes what a wide bind means. A container has its own network
|
|
99
|
+
namespace, so binding 127.0.0.1 inside one hides the port from the
|
|
100
|
+
machine that started it — the interface becomes unreachable by the
|
|
101
|
+
person who asked for it. Binding everything is therefore the correct
|
|
102
|
+
thing to do, and who may reach it is decided one layer out, by how the
|
|
103
|
+
port was published.
|
|
104
|
+
"""
|
|
105
|
+
return in_a_container()
|
|
106
|
+
|
|
74
107
|
@property
|
|
75
108
|
def url(self) -> str:
|
|
76
109
|
shown = "127.0.0.1" if self.host in ("", "0.0.0.0", "::") else self.host
|
|
@@ -470,3 +470,83 @@ def test_a_screenshot_is_a_real_png(tmp_path):
|
|
|
470
470
|
page.close()
|
|
471
471
|
close_tab(browser.port, session.target_id)
|
|
472
472
|
browser.stop()
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
# --------------------------------------------------------------------------- #
|
|
476
|
+
# where the browser cannot have its own sandbox
|
|
477
|
+
# --------------------------------------------------------------------------- #
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def test_only_a_missing_sandbox_causes_a_retry():
|
|
481
|
+
"""Restarting a browser that failed for some other reason — with a weaker
|
|
482
|
+
configuration, silently — would be a bad way to hide a real problem."""
|
|
483
|
+
from comodor.browser.launch import _refused_for_want_of_a_sandbox
|
|
484
|
+
|
|
485
|
+
assert _refused_for_want_of_a_sandbox(
|
|
486
|
+
"No usable sandbox! If this is a Debian system…")
|
|
487
|
+
assert _refused_for_want_of_a_sandbox(
|
|
488
|
+
"Failed to move to new namespace: Operation not permitted")
|
|
489
|
+
assert not _refused_for_want_of_a_sandbox("could not start: file not found")
|
|
490
|
+
assert not _refused_for_want_of_a_sandbox("the browser did not open a port")
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def test_a_browser_that_fails_for_another_reason_is_not_restarted(tmp_path,
|
|
494
|
+
monkeypatch):
|
|
495
|
+
from comodor.browser import BrowserError
|
|
496
|
+
from comodor.browser.launch import Browser
|
|
497
|
+
|
|
498
|
+
attempts = []
|
|
499
|
+
|
|
500
|
+
def refuse(cls, binary, profile, port, headless, window, sandboxed):
|
|
501
|
+
attempts.append(sandboxed)
|
|
502
|
+
raise BrowserError("the browser did not open a debugging port")
|
|
503
|
+
|
|
504
|
+
monkeypatch.setattr(Browser, "_spawn", classmethod(refuse))
|
|
505
|
+
monkeypatch.setattr("comodor.browser.launch.find", lambda hint="": "/bin/true")
|
|
506
|
+
|
|
507
|
+
with pytest.raises(BrowserError):
|
|
508
|
+
Browser.start(tmp_path / "profile")
|
|
509
|
+
|
|
510
|
+
assert attempts == [True], "it should not have tried again"
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def test_a_missing_sandbox_is_retried_once_without_one(tmp_path, monkeypatch):
|
|
514
|
+
from comodor.browser import BrowserError
|
|
515
|
+
from comodor.browser.launch import Browser
|
|
516
|
+
|
|
517
|
+
attempts = []
|
|
518
|
+
|
|
519
|
+
def once(cls, binary, profile, port, headless, window, sandboxed):
|
|
520
|
+
attempts.append(sandboxed)
|
|
521
|
+
if sandboxed:
|
|
522
|
+
raise BrowserError("it exited. It said: No usable sandbox!")
|
|
523
|
+
return Browser("/bin/true", profile, port, sandboxed=False)
|
|
524
|
+
|
|
525
|
+
monkeypatch.setattr(Browser, "_spawn", classmethod(once))
|
|
526
|
+
monkeypatch.setattr("comodor.browser.launch.find", lambda hint="": "/bin/true")
|
|
527
|
+
|
|
528
|
+
browser = Browser.start(tmp_path / "profile")
|
|
529
|
+
|
|
530
|
+
assert attempts == [True, False]
|
|
531
|
+
assert browser.sandboxed is False
|
|
532
|
+
assert "without its own renderer sandbox" in browser.note
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
def test_a_browser_that_kept_its_sandbox_says_nothing_about_it(tmp_path):
|
|
536
|
+
from comodor.browser.launch import Browser
|
|
537
|
+
|
|
538
|
+
assert Browser("/bin/true", tmp_path, 9222).note == ""
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
@needs_browser
|
|
542
|
+
def test_on_a_real_machine_the_sandbox_is_kept(tmp_path):
|
|
543
|
+
"""The retry must not fire where the sandbox works — that would give up a
|
|
544
|
+
real protection on every laptop to make one container convenient."""
|
|
545
|
+
from comodor.browser import Browser
|
|
546
|
+
|
|
547
|
+
browser = Browser.start(tmp_path / "profile", headless=True)
|
|
548
|
+
try:
|
|
549
|
+
assert browser.sandboxed is True
|
|
550
|
+
assert browser.note == ""
|
|
551
|
+
finally:
|
|
552
|
+
browser.stop()
|
|
@@ -459,3 +459,73 @@ def test_a_body_over_the_cap_is_drained_rather_than_left_in_the_socket(served):
|
|
|
459
459
|
assert second.status == 200
|
|
460
460
|
finally:
|
|
461
461
|
connection.close()
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
# --------------------------------------------------------------------------- #
|
|
465
|
+
# inside a container, a wide bind is the correct one
|
|
466
|
+
# --------------------------------------------------------------------------- #
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def test_a_container_is_recognised(monkeypatch, tmp_path):
|
|
470
|
+
from comodor.web import server as module
|
|
471
|
+
|
|
472
|
+
marker = tmp_path / "dockerenv"
|
|
473
|
+
marker.write_text("", encoding="utf-8")
|
|
474
|
+
monkeypatch.setattr(module, "Path", lambda p: marker if p == "/.dockerenv"
|
|
475
|
+
else tmp_path / "absent")
|
|
476
|
+
|
|
477
|
+
assert module.in_a_container() is True
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def test_a_plain_machine_is_not_mistaken_for_one(monkeypatch, tmp_path):
|
|
481
|
+
from comodor.web import server as module
|
|
482
|
+
|
|
483
|
+
monkeypatch.setattr(module, "Path", lambda p: tmp_path / "absent")
|
|
484
|
+
monkeypatch.delenv("container", raising=False)
|
|
485
|
+
|
|
486
|
+
assert module.in_a_container() is False
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
def test_the_environment_variable_podman_sets_counts(monkeypatch, tmp_path):
|
|
490
|
+
from comodor.web import server as module
|
|
491
|
+
|
|
492
|
+
monkeypatch.setattr(module, "Path", lambda p: tmp_path / "absent")
|
|
493
|
+
monkeypatch.setenv("container", "podman")
|
|
494
|
+
|
|
495
|
+
assert module.in_a_container() is True
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def test_a_container_binding_wide_is_not_called_reckless(config, monkeypatch, capsys):
|
|
499
|
+
"""A container has its own network namespace, so binding 127.0.0.1 there
|
|
500
|
+
hides the port from the machine that started it. Binding everything is
|
|
501
|
+
correct, and the boundary is how the port was published."""
|
|
502
|
+
from comodor.web import commands, server as module
|
|
503
|
+
|
|
504
|
+
monkeypatch.setattr(module, "in_a_container", lambda: True)
|
|
505
|
+
served = module.Server(config, host="0.0.0.0", port=8765)
|
|
506
|
+
try:
|
|
507
|
+
commands._announce(served)
|
|
508
|
+
printed = capsys.readouterr().out
|
|
509
|
+
|
|
510
|
+
assert "public address" not in printed
|
|
511
|
+
assert "-p 127.0.0.1:8765:8765" in printed
|
|
512
|
+
# And it still says what the careless form costs.
|
|
513
|
+
assert "shell on the network" in printed
|
|
514
|
+
finally:
|
|
515
|
+
served.session.close()
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def test_a_real_machine_binding_wide_still_gets_the_warning(config, monkeypatch,
|
|
519
|
+
capsys):
|
|
520
|
+
from comodor.web import commands, server as module
|
|
521
|
+
|
|
522
|
+
monkeypatch.setattr(module, "in_a_container", lambda: False)
|
|
523
|
+
served = module.Server(config, host="0.0.0.0", port=8765)
|
|
524
|
+
try:
|
|
525
|
+
commands._announce(served)
|
|
526
|
+
printed = capsys.readouterr().out
|
|
527
|
+
|
|
528
|
+
assert "public address" in printed
|
|
529
|
+
assert "ssh -N -L" in printed
|
|
530
|
+
finally:
|
|
531
|
+
served.session.close()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|