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.
Files changed (147) hide show
  1. {comodor-0.8.2 → comodor-0.8.3}/PKG-INFO +1 -1
  2. {comodor-0.8.2 → comodor-0.8.3}/src/comodor/_version.py +2 -2
  3. {comodor-0.8.2 → comodor-0.8.3}/src/comodor/browser/launch.py +80 -5
  4. {comodor-0.8.2 → comodor-0.8.3}/src/comodor/web/commands.py +16 -0
  5. {comodor-0.8.2 → comodor-0.8.3}/src/comodor/web/server.py +33 -0
  6. {comodor-0.8.2 → comodor-0.8.3}/tests/test_real_browser.py +80 -0
  7. {comodor-0.8.2 → comodor-0.8.3}/tests/test_web.py +70 -0
  8. {comodor-0.8.2 → comodor-0.8.3}/.gitignore +0 -0
  9. {comodor-0.8.2 → comodor-0.8.3}/LICENSE +0 -0
  10. {comodor-0.8.2 → comodor-0.8.3}/README.md +0 -0
  11. {comodor-0.8.2 → comodor-0.8.3}/pyproject.toml +0 -0
  12. {comodor-0.8.2 → comodor-0.8.3}/src/comodor/__init__.py +0 -0
  13. {comodor-0.8.2 → comodor-0.8.3}/src/comodor/__main__.py +0 -0
  14. {comodor-0.8.2 → comodor-0.8.3}/src/comodor/agent/__init__.py +0 -0
  15. {comodor-0.8.2 → comodor-0.8.3}/src/comodor/agent/context.py +0 -0
  16. {comodor-0.8.2 → comodor-0.8.3}/src/comodor/agent/loop.py +0 -0
  17. {comodor-0.8.2 → comodor-0.8.3}/src/comodor/agent/prompts.py +0 -0
  18. {comodor-0.8.2 → comodor-0.8.3}/src/comodor/agent/spawn.py +0 -0
  19. {comodor-0.8.2 → comodor-0.8.3}/src/comodor/agent/tokens.py +0 -0
  20. {comodor-0.8.2 → comodor-0.8.3}/src/comodor/browser/__init__.py +0 -0
  21. {comodor-0.8.2 → comodor-0.8.3}/src/comodor/browser/cdp.py +0 -0
  22. {comodor-0.8.2 → comodor-0.8.3}/src/comodor/browser/page.py +0 -0
  23. {comodor-0.8.2 → comodor-0.8.3}/src/comodor/catalogue.py +0 -0
  24. {comodor-0.8.2 → comodor-0.8.3}/src/comodor/cli.py +0 -0
  25. {comodor-0.8.2 → comodor-0.8.3}/src/comodor/config.py +0 -0
  26. {comodor-0.8.2 → comodor-0.8.3}/src/comodor/doctor.py +0 -0
  27. {comodor-0.8.2 → comodor-0.8.3}/src/comodor/events.py +0 -0
  28. {comodor-0.8.2 → comodor-0.8.3}/src/comodor/learning/__init__.py +0 -0
  29. {comodor-0.8.2 → comodor-0.8.3}/src/comodor/learning/associations.py +0 -0
  30. {comodor-0.8.2 → comodor-0.8.3}/src/comodor/learning/bm25.py +0 -0
  31. {comodor-0.8.2 → comodor-0.8.3}/src/comodor/learning/hotindex.py +0 -0
  32. {comodor-0.8.2 → comodor-0.8.3}/src/comodor/learning/memory.py +0 -0
  33. {comodor-0.8.2 → comodor-0.8.3}/src/comodor/learning/progress.py +0 -0
  34. {comodor-0.8.2 → comodor-0.8.3}/src/comodor/learning/reflect.py +0 -0
  35. {comodor-0.8.2 → comodor-0.8.3}/src/comodor/learning/rules.py +0 -0
  36. {comodor-0.8.2 → comodor-0.8.3}/src/comodor/learning/signals.py +0 -0
  37. {comodor-0.8.2 → comodor-0.8.3}/src/comodor/learning/store.py +0 -0
  38. {comodor-0.8.2 → comodor-0.8.3}/src/comodor/learning/writer.py +0 -0
  39. {comodor-0.8.2 → comodor-0.8.3}/src/comodor/mcp/__init__.py +0 -0
  40. {comodor-0.8.2 → comodor-0.8.3}/src/comodor/mcp/catalogue.py +0 -0
  41. {comodor-0.8.2 → comodor-0.8.3}/src/comodor/mcp/commands.py +0 -0
  42. {comodor-0.8.2 → comodor-0.8.3}/src/comodor/mcp/http.py +0 -0
  43. {comodor-0.8.2 → comodor-0.8.3}/src/comodor/mcp/manager.py +0 -0
  44. {comodor-0.8.2 → comodor-0.8.3}/src/comodor/mcp/protocol.py +0 -0
  45. {comodor-0.8.2 → comodor-0.8.3}/src/comodor/net/__init__.py +0 -0
  46. {comodor-0.8.2 → comodor-0.8.3}/src/comodor/net/http.py +0 -0
  47. {comodor-0.8.2 → comodor-0.8.3}/src/comodor/net/sse.py +0 -0
  48. {comodor-0.8.2 → comodor-0.8.3}/src/comodor/paths.py +0 -0
  49. {comodor-0.8.2 → comodor-0.8.3}/src/comodor/providers/__init__.py +0 -0
  50. {comodor-0.8.2 → comodor-0.8.3}/src/comodor/providers/anthropic.py +0 -0
  51. {comodor-0.8.2 → comodor-0.8.3}/src/comodor/providers/base.py +0 -0
  52. {comodor-0.8.2 → comodor-0.8.3}/src/comodor/providers/caching.py +0 -0
  53. {comodor-0.8.2 → comodor-0.8.3}/src/comodor/providers/fake.py +0 -0
  54. {comodor-0.8.2 → comodor-0.8.3}/src/comodor/providers/gateway.py +0 -0
  55. {comodor-0.8.2 → comodor-0.8.3}/src/comodor/providers/openai_compat.py +0 -0
  56. {comodor-0.8.2 → comodor-0.8.3}/src/comodor/providers/registry.py +0 -0
  57. {comodor-0.8.2 → comodor-0.8.3}/src/comodor/safety/__init__.py +0 -0
  58. {comodor-0.8.2 → comodor-0.8.3}/src/comodor/safety/checkpoints.py +0 -0
  59. {comodor-0.8.2 → comodor-0.8.3}/src/comodor/safety/permissions.py +0 -0
  60. {comodor-0.8.2 → comodor-0.8.3}/src/comodor/safety/redact.py +0 -0
  61. {comodor-0.8.2 → comodor-0.8.3}/src/comodor/session/__init__.py +0 -0
  62. {comodor-0.8.2 → comodor-0.8.3}/src/comodor/session/search.py +0 -0
  63. {comodor-0.8.2 → comodor-0.8.3}/src/comodor/session/store.py +0 -0
  64. {comodor-0.8.2 → comodor-0.8.3}/src/comodor/setup.py +0 -0
  65. {comodor-0.8.2 → comodor-0.8.3}/src/comodor/skills/__init__.py +0 -0
  66. {comodor-0.8.2 → comodor-0.8.3}/src/comodor/skills/catalogue.py +0 -0
  67. {comodor-0.8.2 → comodor-0.8.3}/src/comodor/skills/commands.py +0 -0
  68. {comodor-0.8.2 → comodor-0.8.3}/src/comodor/skills/examples.py +0 -0
  69. {comodor-0.8.2 → comodor-0.8.3}/src/comodor/skills/loader.py +0 -0
  70. {comodor-0.8.2 → comodor-0.8.3}/src/comodor/skills/propose.py +0 -0
  71. {comodor-0.8.2 → comodor-0.8.3}/src/comodor/skills/registry.py +0 -0
  72. {comodor-0.8.2 → comodor-0.8.3}/src/comodor/tools/__init__.py +0 -0
  73. {comodor-0.8.2 → comodor-0.8.3}/src/comodor/tools/base.py +0 -0
  74. {comodor-0.8.2 → comodor-0.8.3}/src/comodor/tools/browse.py +0 -0
  75. {comodor-0.8.2 → comodor-0.8.3}/src/comodor/tools/browser.py +0 -0
  76. {comodor-0.8.2 → comodor-0.8.3}/src/comodor/tools/delegate.py +0 -0
  77. {comodor-0.8.2 → comodor-0.8.3}/src/comodor/tools/fs.py +0 -0
  78. {comodor-0.8.2 → comodor-0.8.3}/src/comodor/tools/history.py +0 -0
  79. {comodor-0.8.2 → comodor-0.8.3}/src/comodor/tools/mcp.py +0 -0
  80. {comodor-0.8.2 → comodor-0.8.3}/src/comodor/tools/overflow.py +0 -0
  81. {comodor-0.8.2 → comodor-0.8.3}/src/comodor/tools/registry.py +0 -0
  82. {comodor-0.8.2 → comodor-0.8.3}/src/comodor/tools/search.py +0 -0
  83. {comodor-0.8.2 → comodor-0.8.3}/src/comodor/tools/shell.py +0 -0
  84. {comodor-0.8.2 → comodor-0.8.3}/src/comodor/tools/skills.py +0 -0
  85. {comodor-0.8.2 → comodor-0.8.3}/src/comodor/tools/todo.py +0 -0
  86. {comodor-0.8.2 → comodor-0.8.3}/src/comodor/tools/web.py +0 -0
  87. {comodor-0.8.2 → comodor-0.8.3}/src/comodor/ui/__init__.py +0 -0
  88. {comodor-0.8.2 → comodor-0.8.3}/src/comodor/ui/app.py +0 -0
  89. {comodor-0.8.2 → comodor-0.8.3}/src/comodor/ui/bidi.py +0 -0
  90. {comodor-0.8.2 → comodor-0.8.3}/src/comodor/ui/chooser.py +0 -0
  91. {comodor-0.8.2 → comodor-0.8.3}/src/comodor/ui/console.py +0 -0
  92. {comodor-0.8.2 → comodor-0.8.3}/src/comodor/ui/input/__init__.py +0 -0
  93. {comodor-0.8.2 → comodor-0.8.3}/src/comodor/ui/input/keys.py +0 -0
  94. {comodor-0.8.2 → comodor-0.8.3}/src/comodor/ui/input/reader.py +0 -0
  95. {comodor-0.8.2 → comodor-0.8.3}/src/comodor/ui/layout.py +0 -0
  96. {comodor-0.8.2 → comodor-0.8.3}/src/comodor/ui/markdown.py +0 -0
  97. {comodor-0.8.2 → comodor-0.8.3}/src/comodor/ui/screen.py +0 -0
  98. {comodor-0.8.2 → comodor-0.8.3}/src/comodor/ui/theme.py +0 -0
  99. {comodor-0.8.2 → comodor-0.8.3}/src/comodor/ui/widgets/__init__.py +0 -0
  100. {comodor-0.8.2 → comodor-0.8.3}/src/comodor/ui/widgets/buttons.py +0 -0
  101. {comodor-0.8.2 → comodor-0.8.3}/src/comodor/ui/widgets/chat.py +0 -0
  102. {comodor-0.8.2 → comodor-0.8.3}/src/comodor/ui/widgets/history.py +0 -0
  103. {comodor-0.8.2 → comodor-0.8.3}/src/comodor/ui/widgets/overlay.py +0 -0
  104. {comodor-0.8.2 → comodor-0.8.3}/src/comodor/ui/widgets/panel.py +0 -0
  105. {comodor-0.8.2 → comodor-0.8.3}/src/comodor/ui/widgets/progress.py +0 -0
  106. {comodor-0.8.2 → comodor-0.8.3}/src/comodor/ui/widgets/prompt.py +0 -0
  107. {comodor-0.8.2 → comodor-0.8.3}/src/comodor/ui/widgets/statusbar.py +0 -0
  108. {comodor-0.8.2 → comodor-0.8.3}/src/comodor/ui/widgets/toast.py +0 -0
  109. {comodor-0.8.2 → comodor-0.8.3}/src/comodor/uninstall.py +0 -0
  110. {comodor-0.8.2 → comodor-0.8.3}/src/comodor/update.py +0 -0
  111. {comodor-0.8.2 → comodor-0.8.3}/src/comodor/web/__init__.py +0 -0
  112. {comodor-0.8.2 → comodor-0.8.3}/src/comodor/web/index.html +0 -0
  113. {comodor-0.8.2 → comodor-0.8.3}/src/comodor/web/session.py +0 -0
  114. {comodor-0.8.2 → comodor-0.8.3}/src/comodor/workspace.py +0 -0
  115. {comodor-0.8.2 → comodor-0.8.3}/tests/conftest.py +0 -0
  116. {comodor-0.8.2 → comodor-0.8.3}/tests/support/fake_mcp_server.py +0 -0
  117. {comodor-0.8.2 → comodor-0.8.3}/tests/test_agent_loop.py +0 -0
  118. {comodor-0.8.2 → comodor-0.8.3}/tests/test_app.py +0 -0
  119. {comodor-0.8.2 → comodor-0.8.3}/tests/test_associations.py +0 -0
  120. {comodor-0.8.2 → comodor-0.8.3}/tests/test_bidi.py +0 -0
  121. {comodor-0.8.2 → comodor-0.8.3}/tests/test_browser.py +0 -0
  122. {comodor-0.8.2 → comodor-0.8.3}/tests/test_caching.py +0 -0
  123. {comodor-0.8.2 → comodor-0.8.3}/tests/test_chooser.py +0 -0
  124. {comodor-0.8.2 → comodor-0.8.3}/tests/test_delegate.py +0 -0
  125. {comodor-0.8.2 → comodor-0.8.3}/tests/test_doctor.py +0 -0
  126. {comodor-0.8.2 → comodor-0.8.3}/tests/test_history.py +0 -0
  127. {comodor-0.8.2 → comodor-0.8.3}/tests/test_input.py +0 -0
  128. {comodor-0.8.2 → comodor-0.8.3}/tests/test_layout.py +0 -0
  129. {comodor-0.8.2 → comodor-0.8.3}/tests/test_learning.py +0 -0
  130. {comodor-0.8.2 → comodor-0.8.3}/tests/test_markdown.py +0 -0
  131. {comodor-0.8.2 → comodor-0.8.3}/tests/test_mcp.py +0 -0
  132. {comodor-0.8.2 → comodor-0.8.3}/tests/test_mcp_http.py +0 -0
  133. {comodor-0.8.2 → comodor-0.8.3}/tests/test_overflow.py +0 -0
  134. {comodor-0.8.2 → comodor-0.8.3}/tests/test_performance.py +0 -0
  135. {comodor-0.8.2 → comodor-0.8.3}/tests/test_prefix_stability.py +0 -0
  136. {comodor-0.8.2 → comodor-0.8.3}/tests/test_progress.py +0 -0
  137. {comodor-0.8.2 → comodor-0.8.3}/tests/test_propose.py +0 -0
  138. {comodor-0.8.2 → comodor-0.8.3}/tests/test_providers.py +0 -0
  139. {comodor-0.8.2 → comodor-0.8.3}/tests/test_reflex.py +0 -0
  140. {comodor-0.8.2 → comodor-0.8.3}/tests/test_run_loop.py +0 -0
  141. {comodor-0.8.2 → comodor-0.8.3}/tests/test_setup.py +0 -0
  142. {comodor-0.8.2 → comodor-0.8.3}/tests/test_skill_budget.py +0 -0
  143. {comodor-0.8.2 → comodor-0.8.3}/tests/test_skill_catalogue.py +0 -0
  144. {comodor-0.8.2 → comodor-0.8.3}/tests/test_skills.py +0 -0
  145. {comodor-0.8.2 → comodor-0.8.3}/tests/test_tools.py +0 -0
  146. {comodor-0.8.2 → comodor-0.8.3}/tests/test_uninstall.py +0 -0
  147. {comodor-0.8.2 → comodor-0.8.3}/tests/test_update.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: comodor
3
- Version: 0.8.2
3
+ Version: 0.8.3
4
4
  Summary: Comodor — a self-improving terminal coding agent with a Rich TUI
5
5
  Project-URL: Homepage, https://comodor.ai
6
6
  Project-URL: Repository, https://github.com/ifekri/Comodor
@@ -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.2'
22
- __version_tuple__ = version_tuple = (0, 8, 2)
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={chosen}",
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=subprocess.DEVNULL,
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, chosen, headless, process)
145
- browser._await_port()
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