comodor 0.8.2__tar.gz → 0.8.4__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.4}/PKG-INFO +1 -1
  2. {comodor-0.8.2 → comodor-0.8.4}/src/comodor/_version.py +2 -2
  3. {comodor-0.8.2 → comodor-0.8.4}/src/comodor/browser/launch.py +99 -5
  4. {comodor-0.8.2 → comodor-0.8.4}/src/comodor/web/commands.py +16 -0
  5. {comodor-0.8.2 → comodor-0.8.4}/src/comodor/web/server.py +33 -0
  6. {comodor-0.8.2 → comodor-0.8.4}/tests/test_real_browser.py +101 -0
  7. {comodor-0.8.2 → comodor-0.8.4}/tests/test_web.py +70 -0
  8. {comodor-0.8.2 → comodor-0.8.4}/.gitignore +0 -0
  9. {comodor-0.8.2 → comodor-0.8.4}/LICENSE +0 -0
  10. {comodor-0.8.2 → comodor-0.8.4}/README.md +0 -0
  11. {comodor-0.8.2 → comodor-0.8.4}/pyproject.toml +0 -0
  12. {comodor-0.8.2 → comodor-0.8.4}/src/comodor/__init__.py +0 -0
  13. {comodor-0.8.2 → comodor-0.8.4}/src/comodor/__main__.py +0 -0
  14. {comodor-0.8.2 → comodor-0.8.4}/src/comodor/agent/__init__.py +0 -0
  15. {comodor-0.8.2 → comodor-0.8.4}/src/comodor/agent/context.py +0 -0
  16. {comodor-0.8.2 → comodor-0.8.4}/src/comodor/agent/loop.py +0 -0
  17. {comodor-0.8.2 → comodor-0.8.4}/src/comodor/agent/prompts.py +0 -0
  18. {comodor-0.8.2 → comodor-0.8.4}/src/comodor/agent/spawn.py +0 -0
  19. {comodor-0.8.2 → comodor-0.8.4}/src/comodor/agent/tokens.py +0 -0
  20. {comodor-0.8.2 → comodor-0.8.4}/src/comodor/browser/__init__.py +0 -0
  21. {comodor-0.8.2 → comodor-0.8.4}/src/comodor/browser/cdp.py +0 -0
  22. {comodor-0.8.2 → comodor-0.8.4}/src/comodor/browser/page.py +0 -0
  23. {comodor-0.8.2 → comodor-0.8.4}/src/comodor/catalogue.py +0 -0
  24. {comodor-0.8.2 → comodor-0.8.4}/src/comodor/cli.py +0 -0
  25. {comodor-0.8.2 → comodor-0.8.4}/src/comodor/config.py +0 -0
  26. {comodor-0.8.2 → comodor-0.8.4}/src/comodor/doctor.py +0 -0
  27. {comodor-0.8.2 → comodor-0.8.4}/src/comodor/events.py +0 -0
  28. {comodor-0.8.2 → comodor-0.8.4}/src/comodor/learning/__init__.py +0 -0
  29. {comodor-0.8.2 → comodor-0.8.4}/src/comodor/learning/associations.py +0 -0
  30. {comodor-0.8.2 → comodor-0.8.4}/src/comodor/learning/bm25.py +0 -0
  31. {comodor-0.8.2 → comodor-0.8.4}/src/comodor/learning/hotindex.py +0 -0
  32. {comodor-0.8.2 → comodor-0.8.4}/src/comodor/learning/memory.py +0 -0
  33. {comodor-0.8.2 → comodor-0.8.4}/src/comodor/learning/progress.py +0 -0
  34. {comodor-0.8.2 → comodor-0.8.4}/src/comodor/learning/reflect.py +0 -0
  35. {comodor-0.8.2 → comodor-0.8.4}/src/comodor/learning/rules.py +0 -0
  36. {comodor-0.8.2 → comodor-0.8.4}/src/comodor/learning/signals.py +0 -0
  37. {comodor-0.8.2 → comodor-0.8.4}/src/comodor/learning/store.py +0 -0
  38. {comodor-0.8.2 → comodor-0.8.4}/src/comodor/learning/writer.py +0 -0
  39. {comodor-0.8.2 → comodor-0.8.4}/src/comodor/mcp/__init__.py +0 -0
  40. {comodor-0.8.2 → comodor-0.8.4}/src/comodor/mcp/catalogue.py +0 -0
  41. {comodor-0.8.2 → comodor-0.8.4}/src/comodor/mcp/commands.py +0 -0
  42. {comodor-0.8.2 → comodor-0.8.4}/src/comodor/mcp/http.py +0 -0
  43. {comodor-0.8.2 → comodor-0.8.4}/src/comodor/mcp/manager.py +0 -0
  44. {comodor-0.8.2 → comodor-0.8.4}/src/comodor/mcp/protocol.py +0 -0
  45. {comodor-0.8.2 → comodor-0.8.4}/src/comodor/net/__init__.py +0 -0
  46. {comodor-0.8.2 → comodor-0.8.4}/src/comodor/net/http.py +0 -0
  47. {comodor-0.8.2 → comodor-0.8.4}/src/comodor/net/sse.py +0 -0
  48. {comodor-0.8.2 → comodor-0.8.4}/src/comodor/paths.py +0 -0
  49. {comodor-0.8.2 → comodor-0.8.4}/src/comodor/providers/__init__.py +0 -0
  50. {comodor-0.8.2 → comodor-0.8.4}/src/comodor/providers/anthropic.py +0 -0
  51. {comodor-0.8.2 → comodor-0.8.4}/src/comodor/providers/base.py +0 -0
  52. {comodor-0.8.2 → comodor-0.8.4}/src/comodor/providers/caching.py +0 -0
  53. {comodor-0.8.2 → comodor-0.8.4}/src/comodor/providers/fake.py +0 -0
  54. {comodor-0.8.2 → comodor-0.8.4}/src/comodor/providers/gateway.py +0 -0
  55. {comodor-0.8.2 → comodor-0.8.4}/src/comodor/providers/openai_compat.py +0 -0
  56. {comodor-0.8.2 → comodor-0.8.4}/src/comodor/providers/registry.py +0 -0
  57. {comodor-0.8.2 → comodor-0.8.4}/src/comodor/safety/__init__.py +0 -0
  58. {comodor-0.8.2 → comodor-0.8.4}/src/comodor/safety/checkpoints.py +0 -0
  59. {comodor-0.8.2 → comodor-0.8.4}/src/comodor/safety/permissions.py +0 -0
  60. {comodor-0.8.2 → comodor-0.8.4}/src/comodor/safety/redact.py +0 -0
  61. {comodor-0.8.2 → comodor-0.8.4}/src/comodor/session/__init__.py +0 -0
  62. {comodor-0.8.2 → comodor-0.8.4}/src/comodor/session/search.py +0 -0
  63. {comodor-0.8.2 → comodor-0.8.4}/src/comodor/session/store.py +0 -0
  64. {comodor-0.8.2 → comodor-0.8.4}/src/comodor/setup.py +0 -0
  65. {comodor-0.8.2 → comodor-0.8.4}/src/comodor/skills/__init__.py +0 -0
  66. {comodor-0.8.2 → comodor-0.8.4}/src/comodor/skills/catalogue.py +0 -0
  67. {comodor-0.8.2 → comodor-0.8.4}/src/comodor/skills/commands.py +0 -0
  68. {comodor-0.8.2 → comodor-0.8.4}/src/comodor/skills/examples.py +0 -0
  69. {comodor-0.8.2 → comodor-0.8.4}/src/comodor/skills/loader.py +0 -0
  70. {comodor-0.8.2 → comodor-0.8.4}/src/comodor/skills/propose.py +0 -0
  71. {comodor-0.8.2 → comodor-0.8.4}/src/comodor/skills/registry.py +0 -0
  72. {comodor-0.8.2 → comodor-0.8.4}/src/comodor/tools/__init__.py +0 -0
  73. {comodor-0.8.2 → comodor-0.8.4}/src/comodor/tools/base.py +0 -0
  74. {comodor-0.8.2 → comodor-0.8.4}/src/comodor/tools/browse.py +0 -0
  75. {comodor-0.8.2 → comodor-0.8.4}/src/comodor/tools/browser.py +0 -0
  76. {comodor-0.8.2 → comodor-0.8.4}/src/comodor/tools/delegate.py +0 -0
  77. {comodor-0.8.2 → comodor-0.8.4}/src/comodor/tools/fs.py +0 -0
  78. {comodor-0.8.2 → comodor-0.8.4}/src/comodor/tools/history.py +0 -0
  79. {comodor-0.8.2 → comodor-0.8.4}/src/comodor/tools/mcp.py +0 -0
  80. {comodor-0.8.2 → comodor-0.8.4}/src/comodor/tools/overflow.py +0 -0
  81. {comodor-0.8.2 → comodor-0.8.4}/src/comodor/tools/registry.py +0 -0
  82. {comodor-0.8.2 → comodor-0.8.4}/src/comodor/tools/search.py +0 -0
  83. {comodor-0.8.2 → comodor-0.8.4}/src/comodor/tools/shell.py +0 -0
  84. {comodor-0.8.2 → comodor-0.8.4}/src/comodor/tools/skills.py +0 -0
  85. {comodor-0.8.2 → comodor-0.8.4}/src/comodor/tools/todo.py +0 -0
  86. {comodor-0.8.2 → comodor-0.8.4}/src/comodor/tools/web.py +0 -0
  87. {comodor-0.8.2 → comodor-0.8.4}/src/comodor/ui/__init__.py +0 -0
  88. {comodor-0.8.2 → comodor-0.8.4}/src/comodor/ui/app.py +0 -0
  89. {comodor-0.8.2 → comodor-0.8.4}/src/comodor/ui/bidi.py +0 -0
  90. {comodor-0.8.2 → comodor-0.8.4}/src/comodor/ui/chooser.py +0 -0
  91. {comodor-0.8.2 → comodor-0.8.4}/src/comodor/ui/console.py +0 -0
  92. {comodor-0.8.2 → comodor-0.8.4}/src/comodor/ui/input/__init__.py +0 -0
  93. {comodor-0.8.2 → comodor-0.8.4}/src/comodor/ui/input/keys.py +0 -0
  94. {comodor-0.8.2 → comodor-0.8.4}/src/comodor/ui/input/reader.py +0 -0
  95. {comodor-0.8.2 → comodor-0.8.4}/src/comodor/ui/layout.py +0 -0
  96. {comodor-0.8.2 → comodor-0.8.4}/src/comodor/ui/markdown.py +0 -0
  97. {comodor-0.8.2 → comodor-0.8.4}/src/comodor/ui/screen.py +0 -0
  98. {comodor-0.8.2 → comodor-0.8.4}/src/comodor/ui/theme.py +0 -0
  99. {comodor-0.8.2 → comodor-0.8.4}/src/comodor/ui/widgets/__init__.py +0 -0
  100. {comodor-0.8.2 → comodor-0.8.4}/src/comodor/ui/widgets/buttons.py +0 -0
  101. {comodor-0.8.2 → comodor-0.8.4}/src/comodor/ui/widgets/chat.py +0 -0
  102. {comodor-0.8.2 → comodor-0.8.4}/src/comodor/ui/widgets/history.py +0 -0
  103. {comodor-0.8.2 → comodor-0.8.4}/src/comodor/ui/widgets/overlay.py +0 -0
  104. {comodor-0.8.2 → comodor-0.8.4}/src/comodor/ui/widgets/panel.py +0 -0
  105. {comodor-0.8.2 → comodor-0.8.4}/src/comodor/ui/widgets/progress.py +0 -0
  106. {comodor-0.8.2 → comodor-0.8.4}/src/comodor/ui/widgets/prompt.py +0 -0
  107. {comodor-0.8.2 → comodor-0.8.4}/src/comodor/ui/widgets/statusbar.py +0 -0
  108. {comodor-0.8.2 → comodor-0.8.4}/src/comodor/ui/widgets/toast.py +0 -0
  109. {comodor-0.8.2 → comodor-0.8.4}/src/comodor/uninstall.py +0 -0
  110. {comodor-0.8.2 → comodor-0.8.4}/src/comodor/update.py +0 -0
  111. {comodor-0.8.2 → comodor-0.8.4}/src/comodor/web/__init__.py +0 -0
  112. {comodor-0.8.2 → comodor-0.8.4}/src/comodor/web/index.html +0 -0
  113. {comodor-0.8.2 → comodor-0.8.4}/src/comodor/web/session.py +0 -0
  114. {comodor-0.8.2 → comodor-0.8.4}/src/comodor/workspace.py +0 -0
  115. {comodor-0.8.2 → comodor-0.8.4}/tests/conftest.py +0 -0
  116. {comodor-0.8.2 → comodor-0.8.4}/tests/support/fake_mcp_server.py +0 -0
  117. {comodor-0.8.2 → comodor-0.8.4}/tests/test_agent_loop.py +0 -0
  118. {comodor-0.8.2 → comodor-0.8.4}/tests/test_app.py +0 -0
  119. {comodor-0.8.2 → comodor-0.8.4}/tests/test_associations.py +0 -0
  120. {comodor-0.8.2 → comodor-0.8.4}/tests/test_bidi.py +0 -0
  121. {comodor-0.8.2 → comodor-0.8.4}/tests/test_browser.py +0 -0
  122. {comodor-0.8.2 → comodor-0.8.4}/tests/test_caching.py +0 -0
  123. {comodor-0.8.2 → comodor-0.8.4}/tests/test_chooser.py +0 -0
  124. {comodor-0.8.2 → comodor-0.8.4}/tests/test_delegate.py +0 -0
  125. {comodor-0.8.2 → comodor-0.8.4}/tests/test_doctor.py +0 -0
  126. {comodor-0.8.2 → comodor-0.8.4}/tests/test_history.py +0 -0
  127. {comodor-0.8.2 → comodor-0.8.4}/tests/test_input.py +0 -0
  128. {comodor-0.8.2 → comodor-0.8.4}/tests/test_layout.py +0 -0
  129. {comodor-0.8.2 → comodor-0.8.4}/tests/test_learning.py +0 -0
  130. {comodor-0.8.2 → comodor-0.8.4}/tests/test_markdown.py +0 -0
  131. {comodor-0.8.2 → comodor-0.8.4}/tests/test_mcp.py +0 -0
  132. {comodor-0.8.2 → comodor-0.8.4}/tests/test_mcp_http.py +0 -0
  133. {comodor-0.8.2 → comodor-0.8.4}/tests/test_overflow.py +0 -0
  134. {comodor-0.8.2 → comodor-0.8.4}/tests/test_performance.py +0 -0
  135. {comodor-0.8.2 → comodor-0.8.4}/tests/test_prefix_stability.py +0 -0
  136. {comodor-0.8.2 → comodor-0.8.4}/tests/test_progress.py +0 -0
  137. {comodor-0.8.2 → comodor-0.8.4}/tests/test_propose.py +0 -0
  138. {comodor-0.8.2 → comodor-0.8.4}/tests/test_providers.py +0 -0
  139. {comodor-0.8.2 → comodor-0.8.4}/tests/test_reflex.py +0 -0
  140. {comodor-0.8.2 → comodor-0.8.4}/tests/test_run_loop.py +0 -0
  141. {comodor-0.8.2 → comodor-0.8.4}/tests/test_setup.py +0 -0
  142. {comodor-0.8.2 → comodor-0.8.4}/tests/test_skill_budget.py +0 -0
  143. {comodor-0.8.2 → comodor-0.8.4}/tests/test_skill_catalogue.py +0 -0
  144. {comodor-0.8.2 → comodor-0.8.4}/tests/test_skills.py +0 -0
  145. {comodor-0.8.2 → comodor-0.8.4}/tests/test_tools.py +0 -0
  146. {comodor-0.8.2 → comodor-0.8.4}/tests/test_uninstall.py +0 -0
  147. {comodor-0.8.2 → comodor-0.8.4}/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.4
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.4'
22
+ __version_tuple__ = version_tuple = (0, 8, 4)
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,57 @@ 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 against everything it printed rather than the first line:
100
+ #: it does not put the useful part first, and which line comes first depends on
101
+ #: how the container was started.
102
+ _NO_SANDBOX_SIGNS = (
103
+ "no usable sandbox",
104
+ "failed to move to new namespace",
105
+ "setuid sandbox",
106
+ "sandboxhelper",
107
+ "chrome-sandbox",
108
+ "zygote_host",
109
+ )
110
+
111
+
112
+ class SandboxUnavailable(BrowserError):
113
+ """It will not start because it cannot isolate its renderers here.
114
+
115
+ Its own class rather than a string match at the call site, because the
116
+ decision belongs where the full output is: by the time this reaches a
117
+ caller the message has been trimmed to the one line worth showing.
118
+ """
119
+
120
+
121
+ def _looks_like_a_sandbox_problem(output: str) -> bool:
122
+ lowered = output.lower()
123
+ return any(sign in lowered for sign in _NO_SANDBOX_SIGNS)
124
+
125
+
126
+ def _read_all(handle) -> str:
127
+ try:
128
+ handle.seek(0)
129
+ return handle.read(20_000).decode("utf-8", "replace").strip()
130
+ except (OSError, ValueError):
131
+ return ""
132
+
133
+
134
+ def _first_line(output: str) -> str:
135
+ """One line for a person. Forty lines of Chromium logging is not an error
136
+ message, it is a haystack."""
137
+ if not output:
138
+ return ""
139
+ first = next((line for line in output.splitlines() if line.strip()), "")
140
+ return f" It said: {first.strip()[:300]}"
141
+
142
+
97
143
  class Browser:
