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
17 changes: 8 additions & 9 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -188,15 +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 a window listing cannot carry no longer strands — or crashes — the scans over it.**
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. A byte that is not valid in the
filesystem encoding arrived surrogate-escaped and made the listing read raise `UnicodeDecodeError`
outright. Both are now percent-encoded in the tag; every other path is tagged byte-identically, so
tags already stored on live windows and sessions keep comparing equal. Reading a tag stored raw 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
Expand Down
19 changes: 9 additions & 10 deletions docs/multiplexer-backends.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <session> "@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

Expand Down
14 changes: 4 additions & 10 deletions src/bmad_loop/adapters/psmux_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down
21 changes: 12 additions & 9 deletions src/bmad_loop/adapters/tmux_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -361,15 +361,18 @@ def list_windows(self, session: str, fields: list[str]) -> list[tuple[str, ...]]
return []
rows: list[tuple[str, ...]] = []
for line in probe.stdout.splitlines():
# Bounded split, so the LAST field may itself contain tabs. Fields
# carrying arbitrary text do exist — PROJECT_OPTION holds a resolved
# filesystem path, and a tab is a legal POSIX filename byte — and an
# unbounded split turns one such row into extra parts that the slice
# below then truncates, silently corrupting the field's value.
# Callers requesting a free-text field must therefore ask for it
# last; every current caller does. (A newline in that value still
# splits the row, which no parse here can undo — so runs.project_tag
# encodes a path holding one rather than leaning on this split.)
# Bounded split, so the LAST field may itself contain tabs. An
# unbounded split turns a row whose last field carries arbitrary
# text into extra parts that the slice below then truncates,
# silently corrupting the value. Callers requesting a free-text
# field must therefore ask for it last; every current caller does.
# This is the seam's standing contract, not a fix for one caller:
# no field requested today can hold a tab — PROJECT_OPTION is a
# digest and window names carry a shape-validated run id — so the
# bound is here for the next free-text field rather than these.
# (A newline in a value still splits the row, which no parse here
# can undo; runs.project_tag's digest is what keeps the tag clear
# of one.)
parts = line.split("\t", len(fields) - 1)
parts += [""] * (len(fields) - len(parts)) # tolerate unset trailing fields
rows.append(tuple(parts[: len(fields)]))
Expand Down
159 changes: 58 additions & 101 deletions src/bmad_loop/runs.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,16 @@

from __future__ import annotations

import hashlib
import json
import math
import os
import re
import secrets
import shutil
import sys
import tarfile
import time
from pathlib import Path
from urllib.parse import quote

from . import devcontract, verify
from .adapters.multiplexer import get_multiplexer
Expand Down Expand Up @@ -296,99 +295,53 @@ def kill_session(run_id: str) -> None:
# prunable_sessions and tui.launch.
PROJECT_OPTION = "@bmad_project"

# Marks a tag whose project path could not ride a listing verbatim. A resolved
# absolute path never begins with it — POSIX starts at "/", win32 at a drive
# letter or "\\" — so an encoded tag can never be mistaken for a raw one, in
# either direction, and the two namespaces stay disjoint without a version byte.
_TAG_ENCODED_PREFIX = "%enc%"


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.

The result always reaches a comparison site as it was written, so the tag is
authoritative and no caller needs a can-I-trust-this fallback. Two halves
make that true: nothing `splitlines()` breaks on survives here (those are
encoded), so a row cannot split; and a tab is carried intact by the bounded
field split in `BaseTmuxBackend.list_windows`, so it needs no encoding.

Encoding is conditional on purpose, and that is the whole compatibility
story. Tags are persisted on live windows and sessions, so encoding *every*
path would strand every tag written before this change (AGENTS.md's
compatibility rule). Returning a transportable path byte-identical strands
none: the only tags whose spelling changes are the ones the transport was
already mangling, which by definition never compared equal to anything."""
raw = str(project.resolve())
if _survives_listing(raw):
return raw
# safe="" so no separator can hide in a reserved character; the output is
# unreserved ASCII plus "%", which is inert to both the row and field splits.
# surrogateescape turns a non-UTF-8 filename byte back into that byte before
# percent-encoding it — without it this call raises UnicodeEncodeError on
# exactly the paths the encoding exists to carry.
return _TAG_ENCODED_PREFIX + quote(raw, safe="", errors="surrogateescape")


def _survives_listing(tag: str) -> bool:
"""Whether `tag` survives a multiplexer listing round trip intact.

