Repository navigation
psmux backend: _transportable admits -X=value option values, which psmux 3.4.0 silently stores as -X value (regression from the #900 retirement) #905
Description
Summary
#900 raised the psmux floor to 3.4.0 and dropped _transportable's refusal of every --leading @ option value, because 3.4.0 stores -u, -g, -x and similar values verbatim (psmux/psmux#583). Three shapes still do not survive psmux 3.4.0's client argv normalizers and set-option parser:
-X=value:-X=valuewith a nonempty suffix and an ASCII letter other than lowercasetis rewritten before option parsing; measured examples-x=fooand-a=b cread back as-x fooand-a b cat rc 0 with no warning on both bmad-loop paths (3/3 fresh sessions). The alphabetic family is source-derived from psmux v3.4.0src/cli.rs:386; lowercasettakes the failure path below. Empty-suffix controls-x=and-a=round-trip verbatim. bmad-loop'sset_session_optionand the per-window channel (@<option>__blw@<N>keys) pass these values straight through, so the value reads back different from what was written, silently. Silently diverging values are exactly what_transportableexists to prevent.-t…(-t,-tx,-t=foo,-target, …) and--: psmux refuses them with rc 1. On the session path bmad-loop raisesTmuxErrorpartway through the write. On the window path it warns. Neither frees the key the way the gate's own refusal does: a rewrite of an existing value to one of these shapes leaves the old value in place (observed on both paths).
tmux 3.4 stores all three shapes verbatim. The psmux side is reported in the psmux report (psmux/psmux#795). bmad-loop needs a workaround until a psmux release carries the fix.
Impact is source-derived: no value bmad-loop writes today has these shapes (hex project tags from runs.project_tag, =session:%N return targets and detach all round-trip). This reproduction demonstrates a latent backend contract defect; it does not demonstrate a real current run becoming unprunable. That consequence is conditional on a caller writing such a value, for example a tag that reads back different and leaves a window unprunable. It is still a hole the gate was built to close, and #900 reopened it.
Environment
psmux 3.4.0 (20aa1d7), Windows 11 10.0.26200, PowerShell 7.6.6 (the launching shell; SHELL removed), bmad-loop main 71ee261.
Steps to reproduce
Launched from PowerShell 7 in a bmad-loop checkout (uv sync done). Zero LLM tokens. Each value is written over a preexisting seed, through both bmad-loop paths: set_session_option and the per-window channel (set_window_option / show_window_option).
Remove-Item Env:SHELL -ErrorAction SilentlyContinue
$env:PYTHONUTF8 = '1'; $env:PSMUX_NO_WARM = '1'
$env:PSMUX_DATA_DIR = "$env:TEMP\bl-dash\psmux-data"
New-Item -ItemType Directory -Force $env:PSMUX_DATA_DIR | Out-Null
psmux new-session -d -s bl-dash
uv run python -c @'
import contextlib, io
from bmad_loop.adapters.psmux_backend import PsmuxMultiplexer
m = PsmuxMultiplexer()
S, W = "bl-dash", "bl-dash:@1"
paths = {
"session": (lambda v: m.set_session_option(S, "@k", v),
lambda: m._run(["show-options", "-qv", "-t", S, "@k"], check=False).stdout.strip()),
"window": (lambda v: m.set_window_option(W, "@k", v), lambda: m.show_window_option(W, "@k")),
}
for v in ["plain", "-u", "-1=x", "--x=y", "-x=", "-x=foo", "-a=b c", "-t", "-tx", "-t=foo", "-target", "--"]:
for path, (put, get) in paths.items():
put("seed")
err, note = io.StringIO(), ""
with contextlib.redirect_stderr(err):
try:
put(v)
except Exception as exc:
note = f"{type(exc).__name__}: {exc}"
note = note or err.getvalue().strip()
got = get()
verdict = "ok" if got == v else ("STALE" if got == "seed" else "DIFFERENT")
print(f"{path:7} {v!r:9} -> {got!r:9} {verdict:9} {note}")
'@
if ($env:PSMUX_DATA_DIR -like "$env:TEMP\bl-dash\*") { psmux kill-server }
Remove-Item Env:PSMUX_DATA_DIRExpected
Each value reads back exactly as written, or bmad-loop refuses it with its usual warning and frees the key.
Actual
session 'plain' -> 'plain' ok
window 'plain' -> 'plain' ok
session '-u' -> '-u' ok
window '-u' -> '-u' ok
session '-1=x' -> '-1=x' ok
window '-1=x' -> '-1=x' ok
session '--x=y' -> '--x=y' ok
window '--x=y' -> '--x=y' ok
session '-x=' -> '-x=' ok
window '-x=' -> '-x=' ok
session '-x=foo' -> '-x foo' DIFFERENT
window '-x=foo' -> '-x foo' DIFFERENT
session '-a=b c' -> '-a b c' DIFFERENT
window '-a=b c' -> '-a b c' DIFFERENT
session '-t' -> 'seed' STALE TmuxError: psmux set-option -t failed: psmux: set-option: target required after -t
window '-t' -> 'seed' STALE warning: set-option @k__blw@1 failed: psmux: set-option: target required after -t
session '-tx' -> 'seed' STALE TmuxError: psmux set-option -t failed: psmux: set-option: empty value for '@k'
window '-tx' -> 'seed' STALE warning: set-option @k__blw@1 failed: psmux: set-option: empty value for '@k__blw@1'
session '-t=foo' -> 'seed' STALE TmuxError: psmux set-option -t failed: psmux: set-option: empty value for '@k'
window '-t=foo' -> 'seed' STALE warning: set-option @k__blw@1 failed: psmux: set-option: empty value for '@k__blw@1'
session '-target' -> 'seed' STALE TmuxError: psmux set-option -t failed: psmux: set-option: empty value for '@k'
window '-target' -> 'seed' STALE warning: set-option @k__blw@1 failed: psmux: set-option: empty value for '@k__blw@1'
session '--' -> 'seed' STALE TmuxError: psmux set-option -t failed: psmux: set-option: empty value for '@k'
window '--' -> 'seed' STALE warning: set-option @k__blw@1 failed: psmux: set-option: empty value for '@k__blw@1'
The -X=value rows: no warning and no exception, and the write reports success. Bare -t raises TmuxError: psmux set-option -t failed: psmux: set-option: target required after -t. The measured -tx, -t=foo, -target and -- values raise empty value for '@k' on the session path. The window path warns instead. Both leave a preexisting value unchanged. Independently reproduced in 3 of 3 fresh sessions on both paths. For contrast, a value the gate itself refuses (for example one containing ") warns "the key is freed" and reads back as unset on both paths.
Source
src/bmad_loop/adapters/psmux_backend.py at 71ee261. _transportable (line 932) has no dash rule since #900; its checks are only these:
# src/bmad_loop/adapters/psmux_backend.py:950
if not value:
return False
if '"' in value or value != value.strip():
return False
return value.splitlines() == [value]set_session_option gates on it at line 977 and frees refused values at :984; an admitted value goes straight to the raw write:
# src/bmad_loop/adapters/psmux_backend.py:984
self._write_scoped(["set-option", "-u", "-t", name, option], option)
return
super().set_session_option(name, option, value)The window channel gates on it at line 1019, frees refused keys at :1028 and writes admitted values at :1030 through _write_scoped (:989-1003), which warns on a failed write instead of raising.
Suggested fix
Proposed, not validated: have _transportable refuse the shapes psmux 3.4.0 cannot carry, so they take the existing visible path: a warning, and the key freed. Concretely:
- a value matching
^-[A-Za-z]=.(at least one character after the equals sign, in addition to the gate's existing whitespace/line-break exclusions): the silent one; - a value starting with
-t, or equal to--(refused by psmux today, but refusing them up front keeps the session path from raising partway through and the window path from keeping a stale value).
Preserve the existing gate's other restrictions and keep the measured safe controls -u, -1=x, --x=y, -x= and -a= admitted. The existing single-value live row does not establish all other dash-leading strings, and not every ASCII-letter variant was live-tested. Add a live-gate tripwire next to test_adopted_a_dash_leading_option_value_is_stored: a test_premise_* row that turns red when a psmux release stores -x=foo verbatim, so the narrowed refusal can retire behind a floor bump, the same way #900 retired the old one. One tripwire for -x=foo alone does not prove the -t…/-- defects are repaired; add independent retirement checks for those refusals.
Refs #900
Activity
fix(psmux): refuse -X=value, -t... and -- option values psmux 3.4.0 c…