From 0b1a74f162f3b6ad33844f86346134ab2b69e20a Mon Sep 17 00:00:00 2001 From: hellices Date: Fri, 23 Oct 2026 19:20:45 +0900 Subject: [PATCH 1/8] test: drop the relay child-watcher race from stdio stderr checks test_stdout_noise_is_diverted_and_wire_is_restored[True] failed on 3.12 because the stdio server's stderr carried "child process pid 3098 exit status already read: will report returncode 255" (#472). relay=True forces the Windows-only relays onto POSIX. When the stdin relay exits on its own as the test closes stdin, and _relay_stdin's cleanup runs before the event loop has processed that exit, process.kill() goes through Popen.send_signal, which polls first (bpo-38630). That waitpid reaps the relay, and asyncio's child watcher then finds it gone, reports 255 and logs the warning. On Windows, where the relays run in production, nothing reaps. The three forced-relay tests that compare the server's stderr exactly now read it through _server_stderr, which drops whole lines matching either watcher's warning (pidfd: "exit status already read", threaded: "Unknown child process pid") and keeps everything else. Two tests pin both directions: both warnings are dropped with the surrounding lines intact, and a line that only contains one is kept. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tests/mcp/test_stdio.py | 58 ++++++++++++++++++++++++++++++++++++++--- 1 file changed, 55 insertions(+), 3 deletions(-) diff --git a/tests/mcp/test_stdio.py b/tests/mcp/test_stdio.py index 8e8a097f7..8bdfab68e 100644 --- a/tests/mcp/test_stdio.py +++ b/tests/mcp/test_stdio.py @@ -7,6 +7,7 @@ import io import json import os +import re import signal import sys from collections.abc import AsyncIterator @@ -58,6 +59,54 @@ def _parameters(state: Path, *args: str, relay: bool = False) -> StdioServerPara ) +#: asyncio's child-watcher warning when a child it watches was reaped first +#: (#472). The tests force the Windows-only relays onto POSIX, where killing a +#: relay that just exited on its own runs `Popen.send_signal`'s poll +#: (bpo-38630); that `waitpid` reaps the relay before the event loop's watcher +#: does, and the watcher logs this line. On Windows nothing reaps, so +#: production never writes it. Pidfd watcher first, then the threaded one. +_RELAY_WATCHER_RACE = re.compile( + r"child process pid \d+ exit status already read: +will report returncode 255" + r"|Unknown child process pid \d+, will report returncode 255" +) + + +def _server_stderr(err: bytes) -> str: + """The stdio server's stderr, without the relay child-watcher race.""" + return "".join( + line + for line in err.decode().splitlines(keepends=True) + if not _RELAY_WATCHER_RACE.fullmatch(line.rstrip("\r\n")) + ) + + +@pytest.mark.parametrize( + "warning", + [ + "child process pid 3098 exit status already read: will report returncode 255", + "Unknown child process pid 3098, will report returncode 255", + ], + ids=["pidfd-watcher", "threaded-watcher"], +) +def test_server_stderr_drops_the_relay_child_watcher_race(warning: str) -> None: + """Forcing the Windows-only relays onto POSIX lets asyncio's child watcher + lose a race with `Popen.send_signal`'s poll (#472). Its warning is the + interpreter racing itself, not output the server chose to write.""" + err = f"fd-handler-noise\n{warning}\nkorvid: MCP stdio connection interrupted.\n" + assert _server_stderr(err.encode()) == ( + "fd-handler-noise\nkorvid: MCP stdio connection interrupted.\n" + ) + + +def test_server_stderr_keeps_every_other_line() -> None: + err = ( + "korvid: MCP stdio connection interrupted.\n" + "relay: Unknown child process pid 7, will report returncode 255\n" + "child process pid 7 exit status already read\n" + ) + assert _server_stderr(err.encode()) == err + + @asynccontextmanager async def _backend( state: Path, executor: RecordingExecutor | None = None @@ -572,7 +621,7 @@ async def test_sigint_exits_without_host_stdin_eof( err = await process.stderr.read() assert process.returncode == 1, err.decode() assert all(json.loads(line)["jsonrpc"] == "2.0" for line in out.splitlines()) - assert err.decode() == "korvid: MCP stdio connection interrupted.\n" + assert _server_stderr(err) == "korvid: MCP stdio connection interrupted.\n" finally: executor.release.set() @@ -621,7 +670,7 @@ async def test_shutdown_exits_without_draining_stdout( assert process.stderr is not None assert process.returncode == (1 if interrupt else 0) expected = b"korvid: MCP stdio connection interrupted.\n" if interrupt else b"" - assert await process.stderr.read() == expected + assert _server_stderr(await process.stderr.read()) == expected.decode() finally: host.close() @@ -872,7 +921,10 @@ async def noisy_list_tools(self, ctx, params): out, err = await asyncio.wait_for(process.communicate(), 5) assert process.returncode == 0, err.decode() assert json.loads(out) == {"restored": True} - assert sorted(err.decode().splitlines()) == ["fd-handler-noise", "python-handler-noise"] + assert sorted(_server_stderr(err).splitlines()) == [ + "fd-handler-noise", + "python-handler-noise", + ] @pytest.mark.parametrize("relay", [False, True]) From 96a56bd6e2547835aec307f82e437ace96f171fb Mon Sep 17 00:00:00 2001 From: hellices Date: Sat, 24 Oct 2026 02:24:25 +0900 Subject: [PATCH 2/8] test: emit the relay watcher race in every forced-relay run The race is rare on its own, so the first fix only covered the three exact stderr checks it was seen against. Under four xdist workers on korvid-runners it hit a fourth on 3.12, twice: test_stdio_delivers_large_utf8_output_without_truncation[True] expected an empty stderr log and got "child process pid ... exit status already read: will report returncode 255". The forced-relay entrypoint now logs that exact asyncio warning at startup, so every relay=True test that reads the server's stderr has to filter it on every run instead of passing on a lucky one. With the line emitted, exactly that test failed; it now reads its stderr log through _server_stderr, which accepts text as well as bytes, and test_stdio.py passes in full. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tests/mcp/test_stdio.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/tests/mcp/test_stdio.py b/tests/mcp/test_stdio.py index 8bdfab68e..9ed814023 100644 --- a/tests/mcp/test_stdio.py +++ b/tests/mcp/test_stdio.py @@ -50,6 +50,13 @@ def _parameters(state: Path, *args: str, relay: bool = False) -> StdioServerPara "stdio.cancellable_stdin = _relay_stdin\n" "from korvid.mcp import _stdio_output\n" "_stdio_output._posix_stdout = _stdio_output._relay_stdout\n" + # The child-watcher race (#472) is rare on its own; emit its line + # every time, so every relay test that reads the server's stderr + # proves it filters it rather than passing on a lucky run. + "import logging\n" + "logging.getLogger('asyncio').warning(\n" + " 'child process pid %d exit status already read: will report returncode 255', 0\n" + ")\n" "from korvid.cli import main", ) return StdioServerParameters( @@ -71,11 +78,12 @@ def _parameters(state: Path, *args: str, relay: bool = False) -> StdioServerPara ) -def _server_stderr(err: bytes) -> str: +def _server_stderr(err: bytes | str) -> str: """The stdio server's stderr, without the relay child-watcher race.""" + text = err.decode() if isinstance(err, bytes) else err return "".join( line - for line in err.decode().splitlines(keepends=True) + for line in text.splitlines(keepends=True) if not _RELAY_WATCHER_RACE.fullmatch(line.rstrip("\r\n")) ) @@ -724,7 +732,7 @@ async def test_stdio_delivers_large_utf8_output_without_truncation( assert not result.is_error assert getattr(result.content[0], "text", None) == executor.result errors.seek(0) - assert errors.read() == "" + assert _server_stderr(errors.read()) == "" @pytest.mark.skipif(sys.platform == "win32", reason="POSIX nonblocking partial writes") From d64ad2b9f0a9a372d773870cd2c3cadd3c0483c2 Mon Sep 17 00:00:00 2001 From: hellices Date: Sat, 24 Oct 2026 02:04:10 +0900 Subject: [PATCH 3/8] fix: drop agent events that arrive while the app shuts down Textual's App._shutdown clears _running, removes every screen and widget, and only then sends the App its Unmount, which is where KorvidApp marks the agent session down. A turn the user interrupted just before quitting can settle in between. _finish_interrupted_turn then still sees _shutting_down as False and hands its TurnInterrupted to the panel port, which looks the panel up on a pruned screen. The resulting NoMatches fails the turn task and escapes app shutdown through wait_for_turn (#476). On #471's CI it surfaced as "No nodes match '#agent-status' on AgentPanel()". AppAgentPanel.apply_event, the one place agent events reach the panel, now drops an event once the app is no longer running. The new test reproduces the ordering deterministically: the interrupted turn's cleanup waits for the panel's Unmount, so it settles after Textual pruned the panel and before the App's Unmount. Without the guard it fails with NoMatches every run. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/korvid/ui/app_surfaces.py | 5 ++++ tests/ui/test_agent_interrupt.py | 44 +++++++++++++++++++++++++++++++- 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/src/korvid/ui/app_surfaces.py b/src/korvid/ui/app_surfaces.py index 36b1325a9..1dc2b838d 100644 --- a/src/korvid/ui/app_surfaces.py +++ b/src/korvid/ui/app_surfaces.py @@ -184,6 +184,11 @@ def echo_user(self, text: str) -> None: self._app._agent_panel.echo_user(text) def apply_event(self, event: AgentEvent) -> None: + if not self._app.is_running: + # Textual prunes every widget before it sends the App its Unmount, + # which is where the agent session is marked down. A turn that + # settles in between has no panel left to update (#476). + return self._app._agent_panel.apply_event(event) diff --git a/tests/ui/test_agent_interrupt.py b/tests/ui/test_agent_interrupt.py index ba47c68e9..779ecaeda 100644 --- a/tests/ui/test_agent_interrupt.py +++ b/tests/ui/test_agent_interrupt.py @@ -21,7 +21,7 @@ from .agent_write_support import Recorder, _expand_panel from .agent_write_support import make_app as make_write_app -from .waits import until +from .waits import HANG_GUARD_SECONDS, until def test_fake_session_interrupt_usage_matches_live_cumulative_totals() -> None: @@ -335,6 +335,48 @@ async def _slow(self) -> AsyncIterator[AgentEvent]: assert session.cleanup_done +async def test_a_turn_that_settles_after_the_panel_is_pruned_does_not_raise( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Textual removes every widget before it sends the App its Unmount, which + is where the agent session is marked down. A turn interrupted just before + quitting can settle in between, after the panel's children are gone, and + must not raise from shutdown (#476).""" + pruned = asyncio.Event() + + def note_unmount(self: AgentPanel) -> None: + pruned.set() + + monkeypatch.setattr(AgentPanel, "on_unmount", note_unmount, raising=False) + + class PrunedPanelSession(BlockingSession): + def __init__(self) -> None: + super().__init__() + self.settled_after_prune = False + + def run_turn(self, user_text: str) -> AsyncIterator[AgentEvent]: + self.prompts.append(user_text) + return self._until_pruned() + + async def _until_pruned(self) -> AsyncIterator[AgentEvent]: + try: + yield TextDelta(text="let me check") + await asyncio.Event().wait() + finally: + # Settle the interrupted turn as soon as Textual has pruned the + # panel: after `_close_all` removed it, before the App's Unmount. + await asyncio.wait_for(pruned.wait(), timeout=HANG_GUARD_SECONDS) + self.settled_after_prune = True + + session = PrunedPanelSession() + app = make_app(session) + async with app.run_test() as pilot: + await _start_turn(app, pilot, "check") + await until(pilot, lambda: bool(session.prompts), label="turn running") + app.action_interrupt_agent() + assert session.settled_after_prune + + async def test_unmount_marks_the_agent_down_before_its_first_await( monkeypatch: pytest.MonkeyPatch, ) -> None: From b72b03a310da9204414e40f21b8d3261a2e9a188 Mon Sep 17 00:00:00 2001 From: hellices Date: Sat, 24 Oct 2026 02:12:50 +0900 Subject: [PATCH 4/8] test: wait for an approval dialog to compose before reading it The operation-journey driver read `.confirm-title` as soon as app.screen was a ConfirmScreen. Textual makes a pushed screen current before it composes the screen's widgets, so on a loaded runner the poll landed in between and the read raised "No nodes match '.confirm-title' on ConfirmScreen()". That failed two journeys on main's 3.11 (#478); the second NoMatches in that log came from the journey tearing down after the first. handle() now first waits, through _await_dialog_composed, until the current dialog has its title or is no longer current; a dialog that closes first falls through to the existing closed-before-a-decision path. The new test delays ConfirmScreen's compose by 0.3 seconds and still expects one matched dialog_observed and no hard failures; without the wait it fails every run with CI's exact pair of errors. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tests/evals/operation_app.py | 18 ++++++++++++++++++ tests/evals/test_operation_journeys.py | 21 +++++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/tests/evals/operation_app.py b/tests/evals/operation_app.py index 02f5d0d57..93b4d3abf 100644 --- a/tests/evals/operation_app.py +++ b/tests/evals/operation_app.py @@ -725,7 +725,25 @@ def _apply_intervention(self) -> None: f"{self._journey.id}: dialog_intervention replacement did not apply" ) + async def _await_dialog_composed(self, pilot: Any) -> None: + """Wait until a current approval dialog has its widgets, or closes. + + Textual makes a pushed screen current before it composes the screen's + widgets, and a loaded runner can poll in between (#478): reading the + title then would find none. A dialog that closes first is left to + `handle`, which already treats it as closed before a decision. + """ + screen = self._app.screen + if not isinstance(screen, ConfirmScreen): + return + await until( + pilot, + lambda: self._app.screen is not screen or bool(screen.query(".confirm-title")), + label="approval dialog composed", + ) + async def handle(self, pilot: Any) -> None: + await self._await_dialog_composed(pilot) screen = self._app.screen if not isinstance(screen, ConfirmScreen): if self._remaining <= 0 or not self._dialog_seen: diff --git a/tests/evals/test_operation_journeys.py b/tests/evals/test_operation_journeys.py index 54e97d943..7ba9d569c 100644 --- a/tests/evals/test_operation_journeys.py +++ b/tests/evals/test_operation_journeys.py @@ -8,6 +8,7 @@ from __future__ import annotations +import asyncio import json import os from collections.abc import AsyncIterator, Callable @@ -31,6 +32,7 @@ from korvid.evals.scripted import ScriptedProvider from korvid.tools.executor import RecordedExecution, ToolOutcome from korvid.tools.structured import dump_yaml +from korvid.ui.widgets.confirm_screen import ConfirmScreen from . import operation_app from .operation_app import ( @@ -1331,6 +1333,25 @@ def polls_that_miss_every_dialog( assert run.audit == () +async def test_a_dialog_still_composing_is_read_once_it_has_its_widgets( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Textual makes a pushed screen current before it composes the screen's + widgets, and a loaded runner can poll in between (#478). The driver must + wait for the dialog's title instead of reading a dialog that has none.""" + original = ConfirmScreen._compose + + async def slow_compose(self: ConfirmScreen) -> None: + await asyncio.sleep(0.3) + await original(self) + + monkeypatch.setattr(ConfirmScreen, "_compose", slow_compose) + run = await run_scripted_journey("scale-deployment-up", tmp_path) + observed = [entry for entry in run.journal if entry["event"] == "dialog_observed"] + assert [entry["result"] for entry in observed] == ["matched"] + assert run.grade.hard_failures == () + + def test_the_replacement_journey_declares_its_intervention_in_the_fixture() -> None: """B1: the mid-dialog replacement is fixture data, not test code, so `tests.evals.operation_campaign` runs the identical journey.""" From 489290540696a718e554769a009bff40940ca85b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 23 Oct 2026 10:15:15 +0000 Subject: [PATCH 5/8] build(deps-dev): bump gitpython from 3.1.62 to 3.2.1 Bumps [gitpython](https://github.com/gitpython-developers/GitPython) from 3.1.62 to 3.2.1. - [Release notes](https://github.com/gitpython-developers/GitPython/releases) - [Changelog](https://github.com/gitpython-developers/GitPython/blob/main/doc/source/changes.rst) - [Commits](https://github.com/gitpython-developers/GitPython/compare/3.1.62...3.2.1) --- updated-dependencies: - dependency-name: gitpython dependency-version: 3.2.1 dependency-type: direct:development ... Signed-off-by: dependabot[bot] (cherry picked from commit 3861683ab6f577bb9d759f2f677d08c6d24fbe4b) --- uv.lock | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/uv.lock b/uv.lock index 0695a302b..be98f053b 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 3 +revision = 5 requires-python = ">=3.11" resolution-markers = [ "python_full_version >= '3.15'", @@ -966,14 +966,14 @@ wheels = [ [[package]] name = "gitpython" -version = "3.1.62" +version = "3.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "gitdb" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e0/db/3ca813cbacb23ab6fe46ff38a9b5ef8e73e970c8051f2ce903aacafe0446/gitpython-3.1.62.tar.gz", hash = "sha256:1791de66309bc0c7cfca40bf8d2e3de7ca091cbf94e6051be1ad0722c61062af", size = 231728, upload-time = "2026-09-07T02:57:21.155Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c0/ff/fee293f1ec3e1b64db6eb270e00964f29c086d769201474cedb8d1608930/gitpython-3.2.1.tar.gz", hash = "sha256:0f1229ec5c057bdabdeeda8bda4f17bda679ab33dbccc00f24e6a480553c8613", size = 241688, upload-time = "2026-10-14T12:20:20.589Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d6/0b/29d7965215f8ef830a7ca1f42997fe13e5693d85e9edb18f938d063ef5f2/gitpython-3.1.62-py3-none-any.whl", hash = "sha256:7002251225e10e29d2e1f49e6532613fe5d5d9f0b6f1f02997a52b38fe56899e", size = 222753, upload-time = "2026-09-07T02:57:19.762Z" }, + { url = "https://files.pythonhosted.org/packages/6b/50/2a923c4dc02522ff9567f07c67ed1deca6d5fa9474f4708168b8088dfa37/gitpython-3.2.1-py3-none-any.whl", hash = "sha256:d66b0832aa8755c1a84892937282df0befb16c97e5a64aeebd68be9c5e323f53", size = 231548, upload-time = "2026-10-14T12:20:18.844Z" }, ] [[package]] @@ -1027,8 +1027,8 @@ name = "httpcore2" version = "2.13.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "h11", marker = "python_full_version < '3.12' or python_full_version >= '3.14' or sys_platform != 'emscripten'" }, - { name = "truststore", marker = "python_full_version < '3.12' or python_full_version >= '3.14' or sys_platform != 'emscripten'" }, + { name = "h11" }, + { name = "truststore" }, ] sdist = { url = "https://files.pythonhosted.org/packages/cb/f3/1db7aa2bc2524062192bb0e0323969492d1883152a232fe36eea65f4e35c/httpcore2-2.13.1.tar.gz", hash = "sha256:e0aa977abe17e69a3b820a24542a6fa88702676d83880b8d194dcd18408e5103", size = 68071, upload-time = "2026-09-23T07:47:22.372Z" } wheels = [ @@ -3079,8 +3079,8 @@ name = "secretstorage" version = "3.5.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cryptography", marker = "(python_full_version < '3.12' and sys_platform == 'emscripten') or (python_full_version >= '3.14' and sys_platform == 'emscripten') or (python_full_version >= '3.15' and sys_platform == 'win32') or (sys_platform != 'emscripten' and sys_platform != 'win32')" }, - { name = "jeepney", marker = "(python_full_version < '3.12' and sys_platform == 'emscripten') or (python_full_version >= '3.14' and sys_platform == 'emscripten') or (python_full_version >= '3.15' and sys_platform == 'win32') or (sys_platform != 'emscripten' and sys_platform != 'win32')" }, + { name = "cryptography" }, + { name = "jeepney" }, ] sdist = { url = "https://files.pythonhosted.org/packages/1c/03/e834bcd866f2f8a49a85eaff47340affa3bfa391ee9912a952a1faa68c7b/secretstorage-3.5.0.tar.gz", hash = "sha256:f04b8e4689cbce351744d5537bf6b1329c6fc68f91fa666f60a380edddcd11be", size = 19884, upload-time = "2025-11-23T19:02:53.191Z" } wheels = [ From 293d7a85fb20f9a7c3cc60656d60740fe043cbe0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 23 Oct 2026 10:15:21 +0000 Subject: [PATCH 6/8] build(deps): bump virtualenv from 21.7.13 to 21.14.9 Bumps [virtualenv](https://github.com/pypa/virtualenv) from 21.7.13 to 21.14.9. - [Release notes](https://github.com/pypa/virtualenv/releases) - [Changelog](https://github.com/pypa/virtualenv/blob/main/docs/changelog.rst) - [Commits](https://github.com/pypa/virtualenv/compare/21.7.13...21.14.9) --- updated-dependencies: - dependency-name: virtualenv dependency-version: 21.14.9 dependency-type: indirect ... Signed-off-by: dependabot[bot] (cherry picked from commit 5e10f14c22a3c8b738eb414ca196ca8bb3fa7dab) --- uv.lock | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/uv.lock b/uv.lock index be98f053b..6a022df0c 100644 --- a/uv.lock +++ b/uv.lock @@ -2125,11 +2125,11 @@ wheels = [ [[package]] name = "packaging" -version = "26.2" +version = "26.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, ] [[package]] @@ -3434,17 +3434,18 @@ wheels = [ [[package]] name = "virtualenv" -version = "21.7.13" +version = "21.14.9" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "distlib" }, { name = "filelock" }, + { name = "packaging" }, { name = "platformdirs" }, { name = "python-discovery" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/13/50/c9b84eb106d0db420b9878dac0a386c5791726f127ce20b06897f6e3a1e9/virtualenv-21.7.13.tar.gz", hash = "sha256:0355558b6f33619aab31347e43643b0ebc97f61ea3acf617b2b69e1f8a843d11", size = 5360306, upload-time = "2026-09-18T04:35:49.354Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/04/2f69ba1207ce59630ecea8ebfdaf5c8f9a14726f2408ccd145a18d1ef619/virtualenv-21.14.9.tar.gz", hash = "sha256:4d1bee354cd81c820da3bee56e38be3426490f8d1fc54e10ea5b2072ebad8ef8", size = 5500672, upload-time = "2026-10-17T16:09:42.345Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9a/ce/e74453531b0c49a58d0e8a4f9bab4495705859fee4a4f7de27d58f4a791a/virtualenv-21.7.13-py3-none-any.whl", hash = "sha256:1bea5af7463f59c4719db48fe739579a2a4f569c96f26c086edda85c96da9f59", size = 5328680, upload-time = "2026-09-18T04:35:47.194Z" }, + { url = "https://files.pythonhosted.org/packages/64/e5/9a3918219745e8bdd64fe0fd9a2d7ce4fffde9268fbc791634b7d4779de9/virtualenv-21.14.9-py3-none-any.whl", hash = "sha256:ac51744da66ae56ff814b087d65096c94de698ec520adc624b108b679655809b", size = 5494452, upload-time = "2026-10-17T16:09:40.584Z" }, ] [[package]] From cd253a2f59038c2e511ba3857f6ce95cce0b2e0c Mon Sep 17 00:00:00 2001 From: hellices Date: Sun, 25 Oct 2026 12:19:36 +0900 Subject: [PATCH 7/8] build: relock filelock Since 2026-10-23 pip-audit fails every branch on filelock 3.32.0: GHSA-v4x8-3wr5-3wq8 (high), GHSA-9vhv-jxww-xfm5 and GHSA-567q-5qvw-pf92, all fixed in 4.2.1 (#480). The locked virtualenv 21.7.13 required filelock<4; the virtualenv 21.14.9 bump in this branch allows <5, so filelock can now move to 4.2.4. These are the bytes the Relock workflow produced and verified on this branch: run 40155755520, package=filelock, base 293d7a85. Its relock job resolved the lock on a runner with direct PyPI access, confirmed every url, registry and index names PyPI, and ran uv sync --locked, ruff, the format check, mypy, tach and the test suite (13,753 passed). Its propose job then could not open a branch, because the base's uv.lock differs from main's (#481), so the relocked-lock artifact was committed by hand after matching its SHA-256 to the digest the relock job recorded before any resolved code ran (493ce1c4...4f96) and re-checking it with main's scripts/check_lock_hosts.py. uv 0.10.9, which the workflow pins, writes lock revision 3 and keeps the httpcore2 and secretstorage markers, so this also returns those lines to main's form: the branch's net lock change is the four version bumps. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- uv.lock | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/uv.lock b/uv.lock index 6a022df0c..08ef2b480 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 5 +revision = 3 requires-python = ">=3.11" resolution-markers = [ "python_full_version >= '3.15'", @@ -819,11 +819,11 @@ wheels = [ [[package]] name = "filelock" -version = "3.32.0" +version = "4.2.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c0/80/8232b582c4b318b817cf1274ba74976b07b34d35ef439b3eb948f98645a1/filelock-3.32.0.tar.gz", hash = "sha256:7be2ad23a14607ccc71808e68fe30848aeace7058ace17852f68e2a68e310402", size = 213757, upload-time = "2026-07-21T13:17:42.898Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/28/3893324bcadf8f89b1616637468d766af3f625008c0735bf704fdfa86617/filelock-4.2.4.tar.gz", hash = "sha256:c6d8663535e1650ee430507b4ffbac7b464dd64b7647ea0112a9f406499d163d", size = 652444, upload-time = "2026-10-19T01:57:51.001Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/06/79/b4c714bef36bc4ec2beeae1e0c124f0223888cd8c6feb1cdc56038116920/filelock-3.32.0-py3-none-any.whl", hash = "sha256:d396bea984af47333ef05e50eae7eff88c84256de6112aea0ec48a233c064fe3", size = 97732, upload-time = "2026-07-21T13:17:41.55Z" }, + { url = "https://files.pythonhosted.org/packages/e0/1b/5e74289a1878e666bb4b65fe5805a04cafcac2aa5f519ef1ead67ef91c56/filelock-4.2.4-py3-none-any.whl", hash = "sha256:301241438c57936ef93f4243a94f2559022268bfcf9b375a9a72eb0f8db8358e", size = 162475, upload-time = "2026-10-19T01:57:49.419Z" }, ] [[package]] @@ -1027,8 +1027,8 @@ name = "httpcore2" version = "2.13.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "h11" }, - { name = "truststore" }, + { name = "h11", marker = "python_full_version < '3.12' or python_full_version >= '3.14' or sys_platform != 'emscripten'" }, + { name = "truststore", marker = "python_full_version < '3.12' or python_full_version >= '3.14' or sys_platform != 'emscripten'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/cb/f3/1db7aa2bc2524062192bb0e0323969492d1883152a232fe36eea65f4e35c/httpcore2-2.13.1.tar.gz", hash = "sha256:e0aa977abe17e69a3b820a24542a6fa88702676d83880b8d194dcd18408e5103", size = 68071, upload-time = "2026-09-23T07:47:22.372Z" } wheels = [ @@ -3079,8 +3079,8 @@ name = "secretstorage" version = "3.5.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cryptography" }, - { name = "jeepney" }, + { name = "cryptography", marker = "(python_full_version < '3.12' and sys_platform == 'emscripten') or (python_full_version >= '3.14' and sys_platform == 'emscripten') or (python_full_version >= '3.15' and sys_platform == 'win32') or (sys_platform != 'emscripten' and sys_platform != 'win32')" }, + { name = "jeepney", marker = "(python_full_version < '3.12' and sys_platform == 'emscripten') or (python_full_version >= '3.14' and sys_platform == 'emscripten') or (python_full_version >= '3.15' and sys_platform == 'win32') or (sys_platform != 'emscripten' and sys_platform != 'win32')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/1c/03/e834bcd866f2f8a49a85eaff47340affa3bfa391ee9912a952a1faa68c7b/secretstorage-3.5.0.tar.gz", hash = "sha256:f04b8e4689cbce351744d5537bf6b1329c6fc68f91fa666f60a380edddcd11be", size = 19884, upload-time = "2025-11-23T19:02:53.191Z" } wheels = [ From f243a0ab245fab88c6793a0cc6a2d01955bcf147 Mon Sep 17 00:00:00 2001 From: hellices Date: Sun, 25 Oct 2026 13:04:30 +0900 Subject: [PATCH 8/8] build: relock virtualenv virtualenv 21.14.9, the version Dependabot proposed in #474, introduced GHSA-cx6f-vjhg-g3cc / CVE-2026-115586 (high): the Bash activator leaves the --prompt value unescaped in PS1 when promptvars is off, so prompt expansion later executes it. The advisory was published on 2026-10-23, after #474 passed its checks, and this branch's dependency-review check failed on it. It is fixed in 21.14.13, which still allows filelock<5 and needs packaging>=26.3, both already locked here. These are the bytes the Relock workflow produced and verified on this branch: run 40158406513, package=virtualenv, base cd253a2f. Its relock job resolved on a runner with direct PyPI access, confirmed every url, registry and index names PyPI, and ran uv sync --locked, ruff, the format check, mypy, tach and the test suite (13,753 passed). Its propose job hit #481 again, so the relocked-lock artifact was committed by hand after matching its SHA-256 to the digest the relock job recorded (1de17a61...95da) and re-checking it with main's scripts/check_lock_hosts.py. Only the virtualenv block changes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- uv.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/uv.lock b/uv.lock index 08ef2b480..d73776f86 100644 --- a/uv.lock +++ b/uv.lock @@ -3434,7 +3434,7 @@ wheels = [ [[package]] name = "virtualenv" -version = "21.14.9" +version = "21.14.13" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "distlib" }, @@ -3443,9 +3443,9 @@ dependencies = [ { name = "platformdirs" }, { name = "python-discovery" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d0/04/2f69ba1207ce59630ecea8ebfdaf5c8f9a14726f2408ccd145a18d1ef619/virtualenv-21.14.9.tar.gz", hash = "sha256:4d1bee354cd81c820da3bee56e38be3426490f8d1fc54e10ea5b2072ebad8ef8", size = 5500672, upload-time = "2026-10-17T16:09:42.345Z" } +sdist = { url = "https://files.pythonhosted.org/packages/70/aa/bff50daee0488e16d611258a9b6c5a1d0c9e128f8ca424e9f4e1c0444ef2/virtualenv-21.14.13.tar.gz", hash = "sha256:de5307ba4b9a0001d9c8ea02832d38f63ca432880f416c1bface61c4323fd905", size = 5570301, upload-time = "2026-10-22T20:10:16.952Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/64/e5/9a3918219745e8bdd64fe0fd9a2d7ce4fffde9268fbc791634b7d4779de9/virtualenv-21.14.9-py3-none-any.whl", hash = "sha256:ac51744da66ae56ff814b087d65096c94de698ec520adc624b108b679655809b", size = 5494452, upload-time = "2026-10-17T16:09:40.584Z" }, + { url = "https://files.pythonhosted.org/packages/bb/a9/c10d8a44a29fb94a18b43fdf11f38caca53ae7c9406f11a72b1cb8f624f1/virtualenv-21.14.13-py3-none-any.whl", hash = "sha256:5fbb04138a4861580b78ba9110d529a26bc48fcc93c8ec36bc0202eeed96a788", size = 5557094, upload-time = "2026-10-22T20:10:15.096Z" }, ] [[package]]