98
144
  """A browser process we started, and are responsible for stopping."""
99
145
 
100
146
  def __init__(self, executable: str, profile: Path, port: int,
101
147
  headless: bool = True, process: subprocess.Popen | None = None,
102
- ours: bool = True) -> None:
148
+ ours: bool = True, sandboxed: bool = True) -> None:
103
149
  self.executable = executable
104
150
  self.profile = profile
105
151
  self.port = port
@@ -108,6 +154,11 @@ class Browser:
108
154
  #: False when we attached to something already running, in which case
109
155
  #: stopping it is not ours to do.
110
156
  self.ours = ours
157
+ #: False when the renderer sandbox had to be given up to start at all.
158
+ #: Worth knowing rather than assuming: it is the difference between a
159
+ #: bad page being contained by Chromium and being contained only by
160
+ #: whatever is around Chromium.
161
+ self.sandboxed = sandboxed
111
162
 
112
163
  @classmethod
113
164
  def start(cls, profile: Path, executable: str = "", headless: bool = True,
@@ -116,9 +167,24 @@ class Browser:
116
167
  profile.mkdir(parents=True, exist_ok=True)
117
168
  chosen = port or free_port()
118
169
 
170
+ try:
171
+ return cls._spawn(binary, profile, chosen, headless, window,
172
+ sandboxed=True)
173
+ except SandboxUnavailable:
174
+ pass
175
+ # It will not run its renderers in a user namespace here — almost
176
+ # always a container, whose own confinement is the stronger boundary
177
+ # anyway. Give up the inner one rather than weaken the outer one, and
178
+ # remember that this is what happened.
179
+ return cls._spawn(binary, profile, chosen, headless, window,
180
+ sandboxed=False)
181
+
182
+ @classmethod
183
+ def _spawn(cls, binary: str, profile: Path, port: int, headless: bool,
184
+ window: tuple[int, int], sandboxed: bool) -> "Browser":
119
185
  arguments = [
120
186
  binary,
121
- f"--remote-debugging-port={chosen}",
187
+ f"--remote-debugging-port={port}",
122
188
  # Loopback only. Without this Chrome will accept a debugging
123
189
  # connection from anything that can route to the port, which is a
124
190
  # remote code execution hole with a browser attached.
@@ -129,20 +195,38 @@ class Browser:
129
195
  ]
130
196
  if headless:
131
197
  arguments.append("--headless=new")
198
+ if not sandboxed:
199
+ arguments.append("--no-sandbox")
132
200
  arguments.append("about:blank")
133
201
 
202
+ # Kept, not discarded: when it exits at once the reason is in there,
203
+ # and the difference between "no sandbox available" and anything else
204
+ # decides whether retrying is sensible or dishonest.
205
+ complaints = tempfile.TemporaryFile()
134
206
  try:
135
207
  process = subprocess.Popen(
136
- arguments, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
208
+ arguments, stdout=subprocess.DEVNULL, stderr=complaints,
137
209
  stdin=subprocess.DEVNULL,
138
210
  creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0)
139
211
  if os.name == "nt" else 0,
140
212
  )
