diff --git a/CHANGES b/CHANGES index 2869aa57..4a344d01 100644 --- a/CHANGES +++ b/CHANGES @@ -6,6 +6,15 @@ _Notes on upcoming releases will be added here_ +### Optional feature requirements + +`load_buffer`, nonempty `paste_text` and exact named-buffer deletion require +tmux 3.4 before native effects, because a named buffer a 3.3a daemon cannot +delete would accumulate there. Ordinary input, capture, polling and completion +channels retain 3.3a. Owned subprocesses enforce libtmux's literal command +policy, including the older layout error boundary. Startup and tool dispatch +refuse a libtmux without `libtmux._internal.support.require_commands`. + ### Compatibility Core operations require tmux 3.3a and libtmux 0.63.0 or newer. diff --git a/docs/reference/compatibility.md b/docs/reference/compatibility.md index e62467d5..498f33f5 100644 --- a/docs/reference/compatibility.md +++ b/docs/reference/compatibility.md @@ -35,6 +35,29 @@ semantics; this does not make those threads interruptible. Higher requirements for individual features are documented separately from the core minimum. Baseline polling does not require control mode. +## Optional operations + +Ordinary input, pane capture, output polling, completion channels and +`run_command` retain the 3.3a core floor. The selected daemon determines +eligibility for each operation. + +| Operation | Requirement | +| --- | --- | +| `load_buffer`, which stages a named buffer, and named buffer deletion or rename | tmux 3.4+, before dispatch; a 3.3a daemon creates no buffer | +| Nonempty `paste_text` | tmux 3.4+, before loading its temporary buffer; failure cleanup needs exact named deletion | +| Empty `paste_text`, `show_buffer` of an existing buffer and direct `paste_buffer` | Retain 3.3a | +| Literal `select-layout` on 3.3a | Validate the preset or serialized header before dispatch; ordinary valid layouts remain available | + +The backend must provide `libtmux._internal.support.require_commands`; startup and +tools refuse a libtmux without it before work. Owned subprocess helpers also +enforce the parent's literal command policy, which a package pin by itself +cannot cover for commands that bypass libtmux's dispatcher. + +`run_command` sends an authored command to a pane and observes completion. It +keeps 3.3a support independently of the parent library's captured foreground +`run-shell` output, which requires 3.5. Polling tools do not acquire control. +These optional refusals do not raise the whole MCP server's minimum. + ## Dependencies | Package | Required version | @@ -43,15 +66,16 @@ the core minimum. Baseline polling does not require control mode. | [FastMCP](https://github.com/jlowin/fastmcp) | >= 4.0.2, < 5.0.0 | | [regex](https://github.com/mrabarnett/mrab-regex) | >= 2024.11.6 | -The libtmux backend must publish `libtmux._internal.support`, which first ships in -libtmux 0.63.0; the dependency range names that release, so an installer never -selects the 0.62 series. Until libtmux 0.63.0 is released, this branch's uv -source override pins the implementation from -[Python core PR779](https://github.com/tmux-python/libtmux/pull/779) for +The libtmux backend must publish `libtmux._internal.support`, including +`require_commands`, which first ships in libtmux 0.63.0; the dependency range +names that release, so an installer never selects the 0.62 series. Until +libtmux 0.63.0 is released, this branch's uv source override pins the +implementation from +[Python feature PR780](https://github.com/tmux-python/libtmux/pull/780) for development; ordinary package installers ignore that override. Startup and tool -dispatch refuse a backend without that module. Release libtmux first, -then drop the source override and merge this change. Use -`uv sync --all-extras --dev` for the development pair. +dispatch refuse a backend without those names. Release libtmux first, then drop +the source override and merge this change. Use `uv sync --all-extras --dev` for +the development pair. ## MCP clients diff --git a/pyproject.toml b/pyproject.toml index 4ce15372..523bf09c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -117,7 +117,7 @@ build-backend = "hatchling.build" [tool.uv.sources] # Coordinated version-check backend, pending its package release. -libtmux = { git = "https://github.com/tmux-python/libtmux.git", rev = "f6e4630a15f342382487a60e1e1477abcd52294d" } +libtmux = { git = "https://github.com/tmux-python/libtmux.git", rev = "5df9cb3e89647577b7dd8484f844ffe1053129bb" } [tool.uv.exclude-newer-package] # git-pull packages release in lockstep with their workspaces, so a diff --git a/src/libtmux_mcp/_tmux_proc.py b/src/libtmux_mcp/_tmux_proc.py index 8042651a..97a63bcf 100644 --- a/src/libtmux_mcp/_tmux_proc.py +++ b/src/libtmux_mcp/_tmux_proc.py @@ -148,7 +148,7 @@ def _backend() -> types.ModuleType: raise exc.LibTmuxException(msg) from error if not all( callable(getattr(support, name, None)) - for name in ("require_version", "check_fence") + for name in ("require_version", "check_fence", "require_commands") ) or not isinstance(getattr(support, "MINIMUM", None), str): msg = "Unsupported libtmux backend: the tmux version check is unavailable" raise exc.LibTmuxException(msg) @@ -247,13 +247,17 @@ def _remaining(deadline: float) -> float: return result -def _fence(argv: list[str], version: str, live: bool) -> tuple[list[str], str | None]: - _backend().require_version(version, minimum=_MINIMUM_TMUX) +def _fence( + argv: list[str], version: str, live: bool, *, minimum: str = _MINIMUM_TMUX +) -> tuple[list[str], str | None]: + policy = _backend() + policy.require_version(version, minimum=minimum) _, commands = _parts(argv) prefix = argv[: len(argv) - len(commands)] if commands else argv marker = "libtmux-mcp-version-" + uuid.uuid4().hex # These callers select one native command; preserve literal terminal text. literal = [item[:-1] + "\\;" if item.endswith(";") else item for item in commands] + policy.require_commands(version, literal, repaired_break=False) return [ *prefix, "-u", @@ -269,13 +273,13 @@ def _fence(argv: list[str], version: str, live: bool) -> tuple[list[str], str | async def _prepare_bounded( - argv: list[str], deadline: float + argv: list[str], deadline: float, *, minimum: str = _MINIMUM_TMUX ) -> tuple[list[str], str | None]: _backend() selectors, _commands = _parts(argv) cached = _VERSION_CACHE.get(_endpoint_key(argv[0], selectors)) if cached is not None: - return _fence(argv, cached, True) + return _fence(argv, cached, True, minimum=minimum) probe, marker = _probe(argv[0], selectors) code, output, error = await _run_tmux_unchecked(probe, timeout=_remaining(deadline)) live = not _absent(code, error) @@ -287,17 +291,17 @@ async def _prepare_bounded( [argv[0], "-V"], timeout=_remaining(deadline) ) version = _version(output, error, code, binary=argv[0]) - return _fence(argv, version, live) + return _fence(argv, version, live, minimum=minimum) async def _run_tmux_bounded( - argv: list[str], *, timeout: float + argv: list[str], *, timeout: float, minimum: str = _MINIMUM_TMUX ) -> tuple[int, bytes, bytes]: """Check the version and dispatch within one cancellable process budget.""" deadline = time.monotonic() + timeout selectors, _ = _parts(argv) for attempt in (0, 1): - prepared, marker = await _prepare_bounded(argv, deadline) + prepared, marker = await _prepare_bounded(argv, deadline, minimum=minimum) result = await _run_tmux_unchecked(prepared, timeout=_remaining(deadline)) stderr = result[2] if attempt == 0 and _absent(result[0], stderr): @@ -316,7 +320,7 @@ async def _run_tmux_bounded( def _run_tmux_sync_bounded( - argv: list[str], *, timeout: float, check: bool = True + argv: list[str], *, timeout: float, check: bool = True, minimum: str = _MINIMUM_TMUX ) -> subprocess.CompletedProcess[bytes]: """Check the version and run a synchronous child within one remaining budget.""" policy = _backend() @@ -345,7 +349,7 @@ def run(command: list[str]) -> subprocess.CompletedProcess[bytes]: version = _version( probe.stdout, probe.stderr, probe.returncode, binary=argv[0] ) - prepared, marker = _fence(argv, version, live) + prepared, marker = _fence(argv, version, live, minimum=minimum) result = run(prepared) if ( attempt == 0 diff --git a/src/libtmux_mcp/tools/buffer_tools.py b/src/libtmux_mcp/tools/buffer_tools.py index b9626ec8..bf0fa31d 100644 --- a/src/libtmux_mcp/tools/buffer_tools.py +++ b/src/libtmux_mcp/tools/buffer_tools.py @@ -186,6 +186,10 @@ def load_buffer( :attr:`~libtmux_mcp.models.BufferRef.buffer_name` on subsequent paste/show/delete calls. + Requires tmux 3.4: the staged buffer is named, and exact named-buffer + deletion, the only way to remove it, needs 3.4. Older daemons refuse + before creating the buffer. + **When to use this vs. paste_text:** ``load_buffer`` is the stage-then-fire path — you get a handle back and can inspect via ``show_buffer``, paste into multiple panes via ``paste_buffer``, @@ -218,7 +222,7 @@ def load_buffer( f.write(content) argv = _tmux_argv(server, "load-buffer", "-b", buffer_name, tmppath) try: - _run_tmux_sync_bounded(argv, timeout=5.0) + _run_tmux_sync_bounded(argv, timeout=5.0, minimum="3.4") except subprocess.TimeoutExpired as e: msg = f"load-buffer timeout after 5s for {buffer_name!r}" raise ExpectedToolError(msg) from e @@ -346,6 +350,9 @@ def delete_buffer( ) -> str: """Delete an MCP-owned buffer. + Requires tmux 3.4 for exact named-buffer deletion. Older daemons refuse + before modifying their buffer list. + Parameters ---------- buffer_name : str diff --git a/src/libtmux_mcp/tools/pane_tools/io.py b/src/libtmux_mcp/tools/pane_tools/io.py index 68358f57..bb097979 100644 --- a/src/libtmux_mcp/tools/pane_tools/io.py +++ b/src/libtmux_mcp/tools/pane_tools/io.py @@ -767,6 +767,9 @@ def paste_text( you need to stage content first, paste it into multiple panes, or inspect it with ``show_buffer`` before pasting. + Nonempty text requires tmux 3.4 for reliable named-buffer cleanup on + failure. Empty text has no input effect on every supported release. + Parameters ---------- text : str @@ -800,6 +803,9 @@ def paste_text( window_id=window_id, ) + if not text: + return f"Text pasted to pane {pane.pane_id}" + # Use a unique named tmux buffer so we don't clobber the user's # unnamed paste buffer, and so we can reliably clean up on error # paths (paste-buffer -b NAME -d deletes the named buffer). The @@ -819,7 +825,7 @@ def paste_text( load_args = _tmux_argv(server, "load-buffer", "-b", buffer_name, tmppath) try: - _run_tmux_sync_bounded(load_args, timeout=5.0) + _run_tmux_sync_bounded(load_args, timeout=5.0, minimum="3.4") except subprocess.TimeoutExpired as e: msg = f"load-buffer timeout after 5s for {buffer_name!r}" raise ExpectedToolError(msg) from e diff --git a/src/libtmux_mcp/tools/window_tools.py b/src/libtmux_mcp/tools/window_tools.py index a170de44..31bd2203 100644 --- a/src/libtmux_mcp/tools/window_tools.py +++ b/src/libtmux_mcp/tools/window_tools.py @@ -357,13 +357,16 @@ def select_layout( Choose from: even-horizontal, even-vertical, main-horizontal, main-vertical, or tiled. Rearranges all panes in the window. + On tmux 3.3a, unknown names and malformed serialized headers are + refused before dispatch. Valid layouts retain the core minimum. + Parameters ---------- layout : str Layout name or custom layout string. Built-in layouts: 'even-horizontal', 'even-vertical', 'main-horizontal', 'main-horizontal-mirrored', 'main-vertical', - 'main-vertical-mirrored', 'tiled'. + 'main-vertical-mirrored', 'tiled'. Mirrored layouts require tmux 3.5. window_id : str, optional Window ID (e.g. '@1'). window_index : str, optional diff --git a/tests/test_buffer_tools.py b/tests/test_buffer_tools.py index 822bdb56..1b2a3326 100644 --- a/tests/test_buffer_tools.py +++ b/tests/test_buffer_tools.py @@ -6,6 +6,7 @@ import pytest from fastmcp.exceptions import ToolError +from libtmux._internal import support from libtmux_mcp.middleware import _SENSITIVE_ARG_NAMES from libtmux_mcp.models import BufferContent, BufferRef @@ -23,6 +24,17 @@ from libtmux.server import Server +def _require_named_buffers(server: Server) -> None: + """Skip workflows that stage a named buffer a 3.3a daemon cannot delete.""" + if not support.is_at_least(server._get_version(), "3.4"): + pytest.skip("load_buffer stages a named buffer and needs tmux 3.4 to delete it") + + +def _assert_delete_policy(server: Server, name: str) -> None: + """Delete an agent buffer exactly.""" + delete_buffer(name, socket_name=server.socket_name) + + @pytest.mark.parametrize("name", ["my-buffer", "clipboard.v2", "a", "x" * 64]) def test_logical_name_accepts_valid(name: str) -> None: """Valid logical names pass through.""" @@ -63,6 +75,7 @@ def test_load_buffer_allocates_unique_name(mcp_server: Server) -> None: The UUID nonce in the allocated name prevents collisions even when the caller reuses the same logical name. """ + _require_named_buffers(mcp_server) a = load_buffer( content="first", logical_name="clipboard", @@ -81,13 +94,14 @@ def test_load_buffer_allocates_unique_name(mcp_server: Server) -> None: # Clean up — buffers are server-global; leaking them would pollute # the test run even though the libtmux Server fixture is torn down. - delete_buffer(a.buffer_name, socket_name=mcp_server.socket_name) - delete_buffer(b.buffer_name, socket_name=mcp_server.socket_name) + _assert_delete_policy(mcp_server, a.buffer_name) + _assert_delete_policy(mcp_server, b.buffer_name) @pytest.mark.usefixtures("mcp_session") def test_buffer_round_trip(mcp_server: Server) -> None: """Load -> show -> delete round-trip preserves content exactly.""" + _require_named_buffers(mcp_server) payload = "line1\nline2\nline3" ref = load_buffer( content=payload, @@ -122,6 +136,7 @@ def test_paste_buffer_requires_mcp_namespace( def test_paste_buffer_into_pane(mcp_server: Server, mcp_pane: Pane) -> None: """``paste_buffer`` pastes an MCP buffer into the target pane.""" + _require_named_buffers(mcp_server) ref = load_buffer( content="echo PASTE_BUFFER_MARKER", logical_name="smoke", @@ -135,7 +150,7 @@ def test_paste_buffer_into_pane(mcp_server: Server, mcp_pane: Pane) -> None: ) assert ref.buffer_name in result finally: - delete_buffer(ref.buffer_name, socket_name=mcp_server.socket_name) + _assert_delete_policy(mcp_server, ref.buffer_name) def test_content_is_in_sensitive_args() -> None: @@ -155,6 +170,7 @@ def test_show_buffer_tail_preserves_on_truncation(mcp_server: Server) -> None: ``True``, and ``content_truncated_lines`` reports how many were dropped so the caller can re-request with ``max_lines=None``. """ + _require_named_buffers(mcp_server) payload = "\n".join(f"line-{i}" for i in range(20)) ref = load_buffer( content=payload, @@ -173,12 +189,13 @@ def test_show_buffer_tail_preserves_on_truncation(mcp_server: Server) -> None: kept = seen.content.splitlines() assert kept == [f"line-{i}" for i in range(15, 20)] finally: - delete_buffer(ref.buffer_name, socket_name=mcp_server.socket_name) + _assert_delete_policy(mcp_server, ref.buffer_name) @pytest.mark.usefixtures("mcp_session") def test_show_buffer_full_read_when_max_lines_none(mcp_server: Server) -> None: """``max_lines=None`` disables truncation for full-buffer recovery.""" + _require_named_buffers(mcp_server) payload = "\n".join(f"line-{i}" for i in range(50)) ref = load_buffer( content=payload, @@ -195,12 +212,13 @@ def test_show_buffer_full_read_when_max_lines_none(mcp_server: Server) -> None: assert seen.content_truncated_lines == 0 assert seen.content.rstrip("\n") == payload finally: - delete_buffer(ref.buffer_name, socket_name=mcp_server.socket_name) + _assert_delete_policy(mcp_server, ref.buffer_name) @pytest.mark.usefixtures("mcp_session") def test_show_buffer_no_truncation_under_cap(mcp_server: Server) -> None: """Small buffers are returned verbatim with truncation flags off.""" + _require_named_buffers(mcp_server) payload = "one\ntwo\nthree" ref = load_buffer( content=payload, @@ -217,7 +235,7 @@ def test_show_buffer_no_truncation_under_cap(mcp_server: Server) -> None: assert seen.content_truncated_lines == 0 assert seen.content.rstrip("\n") == payload finally: - delete_buffer(ref.buffer_name, socket_name=mcp_server.socket_name) + _assert_delete_policy(mcp_server, ref.buffer_name) @pytest.mark.parametrize( diff --git a/tests/test_pane_tools.py b/tests/test_pane_tools.py index 8745060b..db88f52e 100644 --- a/tests/test_pane_tools.py +++ b/tests/test_pane_tools.py @@ -5581,6 +5581,15 @@ def test_paste_text(mcp_server: Server, mcp_pane: Pane) -> None: trailing newline forces execution, exercising the full paste->execute->output round-trip. """ + if mcp_server._get_version() == "3.3a": + with pytest.raises(ToolError, match=r"3\.4"): + paste_text( + text="echo PASTE_TEST_marker_xyz\n", + pane_id=mcp_pane.pane_id, + socket_name=mcp_server.socket_name, + ) + assert not mcp_server.cmd("list-buffers", "-F", "#{buffer_name}").stdout + return result = paste_text( text="echo PASTE_TEST_marker_xyz\n", pane_id=mcp_pane.pane_id, @@ -5618,9 +5627,18 @@ def test_paste_text_does_not_leak_named_buffer( ``list-buffers`` on that prefix sees every MCP-owned buffer. The check is portable across every tmux version the CI matrix - tests (3.2a through master): ``list-buffers`` with a format string + tests (3.3a through master): ``list-buffers`` with a format string returns buffer names without any version-specific behavior. """ + if mcp_server._get_version() == "3.3a": + with pytest.raises(ToolError, match=r"3\.4"): + paste_text( + text="echo BUFFER_ISOLATION_test", + pane_id=mcp_pane.pane_id, + socket_name=mcp_server.socket_name, + ) + assert not mcp_server.cmd("list-buffers", "-F", "#{buffer_name}").stdout + return paste_text( text="echo BUFFER_ISOLATION_test", pane_id=mcp_pane.pane_id, diff --git a/tests/test_tmux_features.py b/tests/test_tmux_features.py new file mode 100644 index 00000000..1e1c3d3b --- /dev/null +++ b/tests/test_tmux_features.py @@ -0,0 +1,185 @@ +"""Optional feature refusal leaves baseline MCP operations available.""" + +from __future__ import annotations + +import asyncio +import subprocess +import typing as t + +import pytest +from fastmcp.exceptions import ToolError +from libtmux import exc +from libtmux._internal import support + +from libtmux_mcp import _tmux_proc +from libtmux_mcp._utils import _tmux_argv +from libtmux_mcp.tools.buffer_tools import delete_buffer, load_buffer, show_buffer +from libtmux_mcp.tools.pane_tools.io import paste_text +from libtmux_mcp.tools.window_tools import select_layout + +if t.TYPE_CHECKING: + from libtmux import Pane, Server + + +def test_backend_requires_command_policy_before_spawning( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A libtmux without the command policy cannot qualify the owned helpers.""" + monkeypatch.delattr(support, "require_commands") + + def forbidden(*args: object, **kwargs: object) -> t.NoReturn: + pytest.fail("an unqualified backend spawned a native child") + + monkeypatch.setattr(subprocess, "run", forbidden) + with pytest.raises(exc.LibTmuxException, match="version check is unavailable"): + _tmux_proc._run_tmux_sync_bounded(["tmux", "list-sessions"], timeout=1) + + +@pytest.mark.parametrize("asynchronous", [False, True]) +@pytest.mark.parametrize( + "command", + [["delete-buffer", "-b", "missing"], ["setb", "-b", "missing", "-n", "renamed"]], +) +def test_owned_helpers_enforce_named_buffer_identity( + mcp_server: Server, mcp_pane: Pane, asynchronous: bool, command: list[str] +) -> None: + """Owned child paths share the parent literal-command policy.""" + mcp_server.set_buffer("keep", buffer_name="user") + argv = _tmux_argv(mcp_server, *command) + + def invoke() -> object: + if asynchronous: + return asyncio.run(_tmux_proc._run_tmux_bounded(argv, timeout=5)) + return _tmux_proc._run_tmux_sync_bounded(argv, timeout=5, check=False) + + if not support.is_at_least(mcp_server._get_version(), "3.4"): + with pytest.raises(exc.VersionTooLow, match=r"3\.4"): + invoke() + else: + result = invoke() + assert ( + result[0] if isinstance(result, tuple) else t.cast(t.Any, result).returncode + ) != 0 + assert mcp_server.show_buffer(buffer_name="user") == "keep" + + +def test_delete_tool_preserves_user_buffer_when_named_target_is_missing( + mcp_server: Server, mcp_pane: Pane +) -> None: + """A missing agent name must never delete the most recent user buffer.""" + mcp_server.set_buffer("retained", buffer_name="human") + missing = "libtmux_mcp_" + "a" * 32 + "_absent" + expected = ( + "3.4" + if not support.is_at_least(mcp_server._get_version(), "3.4") + else "unknown buffer" + ) + with pytest.raises(ToolError, match=expected): + delete_buffer(missing, socket_name=mcp_server.socket_name) + assert mcp_server.show_buffer(buffer_name="human") == "retained" + + +def test_managed_paste_checks_cleanup_before_staging( + mcp_server: Server, mcp_pane: Pane, monkeypatch: pytest.MonkeyPatch +) -> None: + """Failed paste cleanup is eligible before a temporary native buffer exists.""" + mcp_server.set_buffer("retained", buffer_name="human") + entered: list[bool] = [] + + def failed_paste(*args: object, **kwargs: object) -> t.NoReturn: + entered.append(True) + msg = "intentional paste failure" + raise RuntimeError(msg) + + monkeypatch.setattr(type(mcp_pane), "paste_buffer", failed_paste) + expected = ( + "3.4" + if not support.is_at_least(mcp_server._get_version(), "3.4") + else "intentional paste failure" + ) + with pytest.raises(ToolError, match=expected): + paste_text( + "never-entered", + pane_id=mcp_pane.pane_id, + socket_name=mcp_server.socket_name, + ) + assert entered == ([] if expected == "3.4" else [True]) + assert mcp_server.show_buffer(buffer_name="human") == "retained" + assert mcp_server.cmd("list-buffers", "-F", "#{buffer_name}").stdout == ["human"] + + +def test_empty_paste_retains_the_core_floor(mcp_server: Server, mcp_pane: Pane) -> None: + """Empty paste has no buffer effect on any supported release.""" + assert "pasted" in paste_text( + "", pane_id=mcp_pane.pane_id, socket_name=mcp_server.socket_name + ) + assert not mcp_server.cmd("list-buffers", "-F", "#{buffer_name}").stdout + + +def test_load_buffer_stages_only_where_it_can_be_deleted( + mcp_server: Server, mcp_pane: Pane +) -> None: + """A 3.3a daemon creates no buffer; 3.4 stages one the caller can delete.""" + mcp_server.set_buffer("retained", buffer_name="human") + if not support.is_at_least(mcp_server._get_version(), "3.4"): + with pytest.raises(ToolError, match=r"3\.4"): + load_buffer("never-staged", socket_name=mcp_server.socket_name) + assert mcp_server.cmd("list-buffers", "-F", "#{buffer_name}").stdout == [ + "human" + ] + return + ref = load_buffer("baseline-retained", socket_name=mcp_server.socket_name) + assert ( + show_buffer(ref.buffer_name, socket_name=mcp_server.socket_name).content + == "baseline-retained" + ) + delete_buffer(ref.buffer_name, socket_name=mcp_server.socket_name) + assert mcp_server.cmd("list-buffers", "-F", "#{buffer_name}").stdout == ["human"] + + +def test_owned_layout_dispatch_refuses_only_the_older_unsafe_error_path( + mcp_server: Server, mcp_pane: Pane +) -> None: + """Raw helper validation also covers the 3.3a layout error boundary.""" + window_id = mcp_pane.window.window_id + assert window_id is not None + argv = _tmux_argv(mcp_server, "select-layout", "-t", window_id, "not-a-layout") + if not support.is_at_least(mcp_server._get_version(), "3.4"): + with pytest.raises(ValueError, match="layout"): + _tmux_proc._run_tmux_sync_bounded(argv, timeout=5) + else: + assert ( + _tmux_proc._run_tmux_sync_bounded(argv, timeout=5, check=False).returncode + != 0 + ) + assert mcp_server.is_alive() + + +def test_layout_tool_reports_invalid_layout_without_ending_the_daemon( + mcp_server: Server, mcp_pane: Pane +) -> None: + """The advertised layout tool carries the parent validation boundary.""" + with pytest.raises(ToolError, match="layout"): + select_layout( + "not-a-layout", + window_id=mcp_pane.window.window_id, + socket_name=mcp_server.socket_name, + ) + assert mcp_server.is_alive() + + +@pytest.mark.parametrize( + "command", + [ + ["new-window", "-n", "a.b"], + ["rename-window", "a:b"], + ["new-session", "-s", "a.b"], + ], +) +def test_owned_helpers_inherit_the_exact_37_name_guard( + monkeypatch: pytest.MonkeyPatch, command: list[str] +) -> None: + """The owned fence runs libtmux's command policy, names included.""" + with pytest.raises(exc.VersionTooLow, match="invalid window or session name"): + _tmux_proc._fence(["tmux", *command], "3.7", live=True) + _tmux_proc._fence(["tmux", *command], "3.7a", live=True) diff --git a/uv.lock b/uv.lock index 260a8a00..ccf6e525 100644 --- a/uv.lock +++ b/uv.lock @@ -1492,7 +1492,7 @@ wheels = [ [[package]] name = "libtmux" version = "0.62.0" -source = { git = "https://github.com/tmux-python/libtmux.git?rev=f6e4630a15f342382487a60e1e1477abcd52294d#f6e4630a15f342382487a60e1e1477abcd52294d" } +source = { git = "https://github.com/tmux-python/libtmux.git?rev=5df9cb3e89647577b7dd8484f844ffe1053129bb#5df9cb3e89647577b7dd8484f844ffe1053129bb" } [[package]] name = "libtmux-mcp" @@ -1558,7 +1558,7 @@ testing = [ [package.metadata] requires-dist = [ { name = "fastmcp", specifier = ">=4.0.2,<5.0.0" }, - { name = "libtmux", git = "https://github.com/tmux-python/libtmux.git?rev=f6e4630a15f342382487a60e1e1477abcd52294d" }, + { name = "libtmux", git = "https://github.com/tmux-python/libtmux.git?rev=5df9cb3e89647577b7dd8484f844ffe1053129bb" }, { name = "regex", specifier = ">=2024.11.6" }, ]