comodor 0.8.0__tar.gz → 0.8.2__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.0 → comodor-0.8.2}/PKG-INFO +1 -1
- {comodor-0.8.0 → comodor-0.8.2}/src/comodor/_version.py +2 -2
- {comodor-0.8.0 → comodor-0.8.2}/src/comodor/tools/delegate.py +21 -6
- {comodor-0.8.0 → comodor-0.8.2}/src/comodor/ui/app.py +6 -1
- {comodor-0.8.0 → comodor-0.8.2}/src/comodor/web/server.py +32 -4
- {comodor-0.8.0 → comodor-0.8.2}/tests/test_delegate.py +51 -1
- {comodor-0.8.0 → comodor-0.8.2}/tests/test_run_loop.py +7 -1
- {comodor-0.8.0 → comodor-0.8.2}/tests/test_web.py +86 -0
- {comodor-0.8.0 → comodor-0.8.2}/.gitignore +0 -0
- {comodor-0.8.0 → comodor-0.8.2}/LICENSE +0 -0
- {comodor-0.8.0 → comodor-0.8.2}/README.md +0 -0
- {comodor-0.8.0 → comodor-0.8.2}/pyproject.toml +0 -0
- {comodor-0.8.0 → comodor-0.8.2}/src/comodor/__init__.py +0 -0
- {comodor-0.8.0 → comodor-0.8.2}/src/comodor/__main__.py +0 -0
- {comodor-0.8.0 → comodor-0.8.2}/src/comodor/agent/__init__.py +0 -0
- {comodor-0.8.0 → comodor-0.8.2}/src/comodor/agent/context.py +0 -0
- {comodor-0.8.0 → comodor-0.8.2}/src/comodor/agent/loop.py +0 -0
- {comodor-0.8.0 → comodor-0.8.2}/src/comodor/agent/prompts.py +0 -0
- {comodor-0.8.0 → comodor-0.8.2}/src/comodor/agent/spawn.py +0 -0
- {comodor-0.8.0 → comodor-0.8.2}/src/comodor/agent/tokens.py +0 -0
- {comodor-0.8.0 → comodor-0.8.2}/src/comodor/browser/__init__.py +0 -0
- {comodor-0.8.0 → comodor-0.8.2}/src/comodor/browser/cdp.py +0 -0
- {comodor-0.8.0 → comodor-0.8.2}/src/comodor/browser/launch.py +0 -0
- {comodor-0.8.0 → comodor-0.8.2}/src/comodor/browser/page.py +0 -0
- {comodor-0.8.0 → comodor-0.8.2}/src/comodor/catalogue.py +0 -0
- {comodor-0.8.0 → comodor-0.8.2}/src/comodor/cli.py +0 -0
- {comodor-0.8.0 → comodor-0.8.2}/src/comodor/config.py +0 -0
- {comodor-0.8.0 → comodor-0.8.2}/src/comodor/doctor.py +0 -0
- {comodor-0.8.0 → comodor-0.8.2}/src/comodor/events.py +0 -0
- {comodor-0.8.0 → comodor-0.8.2}/src/comodor/learning/__init__.py +0 -0
- {comodor-0.8.0 → comodor-0.8.2}/src/comodor/learning/associations.py +0 -0
- {comodor-0.8.0 → comodor-0.8.2}/src/comodor/learning/bm25.py +0 -0
- {comodor-0.8.0 → comodor-0.8.2}/src/comodor/learning/hotindex.py +0 -0
- {comodor-0.8.0 → comodor-0.8.2}/src/comodor/learning/memory.py +0 -0
- {comodor-0.8.0 → comodor-0.8.2}/src/comodor/learning/progress.py +0 -0
- {comodor-0.8.0 → comodor-0.8.2}/src/comodor/learning/reflect.py +0 -0
- {comodor-0.8.0 → comodor-0.8.2}/src/comodor/learning/rules.py +0 -0
- {comodor-0.8.0 → comodor-0.8.2}/src/comodor/learning/signals.py +0 -0
- {comodor-0.8.0 → comodor-0.8.2}/src/comodor/learning/store.py +0 -0
- {comodor-0.8.0 → comodor-0.8.2}/src/comodor/learning/writer.py +0 -0
- {comodor-0.8.0 → comodor-0.8.2}/src/comodor/mcp/__init__.py +0 -0
- {comodor-0.8.0 → comodor-0.8.2}/src/comodor/mcp/catalogue.py +0 -0
- {comodor-0.8.0 → comodor-0.8.2}/src/comodor/mcp/commands.py +0 -0
- {comodor-0.8.0 → comodor-0.8.2}/src/comodor/mcp/http.py +0 -0
- {comodor-0.8.0 → comodor-0.8.2}/src/comodor/mcp/manager.py +0 -0
- {comodor-0.8.0 → comodor-0.8.2}/src/comodor/mcp/protocol.py +0 -0
- {comodor-0.8.0 → comodor-0.8.2}/src/comodor/net/__init__.py +0 -0
- {comodor-0.8.0 → comodor-0.8.2}/src/comodor/net/http.py +0 -0
- {comodor-0.8.0 → comodor-0.8.2}/src/comodor/net/sse.py +0 -0
- {comodor-0.8.0 → comodor-0.8.2}/src/comodor/paths.py +0 -0
- {comodor-0.8.0 → comodor-0.8.2}/src/comodor/providers/__init__.py +0 -0
- {comodor-0.8.0 → comodor-0.8.2}/src/comodor/providers/anthropic.py +0 -0
- {comodor-0.8.0 → comodor-0.8.2}/src/comodor/providers/base.py +0 -0
- {comodor-0.8.0 → comodor-0.8.2}/src/comodor/providers/caching.py +0 -0
- {comodor-0.8.0 → comodor-0.8.2}/src/comodor/providers/fake.py +0 -0
- {comodor-0.8.0 → comodor-0.8.2}/src/comodor/providers/gateway.py +0 -0
- {comodor-0.8.0 → comodor-0.8.2}/src/comodor/providers/openai_compat.py +0 -0
- {comodor-0.8.0 → comodor-0.8.2}/src/comodor/providers/registry.py +0 -0
- {comodor-0.8.0 → comodor-0.8.2}/src/comodor/safety/__init__.py +0 -0
- {comodor-0.8.0 → comodor-0.8.2}/src/comodor/safety/checkpoints.py +0 -0
- {comodor-0.8.0 → comodor-0.8.2}/src/comodor/safety/permissions.py +0 -0
- {comodor-0.8.0 → comodor-0.8.2}/src/comodor/safety/redact.py +0 -0
- {comodor-0.8.0 → comodor-0.8.2}/src/comodor/session/__init__.py +0 -0
- {comodor-0.8.0 → comodor-0.8.2}/src/comodor/session/search.py +0 -0
- {comodor-0.8.0 → comodor-0.8.2}/src/comodor/session/store.py +0 -0
- {comodor-0.8.0 → comodor-0.8.2}/src/comodor/setup.py +0 -0
- {comodor-0.8.0 → comodor-0.8.2}/src/comodor/skills/__init__.py +0 -0
- {comodor-0.8.0 → comodor-0.8.2}/src/comodor/skills/catalogue.py +0 -0
- {comodor-0.8.0 → comodor-0.8.2}/src/comodor/skills/commands.py +0 -0
- {comodor-0.8.0 → comodor-0.8.2}/src/comodor/skills/examples.py +0 -0
- {comodor-0.8.0 → comodor-0.8.2}/src/comodor/skills/loader.py +0 -0
- {comodor-0.8.0 → comodor-0.8.2}/src/comodor/skills/propose.py +0 -0
- {comodor-0.8.0 → comodor-0.8.2}/src/comodor/skills/registry.py +0 -0
- {comodor-0.8.0 → comodor-0.8.2}/src/comodor/tools/__init__.py +0 -0
- {comodor-0.8.0 → comodor-0.8.2}/src/comodor/tools/base.py +0 -0
- {comodor-0.8.0 → comodor-0.8.2}/src/comodor/tools/browse.py +0 -0
- {comodor-0.8.0 → comodor-0.8.2}/src/comodor/tools/browser.py +0 -0
- {comodor-0.8.0 → comodor-0.8.2}/src/comodor/tools/fs.py +0 -0
- {comodor-0.8.0 → comodor-0.8.2}/src/comodor/tools/history.py +0 -0
- {comodor-0.8.0 → comodor-0.8.2}/src/comodor/tools/mcp.py +0 -0
- {comodor-0.8.0 → comodor-0.8.2}/src/comodor/tools/overflow.py +0 -0
- {comodor-0.8.0 → comodor-0.8.2}/src/comodor/tools/registry.py +0 -0
- {comodor-0.8.0 → comodor-0.8.2}/src/comodor/tools/search.py +0 -0
- {comodor-0.8.0 → comodor-0.8.2}/src/comodor/tools/shell.py +0 -0
- {comodor-0.8.0 → comodor-0.8.2}/src/comodor/tools/skills.py +0 -0
- {comodor-0.8.0 → comodor-0.8.2}/src/comodor/tools/todo.py +0 -0
- {comodor-0.8.0 → comodor-0.8.2}/src/comodor/tools/web.py +0 -0
- {comodor-0.8.0 → comodor-0.8.2}/src/comodor/ui/__init__.py +0 -0
- {comodor-0.8.0 → comodor-0.8.2}/src/comodor/ui/bidi.py +0 -0
- {comodor-0.8.0 → comodor-0.8.2}/src/comodor/ui/chooser.py +0 -0
- {comodor-0.8.0 → comodor-0.8.2}/src/comodor/ui/console.py +0 -0
- {comodor-0.8.0 → comodor-0.8.2}/src/comodor/ui/input/__init__.py +0 -0
- {comodor-0.8.0 → comodor-0.8.2}/src/comodor/ui/input/keys.py +0 -0
- {comodor-0.8.0 → comodor-0.8.2}/src/comodor/ui/input/reader.py +0 -0
- {comodor-0.8.0 → comodor-0.8.2}/src/comodor/ui/layout.py +0 -0
- {comodor-0.8.0 → comodor-0.8.2}/src/comodor/ui/markdown.py +0 -0
- {comodor-0.8.0 → comodor-0.8.2}/src/comodor/ui/screen.py +0 -0
- {comodor-0.8.0 → comodor-0.8.2}/src/comodor/ui/theme.py +0 -0
- {comodor-0.8.0 → comodor-0.8.2}/src/comodor/ui/widgets/__init__.py +0 -0
- {comodor-0.8.0 → comodor-0.8.2}/src/comodor/ui/widgets/buttons.py +0 -0
- {comodor-0.8.0 → comodor-0.8.2}/src/comodor/ui/widgets/chat.py +0 -0
- {comodor-0.8.0 → comodor-0.8.2}/src/comodor/ui/widgets/history.py +0 -0
- {comodor-0.8.0 → comodor-0.8.2}/src/comodor/ui/widgets/overlay.py +0 -0
- {comodor-0.8.0 → comodor-0.8.2}/src/comodor/ui/widgets/panel.py +0 -0
- {comodor-0.8.0 → comodor-0.8.2}/src/comodor/ui/widgets/progress.py +0 -0
- {comodor-0.8.0 → comodor-0.8.2}/src/comodor/ui/widgets/prompt.py +0 -0
- {comodor-0.8.0 → comodor-0.8.2}/src/comodor/ui/widgets/statusbar.py +0 -0
- {comodor-0.8.0 → comodor-0.8.2}/src/comodor/ui/widgets/toast.py +0 -0
- {comodor-0.8.0 → comodor-0.8.2}/src/comodor/uninstall.py +0 -0
- {comodor-0.8.0 → comodor-0.8.2}/src/comodor/update.py +0 -0
- {comodor-0.8.0 → comodor-0.8.2}/src/comodor/web/__init__.py +0 -0
- {comodor-0.8.0 → comodor-0.8.2}/src/comodor/web/commands.py +0 -0
- {comodor-0.8.0 → comodor-0.8.2}/src/comodor/web/index.html +0 -0
- {comodor-0.8.0 → comodor-0.8.2}/src/comodor/web/session.py +0 -0
- {comodor-0.8.0 → comodor-0.8.2}/src/comodor/workspace.py +0 -0
- {comodor-0.8.0 → comodor-0.8.2}/tests/conftest.py +0 -0
- {comodor-0.8.0 → comodor-0.8.2}/tests/support/fake_mcp_server.py +0 -0
- {comodor-0.8.0 → comodor-0.8.2}/tests/test_agent_loop.py +0 -0
- {comodor-0.8.0 → comodor-0.8.2}/tests/test_app.py +0 -0
- {comodor-0.8.0 → comodor-0.8.2}/tests/test_associations.py +0 -0
- {comodor-0.8.0 → comodor-0.8.2}/tests/test_bidi.py +0 -0
- {comodor-0.8.0 → comodor-0.8.2}/tests/test_browser.py +0 -0
- {comodor-0.8.0 → comodor-0.8.2}/tests/test_caching.py +0 -0
- {comodor-0.8.0 → comodor-0.8.2}/tests/test_chooser.py +0 -0
- {comodor-0.8.0 → comodor-0.8.2}/tests/test_doctor.py +0 -0
- {comodor-0.8.0 → comodor-0.8.2}/tests/test_history.py +0 -0
- {comodor-0.8.0 → comodor-0.8.2}/tests/test_input.py +0 -0
- {comodor-0.8.0 → comodor-0.8.2}/tests/test_layout.py +0 -0
- {comodor-0.8.0 → comodor-0.8.2}/tests/test_learning.py +0 -0
- {comodor-0.8.0 → comodor-0.8.2}/tests/test_markdown.py +0 -0
- {comodor-0.8.0 → comodor-0.8.2}/tests/test_mcp.py +0 -0
- {comodor-0.8.0 → comodor-0.8.2}/tests/test_mcp_http.py +0 -0
- {comodor-0.8.0 → comodor-0.8.2}/tests/test_overflow.py +0 -0
- {comodor-0.8.0 → comodor-0.8.2}/tests/test_performance.py +0 -0
- {comodor-0.8.0 → comodor-0.8.2}/tests/test_prefix_stability.py +0 -0
- {comodor-0.8.0 → comodor-0.8.2}/tests/test_progress.py +0 -0
- {comodor-0.8.0 → comodor-0.8.2}/tests/test_propose.py +0 -0
- {comodor-0.8.0 → comodor-0.8.2}/tests/test_providers.py +0 -0
- {comodor-0.8.0 → comodor-0.8.2}/tests/test_real_browser.py +0 -0
- {comodor-0.8.0 → comodor-0.8.2}/tests/test_reflex.py +0 -0
- {comodor-0.8.0 → comodor-0.8.2}/tests/test_setup.py +0 -0
- {comodor-0.8.0 → comodor-0.8.2}/tests/test_skill_budget.py +0 -0
- {comodor-0.8.0 → comodor-0.8.2}/tests/test_skill_catalogue.py +0 -0
- {comodor-0.8.0 → comodor-0.8.2}/tests/test_skills.py +0 -0
- {comodor-0.8.0 → comodor-0.8.2}/tests/test_tools.py +0 -0
- {comodor-0.8.0 → comodor-0.8.2}/tests/test_uninstall.py +0 -0
- {comodor-0.8.0 → comodor-0.8.2}/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.2'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 8, 2)
|
|
23
23
|
|
|
24
24
|
__commit_id__ = commit_id = None
|
|
@@ -192,9 +192,11 @@ class _Worktree:
|
|
|
192
192
|
return None
|
|
193
193
|
return cls(path, branch, root)
|
|
194
194
|
|
|
195
|
-
def diff(self) ->
|
|
195
|
+
def diff(self) -> bytes:
|
|
196
|
+
"""The patch, as bytes. Decoding it would ruin a binary diff and
|
|
197
|
+
re-encoding it is one more chance to change a line ending."""
|
|
196
198
|
_git(self.path, "add", "-A")
|
|
197
|
-
return
|
|
199
|
+
return _git_bytes(self.path, "diff", "--cached") or b""
|
|
198
200
|
|
|
199
201
|
def remove(self) -> None:
|
|
200
202
|
_git(self.origin, "worktree", "remove", "--force", str(self.path))
|
|
@@ -236,7 +238,7 @@ def _with_changes(outcome: ToolResult, worktree: _Worktree, ctx: ToolContext,
|
|
|
236
238
|
content=outcome.content + "\n\n[it changed no files]",
|
|
237
239
|
**outcome.meta)
|
|
238
240
|
|
|
239
|
-
applied = _git(ctx.cwd, "apply", "--3way", "-", stdin=patch + "\n")
|
|
241
|
+
applied = _git(ctx.cwd, "apply", "--3way", "-", stdin=patch + b"\n")
|
|
240
242
|
if applied is None:
|
|
241
243
|
worktree.keep = True
|
|
242
244
|
return ToolResult.success(
|
|
@@ -246,7 +248,8 @@ def _with_changes(outcome: ToolResult, worktree: _Worktree, ctx: ToolContext,
|
|
|
246
248
|
f"read the files there, or discard it.]"),
|
|
247
249
|
worktree=str(worktree.path), applied=False, **outcome.meta)
|
|
248
250
|
|
|
249
|
-
|
|
251
|
+
listed = patch.decode("utf-8", "replace")
|
|
252
|
+
files = sorted({line.split("/", 1)[-1] for line in listed.splitlines()
|
|
250
253
|
if line.startswith("+++ b/")})
|
|
251
254
|
listed = ", ".join(files[:12]) or "none"
|
|
252
255
|
return ToolResult.success(
|
|
@@ -263,11 +266,23 @@ def _git_root(cwd: Path) -> Path | None:
|
|
|
263
266
|
return root if root.is_dir() else None
|
|
264
267
|
|
|
265
268
|
|
|
266
|
-
def _git(cwd: Path, *args: str, stdin:
|
|
269
|
+
def _git(cwd: Path, *args: str, stdin: bytes | None = None) -> str | None:
|
|
267
270
|
"""Run one git command. ``None`` means it failed, and failure is expected."""
|
|
271
|
+
raw = _git_bytes(cwd, *args, stdin=stdin)
|
|
272
|
+
return None if raw is None else raw.decode("utf-8", "replace")
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _git_bytes(cwd: Path, *args: str, stdin: bytes | None = None) -> bytes | None:
|
|
276
|
+
"""The same, without decoding anything.
|
|
277
|
+
|
|
278
|
+
Deliberately not `text=True`. That wraps stdin in a stream with default
|
|
279
|
+
newline handling, and on Windows every `\n` written becomes `\r\n` — which
|
|
280
|
+
turns a patch into one git will not apply, on that platform only, with an
|
|
281
|
+
error that reads exactly like a merge conflict.
|
|
282
|
+
"""
|
|
268
283
|
try:
|
|
269
284
|
done = subprocess.run(
|
|
270
|
-
["git", *args], cwd=str(cwd), input=stdin,
|
|
285
|
+
["git", *args], cwd=str(cwd), input=stdin,
|
|
271
286
|
capture_output=True, timeout=120.0,
|
|
272
287
|
)
|
|
273
288
|
except (OSError, subprocess.SubprocessError):
|
|
@@ -46,6 +46,11 @@ from .widgets.history import SessionRef
|
|
|
46
46
|
from .widgets.overlay import info_overlay, permission_overlay, select_overlay
|
|
47
47
|
from .widgets.statusbar import StatusModel
|
|
48
48
|
|
|
49
|
+
#: How long quitting waits for a turn in flight. Quitting mid-task should give
|
|
50
|
+
#: the agent a moment to stop rather than abandoning it, so shutting down can
|
|
51
|
+
#: legitimately take this long — which is what anything timing the exit has to
|
|
52
|
+
#: allow for.
|
|
53
|
+
SHUTDOWN_JOIN_SECONDS = 2.0
|
|
49
54
|
IDLE_SLEEP = 0.02
|
|
50
55
|
SPINNER_INTERVAL = 0.1
|
|
51
56
|
# How long the prompt must sit still before recall is warmed for the draft.
|
|
@@ -199,7 +204,7 @@ class App:
|
|
|
199
204
|
if self.mcp is not None:
|
|
200
205
|
self.mcp.close()
|
|
201
206
|
if self.worker and self.worker.is_alive():
|
|
202
|
-
self.worker.join(timeout=
|
|
207
|
+
self.worker.join(timeout=SHUTDOWN_JOIN_SECONDS)
|
|
203
208
|
# Flush whatever the agent produced after the last event we pumped —
|
|
204
209
|
# quitting mid-turn must not lose the transcript.
|
|
205
210
|
self._persist_new_messages()
|
|
@@ -140,20 +140,43 @@ def _handler_for(server: Server) -> type[BaseHTTPRequestHandler]:
|
|
|
140
140
|
return ""
|
|
141
141
|
|
|
142
142
|
def _body(self) -> dict[str, Any]:
|
|
143
|
+
"""Read the body, whatever is going to be done with it.
|
|
144
|
+
|
|
145
|
+
Always read, and read all of it. Bytes left in the socket are read
|
|
146
|
+
by the *next* request on the connection as its request line, and a
|
|
147
|
+
socket closed with unread data sends a reset instead of a close —
|
|
148
|
+
which the client sees as a connection aborted rather than as the
|
|
149
|
+
401 that was actually sent.
|
|
150
|
+
"""
|
|
143
151
|
try:
|
|
144
152
|
length = int(self.headers.get("Content-Length") or 0)
|
|
145
153
|
except ValueError:
|
|
154
|
+
length = 0
|
|
155
|
+
if length <= 0:
|
|
146
156
|
return {}
|
|
147
|
-
|
|
157
|
+
|
|
158
|
+
raw = b""
|
|
159
|
+
remaining = length
|
|
160
|
+
while remaining > 0:
|
|
161
|
+
chunk = self.rfile.read(min(remaining, 65536))
|
|
162
|
+
if not chunk:
|
|
163
|
+
break
|
|
164
|
+
remaining -= len(chunk)
|
|
165
|
+
# Past the cap it is drained and dropped, not left unread.
|
|
166
|
+
if len(raw) < MAX_BODY:
|
|
167
|
+
raw += chunk
|
|
168
|
+
if length > MAX_BODY:
|
|
148
169
|
return {}
|
|
149
170
|
try:
|
|
150
|
-
return json.loads(
|
|
151
|
-
except
|
|
171
|
+
return json.loads(raw or b"{}")
|
|
172
|
+
except ValueError:
|
|
152
173
|
return {}
|
|
153
174
|
|
|
154
175
|
# -- reading ------------------------------------------------------- #
|
|
155
176
|
|
|
156
177
|
def do_GET(self) -> None:
|
|
178
|
+
if self.headers.get("Content-Length"):
|
|
179
|
+
self._body() # rare, but the same rule applies
|
|
157
180
|
parts = urlparse(self.path)
|
|
158
181
|
query = parse_qs(parts.query)
|
|
159
182
|
route = parts.path
|
|
@@ -202,6 +225,10 @@ def _handler_for(server: Server) -> type[BaseHTTPRequestHandler]:
|
|
|
202
225
|
# -- doing --------------------------------------------------------- #
|
|
203
226
|
|
|
204
227
|
def do_POST(self) -> None:
|
|
228
|
+
# Before any verdict: an unread body breaks the connection it
|
|
229
|
+
# arrived on, and the caller sees a reset instead of the refusal.
|
|
230
|
+
body = self._body()
|
|
231
|
+
|
|
205
232
|
if not server.authorised(self._token()):
|
|
206
233
|
self._json(401, {"error": "unauthorised"})
|
|
207
234
|
return
|
|
@@ -213,7 +240,6 @@ def _handler_for(server: Server) -> type[BaseHTTPRequestHandler]:
|
|
|
213
240
|
return
|
|
214
241
|
|
|
215
242
|
route = urlparse(self.path).path
|
|
216
|
-
body = self._body()
|
|
217
243
|
|
|
218
244
|
if route == "/api/send":
|
|
219
245
|
started = server.session.send(str(body.get("text") or ""))
|
|
@@ -243,6 +269,8 @@ def _handler_for(server: Server) -> type[BaseHTTPRequestHandler]:
|
|
|
243
269
|
def do_OPTIONS(self) -> None:
|
|
244
270
|
# No CORS for anyone. A preflight that gets no permissions means a
|
|
245
271
|
# cross-origin fetch cannot send the header the POST routes want.
|
|
272
|
+
if self.headers.get("Content-Length"):
|
|
273
|
+
self._body()
|
|
246
274
|
self._send(405)
|
|
247
275
|
|
|
248
276
|
return Handler
|
|
@@ -184,7 +184,7 @@ def test_the_parents_working_tree_is_not_where_it_experiments(config, bus,
|
|
|
184
184
|
(worktree.path / "app.py").write_text("value = 2\n", encoding="utf-8")
|
|
185
185
|
|
|
186
186
|
assert (repo / "app.py").read_text() == "value = 1\n"
|
|
187
|
-
assert "value = 2" in worktree.diff()
|
|
187
|
+
assert b"value = 2" in worktree.diff()
|
|
188
188
|
finally:
|
|
189
189
|
worktree.remove()
|
|
190
190
|
|
|
@@ -267,3 +267,53 @@ def test_a_repository_gets_the_checkout_and_says_nothing_about_it(config, bus,
|
|
|
267
267
|
result = tool.run(tool_context, task="Change something.", write=True)
|
|
268
268
|
|
|
269
269
|
assert "directly in this project" not in result.content
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def test_the_patch_reaches_git_as_the_bytes_it_was(config, bus, tool_context, repo):
|
|
273
|
+
"""`subprocess(text=True)` wraps stdin in a stream with default newline
|
|
274
|
+
handling, and on Windows every "\n" written becomes "\r\n". For output that
|
|
275
|
+
is harmless; for a patch it is fatal — every line of the diff gains a
|
|
276
|
+
carriage return, the context no longer matches, and git refuses all of it.
|
|
277
|
+
|
|
278
|
+
The failure was silent and one-platform: on Linux and macOS nothing is
|
|
279
|
+
translated, so every delegate that changed a file worked here and reported
|
|
280
|
+
an imaginary merge conflict on Windows."""
|
|
281
|
+
from comodor.tools.base import ToolResult
|
|
282
|
+
from comodor.tools.delegate import _Worktree, _with_changes
|
|
283
|
+
|
|
284
|
+
tool_context.cwd = repo
|
|
285
|
+
worktree = _Worktree.create(repo)
|
|
286
|
+
assert worktree is not None
|
|
287
|
+
try:
|
|
288
|
+
(worktree.path / "app.py").write_text("value = 2\nsecond = 3\n",
|
|
289
|
+
encoding="utf-8", newline="")
|
|
290
|
+
patch = worktree.diff()
|
|
291
|
+
|
|
292
|
+
assert isinstance(patch, bytes), "a patch is bytes, not text"
|
|
293
|
+
assert b"\r\n" not in patch, "the patch already has carriage returns"
|
|
294
|
+
|
|
295
|
+
result = _with_changes(ToolResult.success("did it"), worktree,
|
|
296
|
+
tool_context, 1.0)
|
|
297
|
+
|
|
298
|
+
assert result.meta["applied"] is True, result.content
|
|
299
|
+
# Read as text: git applies the checkout's own line-ending policy when
|
|
300
|
+
# it writes, and on Windows that legitimately produces CRLF. What had
|
|
301
|
+
# to survive is the content, which is what the mangled patch destroyed.
|
|
302
|
+
assert (repo / "app.py").read_text() == "value = 2\nsecond = 3\n"
|
|
303
|
+
finally:
|
|
304
|
+
worktree.keep = False
|
|
305
|
+
worktree.remove()
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def test_git_is_never_asked_to_translate_anything():
|
|
309
|
+
"""The one line that caused it, pinned so it cannot come back."""
|
|
310
|
+
import inspect
|
|
311
|
+
|
|
312
|
+
from comodor.tools import delegate
|
|
313
|
+
|
|
314
|
+
source = inspect.getsource(delegate._git_bytes)
|
|
315
|
+
# The docstring explains the trap by name, so look at the code only.
|
|
316
|
+
code = source.split('"""')[-1]
|
|
317
|
+
|
|
318
|
+
assert "text=True" not in code
|
|
319
|
+
assert "universal_newlines" not in code
|
|
@@ -83,7 +83,13 @@ def run_with(app, events, monkeypatch, budget: float = 3.0):
|
|
|
83
83
|
app.run()
|
|
84
84
|
finally:
|
|
85
85
|
watchdog.cancel()
|
|
86
|
-
|
|
86
|
+
# Shutting down is allowed to wait for a turn still in flight, so the
|
|
87
|
+
# exit cannot be quicker than that however fast the loop itself is. The
|
|
88
|
+
# tolerance is that budget plus slack, taken from the source rather than
|
|
89
|
+
# guessed, or the two drift apart and the test fails on a loaded machine
|
|
90
|
+
# for a reason that is not a bug.
|
|
91
|
+
allowed = app_module.SHUTDOWN_JOIN_SECONDS + 1.5
|
|
92
|
+
assert time.monotonic() < deadline + allowed, "the loop did not exit"
|
|
87
93
|
return terminal, live
|
|
88
94
|
|
|
89
95
|
|
|
@@ -373,3 +373,89 @@ def test_an_events_own_fields_cannot_overwrite_its_kind(served):
|
|
|
373
373
|
|
|
374
374
|
assert frames and frames[0]["kind"] == "request"
|
|
375
375
|
assert frames[0]["about"] == "permission"
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
# --------------------------------------------------------------------------- #
|
|
379
|
+
# a refusal must still leave the connection usable
|
|
380
|
+
# --------------------------------------------------------------------------- #
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def test_a_refused_post_leaves_the_connection_clean(served):
|
|
384
|
+
"""The bug this exists for. A verdict reached before the body is read
|
|
385
|
+
leaves those bytes in the socket; the next request on the connection reads
|
|
386
|
+
them as its own request line, and a socket closed with unread data sends a
|
|
387
|
+
reset — which the caller sees as an aborted connection rather than the 401
|
|
388
|
+
that was actually sent."""
|
|
389
|
+
import http.client
|
|
390
|
+
|
|
391
|
+
connection = http.client.HTTPConnection("127.0.0.1", served.port, timeout=15)
|
|
392
|
+
try:
|
|
393
|
+
payload = json.dumps({"text": "x" * 50_000})
|
|
394
|
+
# Rejected for the missing guard header, with a body already in flight.
|
|
395
|
+
connection.request("POST", "/api/send", body=payload,
|
|
396
|
+
headers={"Content-Type": "application/json",
|
|
397
|
+
"Cookie": f"{COOKIE}={served.token}"})
|
|
398
|
+
first = connection.getresponse()
|
|
399
|
+
first.read()
|
|
400
|
+
assert first.status == 403
|
|
401
|
+
|
|
402
|
+
# The same connection, reused. If the body was not drained this reads
|
|
403
|
+
# the leftover as a request line and fails.
|
|
404
|
+
connection.request("GET", "/api/state",
|
|
405
|
+
headers={"Cookie": f"{COOKIE}={served.token}"})
|
|
406
|
+
second = connection.getresponse()
|
|
407
|
+
state = json.loads(second.read())
|
|
408
|
+
|
|
409
|
+
assert second.status == 200
|
|
410
|
+
assert "mode" in state
|
|
411
|
+
finally:
|
|
412
|
+
connection.close()
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def test_an_unauthorised_post_leaves_the_connection_clean(served):
|
|
416
|
+
import http.client
|
|
417
|
+
|
|
418
|
+
connection = http.client.HTTPConnection("127.0.0.1", served.port, timeout=15)
|
|
419
|
+
try:
|
|
420
|
+
connection.request("POST", "/api/send",
|
|
421
|
+
body=json.dumps({"text": "y" * 20_000}),
|
|
422
|
+
headers={"Content-Type": "application/json",
|
|
423
|
+
"X-Comodor": "1"})
|
|
424
|
+
first = connection.getresponse()
|
|
425
|
+
first.read()
|
|
426
|
+
assert first.status == 401
|
|
427
|
+
|
|
428
|
+
connection.request("GET", "/api/state",
|
|
429
|
+
headers={"Cookie": f"{COOKIE}={served.token}"})
|
|
430
|
+
second = connection.getresponse()
|
|
431
|
+
second.read()
|
|
432
|
+
|
|
433
|
+
assert second.status == 200
|
|
434
|
+
finally:
|
|
435
|
+
connection.close()
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def test_a_body_over_the_cap_is_drained_rather_than_left_in_the_socket(served):
|
|
439
|
+
"""Refusing to read is what breaks the connection, so an oversized body is
|
|
440
|
+
read and dropped instead."""
|
|
441
|
+
import http.client
|
|
442
|
+
|
|
443
|
+
from comodor.web.server import MAX_BODY
|
|
444
|
+
|
|
445
|
+
connection = http.client.HTTPConnection("127.0.0.1", served.port, timeout=30)
|
|
446
|
+
try:
|
|
447
|
+
connection.request("POST", "/api/send", body="z" * (MAX_BODY + 5_000),
|
|
448
|
+
headers={"Content-Type": "application/json",
|
|
449
|
+
"Cookie": f"{COOKIE}={served.token}",
|
|
450
|
+
"X-Comodor": "1"})
|
|
451
|
+
first = connection.getresponse()
|
|
452
|
+
first.read()
|
|
453
|
+
|
|
454
|
+
connection.request("GET", "/api/state",
|
|
455
|
+
headers={"Cookie": f"{COOKIE}={served.token}"})
|
|
456
|
+
second = connection.getresponse()
|
|
457
|
+
second.read()
|
|
458
|
+
|
|
459
|
+
assert second.status == 200
|
|
460
|
+
finally:
|
|
461
|
+
connection.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
|