Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,9 @@ breaking changes may land in a minor release.
directory. Under worktree isolation on Windows psmux, the session held the first unit's
worktree, so its teardown journaled `worktree-teardown-degraded` and left an empty
`worktrees/1` behind (#902).
- Refuse psmux option values shaped `-X=value`, `-t…` or `--`: psmux 3.4.0 rewrites the
first silently and rejects the others, so the value is now warned about and its key freed
(#905).
- Fire the TUI's stale state-root note on psmux too: the psmux backend now answers which
value a new pane inherits through psmux 3.4.0's named `show-environment` (#660).
- Fix the OpenCode V2 SSE-reconnect end-to-end test failing on slow runners (Windows): the
Expand Down
5 changes: 4 additions & 1 deletion docs/multiplexer-backends.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,10 @@ session-qualified (`session:@N`).

**Refused option values.** An `@` option value must read back verbatim. A value that is
empty, contains a double quote or a line break, or has leading or trailing whitespace is
refused with a stderr warning, and the option reads as unset. Ordinary
refused with a stderr warning, and the option reads as unset. So are three `-`-leading
shapes that psmux's client rewrites before parsing: `-X=value` (one
letter, `=`, then at least one character), any value starting with `-t`, and `--`. Other
`-`-leading values (`-u`, `-1=x`, `--x=y`) pass. Ordinary
Windows paths (spaced, UNC, apostrophed, trailing separator) pass. The project ownership
tag is a hex digest of the project path, so it always passes.

Expand Down
11 changes: 11 additions & 0 deletions src/bmad_loop/adapters/psmux_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -947,10 +947,21 @@ def _transportable(value: str) -> bool:
# the wire now carries them — a value that reads back different is the
# same silently-unprunable window whichever hop mangled it. An empty
# value is a silent server-side no-op (`-u` is the verb for that).
#
# A few `-`-leading shapes still lose the WIRE half, to the client's
# argv normalizers that run before `set-option` parses (psmux/psmux#795,
# cli.rs at v3.4.0): `normalize_flag_equals` splits `-X=value` (one
# ASCII letter, a nonempty suffix) into `-X value` and the write
# succeeds silently; `normalize_attached_target_flag` splits `-t…` into
# a target flag, and `--` ends the options, so those two fail rc 1 and
# would leave a stale value in place. Other `-` values (`-u`, `-1=x`,
# `--x=y`, `-x=`) are stored verbatim since #583.
if not value:
return False
if '"' in value or value != value.strip():
return False
if value == "--" or value.startswith("-t") or re.match(r"-[A-Za-z]=.", value):
return False
return value.splitlines() == [value]

def set_session_option(self, name: str, option: str, value: str) -> None:
Expand Down
13 changes: 13 additions & 0 deletions tests/test_psmux_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -1681,6 +1681,15 @@ def test_kill_window_unroutable_target_skips_cleanup_but_kills(monkeypatch):
assert [c[0][1] for c in rec_.calls] == ["kill-window"]


# Shapes psmux's client argv normalizers rewrite or refuse before `set-option`
# parses its value (psmux/psmux#795): `-X=value` reads back as `-X value` with
# rc 0, and `-t…`/`--` fail rc 1, leaving a prior value in place.
_PSMUX_ARGV_SHAPES = ["-x=foo", "-a=b c", "-Z=1", "-t", "-tx", "-t=foo", "-target", "--"]
# Their measured neighbours, which psmux 3.4.0 stores verbatim: the refusal must
# not widen to them.
_DASH_SAFE_CONTROLS = ["-1=x", "--x=y", "-x=", "-a="]


@pytest.mark.parametrize(
"value",
[
Expand All @@ -1691,6 +1700,7 @@ def test_kill_window_unroutable_target_skips_cleanup_but_kills(monkeypatch):
"", # empty write is a silent server-side no-op; unset exists for this
" C:\\p x", # leading space survives the wire but this backend's reads strip
"C:\\p x ", # trailing space, same round-trip loss
*_PSMUX_ARGV_SHAPES,
],
)
def test_set_window_option_refuses_untransportable_values(monkeypatch, capsys, value):
Expand Down Expand Up @@ -1721,6 +1731,7 @@ def test_set_window_option_refuses_untransportable_values(monkeypatch, capsys, v
"x\u00a0y", # non-ASCII whitespace no longer splits server-side (#536)
"-u", # a `-`-leading value is stored, not read as a flag (psmux/psmux#583)
"-flag",
*_DASH_SAFE_CONTROLS,
],
)
def test_set_window_option_accepts_wire_safe_values(monkeypatch, value):
Expand All @@ -1747,6 +1758,7 @@ def test_set_window_option_write_failure_warns(monkeypatch, capsys):
'C:\\a"b\\proj', # `"` cannot survive the listing's one-quote-pair strip
"bad\nline", # the listing is parsed line-by-line
"", # an empty write is a silent server-side no-op
*_PSMUX_ARGV_SHAPES,
],
)
def test_set_session_option_refuses_untransportable_values(monkeypatch, capsys, value):
Expand Down Expand Up @@ -1775,6 +1787,7 @@ def test_set_session_option_refuses_untransportable_values(monkeypatch, capsys,
"\\\\srv\\share name\\proj", # spaced UNC — `\\` no longer collapses (#547)
"C:\\Users\\O'Brien\\proj", # unspaced `'` no longer opens a quote
"-u", # a `-`-leading value is stored, not read as a flag (psmux/psmux#583)
*_DASH_SAFE_CONTROLS,
],
)
def test_set_session_option_accepts_transportable_values(monkeypatch, value):
Expand Down
31 changes: 31 additions & 0 deletions tests/test_psmux_live.py
Original file line number Diff line number Diff line change
Expand Up @@ -968,6 +968,37 @@ def test_adopted_a_dash_leading_option_value_is_stored(probe):
)