The backends emit one window per line with tab-separated fields and split
the result with `str.splitlines()`, so anything *that* treats as a line
break splits the tag across two rows — which no parse on the receiving side
can undo, because the row boundary is the framing itself. A comparison
against the truncated remainder does not merely fail to match: it makes a
window look like it belongs to *another* project, so the caller discards the
project's own windows.

Asked of `splitlines` itself rather than by listing the characters. The set
is far wider than LF and CR — VT, FF, FS, GS, RS, NEL, U+2028, U+2029 all
split — and every one of them is a legal byte in a POSIX directory name, so
an enumeration here would be a second copy of CPython's table that silently
rots when it grows. Routing the question through the same function the
parser uses cannot drift from it.

The comparison is against the whole tag, not a row count, because a
*trailing* separator is equally fatal and does not add a row: `"/p\\r"`
splits to `["/p"]`, one element, yet the tag read back is `"/p"` and no
longer equals what was written. `text=True` on the subprocess also folds
CR and CRLF to LF before any of this, which is one more reason not to
reason about individual characters here.

Tabs are deliberately NOT rejected here, and `splitlines` does not split on
them. They are equally legal in a path, but the backends' bounded split lets
a trailing field carry them intact (see BaseTmuxBackend.list_windows), so a
tab round-trips and a tagged comparison stays exact. Widening this to tabs
would rewrite the stored tag of every project holding one, for nothing — and
would mask the parser's guarantee rather than rest on it. The two mechanisms
stay disjoint on purpose: delete the bounded split and the tab cases fail;
delete the encoding and the separator cases fail. Neither covers the other.

Two ways to fail, and both are asked in the transport's own terms rather
than by enumerating characters. A separator splits the *row*, which the
framing puts beyond any receiving-side parse. A surrogateescaped byte — what
`os.fsdecode` leaves behind for a filename byte that is not valid in the
filesystem encoding, and legal in every POSIX name — cannot be encoded at
all, so the listing carries the original byte and the backend's strict
decode raises `UnicodeDecodeError` on the way back. Left raw, that turns an
attach or a stop into a crash rather than a mismatch.

This selects `project_tag`'s spelling; it is not a question any comparison
site asks. An earlier shape exposed it to callers so they could fall back to
the untagged path when their own tag looked unsafe, but "stop comparing
tags" admits rows carrying *another* project's tag — the fallback restored
reach by giving up the discriminator that keeps a stop from crossing project
boundaries. Encoding the few paths that need it keeps the discriminator for
every project instead."""
if tag.splitlines()[:1] != [tag]:
return False
try:
tag.encode(sys.getfilesystemencoding())
except UnicodeEncodeError:
return False # a surrogateescaped byte — no codec can carry it as text
return True
"""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 by construction, on both
transports a tag has to cross. It clears psmux's control line (#419), whose
gate refuses any value the CLI->server hop would mangle — a UNC share whose
name holds a space is refused verbatim, and that refusal left the session
untagged, which is weak ownership twice over. It equally clears the listing
round trip (#518): a hex digest holds nothing `str.splitlines()` breaks on,
no tab, and no byte outside ASCII, so it can neither split a row nor fail the
backends' strict decode.

That subsumes the conditional percent-encoding this function briefly applied.
Encoding answered only the listing half, so a path the listing could carry but
the control line could not — the spaced UNC above — still went untagged. The
compatibility objection encoding was shaped around, that rewriting every tag
strands the ones already stored on live sessions and windows, is answered on
the read side instead, by `accepted_tags`.

16 hex characters are ample for one machine's project population.
"""
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.

The two shapes cannot collide into false ownership: a legacy tag is an absolute
path, so it always holds a separator, while a digest is bare 16-hex.

Deliberately two members and not three — a tag spelled with the `%enc%` prefix,
from the window when this module encoded rather than hashed, is not accepted.
Only a path the listing could not carry was ever spelled that way (one holding
a line separator, or a byte invalid in the filesystem encoding), and that
spelling never reached a release. An unaccepted tag reads as foreign, which
skips the session rather than pruning it, so the edge is fail-safe and clears
itself on the next tag write.
"""
return frozenset({project_tag(project), str(project.resolve())})
Comment thread
pbean marked this conversation as resolved.


def mux_sessions() -> list[str]:
Expand All @@ -412,16 +365,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()
Expand All @@ -434,7 +391,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
Expand Down
Loading