141
213
  except OSError as error:
214
+ complaints.close()
142
215
  raise BrowserError(f"could not start {binary}: {error}") from error
143
216
 
144
- browser = cls(binary, profile, chosen, headless, process)
145
- browser._await_port()
217
+ browser = cls(binary, profile, port, headless, process,
218
+ sandboxed=sandboxed)
219
+ try:
220
+ browser._await_port()
221
+ except BrowserError as error:
222
+ printed = _read_all(complaints)
223
+ message = f"{error}{_first_line(printed)}"
224
+ # Judged on everything it printed; reported as one line.
225
+ if sandboxed and _looks_like_a_sandbox_problem(printed):
226
+ raise SandboxUnavailable(message) from None
227
+ raise BrowserError(message) from None
228
+ finally:
229
+ complaints.close()
146
230
  return browser
147
231
 
148
232
  def _await_port(self) -> None:
@@ -171,6 +255,16 @@ class Browser:
171
255
  raise BrowserError(f"nothing is listening for DevTools on port {port}")
172
256
  return cls("", Path(), port, headless=False, process=None, ours=False)
173
257
 
258
+ @property
259
+ def note(self) -> str:
260
+ """What is worth saying about how it started, if anything."""
261
+ if self.sandboxed:
262
+ return ""
263
+ return ("Chromium is running without its own renderer sandbox: it "
264
+ "could not create a user namespace here, which is normal "
265
+ "inside a container. The container's confinement is what is "
266
+ "holding a bad page, rather than the browser's.")
267
+
174
268
  @property