@pytest.mark.parametrize(
("value", "outcome"),
[
("-x=foo", (0, "-x foo")), # rewritten, and the write reports success
("-tx", (1, "seed")), # read as a target flag: refused, the seed stays
("--", (1, "seed")), # read as the end of options: same
],
)
def test_premise_an_argv_flag_shaped_option_value_is_not_stored(probe, value, outcome):
# psmux's client argv normalizers rewrite `-X=value` to `-X value` and read
# `-t…`/`--` as flags before `set-option` sees its value (psmux/psmux#795).
# One row per family, so each retires on its own: red means psmux handles
# that shape differently now, and if it reads back verbatim,
# _transportable's matching refusal is droppable behind a floor bump. The
# write's rc is part of the premise, so a probe whose write never ran
# cannot pass on the seed alone.
mux, session, _ = probe
seeded = mux._run(["set-option", "-t", session, "@k", "seed"], check=False)
assert seeded.returncode == 0, f"probe setup: seeding @k failed: {seeded.stderr.strip()!r}"
written = mux._run(["set-option", "-t", session, "@k", value], check=False)
got = mux._run(["show-options", "-qv", "-t", session, "@k"], check=False)
assert got.returncode == 0, f"probe setup: reading @k back failed: {got.stderr.strip()!r}"
observed = (written.returncode, got.stdout.strip())
assert observed == outcome, (
f"psmux now answers the option value {value!r} with (rc, read back) {observed!r}, "
f"not {outcome!r} (psmux/psmux#795) — if it reads back verbatim, "
"psmux_backend._transportable's refusal of that shape is droppable behind a "
"floor bump"
)


def test_adopted_a_pane_inherits_the_servers_start_path(tmp_path):
"""A pane inherits the PATH of the process that started its server, and a
bare `psmux` in the pane resolves through it (psmux/psmux#773); 3.3.8
Expand Down
Loading