From 42643acbbf133cbb73d5c1c045a1cd6b60e2ab15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Davor=20Raci=C4=87?= Date: Mon, 10 Aug 2026 14:02:23 +0200 Subject: [PATCH 1/3] fix(runs,tui): tag project ownership with a transportable digest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The project tag stored the resolved path, and psmux refuses a value that cannot cross its CLI->server control line verbatim. Filter those refusal shapes through Path.resolve() on Windows and one survives: a spaced UNC share, \srv\share name\proj. There every session went untagged, and untagged is weak ownership twice over — it dies with the run dir, so an orphaned session leaked once `clean` removed the directory, and it proves ownership by run-id collision on disk rather than by identity, so a reused --run-id let one project prune another's session. project_tag now returns a sha256 prefix of the resolved path, which clears the transport gate by construction. Read sites compare against accepted_tags so a tag written by an earlier release still proves ownership: the ctl session is long-lived and shared across projects, and without that its windows would read as another project's after an upgrade. The gate itself is unchanged — it remains the general contract for `@` session options, it is simply no longer the project tag's expected path. Closes #419 --- CHANGELOG.md | 4 +++ docs/multiplexer-backends.md | 19 +++++------ src/bmad_loop/adapters/psmux_backend.py | 14 +++----- src/bmad_loop/runs.py | 45 ++++++++++++++++++------- src/bmad_loop/tui/launch.py | 11 +++--- tests/test_psmux_backend.py | 4 ++- tests/test_runs.py | 39 +++++++++++++++++++++ tests/test_tui_launch.py | 26 ++++++++++++++ 8 files changed, 122 insertions(+), 40 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ff817c6f..c5b3c08b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -160,6 +160,10 @@ whose seams had diverged enough that several ports needed a different fix, and t ### Fixed +- **Keep psmux sessions and ctl windows on spaced UNC project paths tagged (#419).** Store project ownership as a + transportable digest and accept legacy path tags during pruning, preventing orphan leaks and + cross-project run-id collisions. + - **A native-Windows install driven from a WSL shell now says so (#332).** WSL appends the Windows `PATH` to its own, so a bash prompt can reach a Windows-installed `bmad-loop`: that interpreter reports `win32`, takes the psmux platform default, and never sees the distro's tmux — while diff --git a/docs/multiplexer-backends.md b/docs/multiplexer-backends.md index 447a10a2..986a95a9 100644 --- a/docs/multiplexer-backends.md +++ b/docs/multiplexer-backends.md @@ -76,17 +76,16 @@ upstream release. Practical consequence: such a value is **not** readable via `psmux show-options -w` by hand — read it with `psmux show-options -qv -t "@bmad_project__blw@N"` instead. Session-scoped options need no such substitute — one server per session means that server's single map _is_ the session's — -but they cross the same control line, so the session project tag is gated the same way. One +but they cross the same control line, so session-scoped `@` options are gated the same way. One visible limit: a value that cannot survive psmux's control-line transport verbatim is refused -with a stderr warning at every launch, and that project's windows and agent sessions stay -untagged — the prune then scopes them through the run-dir fallback instead of the tag. Which -paths those are is counter-intuitive, because the psmux client quotes a value only when it -contains an ASCII space and `'` is literal inside those quotes: `C:\Users\O'Brien\dev` is -**refused** while `C:\Users\O'Brien Files\dev` is accepted, and a spaced UNC path -(`\\server\share\My Proj`) is refused while the spaceless `\\server\share\proj` is accepted. The -fallback that catches those refusals has a lifecycle ceiling -([#419](https://github.com/bmad-code-org/bmad-loop/issues/419)): an untagged session whose run -directory is later removed by `clean` or `archive` can no longer be pruned by any project. +with a stderr warning and the option reads as unset. Which values those are is +counter-intuitive, because the psmux client quotes a value only when it contains an ASCII space +and `'` is literal inside those quotes: `C:\Users\O'Brien\dev` is **refused** while +`C:\Users\O'Brien Files\dev` is accepted, and a spaced UNC path (`\\server\share\My Proj`) is +refused while the spaceless `\\server\share\proj` is accepted. The project ownership tag no +longer meets this gate: it is stored as a hex digest of the project path, transportable by +construction ([#419](https://github.com/bmad-code-org/bmad-loop/issues/419)), so sessions stay +tagged whatever the path and the run-dir fallback remains only for genuinely untagged state. ## External backends diff --git a/src/bmad_loop/adapters/psmux_backend.py b/src/bmad_loop/adapters/psmux_backend.py index 9bc913b2..3eef7a37 100644 --- a/src/bmad_loop/adapters/psmux_backend.py +++ b/src/bmad_loop/adapters/psmux_backend.py @@ -506,15 +506,9 @@ def set_session_option(self, name: str, option: str, value: str) -> None: # tag is non-empty and never equals the caller's tag again, so the # prune skips that session forever. # - # Refusing leaves the option UNSET, which is the correct degradation - # and not the lesser evil: the prune's untagged path falls back to the - # run dir, claiming our own dead runs and skipping foreign ones. State - # the bound rather than the slogan — that fallback proves ownership by - # run-id collision on disk, not by identity, so it skips a foreign - # session only while no run dir HERE shares its run id. Ids are - # timestamped plus two random bytes, but `--run-id` is caller-supplied, - # so untagged is weaker proof than a tag even though it beats a - # corrupted one. Both edges of that fallback are bounded in #419. + # Refusing leaves the option unset. Project ownership now uses a hex + # digest that clears this gate by construction (#419), but the gate stays + # as the general contract for every `@` session option. # # The refusal frees the key rather than just returning. A session this # backend just created is NOT a blank map — the server loads the user's @@ -526,7 +520,7 @@ def set_session_option(self, name: str, option: str, value: str) -> None: print( f"warning: set-option {option} skipped on session {name} — value does " "not survive psmux's control-line transport verbatim; the key is freed " - "and ownership falls back to the run dir", + "and the option reads as unset", file=sys.stderr, ) self._write_scoped(["set-option", "-u", "-t", name, option], option) diff --git a/src/bmad_loop/runs.py b/src/bmad_loop/runs.py index 59532744..ab6fdfdb 100644 --- a/src/bmad_loop/runs.py +++ b/src/bmad_loop/runs.py @@ -2,6 +2,7 @@ from __future__ import annotations +import hashlib import json import math import os @@ -296,11 +297,25 @@ def kill_session(run_id: str) -> None: def project_tag(project: Path) -> str: - """Canonical project identity stored in PROJECT_OPTION. The single source of - normalization: both the tagging (at session/window creation) and the prune - comparison must route through this so symlinks/relative paths can't make a - project look foreign to its own sessions.""" - return str(project.resolve()) + """Canonical project identity used by both tag writers and prune readers. The + single source of normalization: both sides must route through this so symlinks + and relative paths can't make a project look foreign to its own sessions. + + Hashing the resolved path makes every value safe for psmux's control line; + 16 hex characters are ample for one machine's project population (#419). + """ + return hashlib.sha256(os.fsencode(str(project.resolve()))).hexdigest()[:16] + + +def accepted_tags(project: Path) -> frozenset[str]: + """Current digest plus the legacy resolved-path tag accepted during pruning. + + The legacy member is read-only compatibility for sessions and ctl windows that + survive an upgrade; remove it once no path-tagged multiplexer state can remain. + Returns the whole set rather than answering per tag so a read site resolves the + project once per prune instead of once per session. + """ + return frozenset({project_tag(project), str(project.resolve())}) def mux_sessions() -> list[str]: @@ -324,16 +339,20 @@ def prunable_sessions(project: Path) -> tuple[list[str], list[str], set[str]]: The control session (bmad-loop-ctl) is never a candidate. Pruning is scoped to `project` via the PROJECT_OPTION tag set at session creation: - - tag == this project: ours — prunable unless a provably-alive engine pid is - running (covers finished/stopped/crashed *and* orphans whose run dir was - deleted, since engine_liveness reads 'dead' with no pid). + - tag proves this project (see accepted_tags): ours — prunable unless a + provably-alive engine pid is running (covers finished/stopped/crashed *and* + orphans whose run dir was deleted, since engine_liveness reads 'dead' with + no pid). - tag is another project: skipped — never touched. - - tag empty (pre-upgrade, untagged session): can't prove ownership, so fall - back to the run dir — prunable only when the dir exists under this project - and is dead; skipped when the dir is absent. + - tag empty (untagged session): can't prove ownership, so fall back to the run + dir — prunable only when the dir exists under this project and is dead; + skipped when the dir is absent. Reachable when the tag write failed, when + the option read degrades (session_options reads unset as "no answer", never + as proof nothing was written), or on a session predating a working tag + write — e.g. psmux path tags refused before the digest. """ tags = session_project_tags() - mine = project_tag(project) + mine = accepted_tags(project) prunable: list[str] = [] live: list[str] = [] unknown: set[str] = set() @@ -346,7 +365,7 @@ def prunable_sessions(project: Path) -> tuple[list[str], list[str], set[str]]: run_dir = run_dir_for(project, run_id) tag = tags.get(name, "") if tag: - if tag != mine: + if tag not in mine: continue # another project's session elif not is_run(run_dir): continue # untagged and no run dir here — ownership unprovable diff --git a/src/bmad_loop/tui/launch.py b/src/bmad_loop/tui/launch.py index 23312094..eb1da73d 100644 --- a/src/bmad_loop/tui/launch.py +++ b/src/bmad_loop/tui/launch.py @@ -260,17 +260,16 @@ def _ctl_window_candidates(project: Path) -> list[tuple[str, str]]: the ctl session never targets itself; live runs and the session's own shell window are excluded too. - The control session is shared across projects, so pruning is scoped to - `project` via the per-window PROJECT_OPTION tag (mirrors runs.prunable_sessions): - a window tagged for another project is left alone; an untagged (pre-upgrade) - window is only a candidate when its run dir exists under this project. + The control session is shared across projects, so its per-window PROJECT_OPTION + accepts current and legacy project tags; untagged windows still require a run + directory under this project (mirrors runs.prunable_sessions). """ mux = get_multiplexer() if not mux_usable(mux) or not session_exists(CTL_SESSION): return [] current = mux.current_window_id() rows = mux.list_windows(CTL_SESSION, ["window_id", "window_name", runs.PROJECT_OPTION]) - mine = runs.project_tag(project) + mine = runs.accepted_tags(project) candidates: list[tuple[str, str]] = [] for win_id, name, tag in rows: if not win_id or win_id == current: @@ -282,7 +281,7 @@ def _ctl_window_candidates(project: Path) -> list[tuple[str, str]]: continue # a foreign/mangled window name must not steer a run-dir path run_dir = runs.run_dir_for(project, m.group(1)) if tag: - if tag != mine: + if tag not in mine: continue # another project's window elif not runs.is_run(run_dir): continue # untagged and no run dir here — ownership unprovable diff --git a/tests/test_psmux_backend.py b/tests/test_psmux_backend.py index 1e7560a2..7ba823f1 100644 --- a/tests/test_psmux_backend.py +++ b/tests/test_psmux_backend.py @@ -834,7 +834,9 @@ def test_set_window_option_resolves_a_name_token(monkeypatch): def test_set_window_option_value_with_spaces_stays_one_argv_element(monkeypatch): - # project_tag() is an absolute path; on Windows it routinely holds spaces. + # The transport gate is the general contract for every `@` option value: a + # spaced value clears it via client quoting, and the channel must still pass + # it as one argv element. rec_ = _option_fake(monkeypatch) PsmuxMultiplexer().set_window_option("ctl:@2", "@bmad_project", r"C:\Users\Some User\p") assert rec_.argv[-1] == r"C:\Users\Some User\p" diff --git a/tests/test_runs.py b/tests/test_runs.py index 609fb053..5ee5a68d 100644 --- a/tests/test_runs.py +++ b/tests/test_runs.py @@ -12,6 +12,7 @@ from bmad_loop import platform_util, runs, verify from bmad_loop.adapters import tmux_base +from bmad_loop.adapters.psmux_backend import PsmuxMultiplexer from bmad_loop.journal import load_state, save_state from bmad_loop.model import RunState from bmad_loop.process_host import ProcessHost @@ -665,6 +666,44 @@ def test_prunable_sessions_partitions(tmp_path, monkeypatch): assert unknown == set() +def test_project_tag_is_transportable_whatever_the_path(tmp_path): + """Tags have one safe shape even for paths psmux or UTF-8 cannot carry raw. + + Assert the shape, not just that the gate accepts it: an ordinary spaced Windows + path clears the gate on its own, so only "hex whatever the input" fails when + project_tag returns a raw path. + """ + assert not PsmuxMultiplexer._transportable(r"\\srv\share name\proj") # the premise + project = tmp_path / "share name" / "proj" + project.mkdir(parents=True) + tag = runs.project_tag(project) + assert re.fullmatch("[0-9a-f]{16}", tag) + assert PsmuxMultiplexer._transportable(tag) + assert re.fullmatch("[0-9a-f]{16}", runs.project_tag(tmp_path / f"bad{chr(0xDC80)}")) + assert len({tag, runs.project_tag(tmp_path / "other")}) == 2 + + +def test_prunable_sessions_accepts_legacy_path_tag(tmp_path, monkeypatch): + """A pre-digest tag stays ours; another project's path or digest stays foreign.""" + legacy = str(tmp_path.resolve()) + fin = _make_state_run(tmp_path, "legacy-fin") + (fin / "engine.pid").write_text(str(_dead_pid())) + sessions = ["bmad-loop-legacy-fin", "bmad-loop-legacy-other", "bmad-loop-legacy-digest"] + monkeypatch.setattr(runs, "mux_sessions", lambda: sessions) + monkeypatch.setattr( + runs, + "session_project_tags", + lambda: { + "bmad-loop-legacy-fin": legacy, + "bmad-loop-legacy-other": "/some/other/project", + "bmad-loop-legacy-digest": runs.project_tag(tmp_path / "other"), + }, + ) + prunable, live, unknown = runs.prunable_sessions(tmp_path) + assert prunable == ["legacy-fin"] + assert live == [] and unknown == set() + + def test_prunable_sessions_skips_invalid_run_ids(tmp_path, monkeypatch): """A session name is untrusted input (anyone can create one). Stripping the prefix off `bmad-loop-../../x` would hand `run_dir_for` a traversing id, and a diff --git a/tests/test_tui_launch.py b/tests/test_tui_launch.py index 48af8e22..5165d4ba 100644 --- a/tests/test_tui_launch.py +++ b/tests/test_tui_launch.py @@ -412,6 +412,32 @@ def fake(argv, **kwargs): assert killed == [["tmux", "kill-window", "-t", "@3"]] +def test_prune_ctl_windows_accepts_legacy_path_tag(monkeypatch, tmp_path: Path): + """A ctl window carrying a pre-digest tag remains owned after upgrade.""" + from bmad_loop import runs + + legacy = str(tmp_path.resolve()) + windows = ( + f"@2\trun-20260101-000000-dead\t{legacy}\n" # our own pre-upgrade window — kill + "@3\trun-20260101-000000-alien\t/some/other/project\n" # foreign — skip + ) + + def fake(argv, **kwargs): + verb = argv[1] + if verb == "list-windows": + return subprocess.CompletedProcess(argv, 0, stdout=windows, stderr="") + if verb == "display-message": + return subprocess.CompletedProcess(argv, 0, stdout="@9\n", stderr="") + return subprocess.CompletedProcess(argv, 0, stdout="", stderr="") + + monkeypatch.setenv("TMUX", "/tmp/tmux-1000/default,123,0") + monkeypatch.setattr(tmux_base.subprocess, "run", fake) + monkeypatch.setattr(tmux_base.shutil, "which", lambda name: f"/usr/bin/{name}") + + assert launch.prunable_ctl_windows(tmp_path) == ["run-20260101-000000-dead"] + assert runs.project_tag(tmp_path) != legacy # the shapes really are different + + def test_prune_ctl_windows_skips_invalid_run_ids(monkeypatch, tmp_path: Path): """A ctl-window name is untrusted input (anyone can rename a tmux window). Stripping the kind prefix off `run-../../x` would hand run_dir_for a From 351ffba31af474b7464dcd7b29769cf8ce75c316 Mon Sep 17 00:00:00 2001 From: t Date: Mon, 10 Aug 2026 22:36:09 -0700 Subject: [PATCH 2/3] fix(tui): accept a legacy project tag when resolving a control window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ctl_window_id` compared the stored window tag against `project_tag(project)` alone, so a control window tagged before the digest read as foreign and `a` and `x` could no longer reach this project's own orchestrator — while `_ctl_window_candidates`, which accepts the legacy tag, would happily prune the same window. Reachable and pruned-but-unreachable are the wrong pair. The site is one main added (#482 window identity) after this branch forked, so the branch's read-side update never covered it and the merge did not conflict. Both readers now go through `accepted_tags`; the writers keep minting the current digest. Scoping is unchanged: a nonempty tag outside the accepted set is still foreign, so a stop cannot cross a project boundary. Found by codex review on the merge commit. --- src/bmad_loop/tui/launch.py | 19 ++++++++++++------- tests/test_tui_launch.py | 17 +++++++++++++++++ 2 files changed, 29 insertions(+), 7 deletions(-) diff --git a/src/bmad_loop/tui/launch.py b/src/bmad_loop/tui/launch.py index cb3d45f1..55ab0ff9 100644 --- a/src/bmad_loop/tui/launch.py +++ b/src/bmad_loop/tui/launch.py @@ -379,7 +379,7 @@ def ctl_window_id(project: Path, run_id: str) -> str | None: for a fresh `run`, where recording is deliberately skipped.""" if not mux_available(): return None - mine = runs.project_tag(project) + mine = runs.accepted_tags(project) local = runs.is_run(runs.run_dir_for(project, run_id)) tagged: list[str] = [] untagged: list[str] = [] @@ -403,12 +403,17 @@ def ctl_window_id(project: Path, run_id: str) -> str | None: m = _CTL_WINDOW_RE.match(name) if m is None or m.group(1) != run_id: continue - # An exact tag comparison, with no "the tag looks unsafe here" escape: - # runs.project_tag guarantees the value arrives as it was written, so a - # nonempty tag that is not ours belongs to another project and must not - # be a candidate — `x` resolves through here, and admitting a foreign - # row lets a stop cross a project boundary. - if tag == mine: + # Set membership, with no "the tag looks unsafe here" escape: the digest + # arrives as it was written, so a nonempty tag outside the accepted set + # belongs to another project and must not be a candidate — `x` resolves + # through here, and admitting a foreign row lets a stop cross a project + # boundary. The set is what keeps a window tagged by an earlier release + # reachable: the control session is long-lived and survives the upgrade + # that changes the tag's spelling, so comparing against the current + # digest alone would strand this project's own orchestrator — prunable + # by _ctl_window_candidates, which accepts the legacy tag, yet + # unreachable by `a` and `x`, which resolve through here. + if tag in mine: tagged.append(win_id) elif not tag and local: # untagged, and this project holds the run dir — ownership is diff --git a/tests/test_tui_launch.py b/tests/test_tui_launch.py index bc7e373a..dc721aa1 100644 --- a/tests/test_tui_launch.py +++ b/tests/test_tui_launch.py @@ -381,6 +381,23 @@ def test_ctl_window_id_ignores_a_record_naming_another_projects_window(monkeypat assert launch.ctl_window_id(tmp_path, "RID") == "@2" +def test_ctl_window_id_accepts_a_legacy_path_tag(monkeypatch, tmp_path: Path): + # The ctl session is long-lived and shared across projects, so it survives the + # upgrade that changes the tag's spelling from a path to a digest. Comparing + # against the current digest alone strands this project's OWN orchestrator: + # _ctl_window_candidates accepts the legacy tag and would prune the window, + # while `a` and `x` resolve through here and could no longer reach it. + legacy = str(tmp_path.resolve()) + _ctl_listing(monkeypatch, f"@1\trun-RID\t{legacy}\n", tmp_path) + assert launch.ctl_window_id(tmp_path, "RID") == "@1" + + # Still scoped: another project's legacy path tag stays foreign, so accepting + # the legacy spelling does not widen the boundary a stop must not cross. + other = str((tmp_path / "elsewhere").resolve()) + _ctl_listing(monkeypatch, f"@1\trun-RID\t{other}\n", tmp_path) + assert launch.ctl_window_id(tmp_path, "RID") is None + + def test_ctl_window_id_admits_an_untagged_window_with_a_local_run(monkeypatch, tmp_path: Path): # The tag is written by a best-effort set_window_option that can fail, and a # window whose tag never landed must stay reachable by its own project From 468709f000b93601fa07da2a73bf3bb05b4b69b3 Mon Sep 17 00:00:00 2001 From: t Date: Mon, 10 Aug 2026 22:52:03 -0700 Subject: [PATCH 3/3] docs(tests,changelog): drop prose describing the superseded encoding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two test docstrings still explained the tag's safety as percent-encoding, and one credited the tab case to the backends' bounded split. Under the digest neither spelling of the path reaches the listing, so there is one mechanism, not two. Both tests passed either way — only the stated reason was wrong. The #419 CHANGELOG entry is cut roughly in half: the transports and the user-facing consequence stay, the blow-by-blow of which byte defeats which parse goes. Headline keeps its noun-phrase form, which is the section's convention (Unreleased opens 54x "A", 22x "The", 7x "An", 0x imperative). Also narrows a skip reason that overclaimed: NEL, U+2028 and U+2029 are not illegal in win32 names, so the skip rests on the property being a POSIX-name concern rather than on a filesystem rule. Found by CodeRabbit review on the merge commit. --- CHANGELOG.md | 24 ++++++++---------------- tests/test_runs.py | 2 +- tests/test_tui_launch.py | 13 ++++++++----- 3 files changed, 17 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 570d7e38..b56ce44f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -188,22 +188,14 @@ whose seams had diverged enough that several ports needed a different fix, and t truncated tag that reads as another project's. The prune scan then skipped the project's own parked control windows. The last requested field now keeps its delimiters. -- **A project path the multiplexer cannot carry no longer strands the scans over it (#419).** - Ownership was tagged with the resolved path, and two different transports mangled it. psmux's - control line refuses any value its CLI->server hop would corrupt, and a UNC share whose name holds - a space is one — so the write was refused and the session stayed untagged, which is weak ownership - twice over: it dies with the run dir, so an orphaned session leaked once `clean` removed the - directory, and it proves ownership by run-id collision on disk rather than by identity, so a - reused `--run-id` let one project prune another's session. Window listings are one row per window, - split with `str.splitlines()` and decoded strictly, and two kinds of byte defeat that while being - perfectly legal in a POSIX path: a line separator (LF, CR, VT, FF, FS, GS, RS, NEL, U+2028, - U+2029) put the tag on a row of its own, so it never matched and the prune scan skipped the - project's own parked windows and sessions; and a byte invalid in the filesystem encoding arrived - surrogate-escaped and made the listing read raise `UnicodeDecodeError` outright. The tag is now a - 16-hex digest of the resolved path, which clears both transports by construction. Pruning also - accepts the legacy path-shaped tag, so sessions and control windows that survive an upgrade keep - proving ownership. Reading a legacy raw tag stored by an older version is the decode half, tracked - in #380. +- **A project path the multiplexer cannot carry no longer strands the scans over it (#419).** The + ownership tag held the resolved path, and two transports mangled it: psmux's control line refuses + a spaced UNC share, and a listing row splits on any separator `splitlines()` knows (LF, CR, VT, + FF, FS, GS, RS, NEL, U+2028, U+2029) or fails a strict decode on a non-UTF-8 filename byte. Either + way the session or window went untagged — leaking once `clean` removed its run dir, and prunable + by another project on a reused `--run-id`. The tag is now a 16-hex digest of the path, safe on + both transports by construction; pruning still accepts the legacy path tag, so state surviving the + upgrade keeps its ownership. Reading a legacy raw tag is the decode half (#380). - **A run id that is a suffix of another no longer resolves to the neighbour's control window.** `--run-id` is caller-supplied and may contain `-`, so `run-other-RID` satisfied the lookup for diff --git a/tests/test_runs.py b/tests/test_runs.py index 20e15426..f795ba68 100644 --- a/tests/test_runs.py +++ b/tests/test_runs.py @@ -645,7 +645,7 @@ def test_mux_sessions_no_server(monkeypatch): _SEP_IDS = [i for i, _ in _LINE_SEPARATORS] -@pytest.mark.skipif(sys.platform == "win32", reason="separators are illegal in win32 names") +@pytest.mark.skipif(sys.platform == "win32", reason="a separator in a name is a POSIX concern") @pytest.mark.parametrize("separator", _SEP_VALUES, ids=_SEP_IDS) def test_project_tag_carries_a_path_the_listing_cannot_carry(tmp_path, separator): """A listing splits on far more than LF, and every one of those is legal in a diff --git a/tests/test_tui_launch.py b/tests/test_tui_launch.py index dc721aa1..9a88ddf6 100644 --- a/tests/test_tui_launch.py +++ b/tests/test_tui_launch.py @@ -909,9 +909,11 @@ def test_a_delimiter_in_the_project_path_does_not_hide_its_own_window( `a`/`x` then could not reach a run the pre-tag lookup found. Worse than a missed match, because the fallthrough for an unknown tag is exclusion. - Parametrized over all six on purpose: two different mechanisms carry them — - the tab by the backends' bounded field split, the separators by project_tag's - encoding — so one spelling passing says nothing about another.""" + Parametrized over all six on purpose: each is a byte a resolved project path + can legally hold, and project_tag hashes the path rather than carrying any + spelling of it, so none of them reaches the listing. The matrix pins that the + digest is the single mechanism — return a raw path here and the tab and the + separators fail again, by two different routes.""" project = tmp_path / odd_name project.mkdir() _make_run(project) @@ -931,8 +933,9 @@ def test_a_separator_in_the_project_path_does_not_admit_a_foreign_window( when its own could not survive the listing. That admits every row carrying the run id — including one tagged for another project — and `x` resolves through here, so a stop could kill a neighbouring project's orchestrator. - Reach and scoping are not a trade: project_tag encodes the tag instead, so - the comparison stays exact and this row is simply not ours. + Reach and scoping are not a trade: project_tag hashes the resolved path, so + the tag is listing-safe by construction, the comparison stays exact, and this + row is simply not ours. The two assertions differ only in whose tag the row carries, which is what makes the refusal about the tag rather than about the listing being