175
269
  def label(self) -> str:
176
270
  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,104 @@ 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 _looks_like_a_sandbox_problem
484
+
485
+ assert _looks_like_a_sandbox_problem(
486
+ "No usable sandbox! If this is a Debian system...")
487
+ assert _looks_like_a_sandbox_problem(
488
+ "Failed to move to new namespace: Operation not permitted")
489
+ assert not _looks_like_a_sandbox_problem("could not start: file not found")
490
+ assert not _looks_like_a_sandbox_problem("the browser did not open a port")
491
+
492
+
493
+ def test_the_useful_line_is_not_always_the_first_one():
494
+ """Chromium under compose says the setuid helper failed, and only mentions
495
+ the namespace four lines later. Matching the first line alone made the same
496
+ bug appear or not depending on how the container was started."""
497
+ from comodor.browser.launch import _first_line, _looks_like_a_sandbox_problem
498
+
499
+ printed = "\n".join([
500
+ "The setuid sandbox is not running as root. Common causes:",
501
+ " * An unprivileged process using ptrace on it, like a debugger.",
502
+ " * A parent process set prctl(PR_SET_NO_NEW_PRIVS, ...)",
503
+ "Failed to move to new namespace: ... errno = Operation not permitted",
504
+ ])
505
+
506
+ assert _looks_like_a_sandbox_problem(printed)
507
+ # And what a person is shown stays one line.
508
+ assert "\n" not in _first_line(printed)
509
+ assert "setuid sandbox is not running as root" in _first_line(printed)
510
+
511
+
512
+ def test_a_browser_that_fails_for_another_reason_is_not_restarted(tmp_path,
513
+ monkeypatch):
514
+ from comodor.browser import BrowserError
515
+ from comodor.browser.launch import Browser
516
+
517
+ attempts = []
518
+
519
+ def refuse(cls, binary, profile, port, headless, window, sandboxed):
520
+ attempts.append(sandboxed)
521
+ raise BrowserError("the browser did not open a debugging port")
522
+
523
+ monkeypatch.setattr(Browser, "_spawn", classmethod(refuse))
524
+ monkeypatch.setattr("comodor.browser.launch.find", lambda hint="": "/bin/true")
525
+
526
+ with pytest.raises(BrowserError):
527
+ Browser.start(tmp_path / "profile")
528
+
529
+ assert attempts == [True], "it should not have tried again"
530
+
531
+
532
+ def test_a_missing_sandbox_is_retried_once_without_one(tmp_path, monkeypatch):
533
+ from comodor.browser import BrowserError
534
+ from comodor.browser.launch import Browser
535
+
536
+ attempts = []
537
+
538
+ def once(cls, binary, profile, port, headless, window, sandboxed):
539
+ from comodor.browser.launch import SandboxUnavailable
540
+
541
+ attempts.append(sandboxed)
542
+ if sandboxed:
543
+ raise SandboxUnavailable("it exited. It said: No usable sandbox!")
544
+ return Browser("/bin/true", profile, port, sandboxed=False)
545
+
546
+ monkeypatch.setattr(Browser, "_spawn", classmethod(once))
547
+ monkeypatch.setattr("comodor.browser.launch.find", lambda hint="": "/bin/true")
548
+
549
+ browser = Browser.start(tmp_path / "profile")
550
+
551
+ assert attempts == [True, False]
552
+ assert browser.sandboxed is False
553
+ assert "without its own renderer sandbox" in browser.note
554
+
555
+
556
+ def test_a_browser_that_kept_its_sandbox_says_nothing_about_it(tmp_path):
557
+ from comodor.browser.launch import Browser
558
+
559
+ assert Browser("/bin/true", tmp_path, 9222).note == ""
560
+
561
+
562
+ @needs_browser
563
+ def test_on_a_real_machine_the_sandbox_is_kept(tmp_path):
564
+ """The retry must not fire where the sandbox works — that would give up a
565
+ real protection on every laptop to make one container convenient."""
566
+ from comodor.browser import Browser
567
+
568
+ browser = Browser.start(tmp_path / "profile", headless=True)
569
+ try:
570
+ assert browser.sandboxed is True
571
+ assert browser.note == ""
572
+ finally:
573
+ 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