From c38f527e75c50e53bcc631d3123fcb366f6b4a2c Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 5 Sep 2026 04:23:51 -0500 Subject: [PATCH 01/65] Libtmux(feat[runtime]): Add typed tmux operations --- .../src/main/java/io/github/libtmux/Pane.java | 38 +++++++++++++++++-- .../main/java/io/github/libtmux/Server.java | 37 ++++++++++++++++++ .../main/java/io/github/libtmux/Session.java | 10 ++++- .../java/io/github/libtmux/SessionSpec.java | 6 +-- .../java/io/github/libtmux/SplitSpec.java | 2 +- .../java/io/github/libtmux/TmuxFormats.java | 14 +++++++ .../main/java/io/github/libtmux/Window.java | 35 ++++++++++++++++- .../java/io/github/libtmux/WindowSpec.java | 4 +- .../io/github/libtmux/CreationSpecTest.java | 16 ++++++++ 9 files changed, 151 insertions(+), 11 deletions(-) create mode 100644 libtmux/src/main/java/io/github/libtmux/TmuxFormats.java diff --git a/libtmux/src/main/java/io/github/libtmux/Pane.java b/libtmux/src/main/java/io/github/libtmux/Pane.java index afd7945..b3304f6 100644 --- a/libtmux/src/main/java/io/github/libtmux/Pane.java +++ b/libtmux/src/main/java/io/github/libtmux/Pane.java @@ -8,6 +8,7 @@ import java.nio.file.Path; import java.util.ArrayList; import java.util.List; +import java.util.Map; import java.util.Objects; import java.util.Optional; import java.util.UUID; @@ -222,7 +223,7 @@ public void select() { */ public Pane retitle(String title) { Objects.requireNonNull(title, "title"); - server.run(snapshot, List.of("select-pane", "-t", state.id().value(), "-T", title)); + server.run(snapshot, List.of("select-pane", "-t", state.id().value(), "-T", TmuxFormats.literal(title))); return refresh(); } @@ -312,6 +313,21 @@ public void send(String keys) { server.run(snapshot, List.of("send-keys", "-t", state.id().value(), keys)); } + /** Sends an ordered group of key names, or literal strings, to this pane. */ + public void sendKeys(List keys, boolean literal) { + Objects.requireNonNull(keys, "keys"); + if (keys.isEmpty()) { + throw new IllegalArgumentException("keys are empty"); + } + List argv = new ArrayList<>(List.of("send-keys")); + if (literal) { + argv.add("-l"); + } + argv.addAll(List.of("-t", state.id().value())); + argv.addAll(keys); + server.run(snapshot, argv); + } + /** Sends a line to this pane and presses Enter, which is how a command gets run. */ public void sendLine(String command) { Objects.requireNonNull(command, "command"); @@ -362,6 +378,11 @@ public String expand(String format) { return String.join("\n", reported); } + /** Reads validated tmux variables in this pane's format context. */ + public Map variables(List names) { + return server.variables(names, this::expand); + } + /** * Kills whatever runs here and starts the pane's default command again. * @@ -373,6 +394,16 @@ public void respawn() { server.run(snapshot, List.of("respawn-pane", "-k", "-t", state.id().value())); } + /** Restarts the configured pane process in a caller-supplied literal directory. */ + public void respawnIn(Path directory) { + Objects.requireNonNull(directory, "directory"); + server.run(snapshot, respawnArgv(state.id(), directory)); + } + + static List respawnArgv(PaneId pane, Path directory) { + return List.of("respawn-pane", "-k", "-c", TmuxFormats.literal(directory.toString()), "-t", pane.value()); + } + /** * Kills whatever runs here and starts the given command instead. * @@ -416,7 +447,7 @@ public Window breakOut(String windowName) { * @param supplied the name to hand tmux, which is never absent because 3.7 crashes without one */ private Window breakNamed(Optional wanted, String supplied) { - List argv = new ArrayList<>(List.of("break-pane", "-d", "-n", supplied)); + List argv = new ArrayList<>(List.of("break-pane", "-d", "-n", TmuxFormats.literal(supplied))); argv.addAll(List.of("-s", state.id().value(), "-P", "-F", BROKEN_OUT.template())); List fields = BROKEN_OUT.split(server.run(snapshot, argv).stdout().get(0)); @@ -426,7 +457,8 @@ private Window breakNamed(Optional wanted, String supplied) { new WindowId(fields.get(1))); if (server.version(snapshot).equals(BREAK_PANE_NAMING_BROKEN)) { // 3.7 took the name and ignored it, so the caller's choice is applied afterwards. - wanted.ifPresent(name -> server.run(snapshot, List.of("rename-window", "-t", fields.get(1), name))); + wanted.ifPresent(name -> + server.run(snapshot, List.of("rename-window", "-t", fields.get(1), TmuxFormats.literal(name)))); } ServerSnapshot fresh = server.refresh(snapshot); return fresh.window(created) diff --git a/libtmux/src/main/java/io/github/libtmux/Server.java b/libtmux/src/main/java/io/github/libtmux/Server.java index 9c03d7d..22dd436 100644 --- a/libtmux/src/main/java/io/github/libtmux/Server.java +++ b/libtmux/src/main/java/io/github/libtmux/Server.java @@ -13,10 +13,15 @@ import java.time.Duration; import java.time.Instant; import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import java.util.Objects; import java.util.concurrent.atomic.AtomicBoolean; import java.util.function.Consumer; +import java.util.function.Function; +import java.util.regex.Pattern; import java.util.stream.Collectors; import org.jspecify.annotations.Nullable; @@ -32,6 +37,8 @@ */ public final class Server implements AutoCloseable { + private static final Pattern VARIABLE_NAME = Pattern.compile("[A-Za-z][A-Za-z0-9_]*"); + private final ServerConfig config; private final TmuxTransport transport; private final boolean owned; @@ -334,6 +341,36 @@ public Channel channel(String name) { return new Channel(this, name); } + /** Reads only validated tmux variable names, never caller-authored format syntax. */ + public Map variables(List names) { + return variables(names, this::expand); + } + + Map variables(List names, Function expand) { + Objects.requireNonNull(names, "names"); + Objects.requireNonNull(expand, "expand"); + if (names.isEmpty()) { + throw new IllegalArgumentException("variable names are empty"); + } + if (names.size() > 32) { + throw new IllegalArgumentException("at most 32 tmux variables may be read at once"); + } + Map values = new LinkedHashMap<>(); + for (String name : names) { + if (!VARIABLE_NAME.matcher(name).matches()) { + throw new IllegalArgumentException( + "invalid tmux variable '" + name + "'; expected [A-Za-z][A-Za-z0-9_]*"); + } + values.put(name, expand.apply("#{" + name + "}")); + } + return Collections.unmodifiableMap(values); + } + + /** Enables or disables mouse handling for sessions on this server. */ + public void setMouseEnabled(boolean enabled) { + globalOptions().set("mouse", enabled ? "on" : "off"); + } + WakeReason awaitChannel(String channel, Duration timeout, boolean reserveSignalCapacity) { try { CommandRequest request = request(List.of("wait-for", channel), timeout); diff --git a/libtmux/src/main/java/io/github/libtmux/Session.java b/libtmux/src/main/java/io/github/libtmux/Session.java index 95a7eea..e2f8141 100644 --- a/libtmux/src/main/java/io/github/libtmux/Session.java +++ b/libtmux/src/main/java/io/github/libtmux/Session.java @@ -118,6 +118,14 @@ public Hooks hooks() { return Hooks.session(server, snapshot, state.id()); } + /** Sets the scrollback retained by panes created in this session. */ + public void setHistoryLimit(int lines) { + if (lines < 0) { + throw new IllegalArgumentException("history limit is negative: " + lines); + } + options().set("history-limit", Integer.toString(lines)); + } + /** This session's windows, in tmux's order. A pure read of the capture. */ public List windows() { return snapshot.windowsOf(state.id()).stream() @@ -206,7 +214,7 @@ public String expand(String format) { /** Renames this session and returns a handle on it as it is now. */ public Session rename(String name) { - server.run(snapshot, List.of("rename-session", "-t", state.id().value(), name)); + server.run(snapshot, List.of("rename-session", "-t", state.id().value(), TmuxFormats.literal(name))); return refresh(); } diff --git a/libtmux/src/main/java/io/github/libtmux/SessionSpec.java b/libtmux/src/main/java/io/github/libtmux/SessionSpec.java index 1bcd541..768ea57 100644 --- a/libtmux/src/main/java/io/github/libtmux/SessionSpec.java +++ b/libtmux/src/main/java/io/github/libtmux/SessionSpec.java @@ -143,11 +143,11 @@ List argv(String format, Supplier running) { } if (name != null) { argv.add("-s"); - argv.add(name); + argv.add(TmuxFormats.literal(name)); } if (windowName != null) { argv.add("-n"); - argv.add(windowName); + argv.add(TmuxFormats.literal(windowName)); } if (size != null) { argv.add("-x"); @@ -157,7 +157,7 @@ List argv(String format, Supplier running) { } if (directory != null) { argv.add("-c"); - argv.add(directory.toString()); + argv.add(TmuxFormats.literal(directory.toString())); } if (!clientFlags.isEmpty()) { // tmux reads -f as one comma-separated list, not as a flag that may repeat. diff --git a/libtmux/src/main/java/io/github/libtmux/SplitSpec.java b/libtmux/src/main/java/io/github/libtmux/SplitSpec.java index 41f38f9..e3f90b8 100644 --- a/libtmux/src/main/java/io/github/libtmux/SplitSpec.java +++ b/libtmux/src/main/java/io/github/libtmux/SplitSpec.java @@ -169,7 +169,7 @@ List argv(String target, String format, TmuxVersion running) { } if (directory != null) { argv.add("-c"); - argv.add(directory.toString()); + argv.add(TmuxFormats.literal(directory.toString())); } for (Map.Entry variable : environment.entrySet()) { argv.add("-e"); diff --git a/libtmux/src/main/java/io/github/libtmux/TmuxFormats.java b/libtmux/src/main/java/io/github/libtmux/TmuxFormats.java new file mode 100644 index 0000000..baf5447 --- /dev/null +++ b/libtmux/src/main/java/io/github/libtmux/TmuxFormats.java @@ -0,0 +1,14 @@ +package io.github.libtmux; + +import java.util.Objects; + +/** Values handed to a tmux argument position that expands formats. */ +final class TmuxFormats { + + private TmuxFormats() {} + + /** Makes one caller value literal at one construction boundary. */ + static String literal(String value) { + return Objects.requireNonNull(value, "value").replace("#", "##"); + } +} diff --git a/libtmux/src/main/java/io/github/libtmux/Window.java b/libtmux/src/main/java/io/github/libtmux/Window.java index f646289..be83790 100644 --- a/libtmux/src/main/java/io/github/libtmux/Window.java +++ b/libtmux/src/main/java/io/github/libtmux/Window.java @@ -173,7 +173,7 @@ public String expand(String format) { * both. Unlike a session name, a window name is never rewritten. */ public Window rename(String name) { - server.run(snapshot, List.of("rename-window", "-t", target(), name)); + server.run(snapshot, List.of("rename-window", "-t", target(), TmuxFormats.literal(name))); return refresh(); } @@ -205,6 +205,39 @@ public void moveTo(Session session) { List.of("move-window", "-s", linkTarget(), "-t", session.id().value())); } + /** Moves this window to an exact index in another session. */ + public void moveTo(Session session, int index) { + if (index < 0) { + throw new IllegalArgumentException("window index is negative: " + index); + } + Objects.requireNonNull(session, "session"); + server.requireSameIncarnation(snapshot, session.server(), session.snapshot()); + server.run( + snapshot, + state.context(), + List.of("move-window", "-s", linkTarget(), "-t", session.id().value() + ":" + index)); + } + + /** Resizes this window in terminal cells. */ + public void resizeTo(Dimensions size) { + Objects.requireNonNull(size, "size"); + server.run( + snapshot, + List.of( + "resize-window", + "-t", + target(), + "-x", + Integer.toString(size.width()), + "-y", + Integer.toString(size.height()))); + } + + /** Controls whether input to one pane is copied to every pane in this window. */ + public void setSynchronizePanes(boolean enabled) { + options().set("synchronize-panes", enabled ? "on" : "off"); + } + /** Rotates the panes within this window. */ public void rotate() { server.run(snapshot, List.of("rotate-window", "-t", target())); diff --git a/libtmux/src/main/java/io/github/libtmux/WindowSpec.java b/libtmux/src/main/java/io/github/libtmux/WindowSpec.java index f2865d6..5f9c939 100644 --- a/libtmux/src/main/java/io/github/libtmux/WindowSpec.java +++ b/libtmux/src/main/java/io/github/libtmux/WindowSpec.java @@ -135,11 +135,11 @@ List argv(String target, String format, TmuxVersion running) { } if (name != null) { argv.add("-n"); - argv.add(name); + argv.add(TmuxFormats.literal(name)); } if (directory != null) { argv.add("-c"); - argv.add(directory.toString()); + argv.add(TmuxFormats.literal(directory.toString())); } for (Map.Entry variable : environment.entrySet()) { argv.add("-e"); diff --git a/libtmux/src/test/java/io/github/libtmux/CreationSpecTest.java b/libtmux/src/test/java/io/github/libtmux/CreationSpecTest.java index 029589d..bb3471b 100644 --- a/libtmux/src/test/java/io/github/libtmux/CreationSpecTest.java +++ b/libtmux/src/test/java/io/github/libtmux/CreationSpecTest.java @@ -201,6 +201,22 @@ void aSessionCarriesItsDirectoryEnvironmentAndFirstWindowName() { assertTrue(argv.containsAll(List.of("-D", "-X"))); } + @Test + void callerDirectoriesAreLiteralizedExactlyOnceAtEverySpawnBoundary() { + Path supplied = Path.of("/srv/#one/##two"); + String literal = "/srv/##one/####two"; + + List session = SessionSpec.builder().in(supplied).build().argv(FORMAT, () -> V37B); + List window = WindowSpec.builder().in(supplied).build().argv("$1", FORMAT, V37B); + List split = SplitSpec.builder().in(supplied).build().argv("%1", FORMAT, V37B); + List respawn = Pane.respawnArgv(new PaneId("%1"), supplied); + + assertEquals(literal, session.get(session.indexOf("-c") + 1)); + assertEquals(literal, window.get(window.indexOf("-c") + 1)); + assertEquals(literal, split.get(split.indexOf("-c") + 1)); + assertEquals(literal, respawn.get(respawn.indexOf("-c") + 1)); + } + @Test void anEmptyCommandIsRefusedWhereItIsWritten() { assertThrows(IllegalArgumentException.class, () -> WindowSpec.builder().running()); From 8934111c86454968b215fda08131b4966126c502 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 5 Sep 2026 04:50:54 -0500 Subject: [PATCH 02/65] Mcp(feat[capabilities]): Freeze the tool surface --- gradle/libs.versions.toml | 2 + libtmux-mcp/README.md | 357 ++-- libtmux-mcp/build.gradle.kts | 10 +- .../java/io/github/libtmux/mcp/Answers.java | 17 +- .../java/io/github/libtmux/mcp/Argument.java | 113 +- .../main/java/io/github/libtmux/mcp/Call.java | 23 +- .../java/io/github/libtmux/mcp/Catalog.java | 1656 ++++++++++++----- .../io/github/libtmux/mcp/Completions.java | 93 - .../io/github/libtmux/mcp/Connection.java | 70 +- .../io/github/libtmux/mcp/Instructions.java | 70 +- .../libtmux/mcp/LaunchConfiguration.java | 244 +++ .../java/io/github/libtmux/mcp/Listings.java | 236 +-- .../main/java/io/github/libtmux/mcp/Main.java | 93 +- .../libtmux/mcp/NotificationBuffer.java | 39 - .../io/github/libtmux/mcp/Operations.java | 604 ++++++ .../io/github/libtmux/mcp/OutputSchema.java | 324 ++++ .../java/io/github/libtmux/mcp/Prompts.java | 173 -- .../java/io/github/libtmux/mcp/Reading.java | 58 +- .../libtmux/mcp/ResourceInvalidations.java | 210 --- .../java/io/github/libtmux/mcp/Resources.java | 141 +- .../github/libtmux/mcp/RunningCommands.java | 12 +- .../java/io/github/libtmux/mcp/Safety.java | 60 - .../java/io/github/libtmux/mcp/Screen.java | 20 +- .../github/libtmux/mcp/ServerDiscovery.java | 286 --- .../java/io/github/libtmux/mcp/Shaping.java | 7 - .../io/github/libtmux/mcp/SocketProfile.java | 37 + .../java/io/github/libtmux/mcp/Targets.java | 10 +- .../io/github/libtmux/mcp/TextPatterns.java | 131 ++ .../io/github/libtmux/mcp/TmuxMcpServer.java | 132 +- .../java/io/github/libtmux/mcp/ToolSpec.java | 494 ++++- .../io/github/libtmux/mcp/ToolSurface.java | 277 +++ .../java/io/github/libtmux/mcp/Typing.java | 35 +- .../main/java/io/github/libtmux/mcp/Uris.java | 120 -- .../io/github/libtmux/mcp/WaitingForText.java | 52 +- .../github/libtmux/mcp/WatchAttachment.java | 195 -- .../java/io/github/libtmux/mcp/Watches.java | 350 ---- .../io/github/libtmux/mcp/Workspaces.java | 71 - .../io/github/libtmux/mcp/minimal.conf | 4 + .../libtmux/mcp/CapabilityRegistryTest.java | 996 ++++++++++ .../io/github/libtmux/mcp/CatalogTest.java | 152 -- .../io/github/libtmux/mcp/ConnectionTest.java | 69 - .../java/io/github/libtmux/mcp/MainTest.java | 245 ++- .../github/libtmux/mcp/McpLauncherTest.java | 216 +-- .../libtmux/mcp/NotificationBufferTest.java | 25 - .../io/github/libtmux/mcp/ReadingTest.java | 8 +- .../mcp/ResourceInvalidationsTest.java | 289 --- .../libtmux/mcp/RunningCommandsTest.java | 2 +- .../io/github/libtmux/mcp/SafetyTest.java | 47 - .../libtmux/mcp/ServerDiscoveryTest.java | 373 ---- .../libtmux/mcp/ServerRequestTimeoutTest.java | 2 +- .../java/io/github/libtmux/mcp/TestCalls.java | 9 +- .../github/libtmux/mcp/TextPatternsTest.java | 60 + .../github/libtmux/mcp/TmuxMcpServerTest.java | 154 +- .../libtmux/mcp/ToolsAgainstTmuxTest.java | 196 +- .../io/github/libtmux/mcp/TypingTest.java | 15 + .../java/io/github/libtmux/mcp/UrisTest.java | 48 - .../io/github/libtmux/mcp/WatchesTest.java | 299 --- scripts/mcp_swap.py | 8 +- 58 files changed, 5301 insertions(+), 4738 deletions(-) delete mode 100644 libtmux-mcp/src/main/java/io/github/libtmux/mcp/Completions.java create mode 100644 libtmux-mcp/src/main/java/io/github/libtmux/mcp/LaunchConfiguration.java delete mode 100644 libtmux-mcp/src/main/java/io/github/libtmux/mcp/NotificationBuffer.java create mode 100644 libtmux-mcp/src/main/java/io/github/libtmux/mcp/Operations.java create mode 100644 libtmux-mcp/src/main/java/io/github/libtmux/mcp/OutputSchema.java delete mode 100644 libtmux-mcp/src/main/java/io/github/libtmux/mcp/Prompts.java delete mode 100644 libtmux-mcp/src/main/java/io/github/libtmux/mcp/ResourceInvalidations.java delete mode 100644 libtmux-mcp/src/main/java/io/github/libtmux/mcp/Safety.java delete mode 100644 libtmux-mcp/src/main/java/io/github/libtmux/mcp/ServerDiscovery.java create mode 100644 libtmux-mcp/src/main/java/io/github/libtmux/mcp/SocketProfile.java create mode 100644 libtmux-mcp/src/main/java/io/github/libtmux/mcp/TextPatterns.java create mode 100644 libtmux-mcp/src/main/java/io/github/libtmux/mcp/ToolSurface.java delete mode 100644 libtmux-mcp/src/main/java/io/github/libtmux/mcp/Uris.java delete mode 100644 libtmux-mcp/src/main/java/io/github/libtmux/mcp/WatchAttachment.java delete mode 100644 libtmux-mcp/src/main/java/io/github/libtmux/mcp/Watches.java delete mode 100644 libtmux-mcp/src/main/java/io/github/libtmux/mcp/Workspaces.java create mode 100644 libtmux-mcp/src/main/resources/io/github/libtmux/mcp/minimal.conf create mode 100644 libtmux-mcp/src/test/java/io/github/libtmux/mcp/CapabilityRegistryTest.java delete mode 100644 libtmux-mcp/src/test/java/io/github/libtmux/mcp/CatalogTest.java delete mode 100644 libtmux-mcp/src/test/java/io/github/libtmux/mcp/ConnectionTest.java delete mode 100644 libtmux-mcp/src/test/java/io/github/libtmux/mcp/NotificationBufferTest.java delete mode 100644 libtmux-mcp/src/test/java/io/github/libtmux/mcp/ResourceInvalidationsTest.java delete mode 100644 libtmux-mcp/src/test/java/io/github/libtmux/mcp/SafetyTest.java delete mode 100644 libtmux-mcp/src/test/java/io/github/libtmux/mcp/ServerDiscoveryTest.java create mode 100644 libtmux-mcp/src/test/java/io/github/libtmux/mcp/TextPatternsTest.java delete mode 100644 libtmux-mcp/src/test/java/io/github/libtmux/mcp/UrisTest.java delete mode 100644 libtmux-mcp/src/test/java/io/github/libtmux/mcp/WatchesTest.java diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 06adfd9..a664cd8 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -4,6 +4,7 @@ kotlin = "2.4.10" junit = "5.14.4" jackson = "2.21.5" mcp = "2.0.1" +re2j = "1.8" slf4j = "2.0.17" errorprone = "2.50.0" nullaway = "0.13.8" @@ -23,6 +24,7 @@ jackson-databind = { module = "com.fasterxml.jackson.core:jackson-databind", ver jackson-yaml = { module = "com.fasterxml.jackson.dataformat:jackson-dataformat-yaml", version.ref = "jackson" } mcp-core = { module = "io.modelcontextprotocol.sdk:mcp-core", version.ref = "mcp" } mcp-json-jackson2 = { module = "io.modelcontextprotocol.sdk:mcp-json-jackson2", version.ref = "mcp" } +re2j = { module = "com.google.re2j:re2j", version.ref = "re2j" } slf4j-nop = { module = "org.slf4j:slf4j-nop", version.ref = "slf4j" } errorprone-core = { module = "com.google.errorprone:error_prone_core", version.ref = "errorprone" } nullaway = { module = "com.uber.nullaway:nullaway", version.ref = "nullaway" } diff --git a/libtmux-mcp/README.md b/libtmux-mcp/README.md index 955c4ab..9bc74e3 100644 --- a/libtmux-mcp/README.md +++ b/libtmux-mcp/README.md @@ -4,7 +4,7 @@ Point Claude Code, Claude Desktop, Codex, or any MCP client at a tmux socket and it can find its way around, read what a pane is showing, run a command and wait -for it, and build a whole session from one description. +for it, and build a whole session with typed operations. `io.github.libtmux:libtmux-mcp` — [on Maven Central](https://central.sonatype.com/artifact/io.github.libtmux/libtmux-mcp). @@ -20,16 +20,45 @@ That writes a launcher at `libtmux-mcp/build/install/libtmux-mcp/bin/libtmux-mcp An MCP client starts it as a subprocess and speaks JSON-RPC over its stdin and stdout. +Commands below that use `libtmux-mcp` assume that launcher's `bin` directory is +on `PATH`; otherwise substitute the full path. + | flag | what it chooses | | --- | --- | | `--socket ` | which tmux server, by socket path | | `--socket-name ` | which tmux server, by name under tmux's own directory | | `--tmux ` | which tmux to run | -| `--safety readonly\|mutating\|destructive` | how much the model may do — see [Safety](#safety) | -| `--watch` | push notifications as tmux changes — see [Watching](#watching-instead-of-polling) | -`LIBTMUX_SAFETY` and `LIBTMUX_WATCH` set the last two for an operator who cannot -edit the client's launch command. +Without a socket flag, the launcher pins the named socket `libtmux-mcp`. When +that socket does not exist, it starts tmux with the package's minimal +configuration and enables all four toolsets. A process that finds an existing +or explicitly selected server cannot prove how it was configured, so teardown +is omitted from its default surface. + +| environment | what it chooses | +| --- | --- | +| `LIBTMUX_SOCKET` | one socket name, mutually exclusive with the path | +| `LIBTMUX_SOCKET_PATH` | one absolute socket path | +| `LIBTMUX_TMUX_CONFIG` | one nonempty absolute tmux configuration path | +| `LIBTMUX_TOOLSETS` | any unordered subset of `inspect,manage,execute,teardown` | +| `LIBTMUX_TOOLS` | exact tool names to add | +| `LIBTMUX_EXCLUDE_TOOLS` | exact tool names to remove last | + +The surface is frozen before tmux opens. An empty `LIBTMUX_TOOLSETS` value +selects no toolset; unknown names and empty comma-separated elements stop +startup. Existing launcher configurations must migrate: + +- `--safety` and `LIBTMUX_SAFETY` are retired and fail startup. The old + `readonly`, `mutating`, and `destructive` values map to `inspect`; + `inspect,manage,execute`; and all four toolsets, respectively. Use + `LIBTMUX_TOOLS` and `LIBTMUX_EXCLUDE_TOOLS` for exact exceptions. +- `--watch` and `LIBTMUX_WATCH` are retired and fail startup. MCP no longer + sends dynamic resource notifications. Use `wait_for_text`, + `wait_for_channel`, or `capture_since`; Java applications can use + `ControlClient`. + +See [Watching, instead of polling](#watching-instead-of-polling) for bounded +waits and [Safety](#safety) for the capability and trust boundary. ### Claude Code @@ -52,7 +81,7 @@ Add to `claude_desktop_config.json`: "mcpServers": { "tmux": { "command": "/absolute/path/to/libtmux-mcp", - "args": ["--socket", "/tmp/my-app/s", "--safety", "mutating"] + "args": ["--socket", "/tmp/my-app/s"] } } } @@ -91,49 +120,95 @@ one, which is how a model concludes a build printed nothing. ## Tools + +The complete frozen inventory below is generated from the code registry. + +| toolset | public tools | +| --- | --- | +| `inspect` | `list_sessions` · `list_windows` · `list_panes` · `get_server_info` · `get_session_info` · `get_window_info` · `get_pane_info` · `capture_pane` · `capture_since` · `snapshot_pane` · `search_panes` · `find_pane_by_position` · `wait_for_text` · `get_tmux_variables` · `show_option` · `show_environment` · `show_hooks` · `call_read_tools_batch` | +| `manage` | `rename_session` · `rename_window` · `select_window` · `select_pane` · `select_layout` · `resize_window` · `resize_pane` · `move_window` · `swap_pane` · `set_pane_title` · `enter_copy_mode` · `exit_copy_mode` · `wait_for_channel` · `signal_channel` · `set_mouse_enabled` · `set_history_limit` | +| `execute` | `create_session` · `create_window` · `split_window` · `respawn_pane` · `run_shell_command` · `send_keys` · `send_keys_batch` · `paste_text` · `set_synchronize_panes` | +| `teardown` | `clear_pane_scrollback` · `kill_pane` · `kill_window` · `kill_session` | + + +Existing callers from earlier alpha releases must also migrate tool names: + +- `tmux_capture_pane`, `tmux_capture_since`, `tmux_list_panes`, + `tmux_list_sessions`, `tmux_list_windows`, `tmux_paste_text`, + `tmux_resize_pane`, `tmux_search_panes`, `tmux_select_layout`, + `tmux_send_keys`, `tmux_show_environment`, `tmux_show_hooks`, + `tmux_signal_channel`, `tmux_wait_for_channel`, and `tmux_wait_for_text` + retain their suffix without `tmux_`. `list_panes` no longer accepts a + filter; filter its bounded metadata client-side. Use `search_panes` only for + displayed text. +- `tmux_run`, `tmux_new_session`, `tmux_new_window`, `tmux_split_pane`, and + `tmux_show_options` become `run_shell_command`, `create_session`, + `create_window`, `split_window`, and `show_option`, in the same order. +- `tmux_whoami` splits into `get_server_info` and the caller marker from + `list_panes`. `tmux_rename` becomes `rename_session` or `rename_window`; + `tmux_select` becomes `select_window` or `select_pane`; `tmux_kill` becomes + `kill_session`, `kill_window`, or `kill_pane`. Server termination is not + exposed. +- `tmux_set_option` has no generic equivalent. Migrate supported uses to + `set_mouse_enabled`, `set_history_limit`, `set_synchronize_panes`, or + `set_pane_title`. +- `tmux_apply_workspace` becomes explicit `create_session`, `create_window`, + `split_window`, and `select_layout` calls followed by `run_shell_command`, + `send_keys`, or `paste_text`. +- `tmux_list_servers`, `tmux_list_clients`, and `tmux_drain_channel` have no + direct equivalents. Each process pins one server, described by + `get_server_info`; `list_sessions` marks attached sessions but exposes no + client details; stale channel signals cannot be drained through MCP. + ### Finding your way | tool | gives back | | --- | --- | -| `tmux_whoami` | which server this is, and **which pane this conversation is coming through** | -| `tmux_list_servers` | every tmux server this user has, by socket | -| `tmux_list_sessions` | sessions, with the windows in each | -| `tmux_list_windows` | windows, with the `@id` other tools take | -| `tmux_list_panes` | panes, with the `%id` other tools take — optionally narrowed by a `filter` | -| `tmux_list_clients` | who is attached, so you know whether a person is watching | +| `get_server_info` | the pinned server's identity, version, and current state | +| `list_sessions` | sessions, with stable `$id` values | +| `list_windows` | windows, with the `@id` other tools take | +| `list_panes` | panes, with the `%id` other tools take | +| `get_session_info`, `get_window_info`, `get_pane_info` | one target's metadata | +| `find_pane_by_position` | one pane at a named window corner | ### Reading what panes show | tool | gives back | | --- | --- | -| `tmux_capture_pane` | what a pane shows now, plus a cursor | -| `tmux_capture_since` | **only what is new** since a cursor, plus the next cursor — finished lines only, so half a line is never handed over as though it were the whole of one | -| `tmux_search_panes` | which panes are showing some text | - -`tmux_list_panes` reads metadata — what is *running*, and where. `tmux_search_panes` +| `capture_pane` | what a pane shows now, plus a cursor | +| `capture_since` | **only what is new** since a cursor, plus the next cursor — finished lines only, so half a line is never handed over as though it were the whole of one | +| `snapshot_pane` | bounded content and pane metadata together | +| `search_panes` | which panes show bounded plain text or a bounded RE2 pattern | +| `show_environment`, `show_hooks`, `show_option` | selected configuration state | +| `get_tmux_variables` | a capped set of validated variable names | +| `call_read_tools_batch` | up to sixteen typed inspect calls with full nested MCP results when they fit | + +`list_panes` reads metadata — what is *running*, and where. `search_panes` reads content — what is *displayed*. "Which pane mentions the error" is a search. ### Waiting | tool | for | | --- | --- | -| `tmux_run` | **a command you wrote** — sends it, waits, returns output *and exit status* in one call | -| `tmux_wait_for_text` | output you did not start: a dev server, a daemon, someone else's build | -| `tmux_wait_for_channel` | anything you can compose `; tmux wait-for -S name` into | -| `tmux_signal_channel`, `tmux_drain_channel` | the other end of that | +| `run_shell_command` | **a command you wrote** — sends it, waits, returns output *and exit status* in one call | +| `wait_for_text` | output you did not start: a dev server, a daemon, someone else's build | +| `wait_for_channel` | anything you can compose `; tmux wait-for -S name` into | +| `signal_channel` | the other end of that | ### Input, structure, configuration -`tmux_send_keys` · `tmux_paste_text` · `tmux_new_session` · `tmux_new_window` · -`tmux_split_pane` · `tmux_apply_workspace` · `tmux_rename` · `tmux_select` · -`tmux_select_layout` · `tmux_resize_pane` · `tmux_show_options` · -`tmux_set_option` · `tmux_show_hooks` · `tmux_show_environment` +`rename_session` · `rename_window` · `select_window` · `select_pane` · +`select_layout` · `resize_window` · `resize_pane` · `move_window` · `swap_pane` · +`set_pane_title` · `enter_copy_mode` · `exit_copy_mode` · `set_mouse_enabled` · +`set_history_limit` · `create_session` · `create_window` · `split_window` · +`respawn_pane` · `send_keys` · `send_keys_batch` · `paste_text` · +`set_synchronize_panes` ### Ending things -`tmux_kill` ends a pane, window, session, or the whole server. Offered only at -the `destructive` ceiling, and it **refuses to end the pane this conversation is -running through** unless `confirm_self` is set. +`clear_pane_scrollback` · `kill_pane` · `kill_window` · `kill_session`. The kill +tools refuse to end the pane this conversation is running through, or one of its +containers, unless `confirm_self` is set. No tool ends the tmux server itself. ## Waiting, which is the part that pays for itself @@ -145,7 +220,7 @@ agent's turn, where it has no ceiling at all. inference: ```json -{"name": "tmux_run", +{"name": "run_shell_command", "arguments": {"pane_id": "%1", "command": "pytest -q", "timeout": 120}} ``` @@ -162,7 +237,7 @@ wake, so "it worked" is never the answer on its own. **You did not write it.** Always pass `stop`: ```json -{"name": "tmux_wait_for_text", +{"name": "wait_for_text", "arguments": {"pane_id": "%2", "patterns": ["Listening on"], "stop": ["error:", "EADDRINUSE"], "timeout": 60}} ``` @@ -186,105 +261,146 @@ handler or undo tmux changes it already dispatched. ## Watching, instead of polling -With `--watch`, this server attaches a tmux control client and asks tmux to -report a format whenever its value changes. tmux does the comparing itself, about -once a second, and sends nothing while nothing changes — so a client subscribed -to a pane spends nothing at all while it is idle. +The MCP surface no longer keeps a hidden control client or advertises dynamic +resource subscriptions. Instead it gives an agent three bounded ways to wait +without rereading a screen in a loop. -What arrives is `notifications/resources/updated` naming the resource that went -stale: `tmux://panes/%251/content` when pane `%1` produces output, `tmux://sessions` -and `tmux://panes` when a window appears, closes, or is renamed. +- `wait_for_text` watches one pane for wanted or stop patterns and returns the + output that arrived during the call. +- `wait_for_channel` lets tmux itself block until a cooperating command signals + a channel. +- `capture_since` takes an opaque cursor and returns only finished lines added + since that point. + +```json +{"name": "capture_since", + "arguments": {"pane_id": "%1", "cursor": ""}} +``` -It is off by default because it is not free: watching means attaching a client, -and an attached client is a real change to the server. The one attached here is -hidden from `tmux_list_clients`, so it cannot be mistaken for a person. +The Java library still exposes control-mode subscriptions directly when an +application genuinely needs a long-lived event stream; the +[streaming guide](../docs/guide/streaming.md) covers that lower-level API. ## Safety -Three tiers, the same three every port of libtmux uses. +Four unordered toolsets replace the old safety ceiling: `inspect`, `manage`, +`execute`, and `teardown`. They are capabilities, not increasing levels. Ask for +the independent sets a client needs, then add or exclude exact tool names. -```java -Safety.READONLY.allows(Safety.MUTATING); // → false -Safety.MUTATING.allows(Safety.READONLY); // → true -Safety.MUTATING.allows(Safety.DESTRUCTIVE); // → false -Safety.ofWireName("destructive"); // → DESTRUCTIVE +```console +$ LIBTMUX_TOOLSETS=inspect,manage libtmux-mcp --socket-name my-project ``` -The ceiling filters the tool catalog; it does not confine effects. `MUTATING` -includes `tmux_run`, key input, and pasted text, so it can run programs or -delete data in a pane. Use a separate OS account, socket permissions, or a -container when effects must be contained. +```console +$ LIBTMUX_TOOLSETS= LIBTMUX_TOOLS=capture_pane,wait_for_text libtmux-mcp \ + --socket-name my-project +``` -A tool above the ceiling is **not listed at all**, rather than listed and -refused. A model cannot be tempted by a tool it never saw, and an error it can do -nothing about is wasted context. The server's instructions say plainly what is -missing and why, so a model does not spend a turn looking for it. +The same immutable selection governs both listing and calls. A hidden tool is +not callable, exclusions win, and an aggregate-only +`call_read_tools_batch` retains its eligible nested inspect operations unless +they are excluded explicitly. + +Filtering the catalog does not confine effects. Every call runs with the tmux +user's authority; pane input can reach a shell, and reads may return terminal +content, process environment, or configured commands. Use a separate OS +account, socket permissions, or a container when effects must be contained. Every tool carries MCP's own effect hints — `readOnlyHint`, `destructiveHint`, -`idempotentHint`, and `openWorldHint` — independently of the ceiling. A command -tool can stay at the `MUTATING` ceiling while truthfully warning that its update -may be destructive. +`idempotentHint`, and `openWorldHint` — plus its full native capability row. +Those claims remain conservative when the selected server's configuration is +unknown. ## Resources, prompts, completion -**Resources** are the same state, addressable rather than asked for. A client can -attach one to a conversation and refresh it without spending a tool call or a -model's decision. - -`tmux://server` · `tmux://sessions` · `tmux://panes` · -`tmux://sessions/{session_name}` · `tmux://panes/{pane_id}` · -`tmux://panes/{pane_id}/content` +There is one resource: `tmux://capabilities`. It is static for the process +lifetime and reports the effective tool surface, its selection provenance, the +pinned tmux connection, and the same capability row published on every tool. -**Prompts** are worked recipes for the jobs that take several tools in an order -that matters: `run_and_wait`, `watch_until_ready`, `find_the_pane`, -`build_workspace`, `clean_up_safely`. +```json +{"method": "resources/read", "params": {"uri": "tmux://capabilities"}} +``` -**Completion** is answered live. A client asking what could go in `{pane_id}` -gets the pane ids that exist right now, not a fixed list and not a round trip -through `tmux_list_panes`. +Earlier dynamic resource routes migrate to typed reads: + +- `tmux://server` becomes `get_server_info`; the static capability resource + adds connection and selection provenance. +- `tmux://sessions` and `tmux://panes` become `list_sessions` and + `list_panes`. +- `tmux://sessions/{session_name}` and `tmux://panes/{pane_id}` become + `get_session_info` and `get_pane_info`. +- `tmux://panes/{pane_id}/content` becomes `capture_pane`, `snapshot_pane`, or + `capture_since`. Use `wait_for_text` when the old subscription was waiting + for a terminal condition. + +The removed prompts remain useful as explicit tool workflows: + +- `run_and_wait` becomes one `run_shell_command` call. +- `watch_until_ready` uses `wait_for_text`, or `snapshot_pane` followed by + `capture_since` when output must be carried across turns. After a timeout, + continue from the returned cursor instead of restarting the observation. +- `find_the_pane` composes `list_panes`, `search_panes`, + `find_pane_by_position`, and `get_pane_info` as needed. +- `build_workspace` composes the create, split, layout, title, selection, and + execution tools documented below. +- `clean_up_safely` starts with `list_panes` to identify the MCP pane and + `list_sessions` to identify attached sessions, then uses the specific pane, + window, or session teardown tool. An attached session may have a person + watching it, so apparently abandoned state may still be live. + +Earlier live `completion/complete` suggestions for `pane_id` have no direct +replacement. Call `list_panes`, then pass the exact id through the typed tool +schema. + +There are deliberately no dynamic hierarchy or pane-content resources, +templates, subscriptions, prompts, or live completion routes. State belongs in +typed tools, while the resource answers the one question a client should not +have to infer: what this frozen process can reach and disclose. ## Filtering, which is the interesting part -A server with forty panes gives a model forty things to reason about. -`tmux_list_panes` takes an optional `filter`: the same versioned document every -port of libtmux reads. +A server with forty panes gives a model forty things to reason about. MCP now +does two narrower kinds of filtering: startup selection removes tools the client +does not need, and `search_panes` narrows terminal content without returning +every pane capture. ```json { - "filter": { - "schema": "libtmux.filter/1", - "model": "pane", - "expr": { - "node": "compare", - "field": "pane_current_command", - "op": "starts_with", - "value": "nvim" - } + "name": "search_panes", + "arguments": { + "pattern": "FAILED|ERROR", + "regex": true, + "max_matches_per_pane": 5, + "max_lines": 50 } } ``` -Field and operator names are **tmux's own format names** — `pane_current_command`, -not anything Java calls a field — so a model that has seen the schema once can -write one for any libtmux port. Combine them with `and`, `or`, `not`: +One call examines at most 200 panes, 20,000 lines, 1,000,000 UTF-8 bytes, and +five seconds of matching work. The answer says when a pane, line, byte, time, or +result limit stopped it. Pattern count and UTF-8 size are rejected before tmux +opens; regular expressions use the bounded RE2 dialect. + +For several different observations, batch exact inspect calls instead of asking +for one broad untyped projection: ```json -{"node": "and", "operands": [ - {"node": "compare", "field": "pane_active", "op": "equals", "value": true}, - {"node": "compare", "field": "pane_current_command", "op": "starts_with", "value": "nvim"} -]} +{"name": "call_read_tools_batch", "arguments": {"operations": [ + {"tool": "list_panes", "arguments": {}}, + {"tool": "show_option", "arguments": {"scope": "server", "name": "status"}} +]}} ``` -Schema: [`filter-expr-v1.schema.json`](../libtmux-jackson/src/main/resources/io/github/libtmux/jackson/filter-expr-v1.schema.json). -A malformed document comes back as a tool error naming what was wrong. - -**One capture either way.** The filter runs over what the single read already -returned, so a narrower answer costs no more tmux commands than the whole listing. +The Java library's richer query API and its versioned +[`filter-expr-v1.schema.json`](../libtmux-jackson/src/main/resources/io/github/libtmux/jackson/filter-expr-v1.schema.json) +remain available to application code. MCP does not accept that open expression +document: its authoritative schemas expose only the bounded inputs above, and a +field not in those schemas never reaches tmux. ## A whole session from one description -`tmux_apply_workspace` takes the shape tmuxp uses, so a file somebody already has -is one a model can send: +A workspace may still begin as the same readable shape tmuxp and +[`libtmux-workspace`](../libtmux-workspace/) use: ```yaml session_name: api-work @@ -299,9 +415,34 @@ windows: - docker compose logs -f ``` -One call instead of a dozen. The document and layouts are validated before any -session exists. If a later creation step or command fails, cleanup is best effort; -commands already started cannot be undone. +The MCP server no longer accepts that whole document as one opaque mutation. +Creation stays explicit and typed: create the detached session, retain the IDs +it returns, then create and split windows and choose a layout. + +```json +{"name": "create_session", "arguments": {"session_name": "api-work", + "window_name": "editor"}} +``` + +```json +{"name": "create_window", "arguments": {"session_id": "$1", + "window_name": "services"}} +``` + +```json +{"name": "split_window", "arguments": {"pane_id": "%2", + "direction": "right", "percent": 50}} +``` + +```json +{"name": "select_layout", "arguments": {"window_id": "@2", + "layout": "even-horizontal"}} +``` + +These tools accept no command or environment payload. Start the configured +process first, then use `run_shell_command`, `send_keys`, or `paste_text` for +workload input. If a later step fails, the earlier typed results still identify +exactly what exists and what can be removed. ## Embedding it @@ -316,9 +457,13 @@ Server server = Server.open(config); TmuxMcpServer.overStdio(server); ``` -`TmuxMcpServer.serving(server, ceiling, transport)` takes an MCP transport of -your own, which is how this is tested. Add a `boolean watching` argument to have -it attach a control client and push notifications as tmux changes. +`TmuxMcpServer.serving(server, transport)` takes an MCP transport of your own, +which is how this is tested. It registers the same startup-frozen manifest and +single static capability resource as the stdio launcher. + +The removed `serving(server, ceiling, transport)` and watching-boolean overloads +become `serving(server, transport)`. It reads selection from the process +environment; Java applications own `ControlClient` subscriptions directly. ## Install @@ -330,12 +475,16 @@ dependencies { } ``` -Depends on [`libtmux`](../libtmux/), [`libtmux-jackson`](../libtmux-jackson/) and -[`libtmux-workspace`](../libtmux-workspace/). +Depends on [`libtmux`](../libtmux/) and the MCP Java SDK. The sibling +[`libtmux-jackson`](../libtmux-jackson/) and +[`libtmux-workspace`](../libtmux-workspace/) modules remain available to Java +applications that need filter documents or declarative workspace building. ## Next - [MCP guide](../docs/guide/mcp.md) — the design, and why each tool is shaped as it is - [Filtering guide](../docs/guide/filtering.md) — the expression model behind the wire format +- [Streaming guide](../docs/guide/streaming.md) — the lower-level control client + used by Java applications - [`libtmux`](../libtmux/) — the library underneath - [Root README](../README.md) diff --git a/libtmux-mcp/build.gradle.kts b/libtmux-mcp/build.gradle.kts index 75cc7c4..1bf0b79 100644 --- a/libtmux-mcp/build.gradle.kts +++ b/libtmux-mcp/build.gradle.kts @@ -28,17 +28,11 @@ dependencies { implementation(libs.mcp.json.jackson2) implementation(libs.jackson.databind) - - // A model sends a filter as the versioned JSON document, which is what this module reads it - // from. Every use of it is inside a package-private type, so it is not part of the API. - implementation(project(":libtmux-jackson")) - - // A whole session described in one document, which is what tmux_apply_workspace takes. - implementation(project(":libtmux-workspace")) + implementation(libs.re2j) add(launcherRuntime.name, libs.slf4j.nop) testImplementation(project(":libtmux-junit5")) } -tasks.jar { manifest { attributes("Automatic-Module-Name" to "io.github.libtmux.mcp") } } \ No newline at end of file +tasks.jar { manifest { attributes("Automatic-Module-Name" to "io.github.libtmux.mcp") } } diff --git a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Answers.java b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Answers.java index c113197..6ecd482 100644 --- a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Answers.java +++ b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Answers.java @@ -6,6 +6,7 @@ import com.fasterxml.jackson.databind.PropertyNamingStrategies; import com.fasterxml.jackson.databind.json.JsonMapper; import io.modelcontextprotocol.spec.McpSchema; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -64,8 +65,22 @@ static McpSchema.CallToolResult failure(String message) { .build(); } + /** The complete nested MCP result, preserving structured data, content, metadata and error state. */ + static Map envelope(McpSchema.CallToolResult result) { + Map envelope = new LinkedHashMap<>(); + if (result.meta() != null) { + envelope.put("_meta", result.meta()); + } + envelope.put("content", result.content()); + if (result.structuredContent() != null) { + envelope.put("structuredContent", result.structuredContent()); + } + envelope.put("isError", Boolean.TRUE.equals(result.isError())); + return Map.copyOf(envelope); + } + @SuppressWarnings("unchecked") - private static Map asObject(Object value) { + static Map asObject(Object value) { if (value instanceof Map already) { return (Map) already; } diff --git a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Argument.java b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Argument.java index 201c807..bd6dcc8 100644 --- a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Argument.java +++ b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Argument.java @@ -1,8 +1,10 @@ package io.github.libtmux.mcp; import java.util.LinkedHashMap; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; +import java.util.Set; import org.jspecify.annotations.Nullable; /** @@ -19,12 +21,22 @@ record Argument( String type, String description, boolean required, - @Nullable Object fallback) { + @Nullable Object fallback, + @Nullable Integer maxLength, + @Nullable Integer maxItems) { + + Argument(String name, String type, String description, boolean required, @Nullable Object fallback) { + this(name, type, description, required, fallback, null, null); + } static Argument required(String name, String description) { return new Argument(name, "string", description, true, null); } + static Argument boundedRequired(String name, String description, int maxLength) { + return new Argument(name, "string", description, true, null, maxLength, null); + } + static Argument optional(String name, String description) { return new Argument(name, "string", description, false, null); } @@ -38,6 +50,10 @@ static Argument number(String name, String description, int fallback) { return new Argument(name, "integer", description, false, fallback); } + static Argument requiredNumber(String name, String description) { + return new Argument(name, "integer", description, true, null); + } + static Argument seconds(String name, String description, double fallback) { return new Argument(name, "number", description, false, fallback); } @@ -50,17 +66,40 @@ static Argument strings(String name, String description) { return new Argument(name, "array", description, false, null); } + static Argument boundedStrings(String name, String description, int maxLength, int maxItems) { + return new Argument(name, "array", description, false, null, maxLength, maxItems); + } + + static Argument objects(String name, String description) { + return new Argument(name, "object-array", description, true, null); + } + + static Argument boundedObjects(String name, String description, int maxItems) { + return new Argument(name, "object-array", description, true, null, null, maxItems); + } + /** The JSON Schema fragment describing this one argument. */ Map schema() { Map described = new LinkedHashMap<>(); - if ("array".equals(type)) { + if ("array".equals(type) || "object-array".equals(type)) { // A single value is accepted where a list is wanted, because models send one — and the // schema has to say so. The server validates arguments against this before a tool sees // them, so a reader that quietly coped with a bare string would never be reached. - described.put("type", List.of("array", "string")); - described.put("items", Map.of("type", "string")); + described.put("type", "array".equals(type) ? List.of("array", "string") : "array"); + Map items = new LinkedHashMap<>(); + items.put("type", "array".equals(type) ? "string" : "object"); + if (maxLength != null) { + items.put("maxLength", maxLength); + } + described.put("items", items); + if (maxItems != null) { + described.put("maxItems", maxItems); + } } else { described.put("type", type); + if (maxLength != null) { + described.put("maxLength", maxLength); + } } described.put( "description", @@ -87,6 +126,72 @@ static Map objectSchema(List arguments) { schema.put("type", "object"); schema.put("properties", properties); schema.put("required", required); + schema.put("additionalProperties", false); return schema; } + + /** Applies the same closed, typed input contract to nested and direct dispatch. */ + static void validate(List declared, Map values) { + Set known = declared.stream().map(Argument::name).collect(java.util.stream.Collectors.toSet()); + Set unknown = new LinkedHashSet<>(values.keySet()); + unknown.removeAll(known); + if (!unknown.isEmpty()) { + throw new IllegalArgumentException("unknown argument(s) " + unknown); + } + for (Argument argument : declared) { + Object value = values.get(argument.name()); + if (value == null) { + if (argument.required()) { + throw new IllegalArgumentException("missing required argument '" + argument.name() + "'"); + } + continue; + } + argument.validate(value); + } + } + + private void validate(Object value) { + boolean valid = + switch (type) { + case "string" -> value instanceof String; + case "boolean" -> value instanceof Boolean; + case "number" -> value instanceof Number; + case "integer" -> + value instanceof Byte + || value instanceof Short + || value instanceof Integer + || value instanceof Long + || value instanceof java.math.BigInteger + || (value instanceof java.math.BigDecimal decimal + && decimal.stripTrailingZeros().scale() <= 0) + || (value instanceof Float number + && Float.isFinite(number) + && number == Math.rint(number)) + || (value instanceof Double number + && Double.isFinite(number) + && number == Math.rint(number)); + case "array" -> + value instanceof String + || (value instanceof List list + && list.stream().allMatch(String.class::isInstance)); + case "object-array" -> + value instanceof List list && list.stream().allMatch(Map.class::isInstance); + default -> false; + }; + if (!valid) { + throw new IllegalArgumentException("argument '" + name + "' must match schema type " + type); + } + if (maxLength != null) { + if (value instanceof String text && text.length() > maxLength) { + throw new IllegalArgumentException("argument '" + name + "' exceeds maxLength " + maxLength); + } + if (value instanceof List list + && list.stream().map(String.class::cast).anyMatch(text -> text.length() > maxLength)) { + throw new IllegalArgumentException("argument '" + name + "' contains an overlong value"); + } + } + if (maxItems != null && value instanceof List list && list.size() > maxItems) { + throw new IllegalArgumentException("argument '" + name + "' exceeds maxItems " + maxItems); + } + } } diff --git a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Call.java b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Call.java index b532ea5..8916906 100644 --- a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Call.java +++ b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Call.java @@ -2,6 +2,7 @@ import io.github.libtmux.Server; import java.time.Duration; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Optional; @@ -23,8 +24,8 @@ Caller caller() { return connection.caller(); } - Safety ceiling() { - return connection.ceiling(); + ToolSurface surface() { + return connection.surface(); } /** Reports how a slow tool is going while the client is still listening. */ @@ -113,4 +114,22 @@ List strings(String name) { String single = value.toString(); return single.isEmpty() ? List.of() : List.of(single); } + + /** A JSON array of objects for the two purpose-built aggregate tools. */ + List> objects(String name) { + Object value = arguments.get(name); + if (!(value instanceof List many)) { + throw new IllegalArgumentException("expected an array of objects for '" + name + "'"); + } + return many.stream() + .map(item -> { + if (!(item instanceof Map object)) { + throw new IllegalArgumentException("every item in '" + name + "' must be an object"); + } + Map copy = new LinkedHashMap<>(); + object.forEach((key, nested) -> copy.put(String.valueOf(key), nested)); + return Map.copyOf(copy); + }) + .toList(); + } } diff --git a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Catalog.java b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Catalog.java index bd7ae69..7d95fba 100644 --- a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Catalog.java +++ b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Catalog.java @@ -1,501 +1,1291 @@ package io.github.libtmux.mcp; +import static io.github.libtmux.mcp.Argument.boundedObjects; +import static io.github.libtmux.mcp.Argument.boundedRequired; +import static io.github.libtmux.mcp.Argument.boundedStrings; import static io.github.libtmux.mcp.Argument.flag; import static io.github.libtmux.mcp.Argument.number; +import static io.github.libtmux.mcp.Argument.objects; import static io.github.libtmux.mcp.Argument.optional; import static io.github.libtmux.mcp.Argument.paneId; import static io.github.libtmux.mcp.Argument.required; +import static io.github.libtmux.mcp.Argument.requiredNumber; import static io.github.libtmux.mcp.Argument.seconds; import static io.github.libtmux.mcp.Argument.strings; -import static io.github.libtmux.mcp.ToolSpec.Effect.DESTRUCTIVE; -import static io.github.libtmux.mcp.ToolSpec.Effect.READ_ONLY; +import static io.github.libtmux.mcp.OutputSchema.ValueType.ARRAY; +import static io.github.libtmux.mcp.OutputSchema.ValueType.BOOLEAN; +import static io.github.libtmux.mcp.OutputSchema.ValueType.INTEGER; +import static io.github.libtmux.mcp.OutputSchema.ValueType.OBJECT; +import static io.github.libtmux.mcp.OutputSchema.ValueType.STRING; +import static io.github.libtmux.mcp.ToolSpec.InputSink.REGEX; +import static io.github.libtmux.mcp.ToolSpec.InputSink.SHELL_COMMAND; +import static io.github.libtmux.mcp.ToolSpec.InputSink.TMUX_FORMAT; +import static io.github.libtmux.mcp.ToolSpec.InputSink.TMUX_LOOKUP; +import static io.github.libtmux.mcp.ToolSpec.InputSink.TMUX_STATE; +import static io.github.libtmux.mcp.ToolSpec.OutputClass.CONFIGURED_COMMAND; +import static io.github.libtmux.mcp.ToolSpec.OutputClass.PROCESS_ENVIRONMENT; +import static io.github.libtmux.mcp.ToolSpec.OutputClass.TERMINAL_CONTENT; +import static io.github.libtmux.mcp.ToolSpec.OutputClass.TMUX_METADATA; +import static io.github.libtmux.mcp.ToolSpec.ProcessReach.CONFIGURED_PROCESS; +import static io.github.libtmux.mcp.ToolSpec.ProcessReach.NONE; +import static io.github.libtmux.mcp.ToolSpec.ProcessReach.PANE_COMMAND; +import static io.github.libtmux.mcp.ToolSpec.ProcessReach.PANE_INPUT; +import static io.github.libtmux.mcp.ToolSpec.TmuxEffect.CHANGE; +import static io.github.libtmux.mcp.ToolSpec.TmuxEffect.DELETE; +import static io.github.libtmux.mcp.ToolSpec.TmuxEffect.OBSERVE; +import static io.github.libtmux.mcp.ToolSpec.Toolset.EXECUTE; +import static io.github.libtmux.mcp.ToolSpec.Toolset.INSPECT; +import static io.github.libtmux.mcp.ToolSpec.Toolset.MANAGE; +import static io.github.libtmux.mcp.ToolSpec.Toolset.TEARDOWN; -import io.github.libtmux.jackson.FilterJson; -import io.github.libtmux.jackson.LibTmuxModels; import java.util.ArrayList; +import java.util.EnumSet; +import java.util.HashSet; import java.util.LinkedHashMap; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; +import java.util.Objects; +import java.util.Set; -/** - * Every tool this server can offer, in the order a model meets them. - * - *

Declared in one list so the surface can be read at a glance and so nothing can be added - * without stating what it may destroy. What a launcher actually serves is this list narrowed to a - * {@link Safety} ceiling. - * - *

The descriptions are written for a model rather than a person: each says what the tool is for - * and, where a cheaper tool exists, points at it. That is the only documentation a model gets. - */ +/** Every public structured tool, declared once in deterministic registration order. */ final class Catalog { - /** The filter document shown to a model, and the only one it is given to copy. */ - static final String EXAMPLE_FILTER = "{\"schema\":\"" + FilterJson.SCHEMA + "\",\"model\":\"pane\"," - + "\"expr\":{\"node\":\"compare\",\"field\":\"pane_current_command\"," - + "\"op\":\"starts_with\",\"value\":\"nvim\"}}"; + private static final OutputSchema SESSION_OUTPUT = + shape(field("id", STRING), field("name", STRING), field("attached", BOOLEAN), field("windows", INTEGER)); + private static final OutputSchema WINDOW_OUTPUT = shape( + field("id", STRING), + field("index", INTEGER), + field("name", STRING), + field("session_id", STRING), + field("active", BOOLEAN), + field("panes", INTEGER), + field("size", STRING)); + private static final OutputSchema PANE_OUTPUT = shape( + field("id", STRING), + field("index", INTEGER), + field("window_id", STRING), + field("session_id", STRING), + field("active", BOOLEAN), + field("command", STRING), + field("path", STRING), + field("title", STRING), + field("size", STRING)); + + private static final List TOOLS = build(); private Catalog() {} static List tools() { + return TOOLS; + } + + static ToolSpec named(String name) { + return TOOLS.stream() + .filter(tool -> tool.name().equals(name)) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("unknown catalog tool '" + name + "'")); + } + + static void validate(List tools) { + Set names = new LinkedHashSet<>(); + Map byName = new LinkedHashMap<>(); + for (ToolSpec tool : tools) { + if (!names.add(tool.name())) { + throw new IllegalArgumentException("duplicate tool '" + tool.name() + "'"); + } + byName.put(tool.name(), tool); + } + for (ToolSpec tool : tools) { + validateSchema(tool); + validateReach(tool); + if (tool.outputClasses().isEmpty()) { + throw new IllegalArgumentException(tool.name() + " has no output class"); + } + if (!tool.description().startsWith(tool.controlledOpener() + " ")) { + throw new IllegalArgumentException(tool.name() + " does not begin with its controlled opener"); + } + if (tool.processReach() == ToolSpec.ProcessReach.HOST_COMMAND) { + throw new IllegalArgumentException(tool.name() + " exposes prohibited host-command reach"); + } + Set formatInputs = tool.inputSinks().entrySet().stream() + .filter(entry -> entry.getValue().contains(TMUX_FORMAT)) + .map(Map.Entry::getKey) + .collect(java.util.stream.Collectors.toSet()); + if (!formatInputs.equals(tool.inputLiteralization().keySet()) + || tool.inputLiteralization().values().stream() + .anyMatch(strategy -> !Set.of("double-hash-once", "validated-variable-name") + .contains(strategy))) { + throw new IllegalArgumentException(tool.name() + " has inconsistent tmux-format controls"); + } + for (Map.Entry control : tool.inputLiteralization().entrySet()) { + ToolSpec.InputSink classified = + control.getValue().equals("double-hash-once") ? TMUX_STATE : TMUX_LOOKUP; + if (!Objects.requireNonNull(tool.inputSinks().get(control.getKey()), control.getKey()) + .contains(classified)) { + throw new IllegalArgumentException( + tool.name() + " understates the sink for '" + control.getKey() + "'"); + } + } + if (tool.amplifiesFutureInput() != tool.name().equals("set_synchronize_panes")) { + throw new IllegalArgumentException(tool.name() + " has incorrect future-input amplification"); + } + if (!tool.annotations().equals(conservativeAnnotations())) { + throw new IllegalArgumentException( + tool.name() + " is not conservative under unknown configuration provenance"); + } + for (String nested : tool.nestedAuthority()) { + if (nested.equals(tool.name()) || !names.contains(nested)) { + throw new IllegalArgumentException(tool.name() + " has invalid nested authority '" + nested + "'"); + } + } + if (!tool.nestedAuthority().isEmpty()) { + ToolSpec derived = tool.withNestedAuthority(tool.nestedAuthority(), byName); + if (!tool.effects().equals(derived.effects()) + || !tool.outputClasses().equals(derived.outputClasses()) + || tool.mayExposeSecrets() != derived.mayExposeSecrets() + || tool.mayReturnUntrustedContent() != derived.mayReturnUntrustedContent()) { + throw new IllegalArgumentException(tool.name() + " understates its nested capability union"); + } + } + } + } + + private static List build() { List tools = new ArrayList<>(); - discovery(tools); - reading(tools); - waiting(tools); - typing(tools); - shaping(tools); - settings(tools); - ending(tools); - return List.copyOf(tools); - } - - // ------------------------------------------------------------------ what is there - - private static void discovery(List tools) { - tools.add(ToolSpec.of( - "tmux_whoami", - "Which tmux, and which pane is mine", - "Describes the tmux server this connection acts on, and names the pane this MCP server is " - + "itself running in when there is one. Call this first in an unfamiliar session: it is " - + "the only way to learn which pane belongs to this conversation, and that pane is the " - + "one never to kill or type into.", - Safety.READONLY, - READ_ONLY, - List.of(), - call -> Listings.whoami(call.server(), call.caller(), call.ceiling()))); - - tools.add(ToolSpec.of( - "tmux_list_servers", - "List tmux servers", - "Inspects a bounded set of this user's tmux sockets and reports whether each is running, " - + "unreachable, timed out, or could not be probed. Use it when the sessions you expected " - + "are not on this server: separate sockets cannot see each other. A truncated answer says " - + "the scan cap left directory entries uninspected.", - Safety.READONLY, - READ_ONLY, - List.of(), - call -> Listings.servers(call.server()))); + inspect(tools); + manage(tools); + execute(tools); + teardown(tools); + List built = List.copyOf(tools); + validate(built); + return built; + } - tools.add(ToolSpec.of( - "tmux_list_sessions", + private static void inspect(List tools) { + tools.add(tool( + "list_sessions", "List sessions", - "Lists sessions on this server with the windows in each.", - Safety.READONLY, - READ_ONLY, + "Lists sessions on the pinned tmux server.", + INSPECT, + NONE, + effects(OBSERVE), + outputs(TMUX_METADATA), + true, + true, List.of(), + Map.of(), + record(Listings.Sessions.class, "note"), call -> Listings.sessions(call.connection()))); - - tools.add(ToolSpec.of( - "tmux_list_windows", + tools.add(tool( + "list_windows", "List windows", - "Lists windows with the id other tools take, optionally only those in one session.", - Safety.READONLY, - READ_ONLY, - List.of(optional("session", "Only windows in this session. Omit for every window on the server.")), + "Lists windows, optionally only those in one named session.", + INSPECT, + NONE, + effects(OBSERVE), + outputs(TMUX_METADATA), + true, + true, + List.of(optional("session", "Only windows in this session name.")), + sinks(input("session", TMUX_LOOKUP)), + record(Listings.Windows.class, "note"), Listings::windows)); - - tools.add(ToolSpec.of( - "tmux_list_panes", + tools.add(tool( + "list_panes", "List panes", - "Lists panes with the id every other tool takes as a target, what is running in each, and " - + "where. Optionally narrowed by a filter document. This reads metadata, not screen " - + "contents: to find a pane by what it is showing, use tmux_search_panes.", - Safety.READONLY, - READ_ONLY, - List.of(new Argument( - "filter", - "object", - "A " + FilterJson.SCHEMA + " document over the pane model, for example " + EXAMPLE_FILTER - + ". Field names are tmux's own format names, and these are the only ones a pane " - + "document may compare: " - + String.join(", ", LibTmuxModels.pane().fieldNames()) - + ". Anything else — a window's name, a pane's path — is in the answer rather " - + "than the filter, so list the panes and choose from what comes back. Omit it " - + "to list every pane.", - false, - null)), + "Lists pane metadata and stable pane IDs.", + INSPECT, + NONE, + effects(OBSERVE), + outputs(TMUX_METADATA), + true, + true, + List.of(), + Map.of(), + record(Listings.Panes.class, "note") + .withPropertySchema( + "panes", + arrayOf(record(Listings.PaneSummary.class, "caller") + .wireSchema())), Listings::panes)); - tools.add(ToolSpec.of( - "tmux_list_clients", - "List attached clients", - "Lists the terminals attached to this server. Use it to find out whether a person is " - + "watching a session before changing what it is showing.", - Safety.READONLY, - READ_ONLY, + tools.add(inspectMetadata( + "get_server_info", + "Get server info", + "Reports whether the pinned server exists and its version.", List.of(), - Listings::clients)); - } + Map.of(), + shape( + field("running", BOOLEAN), + field("identity", STRING), + field("version", STRING), + field("sessions", INTEGER)), + Operations::serverInfo)); + tools.add(inspectMetadata( + "get_session_info", + "Get session info", + "Returns metadata for one session.", + List.of(required("session_id", "The session ID, such as $1.")), + sinks(input("session_id", TMUX_LOOKUP)), + SESSION_OUTPUT, + Operations::sessionInfo)); + tools.add(inspectMetadata( + "get_window_info", + "Get window info", + "Returns metadata for one window.", + List.of(required("window_id", "The window ID, such as @1.")), + sinks(input("window_id", TMUX_LOOKUP)), + WINDOW_OUTPUT, + Operations::windowInfo)); + tools.add(inspectMetadata( + "get_pane_info", + "Get pane info", + "Returns metadata for one pane.", + List.of(paneId()), + sinks(input("pane_id", TMUX_LOOKUP)), + PANE_OUTPUT, + Operations::paneInfo)); - // ------------------------------------------------------------------ what panes show - - private static void reading(List tools) { - tools.add(ToolSpec.of( - "tmux_capture_pane", - "Read a pane", - "Returns what a pane is showing, newest last, together with a cursor. To watch the same " - + "pane again, pass that cursor to tmux_capture_since instead of calling this repeatedly " - + "— this returns the whole screen every time.", - Safety.READONLY, - READ_ONLY, - List.of( - paneId(), - flag("history", "Include the pane's scrollback, not only the visible screen.", false), - number("max_lines", "How many lines at most, keeping the newest.", Trim.DEFAULT_LINES)), + List capture = List.of( + paneId(), + flag("history", "Include scrollback rather than only the visible screen.", false), + number("max_lines", "Maximum lines, keeping the newest.", Trim.DEFAULT_LINES)); + Map> captureSinks = sinks( + input("pane_id", TMUX_LOOKUP), + input("history", ToolSpec.InputSink.NONE), + input("max_lines", ToolSpec.InputSink.NONE)); + tools.add(tool( + "capture_pane", + "Capture a pane", + "Returns bounded pane content and a cursor.", + INSPECT, + NONE, + effects(OBSERVE), + outputs(TERMINAL_CONTENT, TMUX_METADATA), + true, + true, + capture, + captureSinks, + record(Reading.Captured.class, "note"), Reading::capture)); + List since = List.of( + paneId(), + optional("cursor", "A cursor returned by an earlier capture."), + number("max_lines", "Maximum new lines, keeping the newest.", Trim.DEFAULT_LINES)); + tools.add(tool( + "capture_since", + "Capture new pane output", + "Returns pane output produced after a cursor.", + INSPECT, + NONE, + effects(OBSERVE, CHANGE), + outputs(TERMINAL_CONTENT, TMUX_METADATA), + true, + true, + since, + sinks( + input("pane_id", TMUX_LOOKUP), + input("cursor", ToolSpec.InputSink.NONE), + input("max_lines", ToolSpec.InputSink.NONE)), + record(Reading.Since.class, "note"), + Reading::since)); + tools.add(tool( + "snapshot_pane", + "Snapshot a pane", + "Returns pane metadata and bounded terminal content together.", + INSPECT, + NONE, + effects(OBSERVE), + outputs(TERMINAL_CONTENT, TMUX_METADATA), + true, + true, + capture, + captureSinks, + shape( + field("pane", OBJECT), + field("content", ARRAY), + field("cursor", STRING), + field("truncated", BOOLEAN), + field("lines_dropped", INTEGER)) + .withPropertySchema("content", arrayOf(Map.of("type", "string"))), + Operations::snapshotPane)); - tools.add(ToolSpec.of( - "tmux_capture_since", - "Read what is new in a pane", - "Returns only the lines a pane has produced since a cursor, and a new cursor. This is how " - + "to watch something without paying for it repeatedly: the tenth look at a build log " - + "costs the few lines it added, not the nine screens already read. Omit the cursor to " - + "start from what the pane shows now.", - Safety.READONLY, - READ_ONLY, + List search = List.of( + boundedRequired( + "pattern", + "The bounded text or regular expression to search for.", + TextPatterns.MAX_PATTERN_BYTES), + flag("regex", "Treat pattern as a regular expression.", false), + number("max_matches_per_pane", "Maximum matching lines per pane.", 5), + number("max_lines", "Maximum matches across all panes.", Trim.DEFAULT_LINES)); + tools.add(tool( + "search_panes", + "Search panes", + "Searches the visible output of every pane.", + INSPECT, + NONE, + effects(OBSERVE), + outputs(TERMINAL_CONTENT, TMUX_METADATA), + true, + true, + search, + sinks( + input("pattern", REGEX), + input("regex", ToolSpec.InputSink.NONE), + input("max_matches_per_pane", ToolSpec.InputSink.NONE), + input("max_lines", ToolSpec.InputSink.NONE)), + record(Reading.Found.class, "note"), + Reading::search)); + tools.add(inspectMetadata( + "find_pane_by_position", + "Find pane by position", + "Finds a pane at one of a window's four corners.", List.of( - paneId(), - optional("cursor", "The cursor from a previous call on this pane. Omit to start here."), - number("max_lines", "How many lines at most, keeping the newest.", Trim.DEFAULT_LINES)), - Reading::since)); + required("window_id", "The window ID, such as @1."), + required("position", "top-left, top-right, bottom-left or bottom-right.")), + sinks(input("window_id", TMUX_LOOKUP), input("position", TMUX_LOOKUP)), + PANE_OUTPUT, + Operations::findPaneByPosition)); + + List waitText = List.of( + paneId(), + boundedStrings( + "patterns", + "Text to wait for; any one ends the wait.", + TextPatterns.MAX_PATTERN_BYTES, + TextPatterns.MAX_PATTERNS), + boundedStrings( + "stop", + "Failure text; any one ends the wait.", + TextPatterns.MAX_PATTERN_BYTES, + TextPatterns.MAX_PATTERNS), + flag("regex", "Treat patterns and stops as regular expressions.", false), + seconds("timeout", "Seconds to wait before giving up.", 30), + optional("cursor", "A cursor returned by an earlier capture."), + number("max_lines", "Maximum observed lines to return.", Trim.DEFAULT_LINES)); + tools.add(tool( + "wait_for_text", + "Wait for pane text", + "Waits for new pane output without accepting executable input.", + INSPECT, + NONE, + effects(OBSERVE), + outputs(TERMINAL_CONTENT, TMUX_METADATA), + true, + true, + waitText, + sinks( + input("pane_id", TMUX_LOOKUP), + input("patterns", REGEX), + input("stop", REGEX), + input("regex", ToolSpec.InputSink.NONE), + input("timeout", ToolSpec.InputSink.NONE), + input("cursor", ToolSpec.InputSink.NONE), + input("max_lines", ToolSpec.InputSink.NONE)), + record(WaitingForText.Waited.class, "matched", "matched_line", "note"), + WaitingForText::waitFor)); - tools.add(ToolSpec.of( - "tmux_search_panes", - "Find panes by what they show", - "Searches what every pane is currently showing and returns the panes that match. Use it to " - + "answer \"which pane has the server in it\". Searches the visible screen, not " - + "scrollback, so text that has scrolled away is not found.", - Safety.READONLY, - READ_ONLY, + tools.add(tool( + "get_tmux_variables", + "Get tmux variables", + "Reads a capped list of validated tmux variable names, not free-form formats.", + INSPECT, + NONE, + effects(OBSERVE), + outputs(TMUX_METADATA, CONFIGURED_COMMAND), + true, + true, + List.of( + new Argument( + "names", + "array", + "One to thirty-two variable names matching [A-Za-z][A-Za-z0-9_]*.", + true, + null, + 128, + 32), + optional("pane", "An optional pane context.")), + sinks(input("names", TMUX_LOOKUP, TMUX_FORMAT), input("pane", TMUX_LOOKUP)), + shape(field("values", OBJECT)), + Operations::tmuxVariables) + .withInputLiteralization(Map.of("names", "validated-variable-name"))); + + List option = List.of( + required("name", "The exact option name."), + optional("scope", "global, server, session, window or pane."), + optional("target", "The target required by session, window and pane scopes."), + flag("effective", "Include an inherited value.", true)); + tools.add(tool( + "show_option", + "Show one option", + "Reads one named tmux option.", + INSPECT, + NONE, + effects(OBSERVE), + outputs(TMUX_METADATA, CONFIGURED_COMMAND), + true, + false, + option, + sinks( + input("name", TMUX_LOOKUP), + input("scope", TMUX_LOOKUP), + input("target", TMUX_LOOKUP), + input("effective", ToolSpec.InputSink.NONE)), + shape(field("scope", STRING), field("target", STRING), field("name", STRING), field("value", STRING)), + Operations::showOption)); + tools.add(tool( + "show_environment", + "Show tmux environment", + "Reads the environment tmux passes to processes.", + INSPECT, + NONE, + effects(OBSERVE), + outputs(PROCESS_ENVIRONMENT), + true, + false, + List.of(optional("session", "A session name; omit for the global environment.")), + sinks(input("session", TMUX_LOOKUP)), + record(Settings.Environment.class), + Settings::environment)); + + List hooks = List.of( + optional("scope", "global, server, session, window or pane."), + optional("target", "The target required by session, window and pane scopes."), + optional("name", "One hook name; omit to read all hooks in the scope.")); + tools.add(tool( + "show_hooks", + "Show hooks", + "Reads configured tmux hooks.", + INSPECT, + NONE, + effects(OBSERVE), + outputs(CONFIGURED_COMMAND), + true, + false, + hooks, + sinks(input("scope", TMUX_LOOKUP), input("target", TMUX_LOOKUP), input("name", TMUX_LOOKUP)), + shape(field("scope", STRING), field("target", STRING), field("count", INTEGER), field("hooks", OBJECT)), + Operations::showHooks)); + + Set nested = new LinkedHashSet<>(List.of( + "list_sessions", + "list_windows", + "list_panes", + "get_server_info", + "get_session_info", + "get_window_info", + "get_pane_info", + "capture_pane", + "capture_since", + "snapshot_pane", + "search_panes", + "find_pane_by_position", + "get_tmux_variables", + "show_option", + "show_environment", + "show_hooks")); + tools.add(tool( + "call_read_tools_batch", + "Call read tools in a batch", + "Calls up to sixteen eligible inspect tools serially; inner tools receive no separate approval, and its nested authority is disclosed. The full serialized outer MCP result is capped at 1 MiB; a removed nested envelope is marked on its row and counted in truncatedBytes.", + INSPECT, + NONE, + effects(OBSERVE, CHANGE), + outputs(TMUX_METADATA, TERMINAL_CONTENT, PROCESS_ENVIRONMENT, CONFIGURED_COMMAND), + true, + true, List.of( - required("pattern", "The text to look for."), - flag("regex", "Treat the pattern as a regular expression rather than plain text.", false), - number("max_matches_per_pane", "How many matching lines to keep from each pane.", 5), - number("max_lines", "How many matches at most, across all panes.", Trim.DEFAULT_LINES)), - Reading::search)); + objects("operations", "Objects with tool and optional arguments fields."), + optional("onError", "stop or continue; defaults to stop.")), + sinks(input("operations", ToolSpec.InputSink.NESTED_TOOL), input("onError", ToolSpec.InputSink.NONE)), + nested, + readBatchOutput(), + Operations::callReadToolsBatch)); } - // ------------------------------------------------------------------ waiting - - private static void waiting(List tools) { - tools.add(ToolSpec.of( - "tmux_run", - "Run a command and wait for it", - "Runs a shell command in a pane with a POSIX-compatible shell, waits for it to finish, and returns its output and exit " - + "status in one call. Use this whenever you wrote the command yourself. Do not send a " - + "command and then poll tmux_capture_pane to guess whether it finished: that costs a " - + "call per look and still cannot tell a finished command from a stalled one. The " - + "command runs in a subshell of the pane's shell, so it sees that shell's environment " - + "but a 'cd' or an export in it does not outlive the call — and neither does an 'exit'.", - Safety.MUTATING, - DESTRUCTIVE, + private static void manage(List tools) { + tools.add(literalized( + manageTool( + "rename_session", + "Rename a session", + "Replaces a session's name.", + List.of( + required("session_id", "The session ID, such as $1."), + required("new_name", "The literal new session name.")), + sinks(input("session_id", TMUX_LOOKUP), input("new_name", TMUX_FORMAT)), + SESSION_OUTPUT, + Operations::renameSession), + "new_name")); + tools.add(literalized( + manageTool( + "rename_window", + "Rename a window", + "Replaces a window's name.", + List.of( + required("window_id", "The window ID, such as @1."), + required("new_name", "The literal new window name.")), + sinks(input("window_id", TMUX_LOOKUP), input("new_name", TMUX_FORMAT)), + WINDOW_OUTPUT, + Operations::renameWindow), + "new_name")); + tools.add(manageTool( + "select_window", + "Select a window", + "Makes one window active.", + List.of(required("window_id", "The window ID, such as @1.")), + sinks(input("window_id", TMUX_LOOKUP)), + WINDOW_OUTPUT, + Operations::selectWindow)); + tools.add(manageTool( + "select_pane", + "Select a pane", + "Makes one pane active.", + List.of(paneId()), + sinks(input("pane_id", TMUX_LOOKUP)), + PANE_OUTPUT, + Operations::selectPane)); + tools.add(manageTool( + "select_layout", + "Select a layout", + "Applies one built-in tmux layout.", List.of( - paneId(), - required("command", "The shell command, run in the pane's own interactive shell."), - seconds( - "timeout", - "Seconds to wait before giving up and reporting what it printed so far.", - 30), - number( - "max_lines", - "How many lines of output at most, keeping the newest.", - Trim.DEFAULT_LINES), - flag( - "suppress_history", - "Prefix the line with a space so a shell configured to ignore such lines keeps it " - + "out of its history. Best-effort: a shell not configured that way records it.", - true)), - RunningCommands::run)); - - tools.add(ToolSpec.of( - "tmux_wait_for_text", - "Wait for text to appear in a pane", - "Waits until text appears in a pane you did not start — a dev server, a daemon, a build " - + "someone else launched. Only output that arrives after this call counts, so text " - + "already on screen does not satisfy it. Always pass 'stop' with the failure text when " - + "there is one: without it a run that fails is waited on until the deadline. If you " - + "wrote the command, use tmux_run instead.", - Safety.READONLY, - READ_ONLY, + required("window_id", "The window ID, such as @1."), + required("layout", "A built-in layout name.")), + sinks(input("window_id", TMUX_LOOKUP), input("layout", TMUX_STATE)), + record(Shaping.Changed.class, "note"), + Shaping::selectLayout)); + tools.add(manageTool( + "resize_window", + "Resize a window", + "Sets a window's width, height or both.", + List.of( + required("window_id", "The window ID, such as @1."), + number("width", "Width in terminal cells; omit to retain it.", 0), + number("height", "Height in terminal cells; omit to retain it.", 0)), + sinks(input("window_id", TMUX_LOOKUP), input("width", TMUX_STATE), input("height", TMUX_STATE)), + WINDOW_OUTPUT, + Operations::resizeWindow)); + tools.add(manageTool( + "resize_pane", + "Resize a pane", + "Sets a pane's width, height or both.", List.of( paneId(), - strings( - "patterns", - "Text to wait for; any one of them ends the wait. Omit to wait for " - + "any new output at all."), - strings( - "stop", - "Text that means it has failed. Matching one ends the wait at once and " - + "reports STOPPED."), - flag("regex", "Treat patterns and stops as regular expressions rather than plain text.", false), - seconds("timeout", "Seconds to wait before giving up.", 30), - optional("cursor", "Carry on from a cursor a previous call returned."), - number("max_lines", "How many lines of what it saw to return.", Trim.DEFAULT_LINES)), - WaitingForText::waitFor)); - - tools.add(ToolSpec.of( - "tmux_wait_for_channel", - "Wait on a tmux channel", - "Consumes the next signal on a tmux channel, blocking until one exists. This is the only wait " - + "that infers nothing " - + "from the screen: compose a command as 'mycommand; tmux wait-for -S mychannel' with " - + "tmux_send_keys, then wait here. The answer says why the wait ended, because tmux " - + "reports a server that died under a waiter as a successful wake.", - Safety.MUTATING, - DESTRUCTIVE, + number("width", "Width in terminal cells; omit to retain it.", 0), + number("height", "Height in terminal cells; omit to retain it.", 0)), + sinks(input("pane_id", TMUX_LOOKUP), input("width", TMUX_STATE), input("height", TMUX_STATE)), + record(Shaping.Changed.class, "note"), + Shaping::resizePane)); + tools.add(manageTool( + "move_window", + "Move a window", + "Moves a window to another session, optionally at an index.", List.of( - required("channel", "The channel name, which everything on this server shares."), - seconds("timeout", "Seconds to wait before giving up.", 30), - flag( - "drain_first", - "Consume a signal left over from before this call, so the wait starts from a " - + "known state.", - false)), - Channels::waitFor)); + required("window_id", "The window ID, such as @1."), + required("session_id", "The destination session ID, such as $1."), + number("index", "A destination window index; omit for tmux's choice.", -1)), + sinks(input("window_id", TMUX_LOOKUP), input("session_id", TMUX_LOOKUP), input("index", TMUX_STATE)), + shape(field("window_id", STRING), field("session_id", STRING), field("index", INTEGER)), + Operations::moveWindow)); + tools.add(manageTool( + "swap_pane", + "Swap panes", + "Swaps the positions of two panes.", + List.of(paneId(), required("other_pane_id", "The other pane ID, such as %2.")), + sinks(input("pane_id", TMUX_LOOKUP), input("other_pane_id", TMUX_LOOKUP)), + shape(field("pane_id", STRING), field("other_pane_id", STRING)), + Operations::swapPane)); + tools.add(literalized( + manageTool( + "set_pane_title", + "Set pane title", + "Replaces a pane's literal title.", + List.of(paneId(), required("title", "The literal title.")), + sinks(input("pane_id", TMUX_LOOKUP), input("title", TMUX_FORMAT)), + PANE_OUTPUT, + Operations::setPaneTitle), + "title")); + tools.add(manageTool( + "enter_copy_mode", + "Enter copy mode", + "Puts a pane into copy mode.", + List.of(paneId()), + sinks(input("pane_id", TMUX_LOOKUP)), + shape(field("pane_id", STRING), field("mode", STRING)), + Operations::enterCopyMode)); + tools.add(manageTool( + "exit_copy_mode", + "Exit copy mode", + "Leaves the pane's current mode.", + List.of(paneId()), + sinks(input("pane_id", TMUX_LOOKUP)), + shape(field("pane_id", STRING), field("mode", STRING)), + Operations::exitCopyMode)); - tools.add(ToolSpec.of( - "tmux_signal_channel", - "Signal a tmux channel", - "Wakes whatever is waiting on a tmux channel. A signal sent when nothing is waiting is " - + "remembered and satisfies the next wait.", - Safety.MUTATING, - DESTRUCTIVE, + List channelWait = List.of( + required("channel", "A server-wide tmux channel name."), + seconds("timeout", "Seconds to wait before giving up.", 30), + flag("drain_first", "Consume a pending signal before waiting.", false)); + tools.add(tool( + "wait_for_channel", + "Wait for a channel", + "Waits on tmux's channel state with a bounded timeout.", + MANAGE, + NONE, + effects(CHANGE), + outputs(TMUX_METADATA), + false, + true, + channelWait, + sinks( + input("channel", TMUX_STATE), + input("timeout", ToolSpec.InputSink.NONE), + input("drain_first", TMUX_STATE)), + record(Channels.Woke.class, "note"), + Channels::waitFor)); + tools.add(changeOnlyTool( + "signal_channel", + "Signal a channel", + "Signals one server-wide tmux channel.", List.of(required("channel", "The channel name.")), + sinks(input("channel", TMUX_STATE)), + record(Channels.Signalled.class), Channels::signal)); - - tools.add(ToolSpec.of( - "tmux_drain_channel", - "Clear a stale channel signal", - "Consumes a signal already waiting on a channel, so a leftover one cannot satisfy a wait " - + "that has not happened yet.", - Safety.MUTATING, - DESTRUCTIVE, - List.of(required("channel", "The channel name.")), - Channels::drain)); + tools.add(changeOnlyTool( + "set_mouse_enabled", + "Set mouse handling", + "Enables or disables tmux mouse handling.", + List.of(flag("enabled", "Whether mouse handling is enabled.", false)), + sinks(input("enabled", TMUX_STATE)), + shape(field("enabled", BOOLEAN)), + Operations::setMouseEnabled)); + tools.add(changeOnlyTool( + "set_history_limit", + "Set history limit", + "Sets a bounded integer scrollback limit for future panes in a session.", + List.of( + required("session_id", "The session ID, such as $1."), + requiredNumber("lines", "The nonnegative retained line count.")), + sinks(input("session_id", TMUX_LOOKUP), input("lines", TMUX_STATE)), + shape(field("session_id", STRING), field("lines", INTEGER)), + Operations::setHistoryLimit)); } - // ------------------------------------------------------------------ input + private static void execute(List tools) { + tools.add(literalized( + tool( + "create_session", + "Create a session", + "Creates a detached session whose first pane runs the configured process.", + EXECUTE, + CONFIGURED_PROCESS, + effects(OBSERVE, CHANGE), + outputs(TMUX_METADATA), + false, + true, + List.of( + optional("session_name", "A literal session name."), + optional("window_name", "A literal first-window name."), + optional("start_directory", "An absolute literal start directory."), + number("width", "Initial width; supply with height.", -1), + number("height", "Initial height; supply with width.", -1)), + sinks( + input("session_name", TMUX_FORMAT), + input("window_name", TMUX_FORMAT), + input("start_directory", TMUX_FORMAT), + input("width", TMUX_STATE), + input("height", TMUX_STATE)), + SESSION_OUTPUT, + Operations::createSession), + "session_name", + "window_name", + "start_directory")); + tools.add(literalized( + tool( + "create_window", + "Create a window", + "Creates a window whose first pane runs the configured process.", + EXECUTE, + CONFIGURED_PROCESS, + effects(OBSERVE, CHANGE), + outputs(TMUX_METADATA), + false, + true, + List.of( + required("session_id", "The session ID, such as $1."), + optional("window_name", "A literal window name."), + optional("start_directory", "An absolute literal start directory."), + flag("attach", "Make the new window active.", false), + optional("direction", "before or after.")), + sinks( + input("session_id", TMUX_LOOKUP), + input("window_name", TMUX_FORMAT), + input("start_directory", TMUX_FORMAT), + input("attach", TMUX_STATE), + input("direction", TMUX_STATE)), + WINDOW_OUTPUT, + Operations::createWindow), + "window_name", + "start_directory")); + tools.add(literalized( + tool( + "split_window", + "Split a window", + "Creates a pane whose configured process starts after the split.", + EXECUTE, + CONFIGURED_PROCESS, + effects(OBSERVE, CHANGE), + outputs(TMUX_METADATA), + false, + true, + List.of( + paneId(), + optional("direction", "below, above, left or right."), + number("percent", "Share of the split occupied by the new pane.", 50), + optional("start_directory", "An absolute literal start directory.")), + sinks( + input("pane_id", TMUX_LOOKUP), + input("direction", TMUX_STATE), + input("percent", TMUX_STATE), + input("start_directory", TMUX_FORMAT)), + PANE_OUTPUT, + Operations::splitWindow), + "start_directory")); + tools.add(literalized( + tool( + "respawn_pane", + "Respawn a pane", + "Kills the pane's current process and starts its configured process again.", + EXECUTE, + CONFIGURED_PROCESS, + effects(OBSERVE, CHANGE, DELETE), + outputs(TMUX_METADATA), + false, + true, + List.of(paneId(), optional("start_directory", "An absolute literal start directory.")), + sinks(input("pane_id", TMUX_LOOKUP), input("start_directory", TMUX_FORMAT)), + shape(field("pane_id", STRING), field("restarted", BOOLEAN)), + Operations::respawnPane), + "start_directory")); - private static void typing(List tools) { - tools.add(ToolSpec.of( - "tmux_send_keys", - "Send keys to a pane", - "Sends keypresses by tmux's names for them — 'C-c' to interrupt, 'q' to quit a pager, " - + "'Up' for the previous command. This is for controlling a program, not for running " - + "commands: a command you wrote belongs in tmux_run, which waits for it.", - Safety.MUTATING, - DESTRUCTIVE, - List.of( - paneId(), - strings("keys", "The keys, as tmux names them, for example [\"C-c\"] or [\"y\", \"Enter\"]."), - flag("literal", "Send the strings as text rather than looking them up as key names.", false)), - Typing::sendKeys)); + List run = List.of( + paneId(), + required("command", "The shell command, run in the pane's interactive shell."), + seconds("timeout", "Seconds to wait before giving up.", 30), + number("max_lines", "Maximum output lines, keeping the newest.", Trim.DEFAULT_LINES), + flag("suppress_history", "Best-effort persistent history suppression.", true)); + tools.add(tool( + "run_shell_command", + "Run a shell command", + "Runs one authored command in a pane and waits for its framed completion.", + EXECUTE, + PANE_COMMAND, + effects(OBSERVE, CHANGE), + outputs(TERMINAL_CONTENT, TMUX_METADATA), + true, + true, + run, + sinks( + input("pane_id", TMUX_LOOKUP), + input("command", ToolSpec.InputSink.PANE_INPUT, SHELL_COMMAND), + input("timeout", ToolSpec.InputSink.NONE), + input("max_lines", ToolSpec.InputSink.NONE), + input("suppress_history", ToolSpec.InputSink.NONE)), + record(RunningCommands.Ran.class, "exit_status", "note"), + RunningCommands::run)); - tools.add(ToolSpec.of( - "tmux_paste_text", - "Paste text into a pane", - "Puts text into a pane as a paste rather than as keystrokes, so brackets, newlines and " - + "anything that spells a key name arrive as the characters they are. Use it for an " - + "editor, a REPL, or a here-document. Requires tmux 3.4 or newer so a failed paste " - + "can remove only its own temporary buffer.", - Safety.MUTATING, - DESTRUCTIVE, + List keys = List.of( + paneId(), + strings("keys", "The key names or literal strings to send."), + flag("literal", "Send strings literally instead of as key names.", false)); + tools.add(tool( + "send_keys", + "Send keys", + "Sends input to one pane without waiting for output.", + EXECUTE, + PANE_INPUT, + effects(OBSERVE, CHANGE), + outputs(TMUX_METADATA), + false, + true, + keys, + sinks( + input("pane_id", TMUX_LOOKUP), + input("keys", ToolSpec.InputSink.PANE_INPUT), + input("literal", ToolSpec.InputSink.NONE)), + record(Typing.Sent.class, "note"), + Typing::sendKeys)); + tools.add(tool( + "send_keys_batch", + "Send keys in a batch", + "Sends up to sixty-four ordered pane-input operations.", + EXECUTE, + PANE_INPUT, + effects(OBSERVE, CHANGE), + outputs(TMUX_METADATA), + false, + true, + List.of( + boundedObjects("operations", "Objects with pane_id, keys and optional literal fields.", 64), + optional("onError", "stop or continue; defaults to stop.")), + sinks( + input("operations", TMUX_LOOKUP, ToolSpec.InputSink.PANE_INPUT), + input("onError", ToolSpec.InputSink.NONE)), + sendBatchOutput(), + Operations::sendKeysBatch)); + tools.add(tool( + "paste_text", + "Paste text", + "Pastes one literal text block into a pane through an ephemeral buffer.", + EXECUTE, + PANE_INPUT, + effects(OBSERVE, CHANGE), + outputs(TMUX_METADATA), + false, + true, List.of( paneId(), - required("text", "The text to paste."), - flag("enter", "End the paste with a newline, submitting it.", false)), + required("text", "The literal text to paste."), + flag("enter", "Append a newline that submits the text.", false)), + sinks( + input("pane_id", TMUX_LOOKUP), + input("text", ToolSpec.InputSink.PANE_INPUT), + input("enter", ToolSpec.InputSink.PANE_INPUT)), + record(Typing.Pasted.class, "note"), Typing::pasteText)); + tools.add(amplifying(tool( + "set_synchronize_panes", + "Set synchronized panes", + "When enabled, subsequent input is copied to every pane in the window.", + EXECUTE, + NONE, + effects(CHANGE), + outputs(TMUX_METADATA), + false, + true, + List.of( + required("window_id", "The window ID, such as @1."), + flag("enabled", "Whether pane input is synchronized.", false)), + sinks(input("window_id", TMUX_LOOKUP), input("enabled", TMUX_STATE)), + shape(field("window_id", STRING), field("enabled", BOOLEAN)), + Operations::setSynchronizePanes))); } - // ------------------------------------------------------------------ structure + private static void teardown(List tools) { + tools.add(deleteOnlyTool( + "clear_pane_scrollback", + "Clear pane scrollback", + "Deletes retained scrollback from one pane.", + List.of(paneId()), + sinks(input("pane_id", TMUX_LOOKUP)), + shape(field("pane_id", STRING), field("cleared", BOOLEAN)), + Operations::clearPaneScrollback)); + tools.add(teardownTool( + "kill_pane", + "Kill a pane", + "Deletes one pane and ends its process.", + killArguments("pane_id", "The pane ID, such as %1."), + sinks(input("pane_id", TMUX_LOOKUP), input("confirm_self", ToolSpec.InputSink.NONE)), + record(Shaping.Ended.class, "note"), + Operations::killPane)); + tools.add(teardownTool( + "kill_window", + "Kill a window", + "Deletes one window and every pane in it.", + killArguments("window_id", "The window ID, such as @1."), + sinks(input("window_id", TMUX_LOOKUP), input("confirm_self", ToolSpec.InputSink.NONE)), + record(Shaping.Ended.class, "note"), + Operations::killWindow)); + tools.add(teardownTool( + "kill_session", + "Kill a session", + "Deletes one session and every window and pane in it.", + killArguments("session_id", "The session ID, such as $1."), + sinks(input("session_id", TMUX_LOOKUP), input("confirm_self", ToolSpec.InputSink.NONE)), + record(Shaping.Ended.class, "note"), + Operations::killSession)); + } - private static void shaping(List tools) { - tools.add(ToolSpec.of( - "tmux_new_session", - "Create a session", - "Creates a detached session and returns its first pane's id.", - Safety.MUTATING, - DESTRUCTIVE, - List.of( - required("name", "The session name."), - optional("path", "The directory its first pane starts in."), - optional("command", "A command to run in it instead of a shell.")), - Shaping::newSession)); - - tools.add(ToolSpec.of( - "tmux_new_window", - "Create a window", - "Creates a window in a session without switching to it, and returns its first pane's id.", - Safety.MUTATING, - DESTRUCTIVE, - List.of( - required("session", "The session to create it in."), - optional("name", "The window name. Omit to let tmux name it after what runs in it."), - optional("path", "The directory it starts in."), - optional("command", "A command to run in it instead of a shell.")), - Shaping::newWindow)); - - tools.add(ToolSpec.of( - "tmux_split_pane", - "Split a pane", - "Splits a pane in two and returns the id of the new one. The direction says where the new " - + "pane goes.", - Safety.MUTATING, - DESTRUCTIVE, - List.of( - paneId(), - optional("direction", "Where the new pane goes: below, above, left or right."), - number("percent", "How much of the space the new pane takes, 1 to 99.", 50), - optional("path", "The directory it starts in."), - optional("command", "A command to run in it instead of a shell.")), - Shaping::splitPane)); - - tools.add(ToolSpec.of( - "tmux_apply_workspace", - "Build a session from a description", - "Builds a whole session — windows, panes, layouts and the commands to start in them — from " - + "one YAML document in the shape tmuxp uses. One call instead of a dozen, and a " - + "description tmux would refuse is refused before anything is half-built. Example:\n" - + Workspaces.example(), - Safety.MUTATING, - DESTRUCTIVE, - List.of(required("workspace", "The YAML document describing the session.")), - Workspaces::apply)); - - tools.add(ToolSpec.of( - "tmux_rename", - "Rename a window or session", - "Renames a window or session given its stable id.", - Safety.MUTATING, - DESTRUCTIVE, - List.of( - required("target", "A window id such as @1, or a session id such as $1."), - required("name", "The new name.")), - Shaping::rename)); - - tools.add(ToolSpec.of( - "tmux_select", - "Bring a pane or window to the front", - "Makes a pane or window the active one, which is what a person attached to the session then " - + "sees. Not needed to read or act on something: every other tool takes an id and works " - + "whether or not the target is active.", - Safety.MUTATING, - DESTRUCTIVE, - List.of(required("target", "A pane id such as %1, or a window id such as @1.")), - Shaping::select)); - - tools.add(ToolSpec.of( - "tmux_select_layout", - "Rearrange a window's panes", - "Applies one of tmux's layouts to a window: " + String.join(", ", Shaping.layoutNames()) + ".", - Safety.MUTATING, - DESTRUCTIVE, - List.of(required("window_id", "The window id, such as @1."), required("layout", "The layout name.")), - Shaping::selectLayout)); + private static List killArguments(String name, String description) { + return List.of( + required(name, description), + flag("confirm_self", "Permit ending the pane this MCP process runs in.", false)); + } - tools.add(ToolSpec.of( - "tmux_resize_pane", - "Resize a pane", - "Sets a pane's size in cells. A pane cannot grow past its window, and its neighbours have to " - + "give up what it takes, so the size that results may not be the one asked for — the " - + "answer says what it actually became.", - Safety.MUTATING, - DESTRUCTIVE, - List.of( - paneId(), - number("width", "Width in cells. Omit to leave it.", 0), - number("height", "Height in cells. Omit to leave it.", 0)), - Shaping::resizePane)); + private static ToolSpec inspectMetadata( + String name, + String title, + String details, + List arguments, + Map> sinks, + OutputSchema output, + java.util.function.Function answer) { + return tool( + name, + title, + details, + INSPECT, + NONE, + effects(OBSERVE), + outputs(TMUX_METADATA), + true, + false, + arguments, + sinks, + output, + answer); } - // ------------------------------------------------------------------ configuration + private static ToolSpec manageTool( + String name, + String title, + String details, + List arguments, + Map> sinks, + OutputSchema output, + java.util.function.Function answer) { + return tool( + name, + title, + details, + MANAGE, + NONE, + effects(OBSERVE, CHANGE), + outputs(TMUX_METADATA), + false, + true, + arguments, + sinks, + output, + answer); + } - private static void settings(List tools) { - tools.add(ToolSpec.of( - "tmux_show_options", - "Read tmux options", - "Reads a set of tmux options. tmux keeps four sets and lets a lower one override the one " - + "above, so say which scope you mean: global, server, session, window or pane.", - Safety.READONLY, - READ_ONLY, - List.of( - optional("scope", "global, server, session, window or pane. Defaults to global."), - optional("target", "Which session, window or pane, when the scope is one of those."), - flag( - "effective", - "Include values inherited from a wider scope, not only those set here.", - false)), - Settings::showOptions)); - - tools.add(ToolSpec.of( - "tmux_set_option", - "Set a tmux option", - "Sets one tmux option in one scope. Setting it globally changes it for everything that has " - + "not overridden it, including panes a person is using, so prefer the narrowest scope " - + "that does what you need.", - Safety.MUTATING, - DESTRUCTIVE, - List.of( - required("name", "The option name."), - required("value", "The value to set."), - optional("scope", "global, server, session, window or pane. Defaults to global."), - optional("target", "Which session, window or pane, when the scope is one of those.")), - Settings::setOption)); - - tools.add(ToolSpec.of( - "tmux_show_hooks", - "Read tmux hooks", - "Reads the hooks set in a scope. Read-only: a hook set over MCP would be gone when this " - + "server restarts, so one that should last belongs in a tmux config file.", - Safety.READONLY, - READ_ONLY, - List.of( - optional("scope", "global, server, session, window or pane. Defaults to global."), - optional("target", "Which session, window or pane, when the scope is one of those.")), - Settings::showHooks)); - - tools.add(ToolSpec.of( - "tmux_show_environment", - "Read the tmux environment", - "Reads the environment tmux passes to programs it starts, globally or for one session. This " - + "is what a new pane will inherit, not what a running program currently has.", - Safety.READONLY, - READ_ONLY, - List.of(optional("session", "The session to read. Omit for the global environment.")), - Settings::environment)); + private static ToolSpec changeOnlyTool( + String name, + String title, + String details, + List arguments, + Map> sinks, + OutputSchema output, + java.util.function.Function answer) { + return tool( + name, + title, + details, + MANAGE, + NONE, + effects(CHANGE), + outputs(TMUX_METADATA), + false, + true, + arguments, + sinks, + output, + answer); } - // ------------------------------------------------------------------ ending things + private static ToolSpec teardownTool( + String name, + String title, + String details, + List arguments, + Map> sinks, + OutputSchema output, + java.util.function.Function answer) { + return tool( + name, + title, + details, + TEARDOWN, + NONE, + effects(OBSERVE, DELETE), + outputs(TMUX_METADATA), + false, + false, + arguments, + sinks, + output, + answer); + } - private static void ending(List tools) { - tools.add(ToolSpec.of( - "tmux_kill", - "Destroy a pane, window, session or the server", - "Ends something and everything running in it. This cannot be undone: the processes inside " - + "are killed, and unsaved work in them is gone. Refuses to end the pane this " - + "conversation is running through unless confirm_self is set — call tmux_whoami to see " - + "which pane that is.", - Safety.DESTRUCTIVE, - DESTRUCTIVE, - List.of( - required( - "target", - "A pane id such as %1, a window id such as @1, a session id such as $1, or the " - + "word 'server' to end the whole server."), - flag( - "confirm_self", - "Go ahead even though the target holds the pane this MCP server runs in.", - false)), - Shaping::kill)); - } - - /** The tools a server at this ceiling offers, keyed by name. */ - static Map offered(Safety ceiling) { - Map offered = new LinkedHashMap<>(); - for (ToolSpec tool : tools()) { - if (ceiling.allows(tool.safety())) { - offered.put(tool.name(), tool); + private static ToolSpec deleteOnlyTool( + String name, + String title, + String details, + List arguments, + Map> sinks, + OutputSchema output, + java.util.function.Function answer) { + return tool( + name, + title, + details, + TEARDOWN, + NONE, + effects(DELETE), + outputs(TMUX_METADATA), + false, + false, + arguments, + sinks, + output, + answer); + } + + private static ToolSpec tool( + String name, + String title, + String details, + ToolSpec.Toolset toolset, + ToolSpec.ProcessReach processReach, + Set effects, + Set outputs, + boolean mayExposeSecrets, + boolean mayReturnUntrustedContent, + List arguments, + Map> sinks, + OutputSchema output, + java.util.function.Function answer) { + return tool( + name, + title, + details, + toolset, + processReach, + effects, + outputs, + mayExposeSecrets, + mayReturnUntrustedContent, + arguments, + sinks, + Set.of(), + output, + answer); + } + + private static ToolSpec tool( + String name, + String title, + String details, + ToolSpec.Toolset toolset, + ToolSpec.ProcessReach processReach, + Set effects, + Set outputs, + boolean mayExposeSecrets, + boolean mayReturnUntrustedContent, + List arguments, + Map> sinks, + Set nestedAuthority, + OutputSchema output, + java.util.function.Function answer) { + return ToolSpec.define( + name, + title, + details, + toolset, + processReach, + effects, + outputs, + mayExposeSecrets, + mayReturnUntrustedContent, + conservativeAnnotations(), + arguments, + sinks, + nestedAuthority, + output, + answer); + } + + private static ToolSpec.Annotations conservativeAnnotations() { + return new ToolSpec.Annotations(false, true, false, true); + } + + private static ToolSpec literalized(ToolSpec tool, String... fields) { + Map claims = new LinkedHashMap<>(); + for (String field : fields) { + claims.put(field, "double-hash-once"); + } + return tool.withInputLiteralization(claims); + } + + private static ToolSpec amplifying(ToolSpec tool) { + return tool.amplifyingFutureInput(); + } + + @SafeVarargs + private static > Set enums(E first, E... rest) { + Set values = EnumSet.noneOf(first.getDeclaringClass()); + values.add(first); + for (E value : rest) { + values.add(value); + } + return values; + } + + private static Set effects(ToolSpec.TmuxEffect first, ToolSpec.TmuxEffect... rest) { + return enums(first, rest); + } + + private static Set outputs(ToolSpec.OutputClass first, ToolSpec.OutputClass... rest) { + return enums(first, rest); + } + + private static Input input(String name, ToolSpec.InputSink first, ToolSpec.InputSink... rest) { + return new Input(name, enums(first, rest)); + } + + private static Map> sinks(Input... inputs) { + Map> sinks = new LinkedHashMap<>(); + for (Input input : inputs) { + if (sinks.put(input.name(), input.sinks()) != null) { + throw new IllegalArgumentException("duplicate sink declaration for '" + input.name() + "'"); } } - return offered; + return sinks; + } + + private static OutputSchema shape(OutputSchema.Field first, OutputSchema.Field... rest) { + return OutputSchema.of(first, rest); + } + + private static OutputSchema record(Class type, String... optionalFields) { + return OutputSchema.ofRecord(type).withOptionalFields(optionalFields); + } + + private static OutputSchema.Field field(String name, OutputSchema.ValueType type) { + return new OutputSchema.Field(name, type); + } + + private static OutputSchema readBatchOutput() { + OutputSchema envelope = shape( + field("_meta", OBJECT), + field("content", ARRAY), + field("structuredContent", OBJECT), + field("isError", BOOLEAN)) + .withOptionalFields("_meta", "structuredContent") + .withPropertySchema("content", arrayOf(Map.of("type", "object"))); + OutputSchema row = shape( + field("index", INTEGER), + field("tool", STRING), + field("success", BOOLEAN), + field("error", STRING), + field("result", OBJECT), + field("resultTruncated", BOOLEAN)) + .withPropertySchema("error", nullable(Map.of("type", "string"))) + .withPropertySchema("result", nullable(envelope.wireSchema())); + return shape( + field("results", ARRAY), + field("succeeded", INTEGER), + field("failed", INTEGER), + field("stoppedAt", INTEGER), + field("truncated", BOOLEAN), + field("truncatedBytes", INTEGER), + field("onError", STRING)) + .withPropertySchema("results", arrayOf(row.wireSchema())) + .withPropertySchema("stoppedAt", nullable(Map.of("type", "integer"))); + } + + private static OutputSchema sendBatchOutput() { + OutputSchema row = shape( + field("index", INTEGER), + field("pane_id", STRING), + field("resolved_pane_ids", ARRAY), + field("success", BOOLEAN), + field("error", STRING)) + .withOptionalFields("resolved_pane_ids", "error") + .withPropertySchema("resolved_pane_ids", arrayOf(Map.of("type", "string"))); + return shape(field("results", ARRAY), field("completed", INTEGER)) + .withPropertySchema("results", arrayOf(row.wireSchema())); + } + + private static Map arrayOf(Map item) { + Map schema = new LinkedHashMap<>(); + schema.put("type", "array"); + schema.put("items", item); + return java.util.Collections.unmodifiableMap(schema); + } + + private static Map nullable(Map value) { + return Map.of("oneOf", List.of(value, Map.of("type", "null"))); + } + + private static void validateSchema(ToolSpec tool) { + Set schema = new LinkedHashSet<>(); + for (Argument argument : tool.arguments()) { + if (!schema.add(argument.name())) { + throw new IllegalArgumentException( + tool.name() + " has duplicate schema field '" + argument.name() + "'"); + } + if (Set.of("socket", "socket_name", "socket_path").contains(argument.name())) { + throw new IllegalArgumentException(tool.name() + " exposes a per-call socket selector"); + } + } + if (!schema.equals(tool.inputSinks().keySet())) { + Set missing = new HashSet<>(schema); + missing.removeAll(tool.inputSinks().keySet()); + Set extra = new HashSet<>(tool.inputSinks().keySet()); + extra.removeAll(schema); + throw new IllegalArgumentException( + tool.name() + " sink/schema mismatch; missing=" + missing + ", extra=" + extra); + } + } + + private static void validateReach(ToolSpec tool) { + Set sinks = tool.inputSinks().values().stream() + .flatMap(Set::stream) + .collect(java.util.stream.Collectors.toUnmodifiableSet()); + boolean paneInput = sinks.contains(ToolSpec.InputSink.PANE_INPUT); + boolean shellCommand = sinks.contains(SHELL_COMMAND); + boolean processArgv = sinks.contains(ToolSpec.InputSink.PROCESS_ARGV); + switch (tool.processReach()) { + case NONE -> { + if (paneInput || shellCommand || processArgv) { + throw new IllegalArgumentException(tool.name() + " has executable sinks with reach none"); + } + } + case CONFIGURED_PROCESS -> { + if (paneInput || shellCommand || processArgv) { + throw new IllegalArgumentException(tool.name() + " misstates configured-process reach"); + } + } + case PANE_INPUT -> { + if (!paneInput || shellCommand || processArgv) { + throw new IllegalArgumentException(tool.name() + " pane-input reach disagrees with its sinks"); + } + } + case PANE_COMMAND -> { + if (!shellCommand || processArgv) { + throw new IllegalArgumentException( + tool.name() + " pane-command reach disagrees with its shell-command sink"); + } + } + case HOST_COMMAND -> throw new IllegalArgumentException(tool.name() + " exposes host-command reach"); + } + if (tool.toolset() == INSPECT + && (tool.processReach() != NONE + || !tool.effects().contains(OBSERVE) + || tool.effects().contains(DELETE))) { + throw new IllegalArgumentException(tool.name() + " is not observational inspect authority"); + } + if (tool.toolset() == MANAGE && tool.processReach() != NONE) { + throw new IllegalArgumentException(tool.name() + " manage authority reaches a workload process"); + } + if (tool.toolset() == EXECUTE + && tool.processReach() == NONE + && !tool.name().equals("set_synchronize_panes")) { + throw new IllegalArgumentException(tool.name() + " execute authority has no process reach"); + } + if (tool.toolset() == TEARDOWN + && (tool.processReach() != NONE || !tool.effects().contains(DELETE))) { + throw new IllegalArgumentException(tool.name() + " is not direct teardown authority"); + } + } + + private record Input(String name, Set sinks) { + Input { + Objects.requireNonNull(name, "name"); + sinks = Set.copyOf(sinks); + } } } diff --git a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Completions.java b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Completions.java deleted file mode 100644 index 9cd9cc3..0000000 --- a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Completions.java +++ /dev/null @@ -1,93 +0,0 @@ -package io.github.libtmux.mcp; - -import io.github.libtmux.Session; -import io.modelcontextprotocol.server.McpServerFeatures; -import io.modelcontextprotocol.spec.McpSchema; -import java.util.ArrayList; -import java.util.List; -import java.util.function.Supplier; - -/** - * Offers the ids that actually exist, as someone types one. - * - *

Without this, {@code completion/complete} either is not answered at all or answers from a - * fixed list, and finding a pane id costs a round trip: list every pane, read the listing, pick one. - * Answered live, the ids are simply there — and they are the ids that exist right now rather than - * the ones a listing showed some turns ago. - * - *

The protocol allows completion only against a prompt argument or a resource template variable, - * not against a tool argument, so those are what is wired here. - */ -final class Completions { - - /** The protocol caps a completion response at a hundred values, and a person cannot read that many. */ - private static final int MOST = 100; - - private Completions() {} - - static List all(Connection connection) { - List specifications = new ArrayList<>(); - - for (String prompt : List.of("run_and_wait", "watch_until_ready")) { - specifications.add(completing( - new McpSchema.PromptReference(prompt), Prompts.PANE_ARGUMENT, () -> paneIds(connection))); - } - specifications.add(completing( - new McpSchema.PromptReference("clean_up_safely"), - Prompts.SESSION_ARGUMENT, - () -> sessionNames(connection))); - - specifications.add(completing( - new McpSchema.ResourceReference(Resources.PANE_TEMPLATE), "pane_id", () -> paneIds(connection))); - specifications.add(completing( - new McpSchema.ResourceReference(Resources.PANE_CONTENT_TEMPLATE), - "pane_id", - () -> paneIds(connection))); - specifications.add(completing( - new McpSchema.ResourceReference(Resources.SESSION_TEMPLATE), - "session_name", - () -> sessionNames(connection))); - - return List.copyOf(specifications); - } - - private static List paneIds(Connection connection) { - return connection.server().panes().stream() - .map(pane -> pane.id().value()) - .toList(); - } - - private static List sessionNames(Connection connection) { - return connection.server().sessions().stream().map(Session::name).toList(); - } - - /** - * A tmux server that has gone answers no completion rather than failing the request: a client - * asking what to type is not a place to report that the world ended. - */ - private static McpServerFeatures.SyncCompletionSpecification completing( - McpSchema.CompleteReference reference, String argument, Supplier> values) { - return new McpServerFeatures.SyncCompletionSpecification(reference, (exchange, request) -> { - if (!argument.equals(request.argument().name())) { - return empty(); - } - String typed = request.argument().value(); - List candidates; - try { - candidates = values.get(); - } catch (RuntimeException e) { - return empty(); - } - List matching = candidates.stream() - .filter(candidate -> typed == null || typed.isEmpty() || candidate.startsWith(typed)) - .limit(MOST) - .toList(); - return new McpSchema.CompleteResult(new McpSchema.CompleteResult.CompleteCompletion( - matching, candidates.size(), candidates.size() > matching.size())); - }); - } - - private static McpSchema.CompleteResult empty() { - return new McpSchema.CompleteResult(new McpSchema.CompleteResult.CompleteCompletion(List.of(), 0, false)); - } -} diff --git a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Connection.java b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Connection.java index dc53300..f97f552 100644 --- a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Connection.java +++ b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Connection.java @@ -1,30 +1,22 @@ package io.github.libtmux.mcp; import io.github.libtmux.Server; -import java.util.Set; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.locks.Lock; -import java.util.concurrent.locks.ReentrantReadWriteLock; -import java.util.function.Supplier; -/** What every tool, resource and prompt on this connection shares. */ +/** What every tool and the capability resource on this connection share. */ final class Connection { private final Server server; private final Caller caller; - private final Safety ceiling; - private final Set ownClients; - private final ReentrantReadWriteLock clientVisibility = new ReentrantReadWriteLock(true); + private final ToolSurface surface; - Connection(Server server, Caller caller, Safety ceiling, Set ownClients) { + Connection(Server server, Caller caller, ToolSurface surface) { this.server = server; this.caller = caller; - this.ceiling = ceiling; - this.ownClients = ownClients; + this.surface = surface; } - static Connection to(Server server, Safety ceiling) { - return new Connection(server, Caller.of(server), ceiling, ConcurrentHashMap.newKeySet()); + static Connection to(Server server, ToolSurface surface) { + return new Connection(server, Caller.of(server), surface); } Server server() { @@ -35,54 +27,8 @@ Caller caller() { return caller; } - Safety ceiling() { - return ceiling; - } - - /** Runs a watcher-client transition without exposing its half-finished state to a listing. */ - T changeClients(Supplier change) { - return locked(clientVisibility.writeLock(), change); - } - - void changeClients(Runnable change) { - changeClients(() -> { - change.run(); - return null; - }); - } - - /** Reads tmux clients and the hidden-client set as one stable view. */ - T withStableClients(Supplier read) { - return locked(clientVisibility.readLock(), read); - } - - private static T locked(Lock lock, Supplier operation) { - lock.lock(); - try { - return operation.get(); - } finally { - lock.unlock(); - } - } - - /** - * Stops a client this server attached for its own purposes being reported as a person. - * - *

Watching a server means attaching a control client to it, and an attached client is exactly - * what {@code tmux_list_clients} answers "is anybody looking at this" with. Left in, this - * server's own watcher would make every session look occupied. - */ - void hide(String clientName) { - ownClients.add(clientName); - } - - /** Stops hiding a watcher client after that attachment ends. */ - void reveal(String clientName) { - ownClients.remove(clientName); - } - - boolean isOurs(String clientName) { - return ownClients.contains(clientName); + ToolSurface surface() { + return surface; } /** One invocation on this connection. */ diff --git a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Instructions.java b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Instructions.java index 556e9e3..71b0c24 100644 --- a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Instructions.java +++ b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Instructions.java @@ -14,7 +14,7 @@ final class Instructions { private Instructions() {} - static String forServer(Safety ceiling, boolean watching) { + static String forServer(Connection connection) { return """ Drives tmux: a terminal multiplexer holding Server > Session > Window > Pane. Target everything by id — %1 a pane, @1 a window, $1 a session. Ids survive; \ @@ -28,50 +28,36 @@ Do NOT use them for browser tabs, editor splits (VS Code, Neovim), desktop windo sight, ask which is meant before acting. START HERE - tmux_whoami says which server this is and, when the client launched me from inside \ - tmux, which pane this conversation is coming through. That pane is the one never to \ - kill or type into. tmux_list_servers finds other tmux servers when the sessions you \ - expected are missing — separate sockets cannot see each other. + get_server_info identifies the pinned server. list_panes returns stable pane IDs and \ + marks this process's pane when it runs inside the selected server. Direct teardown \ + tools guard that pane. This process cannot address objects outside its selected socket. WAIT, DO NOT POLL - A command you wrote: tmux_run. It sends, waits, and returns output with an exit status \ - in one call. Never send a command and then call tmux_capture_pane repeatedly to guess \ + A command you wrote: run_shell_command. It sends, waits, and returns output with an exit status \ + in one call. Never send a command and then call capture_pane repeatedly to guess \ whether it finished. - Output you did not start: tmux_wait_for_text, always with 'stop' set to the failure \ + Output you did not start: wait_for_text, always with 'stop' set to the failure \ text — without it a run that fails is waited on until the deadline. - Something you can compose a signal into: tmux_wait_for_channel. It blocks inside tmux \ + Something you can compose a signal into: wait_for_channel. It blocks inside tmux \ and infers nothing from the screen. - Watching over several turns: tmux_capture_since with the cursor it returns, so you pay \ + Watching over several turns: capture_since with the cursor it returns, so you pay \ for new lines rather than the whole screen again. Every wait is bounded and says the ceiling it enforced. A wait that ends without what \ you wanted is a cheap retry, not a failure. METADATA IS NOT CONTENT - tmux_list_panes and friends read what tmux knows about a pane — its command, its path, \ - its size. What a pane is SHOWING comes from tmux_capture_pane, tmux_capture_since or \ - tmux_search_panes. "Which pane mentions the error" is a search, not a listing. + list_panes and friends read what tmux knows about a pane — its command, its path, \ + its size. What a pane is SHOWING comes from capture_pane, capture_since or \ + search_panes. "Which pane mentions the error" is a search, not a listing. READING COSTS CONTEXT Reads are capped and say when they dropped anything; raise 'max_lines' deliberately \ - rather than by habit. Prefer a filter on tmux_list_panes over reading every pane. + rather than by habit. Prefer list_panes over reading every pane's content. - RESOURCES AND RECIPES - tmux://... resources expose the same state for a client to attach without spending a \ - tool call. The prompts here are worked recipes for the common jobs. - """ + watching(watching) + ending(ceiling); - } - - /** - * Said only when it is true. A model told it will be notified, that then is not, waits for - * something that never comes — which is worse than knowing it has to ask. - */ - private static String watching(boolean watching) { - return watching - ? "\nPUSHED UPDATES\nThis server watches tmux and sends notifications/resources/updated " - + "when a pane produces output or the shape of the server changes. Subscribe to " - + "tmux://panes/{pane_id}/content rather than re-reading a pane to see whether " - + "anything happened.\n" - : ""; + CAPABILITY DISCLOSURE + tmux://capabilities reports this process's frozen effective tool surface and selected \ + socket. It is the only MCP resource exposed by this server. + """ + ending(connection); } /** @@ -80,20 +66,12 @@ private static String watching(boolean watching) { *

A model that cannot see a tool cannot tell an operator's choice from a gap in the server, * and will otherwise spend a turn looking for a way to do what it has been refused. */ - private static String ending(Safety ceiling) { - return switch (ceiling) { - case READONLY -> - "\nSAFETY\nThis server is read-only. Nothing here changes tmux: no sending " - + "keys, no creating or killing. Ask the operator to raise LIBTMUX_SAFETY if a change " - + "is genuinely needed.\n"; - case MUTATING -> - "\nSAFETY\nThis server can read and change tmux, but tmux_kill is not offered. " - + "Commands and pane input can still end processes or delete data. Ask the operator to set " - + "LIBTMUX_SAFETY=destructive only when the dedicated kill tool is needed.\n"; - case DESTRUCTIVE -> - "\nSAFETY\nEverything is offered, including tmux_kill, which ends processes " - + "and cannot be undone. It refuses to end the pane this conversation runs through " - + "unless confirm_self is set.\n"; - }; + private static String ending(Connection connection) { + var socket = connection.surface().socketReport(connection.server()); + String toolsets = String.join(",", connection.surface().toolsetNames()); + return "\nCAPABILITIES\nOperating on selected socket " + socket.get("selector") + + " (" + socket.get("selectionProvenance") + "). Enabled toolsets: " + + (toolsets.isEmpty() ? "none" : toolsets) + ". Execute tools run with the tmux user's authority. " + + "Tool filtering shapes this advertised interface; it is not authorization or an OS sandbox.\n"; } } diff --git a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/LaunchConfiguration.java b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/LaunchConfiguration.java new file mode 100644 index 0000000..8437f5e --- /dev/null +++ b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/LaunchConfiguration.java @@ -0,0 +1,244 @@ +package io.github.libtmux.mcp; + +import io.github.libtmux.Server; +import io.github.libtmux.ServerConfig; +import io.github.libtmux.ServerEndpoint; +import io.github.libtmux.transport.CommandResult; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.UUID; +import org.jspecify.annotations.Nullable; + +/** Resolves the process-wide socket and tmux configuration before any transport opens. */ +record LaunchConfiguration( + ServerConfig config, + String selector, + String selectionProvenance, + String declaredConfigurationProvenance, + boolean defaultDedicated, + @Nullable String ownerNonce) { + + static final String SOCKET_ENV = "LIBTMUX_SOCKET"; + static final String SOCKET_PATH_ENV = "LIBTMUX_SOCKET_PATH"; + static final String CONFIG_ENV = "LIBTMUX_TMUX_CONFIG"; + private static final String DEFAULT_SOCKET = "libtmux-mcp"; + private static final String MINIMAL_CONFIG_RESOURCE = "/io/github/libtmux/mcp/minimal.conf"; + private static final String OWNER_OPTION = "@libtmux_mcp_owner"; + private static final String OWNER_PLACEHOLDER = "__LIBTMUX_MCP_OWNER_NONCE__"; + + static LaunchConfiguration resolve(List args, Map environment) { + @Nullable Path flaggedPath = null; + @Nullable String flaggedName = null; + String binary = "tmux"; + for (int index = 0; index < args.size(); index++) { + String flag = args.get(index); + switch (flag) { + case "--socket" -> { + flaggedPath = absolute(value(args, ++index, flag), flag); + } + case "--socket-name" -> { + flaggedName = nonempty(value(args, ++index, flag), flag); + } + case "--tmux" -> binary = nonempty(value(args, ++index, flag), flag); + case "--safety" -> + throw new IllegalArgumentException( + "--safety was retired; select unordered toolsets with " + ToolSurface.TOOLSETS_ENV); + default -> throw new IllegalArgumentException("unknown argument '" + flag + "'"); + } + } + if (flaggedPath != null && flaggedName != null) { + throw new IllegalArgumentException("--socket and --socket-name are mutually exclusive"); + } + SocketChoice socket = flaggedPath != null + ? new SocketChoice( + ServerEndpoint.socketPath(flaggedPath), "path:" + flaggedPath, "operator-current", false) + : flaggedName != null + ? new SocketChoice( + ServerEndpoint.namedSocket(flaggedName), + "name:" + flaggedName, + "operator-current", + false) + : socket(environment); + + ConfigChoice configured = configuration(environment.get(CONFIG_ENV), socket.defaultDedicated()); + ServerConfig.Builder config = ServerConfig.builder().binary(binary).endpoint(socket.endpoint()); + if (configured.path() != null) { + config.configFile(configured.path()); + } + return new LaunchConfiguration( + config.build(), + socket.selector(), + socket.selectionProvenance(), + configured.provenance(), + socket.defaultDedicated(), + configured.ownerNonce()); + } + + SocketProfile profile(Server server) { + Objects.requireNonNull(server, "server"); + if (defaultDedicated) { + return dedicatedProfile(server); + } + CommandResult metadata = server.cmd("display-message", "-p", "#{socket_path}"); + if (!metadata.succeeded()) { + if (serverAbsent(metadata)) { + return socketProfile("absent", declaredConfigurationProvenance, false, fallbackSocketPath()); + } + throw new IllegalStateException( + "could not establish explicit tmux socket provenance: " + String.join("; ", metadata.stderr())); + } + String socketPath = oneLine(metadata, "tmux socket path"); + if (socketPath.isBlank()) { + throw new IllegalStateException("tmux returned an empty socket path during startup"); + } + return socketProfile("existing", "unknown", false, socketPath); + } + + private SocketProfile dedicatedProfile(Server server) { + CommandResult started = server.cmd("start-server"); + if (!started.succeeded()) { + throw new IllegalStateException( + "could not start or reach the dedicated tmux server: " + String.join("; ", started.stderr())); + } + CommandResult metadata = server.cmd("display-message", "-p", "#{" + OWNER_OPTION + "}\t#{socket_path}"); + if (!metadata.succeeded()) { + throw new IllegalStateException( + "could not read dedicated tmux startup metadata: " + String.join("; ", metadata.stderr())); + } + String[] fields = oneLine(metadata, "dedicated tmux startup metadata").split("\t", 2); + if (fields.length != 2 || fields[1].isBlank()) { + throw new IllegalStateException("tmux returned malformed dedicated startup metadata"); + } + boolean created = Objects.requireNonNull(ownerNonce, "ownerNonce").equals(fields[0]); + return socketProfile(created ? "created" : "existing", created ? "minimal" : "unknown", created, fields[1]); + } + + private SocketProfile socketProfile( + String serverState, String configurationProvenance, boolean defaultTeardown, String resolvedSocketPath) { + return new SocketProfile( + selector, + selectionProvenance, + serverState, + configurationProvenance, + resolvedSocketPath, + attachCommand(resolvedSocketPath), + defaultTeardown); + } + + private static SocketChoice socket(Map environment) { + @Nullable String configuredName = environment.get(SOCKET_ENV); + @Nullable String configuredPath = environment.get(SOCKET_PATH_ENV); + if (configuredName != null && configuredPath != null) { + throw new IllegalArgumentException(SOCKET_ENV + " and " + SOCKET_PATH_ENV + " are mutually exclusive"); + } + if (configuredName == null && configuredPath == null) { + return new SocketChoice( + ServerEndpoint.namedSocket(DEFAULT_SOCKET), "name:" + DEFAULT_SOCKET, "default-dedicated", true); + } + if (configuredPath != null) { + Path path = absolute(configuredPath, SOCKET_PATH_ENV); + return new SocketChoice(ServerEndpoint.socketPath(path), "path:" + path, "operator-current", false); + } + String name = nonempty(Objects.requireNonNull(configuredName, SOCKET_ENV), SOCKET_ENV); + return new SocketChoice(ServerEndpoint.namedSocket(name), "name:" + name, "operator-current", false); + } + + private static ConfigChoice configuration(@Nullable String configured, boolean defaultDedicated) { + if (configured == null && defaultDedicated) { + return materializeMinimalConfig(); + } + if (configured == null) { + return new ConfigChoice(null, "unknown", null); + } + String value = nonempty(configured, CONFIG_ENV); + return new ConfigChoice(absolute(value, CONFIG_ENV), "user-configured", null); + } + + private static ConfigChoice materializeMinimalConfig() { + String nonce = UUID.randomUUID().toString(); + try (InputStream resource = LaunchConfiguration.class.getResourceAsStream(MINIMAL_CONFIG_RESOURCE)) { + if (resource == null) { + throw new IllegalStateException("shipped minimal tmux configuration is missing"); + } + Path path = Files.createTempFile("libtmux-mcp-", ".conf"); + String template = new String(resource.readAllBytes(), StandardCharsets.UTF_8); + if (!template.contains(OWNER_PLACEHOLDER)) { + throw new IllegalStateException("shipped minimal tmux configuration has no owner placeholder"); + } + Files.writeString(path, template.replace(OWNER_PLACEHOLDER, nonce), StandardCharsets.UTF_8); + path.toFile().deleteOnExit(); + return new ConfigChoice(path, "minimal", nonce); + } catch (IOException failure) { + throw new IllegalStateException("could not materialize the shipped minimal tmux configuration", failure); + } + } + + private String fallbackSocketPath() { + if (config.endpoint() instanceof ServerEndpoint.SocketPath path) { + return path.path().toString(); + } + return ""; + } + + private String attachCommand(String resolvedSocketPath) { + StringBuilder command = new StringBuilder(shellQuote(config.binaryPath())).append(" -N"); + if (!resolvedSocketPath.isBlank()) { + command.append(" -S ").append(shellQuote(resolvedSocketPath)); + } else if (config.endpoint() instanceof ServerEndpoint.NamedSocket named) { + command.append(" -L ").append(shellQuote(named.name())); + } + return command.append(" attach").toString(); + } + + private static String oneLine(CommandResult result, String field) { + if (result.stdout().size() != 1) { + throw new IllegalStateException("tmux returned no unambiguous " + field); + } + return result.stdout().getFirst(); + } + + private static boolean serverAbsent(CommandResult result) { + String diagnostic = String.join("\n", result.stderr()).toLowerCase(java.util.Locale.ROOT); + return diagnostic.contains("no server running on") || diagnostic.contains("no such file or directory"); + } + + private static String shellQuote(String value) { + return "'" + value.replace("'", "'\"'\"'") + "'"; + } + + private static Path absolute(String value, String source) { + Path path = Path.of(nonempty(value, source)); + if (!path.isAbsolute()) { + throw new IllegalArgumentException(source + " path must be absolute: " + value); + } + return path.normalize(); + } + + private static String nonempty(String value, String source) { + if (value.isBlank()) { + throw new IllegalArgumentException(source + " is empty"); + } + return value; + } + + private static String value(List args, int index, String flag) { + if (index >= args.size()) { + throw new IllegalArgumentException(flag + " needs a value"); + } + return args.get(index); + } + + private record SocketChoice( + ServerEndpoint endpoint, String selector, String selectionProvenance, boolean defaultDedicated) {} + + private record ConfigChoice( + @Nullable Path path, + String provenance, + @Nullable String ownerNonce) {} +} diff --git a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Listings.java b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Listings.java index f7a6720..afa7e20 100644 --- a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Listings.java +++ b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Listings.java @@ -1,23 +1,15 @@ package io.github.libtmux.mcp; -import com.fasterxml.jackson.databind.ObjectMapper; import io.github.libtmux.Dimensions; -import io.github.libtmux.LibTmuxException; import io.github.libtmux.Pane; import io.github.libtmux.Server; -import io.github.libtmux.ServerEndpoint; import io.github.libtmux.Session; import io.github.libtmux.SessionId; import io.github.libtmux.Window; -import io.github.libtmux.jackson.FilterJson; -import io.github.libtmux.jackson.LibTmuxModels; -import io.github.libtmux.query.FilterExpr; import io.github.libtmux.snapshot.ServerSnapshot; -import java.nio.file.Path; import java.util.LinkedHashSet; import java.util.List; import java.util.Set; -import java.util.function.Predicate; import java.util.stream.Stream; import org.jspecify.annotations.Nullable; @@ -33,9 +25,6 @@ */ final class Listings { - /** Reads the filter document a model sent. Plain: this parses input rather than writing answers. */ - private static final ObjectMapper JSON = new ObjectMapper(); - private Listings() {} /** @param caller true only on the pane this server is itself running in, and null otherwise */ @@ -54,9 +43,6 @@ record WindowSummary(String id, int index, String name, String session, boolean record SessionSummary(String id, String name, boolean attached, int windows, List windowNames) {} - record ClientSummary( - String name, @Nullable String session, @Nullable String watching) {} - record Panes( int count, List panes, @Nullable String note) {} @@ -70,63 +56,10 @@ record Sessions( List sessions, @Nullable String note) {} - record Clients( - int count, - List clients, - @Nullable String note) {} - - /** - * @param socket where this server listens, which is what another tool would be pointed at - * @param callerPane the pane this MCP server runs in, absent when it does not run in one here - */ - record Whoami( - String realm, - String server, - @Nullable String socket, - @Nullable String version, - @Nullable String callerPane, - int sessions, - int windows, - int panes, - String safety, - String note) {} - - record KnownServer( - String socket, - ServerDiscovery.State state, - @Nullable Integer sessions, - @Nullable String note) {} - - record Servers( - int count, - List servers, - boolean truncated, - @Nullable String scanNote, - String note) {} - static Sessions sessions(Server server) { - return sessions(server, ignored -> false); - } - - /** Lists sessions without treating this connection's own control clients as people. */ - static Sessions sessions(Connection connection) { - return connection.withStableClients(() -> sessions(connection.server(), connection::isOurs)); - } - - static SessionSummary session(Connection connection, String name) { - return sessions(connection).sessions().stream() - .filter(session -> session.name().equals(name)) - .findFirst() - .orElseThrow(() -> new IllegalArgumentException("no session named " + name)); - } - - private static Sessions sessions(Server server, Predicate hiddenClient) { ServerSnapshot snapshot = server.snapshot(); Set attached = new LinkedHashSet<>(); - snapshot.clients().stream() - .filter(client -> !hiddenClient.test(client.name())) - .flatMap(client -> client.session().stream()) - .forEach(attached::add); + snapshot.clients().stream().flatMap(client -> client.session().stream()).forEach(attached::add); List summaries = snapshot.sessions().stream() .map(session -> new SessionSummary( session.id().value(), @@ -140,6 +73,18 @@ private static Sessions sessions(Server server, Predicate hiddenClient) return new Sessions(summaries.size(), summaries, emptiness(server, summaries.size(), "session")); } + /** Lists sessions for the MCP connection. */ + static Sessions sessions(Connection connection) { + return sessions(connection.server()); + } + + static SessionSummary session(Connection connection, String name) { + return sessions(connection).sessions().stream() + .filter(session -> session.name().equals(name)) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("no session named " + name)); + } + static Windows windows(Call call) { Server server = call.server(); Stream windows = server.windows().stream(); @@ -163,38 +108,7 @@ static Panes panes(Call call) { Server server = call.server(); Caller caller = call.caller(); List panes = server.panes(); - String note = null; - Object filter = call.arguments().get("filter"); - if (filter != null) { - List narrowed = panes.stream().filter(paneFilter(filter)).toList(); - note = narrowed.isEmpty() && !panes.isEmpty() - ? "The filter matched none of the " + panes.size() + " panes on this server. " - + "Call again without 'filter' to see them all." - : null; - panes = narrowed; - } - return new Panes( - panes.size(), describe(panes, caller), note != null ? note : emptiness(server, panes.size(), "pane")); - } - - /** - * Reads a filter document, and says what one looks like when it will not read. - * - *

What the parser knows is that a key was missing or a field unrecognised. What a caller - * needs is the shape to send and the names it may use — neither of which the parser has any - * business knowing, and both of which are free here. - */ - private static FilterExpr paneFilter(Object filter) { - try { - return FilterJson.read(JSON.valueToTree(filter), LibTmuxModels.pane()); - } catch (RuntimeException e) { - throw new IllegalArgumentException("that filter is not a " + FilterJson.SCHEMA + " document: " - + e.getMessage() + ". One looks like " + Catalog.EXAMPLE_FILTER - + " and may compare these fields only: " - + String.join(", ", LibTmuxModels.pane().fieldNames()) - + ". To narrow by anything else — a window's name, a pane's path — list the panes " - + "and choose from what comes back."); - } + return new Panes(panes.size(), describe(panes, caller), emptiness(server, panes.size(), "pane")); } /** @@ -211,7 +125,7 @@ private static FilterExpr paneFilter(Object filter) { return server.isAlive() ? "This tmux server is running and has no " + what + "s on it." : "No tmux server is running on the socket this was pointed at, so there is nothing to " - + "list. Call tmux_list_servers to find the ones that are."; + + "list. Check that the MCP process selected the socket you intended."; } static List describe(List panes, Caller caller) { @@ -231,124 +145,4 @@ static List describe(List panes, Caller caller) { }) .toList(); } - - static Clients clients(Call call) { - return call.connection().withStableClients(() -> { - List summaries = call.server().clients().stream() - // A control client this server attached to watch for changes is not a person, - // and the whole point of this tool is answering whether a person is there. - .filter(client -> !call.connection().isOurs(client.name())) - .map(client -> new ClientSummary( - client.name(), - client.session().map(Session::name).orElse(null), - // The pane a person at this terminal is actually looking at, which is - // what "is anyone watching this" means in practice. - client.attachment() - .map(attachment -> - attachment.activePane().id().value()) - .orElse(null))) - .toList(); - return new Clients( - summaries.size(), - summaries, - summaries.isEmpty() - ? "Nothing is attached, so no person is watching these panes right now." - : null); - }); - } - - /** - * Which server this is, and which pane the conversation is coming through. - * - *

The last part is the one a model cannot work out for itself. Without it, "close the window - * we are done with" can name the pane the model is talking through, and the tools that would - * refuse to do that need to know which pane that is. - */ - static Whoami whoami(Server server, Caller caller, Safety ceiling) { - ServerSnapshot snapshot; - try { - snapshot = server.snapshot(); - } catch (LibTmuxException failed) { - if (server.isAlive()) { - throw failed; - } - return absent(server, ceiling); - } - if (snapshot.serverPid().isEmpty()) { - return absent(server, ceiling); - } - return new Whoami( - server.identity().realm(), - server.identity().server(), - socketOf(server), - snapshot.serverVersion().orElseThrow().toString(), - caller.pane().map(id -> id.value()).orElse(null), - snapshot.sessions().size(), - snapshot.windows().size(), - snapshot.panes().size(), - ceiling.wireName(), - caller.pane() - .map(id -> "This MCP server runs in pane " + id.value() - + ", so acting on that pane acts on this conversation. The tools that would " - + "destroy it refuse unless 'confirm_self' is set.") - .orElse("This MCP server is not running inside a pane on this tmux server, " - + "so no pane here is special.")); - } - - private static Whoami absent(Server server, Safety ceiling) { - return new Whoami( - server.identity().realm(), - server.identity().server(), - null, - null, - null, - 0, - 0, - 0, - ceiling.wireName(), - "No tmux server is running on the socket this was pointed at. Nothing here can act " - + "until one is, and tmux_new_session will start one. Call tmux_list_servers to see " - + "the servers that are running — the sessions you expected are probably on one of " - + "them, and a different socket cannot see them."); - } - - private static @Nullable String socketOf(Server server) { - try { - String reported = server.expand("#{socket_path}"); - return reported.isEmpty() ? null : reported; - } catch (RuntimeException e) { - return null; - } - } - - /** Shapes a bounded typed socket inventory for the protocol. */ - static Servers servers(Server server) { - Path currentSocket = currentSocket(server); - ServerDiscovery.Result discovery = - ServerDiscovery.system().discover(server.config().binary(), currentSocket); - List found = discovery.servers().stream() - .map(known -> new KnownServer(known.socket().toString(), known.state(), known.sessions(), known.note())) - .toList(); - return new Servers( - found.size(), - found, - discovery.truncated(), - discovery.scanNote(), - "Point another server at one of these with the --socket flag, or set LIBTMUX_SOCKET. " - + (currentSocket == null - ? "This connection did not name an exact socket path." - : "This connection names " + currentSocket + ".")); - } - - private static @Nullable Path currentSocket(Server server) { - String live = socketOf(server); - if (live != null && !live.isBlank()) { - try { - return Path.of(live); - } catch (RuntimeException ignored) { - // Fall back to an explicitly configured path when tmux reported unusable text. - } - } - return server.config().endpoint() instanceof ServerEndpoint.SocketPath socketPath ? socketPath.path() : null; - } } diff --git a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Main.java b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Main.java index a5f8c1d..8ffeefd 100644 --- a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Main.java +++ b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Main.java @@ -2,9 +2,8 @@ import io.github.libtmux.Server; import io.github.libtmux.ServerConfig; -import io.github.libtmux.ServerEndpoint; -import java.nio.file.Path; import java.util.List; +import java.util.Map; import java.util.concurrent.CountDownLatch; /** @@ -20,13 +19,10 @@ *

{@code
  * libtmux-mcp --socket /run/user/1000/tmux/default
  * libtmux-mcp --socket-name work --tmux /usr/local/bin/tmux
- * libtmux-mcp --safety readonly
- * libtmux-mcp --watch
  * }
* - *

The safety ceiling decides which tools exist at all. A tool above it is never listed, so a - * model is not offered something it will be refused — and {@code LIBTMUX_SAFETY} sets the same - * thing for an operator who cannot edit the client's launch command. + *

The unordered toolset and named-tool environment variables resolve one immutable surface + * before tmux is opened. A tool outside it is neither listed nor callable. */ public final class Main { @@ -35,36 +31,37 @@ private Main() {} /** * Serves a tmux server over stdin and stdout until the client closes them. * - * @param args {@code --socket PATH}, {@code --socket-name NAME}, {@code --tmux BINARY}, - * {@code --safety readonly|mutating|destructive} + * @param args {@code --socket PATH}, {@code --socket-name NAME}, {@code --tmux BINARY} */ public static void main(String[] args) { - ServerConfig config; - Safety ceiling; - boolean watching; + LaunchConfiguration launch; + Map environment = System.getenv(); try { List given = List.of(args); - config = configure(given); - ceiling = safety(given); - watching = watching(given); + ToolSurface.resolve(environment); + launch = LaunchConfiguration.resolve(given, environment); } catch (IllegalArgumentException e) { System.err.println("libtmux-mcp: " + e.getMessage()); - System.err.println("usage: libtmux-mcp [--socket PATH] [--socket-name NAME] [--tmux BINARY]" - + " [--safety readonly|mutating|destructive] [--watch]"); + System.err.println("usage: libtmux-mcp [--socket PATH] [--socket-name NAME] [--tmux BINARY]"); System.exit(2); return; } // The server outlives setup: the MCP transport reads stdin until the client closes it. // Lexical ownership also releases its process transport when protocol startup fails. - try (Server server = Server.open(config)) { + try (Server server = Server.open(launch.config())) { + SocketProfile profile = launch.profile(server); + ToolSurface surface = ToolSurface.resolve(environment, profile); Runtime.getRuntime().addShutdownHook(new Thread(server::close, "libtmux-mcp-shutdown")); - System.err.println("libtmux-mcp: serving " + server.identity() + " at safety " + ceiling.wireName() + " (" - + Catalog.offered(ceiling).size() + " tools)"); + System.err.println("libtmux-mcp: serving " + server.identity() + " on " + profile.selector() + + " (server_state=" + profile.serverState() + + ", configuration_provenance=" + profile.configurationProvenance() + ") with toolsets " + + surface.toolsetNames() + " (" + surface.tools().size() + + " tools, host_command_tools=0)"); // A client that disconnects closes this end. Without noticing that, the process outlives // the client that launched it, and an MCP client leaves one behind every time it restarts. CountDownLatch disconnected = new CountDownLatch(1); - var mcp = TmuxMcpServer.overStdio(server, System.in, ceiling, watching, disconnected::countDown); + var mcp = TmuxMcpServer.overStdio(server, System.in, surface, disconnected::countDown); Runtime.getRuntime().addShutdownHook(new Thread(mcp::close, "libtmux-mcp-protocol-shutdown")); try { disconnected.await(); @@ -77,58 +74,10 @@ public static void main(String[] args) { } static ServerConfig configure(List args) { - ServerConfig.Builder config = ServerConfig.builder(); - for (int index = 0; index < args.size(); index++) { - String flag = args.get(index); - switch (flag) { - case "--socket" -> config.endpoint(ServerEndpoint.socketPath(Path.of(value(args, ++index, flag)))); - case "--socket-name" -> config.endpoint(ServerEndpoint.namedSocket(value(args, ++index, flag))); - case "--tmux" -> config.binary(value(args, ++index, flag)); - // Read elsewhere, but named here so the endpoint parser does not reject a launch - // that is perfectly correct. - case "--safety" -> value(args, ++index, flag); - case "--watch" -> {} - default -> throw new IllegalArgumentException("unknown argument '" + flag + "'"); - } - } - return config.build(); - } - - /** - * The ceiling a launch asked for. - * - *

The flag wins over the environment variable, so an operator who cannot change how a client - * launches this can still set a floor with {@code LIBTMUX_SAFETY}, and one who can override it - * per client. - */ - static Safety safety(List args) { - for (int index = 0; index + 1 < args.size(); index++) { - if ("--safety".equals(args.get(index))) { - return Safety.ofWireName(args.get(index + 1)); - } - } - String configured = System.getenv("LIBTMUX_SAFETY"); - return configured == null || configured.isEmpty() ? Safety.MUTATING : Safety.ofWireName(configured); - } - - /** - * Whether to watch tmux and push notifications as it changes. - * - *

Off unless asked for. Watching attaches a control client, and an attached client is a real - * change to the server a person may be looking at, so it is not something to do uninvited. - */ - static boolean watching(List args) { - if (args.contains("--watch")) { - return true; - } - String configured = System.getenv("LIBTMUX_WATCH"); - return "1".equals(configured) || "true".equalsIgnoreCase(String.valueOf(configured)); + return configure(args, Map.of()); } - private static String value(List args, int index, String flag) { - if (index >= args.size()) { - throw new IllegalArgumentException(flag + " needs a value"); - } - return args.get(index); + static ServerConfig configure(List args, Map environment) { + return LaunchConfiguration.resolve(args, environment).config(); } } diff --git a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/NotificationBuffer.java b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/NotificationBuffer.java deleted file mode 100644 index e952864..0000000 --- a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/NotificationBuffer.java +++ /dev/null @@ -1,39 +0,0 @@ -package io.github.libtmux.mcp; - -import java.util.Collections; -import java.util.LinkedHashSet; -import java.util.Set; - -/** A bounded set that coalesces repeated resource updates while a client is slow. */ -final class NotificationBuffer { - - private final int capacity; - private final Set pending = new LinkedHashSet<>(); - - NotificationBuffer(int capacity) { - if (capacity < 1) { - throw new IllegalArgumentException("notification capacity is not positive"); - } - this.capacity = capacity; - } - - synchronized boolean offer(String uri) { - if (pending.contains(uri)) { - return true; - } - return pending.size() < capacity && pending.add(uri); - } - - synchronized Set drain() { - if (pending.isEmpty()) { - return Set.of(); - } - Set drained = Collections.unmodifiableSet(new LinkedHashSet<>(pending)); - pending.clear(); - return drained; - } - - synchronized int size() { - return pending.size(); - } -} diff --git a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Operations.java b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Operations.java new file mode 100644 index 0000000..95e11e3 --- /dev/null +++ b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Operations.java @@ -0,0 +1,604 @@ +package io.github.libtmux.mcp; + +import io.github.libtmux.Dimensions; +import io.github.libtmux.Pane; +import io.github.libtmux.Server; +import io.github.libtmux.Session; +import io.github.libtmux.SessionSpec; +import io.github.libtmux.SplitSpec; +import io.github.libtmux.Window; +import io.github.libtmux.WindowSpec; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import org.jspecify.annotations.Nullable; + +/** Small typed adapters for the capability-model inventory. */ +final class Operations { + + private static final int MAX_READ_BATCH_BYTES = 1_048_576; + + private Operations() {} + + static Object serverInfo(Call call) { + Server server = call.server(); + boolean running = server.isAlive(); + return values( + "running", + running, + "identity", + server.identity().toString(), + "version", + running ? server.version().toString() : "unknown", + "sessions", + running ? server.sessions().size() : 0); + } + + static Object sessionInfo(Call call) { + return session(Targets.sessionById(call.server(), call.string("session_id"))); + } + + static Object windowInfo(Call call) { + return window(Targets.window(call.server(), call.string("window_id"))); + } + + static Object paneInfo(Call call) { + return pane(Targets.pane(call.server(), call.string("pane_id"))); + } + + static Object snapshotPane(Call call) { + Pane pane = Targets.pane(call.server(), call.string("pane_id")); + Reading.Captured capture = Reading.capture(call); + return values( + "pane", + pane(pane), + "content", + capture.content(), + "cursor", + capture.cursor(), + "truncated", + capture.truncated(), + "lines_dropped", + capture.linesDropped()); + } + + static Object findPaneByPosition(Call call) { + Window window = Targets.window(call.server(), call.string("window_id")); + String position = call.string("position").toLowerCase(Locale.ROOT); + Pane found = window.panes().stream() + .filter(pane -> switch (position) { + case "top-left" -> pane.edges().top() && pane.edges().left(); + case "top-right" -> pane.edges().top() && pane.edges().right(); + case "bottom-left" -> pane.edges().bottom() && pane.edges().left(); + case "bottom-right" -> pane.edges().bottom() && pane.edges().right(); + default -> + throw new IllegalArgumentException("unknown position '" + position + + "'; expected top-left, top-right, bottom-left or bottom-right"); + }) + .findFirst() + .orElseThrow(() -> + new IllegalArgumentException("window " + window.id().value() + " has no pane at " + position)); + return pane(found); + } + + static Object tmuxVariables(Call call) { + List names = call.strings("names"); + Map variables = call.maybe("pane") + .map(target -> Targets.pane(call.server(), target).variables(names)) + .orElseGet(() -> call.server().variables(names)); + return values("values", variables); + } + + static Object showOption(Call call) { + String name = call.string("name"); + Settings.OptionValues values = Settings.showOptions(call); + return values( + "scope", + values.scope(), + "target", + values.target() == null ? "" : values.target(), + "name", + name, + "value", + values.options().getOrDefault(name, "")); + } + + static Object showHooks(Call call) { + Settings.HookValues values = Settings.showHooks(call); + @Nullable String name = call.maybe("name").orElse(null); + Map> hooks = name == null + ? values.hooks() + : values.hooks().containsKey(name) ? Map.of(name, values.hooks().get(name)) : Map.of(); + return values( + "scope", + values.scope(), + "target", + values.target() == null ? "" : values.target(), + "count", + hooks.size(), + "hooks", + hooks); + } + + static Object callReadToolsBatch(Call call) { + List> operations = call.objects("operations"); + if (operations.isEmpty() || operations.size() > 16) { + throw new IllegalArgumentException("operations must contain between 1 and 16 calls"); + } + String onError = call.maybe("onError").orElse("stop"); + if (!Set.of("stop", "continue").contains(onError)) { + throw new IllegalArgumentException("onError must be stop or continue"); + } + boolean keepGoing = "continue".equals(onError); + Set allowed = call.surface().require("call_read_tools_batch").nestedAuthority(); + List validated = new ArrayList<>(); + for (Map operation : operations) { + Set unknown = new java.util.LinkedHashSet<>(operation.keySet()); + unknown.removeAll(Set.of("tool", "arguments")); + if (!unknown.isEmpty()) { + throw new IllegalArgumentException("read operation has unknown field(s) " + unknown); + } + Object requested = operation.get("tool"); + if (!(requested instanceof String name) || name.isBlank()) { + throw new IllegalArgumentException("read operation requires a string 'tool'"); + } + if (!allowed.contains(name)) { + throw new IllegalArgumentException("tool '" + name + "' is not eligible for read batching"); + } + ToolSpec nested = Catalog.named(name); + Map arguments = object(operation.get("arguments"), "arguments"); + nested.validateArguments(arguments); + validated.add(new ReadOperation(name, nested, arguments)); + } + List> results = new ArrayList<>(); + boolean truncated = false; + int truncatedBytes = 0; + @Nullable Integer stoppedAt = null; + for (int index = 0; index < validated.size(); index++) { + ReadOperation operation = validated.get(index); + io.modelcontextprotocol.spec.McpSchema.CallToolResult envelope; + Object error = com.fasterxml.jackson.databind.node.NullNode.getInstance(); + try { + Object answer = + operation.tool().answer().apply(call.connection().call(operation.arguments(), call.progress())); + operation.tool().validateOutput(answer); + envelope = Answers.ok(answer); + } catch (RuntimeException failure) { + String message = String.valueOf(failure.getMessage()); + error = message; + envelope = Answers.failure(message); + } + boolean success = !Boolean.TRUE.equals(envelope.isError()); + results.add(values( + "index", + index, + "tool", + operation.name(), + "success", + success, + "error", + error, + "result", + Answers.envelope(envelope), + "resultTruncated", + false)); + if (!success && !keepGoing) { + stoppedAt = index; + } + while (outerBytes(batchResult(results, stoppedAt, truncated, truncatedBytes, onError)) + > MAX_READ_BATCH_BYTES) { + int row = resultRow(results); + if (row < 0) { + throw new IllegalStateException("read batch accounting exceeds its fixed response limit"); + } + Map original = results.get(row); + int removed = Math.subtractExact( + encodedBytes(java.util.Objects.requireNonNull(original.get("result"))), + encodedBytes(com.fasterxml.jackson.databind.node.NullNode.getInstance())); + truncatedBytes = Math.addExact(truncatedBytes, removed); + Map shortened = new LinkedHashMap<>(original); + shortened.put("result", com.fasterxml.jackson.databind.node.NullNode.getInstance()); + shortened.put("resultTruncated", true); + results.set(row, Collections.unmodifiableMap(shortened)); + truncated = true; + } + if (stoppedAt != null) { + break; + } + } + return batchResult(results, stoppedAt, truncated, truncatedBytes, onError); + } + + private static Map batchResult( + List> results, + @Nullable Integer stoppedAt, + boolean truncated, + int truncatedBytes, + String onError) { + long succeeded = results.stream() + .filter(row -> Boolean.TRUE.equals(row.get("success"))) + .count(); + Map result = new LinkedHashMap<>(values( + "results", + List.copyOf(results), + "succeeded", + Math.toIntExact(succeeded), + "failed", + Math.toIntExact(results.size() - succeeded), + "stoppedAt", + stoppedAt == null ? com.fasterxml.jackson.databind.node.NullNode.getInstance() : stoppedAt, + "truncated", + truncated, + "truncatedBytes", + truncatedBytes, + "onError", + onError)); + return Collections.unmodifiableMap(result); + } + + private static int resultRow(List> results) { + for (int index = results.size() - 1; index >= 0; index--) { + if (!Boolean.TRUE.equals(results.get(index).get("resultTruncated"))) { + return index; + } + } + return -1; + } + + private static int outerBytes(Object value) { + return encodedBytes(Answers.envelope(Answers.ok(value))); + } + + private static int encodedBytes(Object value) { + try { + return Answers.JSON.writeValueAsBytes(value).length; + } catch (com.fasterxml.jackson.core.JacksonException failure) { + throw new IllegalStateException("could not measure a batch result", failure); + } + } + + private record ReadOperation(String name, ToolSpec tool, Map arguments) {} + + static Object renameSession(Call call) { + return session( + Targets.sessionById(call.server(), call.string("session_id")).rename(call.string("new_name"))); + } + + static Object renameWindow(Call call) { + return window(Targets.window(call.server(), call.string("window_id")).rename(call.string("new_name"))); + } + + static Object selectWindow(Call call) { + Window window = Targets.window(call.server(), call.string("window_id")); + window.select(); + return window(window.refresh()); + } + + static Object selectPane(Call call) { + Pane pane = Targets.pane(call.server(), call.string("pane_id")); + pane.select(); + return pane(pane.refresh()); + } + + static Object resizeWindow(Call call) { + Window window = Targets.window(call.server(), call.string("window_id")); + window.resizeTo(dimensions(call, window.size())); + return window(window.refresh()); + } + + static Object moveWindow(Call call) { + Window window = Targets.window(call.server(), call.string("window_id")); + Session session = Targets.sessionById(call.server(), call.string("session_id")); + int index = call.integer("index", -1); + if (index < 0) { + window.moveTo(session); + } else { + window.moveTo(session, index); + } + return values( + "window_id", window.id().value(), "session_id", session.id().value(), "index", index); + } + + static Object swapPane(Call call) { + Pane pane = Targets.pane(call.server(), call.string("pane_id")); + Pane other = Targets.pane(call.server(), call.string("other_pane_id")); + pane.swapWith(other); + return values("pane_id", pane.id().value(), "other_pane_id", other.id().value()); + } + + static Object setPaneTitle(Call call) { + return pane(Targets.pane(call.server(), call.string("pane_id")).retitle(call.string("title"))); + } + + static Object enterCopyMode(Call call) { + Pane pane = Targets.pane(call.server(), call.string("pane_id")); + pane.copyMode(); + return values("pane_id", pane.id().value(), "mode", "copy-mode"); + } + + static Object exitCopyMode(Call call) { + Pane pane = Targets.pane(call.server(), call.string("pane_id")); + pane.exitMode(); + return values("pane_id", pane.id().value(), "mode", "normal"); + } + + static Object setMouseEnabled(Call call) { + boolean enabled = call.flag("enabled", false); + call.server().setMouseEnabled(enabled); + return values("enabled", enabled); + } + + static Object setHistoryLimit(Call call) { + int lines = requiredInteger(call, "lines"); + Session session = Targets.sessionById(call.server(), call.string("session_id")); + session.setHistoryLimit(lines); + return values("session_id", session.id().value(), "lines", lines); + } + + static Object createSession(Call call) { + SessionSpec.Builder spec = SessionSpec.builder(); + call.maybe("session_name").ifPresent(spec::named); + call.maybe("window_name").ifPresent(spec::firstWindowNamed); + call.maybe("start_directory").map(Operations::directory).ifPresent(spec::in); + int width = call.integer("width", -1); + int height = call.integer("height", -1); + if ((width < 0) != (height < 0)) { + throw new IllegalArgumentException("width and height must be supplied together"); + } + if (width >= 0) { + spec.sized(new Dimensions(width, height)); + } + Session made = call.server().newSession(spec.build()); + return session(made); + } + + static Object createWindow(Call call) { + Session session = Targets.sessionById(call.server(), call.string("session_id")); + WindowSpec.Builder spec = WindowSpec.builder(); + call.maybe("window_name").ifPresent(spec::named); + call.maybe("start_directory").map(Operations::directory).ifPresent(spec::in); + if (!call.flag("attach", false)) { + spec.detached(); + } + call.maybe("direction").ifPresent(direction -> { + switch (direction.toLowerCase(Locale.ROOT)) { + case "before" -> spec.before(); + case "after" -> spec.after(); + default -> throw new IllegalArgumentException("direction must be before or after"); + } + }); + return window(session.newWindow(spec.build())); + } + + static Object splitWindow(Call call) { + Pane pane = Targets.pane(call.server(), call.string("pane_id")); + SplitSpec.Builder spec = SplitSpec.builder(); + switch (call.maybe("direction").orElse("below").toLowerCase(Locale.ROOT)) { + case "below", "down" -> spec.below(); + case "above", "up" -> spec.above(); + case "left" -> spec.toLeft(); + case "right" -> spec.toRight(); + default -> throw new IllegalArgumentException("direction must be below, above, left or right"); + } + int percent = call.integer("percent", 50); + spec.percent(Math.clamp(percent, 1, 99)); + call.maybe("start_directory").map(Operations::directory).ifPresent(spec::in); + return pane(pane.split(spec.build())); + } + + static Object respawnPane(Call call) { + Pane pane = Targets.pane(call.server(), call.string("pane_id")); + call.maybe("start_directory").ifPresentOrElse(path -> pane.respawnIn(directory(path)), pane::respawn); + return values("pane_id", pane.id().value(), "restarted", true); + } + + static Object sendKeysBatch(Call call) { + List> operations = call.objects("operations"); + if (operations.isEmpty() || operations.size() > 64) { + throw new IllegalArgumentException("operations must contain between 1 and 64 sends"); + } + String onError = call.maybe("onError").orElse("stop"); + if (!Set.of("stop", "continue").contains(onError)) { + throw new IllegalArgumentException("onError must be stop or continue"); + } + boolean keepGoing = "continue".equals(onError); + List> results = new ArrayList<>(); + for (int index = 0; index < operations.size(); index++) { + Map operation = operations.get(index); + String paneId = requiredText(operation, "pane_id"); + try { + Pane pane = Targets.pane(call.server(), paneId); + List keys = strings(operation.get("keys"), "keys"); + boolean literal = booleanValue(operation.get("literal"), false, "literal"); + Typing.Sent sent = Typing.sendKeys(pane, keys, literal); + results.add(values( + "index", + index, + "pane_id", + paneId, + "resolved_pane_ids", + sent.resolvedPaneIds(), + "success", + true)); + } catch (RuntimeException failure) { + results.add(values( + "index", + index, + "pane_id", + paneId, + "success", + false, + "error", + String.valueOf(failure.getMessage()))); + if (!keepGoing) { + break; + } + } + } + return values("results", List.copyOf(results), "completed", results.size()); + } + + static Object setSynchronizePanes(Call call) { + Window window = Targets.window(call.server(), call.string("window_id")); + boolean enabled = call.flag("enabled", false); + window.setSynchronizePanes(enabled); + return values("window_id", window.id().value(), "enabled", enabled); + } + + static Object clearPaneScrollback(Call call) { + Pane pane = Targets.pane(call.server(), call.string("pane_id")); + pane.clearHistory(); + return values("pane_id", pane.id().value(), "cleared", true); + } + + static Object killPane(Call call) { + return kill(call, call.string("pane_id")); + } + + static Object killWindow(Call call) { + return kill(call, call.string("window_id")); + } + + static Object killSession(Call call) { + return kill(call, call.string("session_id")); + } + + private static Object kill(Call call, String target) { + return Shaping.kill(call.connection() + .call(Map.of("target", target, "confirm_self", call.flag("confirm_self", false)), call.progress())); + } + + private static Dimensions dimensions(Call call, Dimensions current) { + int width = call.integer("width", current.width()); + int height = call.integer("height", current.height()); + if (width < 1 || height < 1) { + throw new IllegalArgumentException("width and height must be positive"); + } + return new Dimensions(width, height); + } + + private static int requiredInteger(Call call, String name) { + if (!call.arguments().containsKey(name)) { + throw new IllegalArgumentException("missing required argument '" + name + "'"); + } + return call.integer(name, 0); + } + + private static Path directory(String value) { + Path path = Path.of(value); + if (!path.isAbsolute()) { + throw new IllegalArgumentException("start_directory must be an absolute path"); + } + return path.normalize(); + } + + private static Map session(Session session) { + return values( + "id", + session.id().value(), + "name", + session.name(), + "attached", + session.attached(), + "windows", + session.windows().size()); + } + + private static Map window(Window window) { + return values( + "id", + window.id().value(), + "index", + window.index().value(), + "name", + window.name(), + "session_id", + window.session().id().value(), + "active", + window.active(), + "panes", + window.panes().size(), + "size", + window.size().toString()); + } + + private static Map pane(Pane pane) { + return values( + "id", + pane.id().value(), + "index", + pane.index(), + "window_id", + pane.window().id().value(), + "session_id", + pane.window().session().id().value(), + "active", + pane.active(), + "command", + pane.currentCommand(), + "path", + pane.currentPath().toString(), + "title", + pane.title(), + "size", + pane.size().toString()); + } + + private static String requiredText(Map object, String name) { + Object value = object.get(name); + if (value == null || value.toString().isBlank()) { + throw new IllegalArgumentException("missing required field '" + name + "'"); + } + return value.toString(); + } + + private static Map object(@Nullable Object value, String name) { + if (value == null) { + return Map.of(); + } + if (!(value instanceof Map source)) { + throw new IllegalArgumentException("'" + name + "' must be an object"); + } + Map copy = new LinkedHashMap<>(); + source.forEach((key, nested) -> copy.put(String.valueOf(key), nested)); + return Collections.unmodifiableMap(copy); + } + + private static List strings(@Nullable Object value, String name) { + if (value instanceof List many) { + List strings = many.stream().map(String::valueOf).toList(); + if (!strings.isEmpty()) { + return strings; + } + } else if (value != null && !value.toString().isEmpty()) { + return List.of(value.toString()); + } + throw new IllegalArgumentException("'" + name + "' must contain at least one string"); + } + + private static boolean booleanValue(@Nullable Object value, boolean fallback, String name) { + if (value == null) { + return fallback; + } + if (value instanceof Boolean flag) { + return flag; + } + if ("true".equalsIgnoreCase(value.toString()) || "false".equalsIgnoreCase(value.toString())) { + return Boolean.parseBoolean(value.toString()); + } + throw new IllegalArgumentException("'" + name + "' must be true or false"); + } + + private static Map values(Object... pairs) { + Map values = new LinkedHashMap<>(); + for (int index = 0; index < pairs.length; index += 2) { + values.put(String.valueOf(pairs[index]), pairs[index + 1]); + } + return Collections.unmodifiableMap(values); + } +} diff --git a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/OutputSchema.java b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/OutputSchema.java new file mode 100644 index 0000000..05308dc --- /dev/null +++ b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/OutputSchema.java @@ -0,0 +1,324 @@ +package io.github.libtmux.mcp; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import org.jspecify.annotations.Nullable; + +/** A small native output contract shared by discovery and runtime validation. */ +record OutputSchema( + Map properties, Set required, Map> propertySchemas) { + + OutputSchema(Map properties) { + this(properties, properties.keySet(), Map.of()); + } + + OutputSchema { + Objects.requireNonNull(properties, "properties"); + if (properties.isEmpty()) { + throw new IllegalArgumentException("output schema has no properties"); + } + Set propertyNames = Set.copyOf(properties.keySet()); + properties = Collections.unmodifiableMap(new LinkedHashMap<>(properties)); + required = Collections.unmodifiableSet(new LinkedHashSet<>(required)); + if (!propertyNames.containsAll(required)) { + throw new IllegalArgumentException("required output fields are absent from properties"); + } + Map> copiedSchemas = new LinkedHashMap<>(); + for (Map.Entry> entry : propertySchemas.entrySet()) { + if (!propertyNames.contains(entry.getKey())) { + throw new IllegalArgumentException("output schema override has no property '" + entry.getKey() + "'"); + } + copiedSchemas.put(entry.getKey(), Collections.unmodifiableMap(new LinkedHashMap<>(entry.getValue()))); + } + propertySchemas = Collections.unmodifiableMap(copiedSchemas); + } + + enum ValueType { + STRING("string"), + INTEGER("integer"), + NUMBER("number"), + BOOLEAN("boolean"), + ARRAY("array"), + OBJECT("object"); + + private final String wireName; + + ValueType(String wireName) { + this.wireName = wireName; + } + + Map schema() { + return Map.of("type", wireName); + } + + boolean accepts(Object value) { + return switch (this) { + case STRING -> value instanceof String; + case INTEGER -> + value instanceof Byte + || value instanceof Short + || value instanceof Integer + || value instanceof Long; + case NUMBER -> value instanceof Number; + case BOOLEAN -> value instanceof Boolean; + case ARRAY -> + value instanceof Iterable + || (value != null && value.getClass().isArray()); + case OBJECT -> value instanceof Map; + }; + } + } + + Map wireSchema() { + Map fields = new LinkedHashMap<>(); + properties.forEach((name, type) -> fields.put(name, propertySchemas.getOrDefault(name, type.schema()))); + Map schema = new LinkedHashMap<>(); + schema.put("type", "object"); + schema.put("properties", Collections.unmodifiableMap(fields)); + schema.put("required", List.copyOf(required)); + schema.put("additionalProperties", false); + return Collections.unmodifiableMap(schema); + } + + void validate(String tool, Object answer) { + validateAgainst(tool, "$", Answers.asObject(answer), wireSchema()); + } + + OutputSchema withPropertySchema(String name, Map schema) { + if (!properties.containsKey(name)) { + throw new IllegalArgumentException("output has no field '" + name + "'"); + } + Map> schemas = new LinkedHashMap<>(propertySchemas); + schemas.put(name, schema); + return new OutputSchema(properties, required, schemas); + } + + OutputSchema withOptionalFields(String... names) { + Set nextRequired = new LinkedHashSet<>(required); + for (String name : names) { + if (!properties.containsKey(name)) { + throw new IllegalArgumentException("output has no field '" + name + "'"); + } + nextRequired.remove(name); + } + return new OutputSchema(properties, nextRequired, propertySchemas); + } + + static OutputSchema of(Field first, Field... rest) { + Map fields = new LinkedHashMap<>(); + add(fields, first); + for (Field field : rest) { + add(fields, field); + } + return new OutputSchema(fields); + } + + static OutputSchema ofRecord(Class type) { + if (!type.isRecord()) { + throw new IllegalArgumentException(type.getName() + " is not a record output type"); + } + Map fields = new LinkedHashMap<>(); + Set required = new LinkedHashSet<>(); + Map> schemas = new LinkedHashMap<>(); + for (java.lang.reflect.RecordComponent component : type.getRecordComponents()) { + String name = toSnakeCase(component.getName()); + fields.put(name, valueType(component.getType())); + schemas.put(name, schemaFor(component.getGenericType())); + required.add(name); + } + return new OutputSchema(fields, required, schemas); + } + + private static Map schemaFor(java.lang.reflect.Type type) { + if (type instanceof java.lang.reflect.ParameterizedType parameterized + && parameterized.getRawType() instanceof Class raw) { + if (Iterable.class.isAssignableFrom(raw)) { + return ordered("type", "array", "items", schemaFor(parameterized.getActualTypeArguments()[0])); + } + if (Map.class.isAssignableFrom(raw)) { + return ordered( + "type", + "object", + "additionalProperties", + schemaFor(parameterized.getActualTypeArguments()[1])); + } + } + if (type instanceof Class concrete) { + if (concrete.isRecord()) { + return ofRecord(concrete).wireSchema(); + } + return valueType(concrete).schema(); + } + return Map.of(); + } + + private static ValueType valueType(Class type) { + if (type == String.class || type == Character.class || type == char.class) { + return ValueType.STRING; + } + if (type == byte.class + || type == short.class + || type == int.class + || type == long.class + || (Number.class.isAssignableFrom(type) && type != Float.class && type != Double.class)) { + return ValueType.INTEGER; + } + if (type == float.class || type == double.class || type == Float.class || type == Double.class) { + return ValueType.NUMBER; + } + if (type == boolean.class || type == Boolean.class) { + return ValueType.BOOLEAN; + } + if (Iterable.class.isAssignableFrom(type) || type.isArray()) { + return ValueType.ARRAY; + } + if (Map.class.isAssignableFrom(type) || type.isRecord()) { + return ValueType.OBJECT; + } + throw new IllegalArgumentException("unsupported output type " + type.getName()); + } + + private static String toSnakeCase(String value) { + return value.replaceAll("([a-z0-9])([A-Z])", "$1_$2").toLowerCase(java.util.Locale.ROOT); + } + + private static void add(Map fields, Field field) { + if (fields.put(field.name(), field.type()) != null) { + throw new IllegalArgumentException("duplicate output field '" + field.name() + "'"); + } + } + + private static boolean nullLike(@Nullable Object value) { + return value == null || value instanceof com.fasterxml.jackson.databind.node.NullNode; + } + + private static void validateAgainst(String tool, String path, @Nullable Object value, Map schema) { + Object alternatives = schema.get("oneOf"); + if (alternatives instanceof Iterable choices) { + for (Object choice : choices) { + if (choice instanceof Map option && matches(tool, path, value, option)) { + return; + } + } + throw invalid(tool, path, "does not match any declared output alternative"); + } + + Object type = schema.get("type"); + if (nullLike(value)) { + if ("null".equals(type)) { + return; + } + throw invalid(tool, path, "is null"); + } + if (!(type instanceof String expected)) { + return; + } + Object present = Objects.requireNonNull(value); + switch (expected) { + case "string" -> require(tool, path, present instanceof String, expected); + case "integer" -> require(tool, path, ValueType.INTEGER.accepts(present), expected); + case "number" -> require(tool, path, present instanceof Number, expected); + case "boolean" -> require(tool, path, present instanceof Boolean, expected); + case "array" -> validateArray(tool, path, present, schema.get("items")); + case "object" -> validateObject(tool, path, present, schema); + case "null" -> throw invalid(tool, path, "is not null"); + default -> throw invalid(tool, path, "uses unsupported schema type " + expected); + } + } + + @SuppressWarnings("unchecked") + private static boolean matches(String tool, String path, @Nullable Object value, Map schema) { + try { + validateAgainst(tool, path, value, (Map) schema); + return true; + } catch (IllegalStateException ignored) { + return false; + } + } + + private static void validateArray(String tool, String path, Object value, @Nullable Object itemSchema) { + if (!(value instanceof Iterable values)) { + throw invalid(tool, path, "is not an array"); + } + if (!(itemSchema instanceof Map schema)) { + return; + } + int index = 0; + for (Object item : values) { + validateAgainst(tool, path + "[" + index + "]", item, castSchema(schema)); + index++; + } + } + + private static void validateObject(String tool, String path, Object value, Map schema) { + Map object; + if (value instanceof Map) { + object = Answers.asObject(value); + } else if (value.getClass().isRecord()) { + object = Answers.asObject(value); + } else { + throw invalid(tool, path, "is not an object"); + } + + Map declared = + schema.get("properties") instanceof Map fields ? castSchema(fields) : Map.of(); + if (schema.get("required") instanceof Iterable requiredFields) { + for (Object requiredField : requiredFields) { + if (!(requiredField instanceof String name) || !object.containsKey(name)) { + throw invalid(tool, path, "omits required field " + requiredField); + } + } + } + Object additional = schema.get("additionalProperties"); + for (Map.Entry entry : object.entrySet()) { + Object fieldSchema = declared.get(entry.getKey()); + if (fieldSchema instanceof Map field) { + validateAgainst(tool, path + "." + entry.getKey(), entry.getValue(), castSchema(field)); + } else if (additional instanceof Map values) { + validateAgainst(tool, path + "." + entry.getKey(), entry.getValue(), castSchema(values)); + } else if (Boolean.FALSE.equals(additional)) { + throw invalid(tool, path, "contains undeclared field " + entry.getKey()); + } + } + } + + @SuppressWarnings("unchecked") + private static Map castSchema(Map schema) { + return (Map) schema; + } + + private static void require(String tool, String path, boolean accepted, String expected) { + if (!accepted) { + throw invalid(tool, path, "is not " + expected); + } + } + + private static IllegalStateException invalid(String tool, String path, String message) { + return new IllegalStateException(tool + " output " + path + " " + message); + } + + private static Map ordered(Object... entries) { + if (entries.length % 2 != 0) { + throw new IllegalArgumentException("schema entries must be key-value pairs"); + } + Map result = new LinkedHashMap<>(); + for (int index = 0; index < entries.length; index += 2) { + result.put((String) entries[index], entries[index + 1]); + } + return Collections.unmodifiableMap(result); + } + + record Field(String name, ValueType type) { + Field { + if (Objects.requireNonNull(name, "name").isBlank()) { + throw new IllegalArgumentException("output field name is blank"); + } + Objects.requireNonNull(type, "type"); + } + } +} diff --git a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Prompts.java b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Prompts.java deleted file mode 100644 index 5bc582a..0000000 --- a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Prompts.java +++ /dev/null @@ -1,173 +0,0 @@ -package io.github.libtmux.mcp; - -import io.modelcontextprotocol.server.McpServerFeatures; -import io.modelcontextprotocol.spec.McpSchema; -import java.util.List; -import java.util.Map; -import java.util.function.Function; - -/** - * Worked recipes, offered rather than remembered. - * - *

A tool description can say what one tool does. It cannot teach the shape of a job that takes - * three of them in an order that matters — and that shape is exactly what an agent gets wrong - * expensively, by polling, or by waiting on a run that has already failed. - * - *

Kept few and deliberate. A prompt list long enough to need reading is one nobody reads. - */ -final class Prompts { - - static final String PANE_ARGUMENT = "pane_id"; - - static final String SESSION_ARGUMENT = "session_name"; - - private Prompts() {} - - static List all() { - return List.of( - prompt( - "run_and_wait", - "Run a command and wait for it", - List.of( - argument(PANE_ARGUMENT, "The pane to run it in, such as %1.", true), - argument("command", "The shell command to run.", true)), - values -> """ - Run this in tmux pane %s and wait for it: - - tmux_run(pane_id="%s", command=%s, timeout=60) - - Read `exit_status` and `outcome` from the result. `outcome` is SIGNALLED when \ - the command finished, TIMED_OUT when it was still running at the deadline, and \ - SERVER_GONE when tmux itself died — those mean different things and only the \ - first makes `exit_status` meaningful. - - Do not send the command with tmux_send_keys and then poll tmux_capture_pane to \ - see whether it is done. That costs a call per look, and a quiet pane looks the \ - same whether the command finished or hung. - """.formatted( - value(values, PANE_ARGUMENT, "%1"), - value(values, PANE_ARGUMENT, "%1"), - quoted(value(values, "command", "true")))), - prompt( - "watch_until_ready", - "Wait for something you did not start", - List.of( - argument(PANE_ARGUMENT, "The pane to watch, such as %1.", true), - argument("ready_text", "The text that means it is up.", true), - argument("failure_text", "The text that means it has failed.", false)), - values -> """ - Wait for the process already running in tmux pane %s: - - tmux_wait_for_text(pane_id="%s", patterns=[%s], stop=[%s], timeout=60) - - Pass `stop` whenever a failure marker exists. Without it, a run that fails in \ - five seconds is still waited on until the deadline, and what comes back is a \ - timeout rather than the error. - - Only output arriving after the call counts, so text already on screen will not \ - satisfy it. If it times out and the thing is simply slow, call again with the \ - `cursor` from the result rather than starting over. - """.formatted( - value(values, PANE_ARGUMENT, "%1"), - value(values, PANE_ARGUMENT, "%1"), - quoted(value(values, "ready_text", "ready")), - quoted(value(values, "failure_text", "error")))), - prompt( - "find_the_pane", - "Find which pane something is in", - List.of(argument("looking_for", "What you are trying to find, in words.", true)), - values -> """ - Find the pane for: %s - - Choose by what you are matching on: - - What a pane is RUNNING, or where it is: tmux_list_panes, narrowed with a \ - filter document. This reads tmux's own metadata and is one call. - - What a pane is SHOWING on screen: tmux_search_panes with the text. Listing \ - tools cannot see pane contents. - - Then act by the `id` it returns — never by position. Indexes move as panes come \ - and go, so a position read a few turns ago can name a different pane now. - """.formatted(value(values, "looking_for", "the pane you need"))), - prompt( - "build_workspace", - "Build a session from a description", - List.of(argument("what_for", "What the workspace is for.", true)), - values -> """ - Build a tmux session for: %s - - Send it as one document to tmux_apply_workspace rather than creating windows \ - and panes one call at a time. The document and layouts are validated before \ - creation. If a later step fails, cleanup is best effort; commands already \ - started cannot be undone. - - %s - The commands in it are started, not waited for. To check one came up, watch its \ - pane with tmux_wait_for_text using the ids the call returns. - """.formatted(value(values, "what_for", "the work at hand"), Workspaces.example())), - prompt( - "clean_up_safely", - "End things without ending this conversation", - List.of(argument(SESSION_ARGUMENT, "The session to tidy up.", false)), - values -> """ - Tidy up %s. - - Call tmux_whoami first. When this MCP server was launched from inside tmux, one \ - pane is the one this conversation travels through, and killing it — or the \ - window or session holding it — ends your ability to act at all. tmux_whoami \ - names it, and tmux_kill refuses it unless `confirm_self` is set. - - Check tmux_list_clients too: an attached client means a person is watching, and \ - what looks abandoned may be someone's screen. - """.formatted(value(values, SESSION_ARGUMENT, "the sessions no longer needed")))); - } - - /** - * A value a client sent, or something readable in its place. - * - *

An argument declared required is not one that arrives: a client may render a prompt before - * anyone has filled it in. A recipe with a placeholder in it still teaches the shape; one that - * failed to render teaches nothing. - */ - private static String value(Map values, String name, String whenMissing) { - String given = values.get(name); - return given == null || given.isEmpty() ? whenMissing : given; - } - - private static String quoted(String value) { - return "\"" + value.replace("\\", "\\\\").replace("\"", "\\\"") + "\""; - } - - private static McpSchema.PromptArgument argument(String name, String description, boolean required) { - return new McpSchema.PromptArgument(name, name, description, required); - } - - private static McpServerFeatures.SyncPromptSpecification prompt( - String name, - String title, - List arguments, - Function, String> body) { - McpSchema.Prompt declared = McpSchema.Prompt.builder(name) - .title(title) - .description(title) - .arguments(arguments) - .build(); - return new McpServerFeatures.SyncPromptSpecification(declared, (exchange, request) -> { - Map values = strings(request.arguments()); - return McpSchema.GetPromptResult.builder(List.of(new McpSchema.PromptMessage( - McpSchema.Role.USER, - McpSchema.TextContent.builder(body.apply(values)).build()))) - .description(title) - .build(); - }); - } - - private static Map strings(Map arguments) { - if (arguments == null) { - return Map.of(); - } - return arguments.entrySet().stream() - .filter(entry -> entry.getValue() != null) - .collect(java.util.stream.Collectors.toMap( - Map.Entry::getKey, entry -> entry.getValue().toString(), (first, second) -> second)); - } -} diff --git a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Reading.java b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Reading.java index 6d003bd..990830c 100644 --- a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Reading.java +++ b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Reading.java @@ -4,8 +4,6 @@ import io.github.libtmux.Server; import java.util.ArrayList; import java.util.List; -import java.util.regex.Pattern; -import java.util.regex.PatternSyntaxException; import org.jspecify.annotations.Nullable; /** @@ -43,6 +41,10 @@ record Hit(String paneId, String session, String window, String line) {} record Found( int count, int panesSearched, + int linesSearched, + int bytesSearched, + double workSeconds, + boolean limited, List matches, @Nullable String note) {} @@ -112,21 +114,31 @@ static Found search(Call call) { Server server = call.server(); String pattern = call.string("pattern"); boolean regex = call.flag("regex", false); - Pattern compiled = compile(pattern, regex); + TextPatterns.Matcher matcher = TextPatterns.compileOne(pattern, regex); + TextPatterns.WorkBudget work = TextPatterns.searchBudget(); int perPane = Math.clamp(call.integer("max_matches_per_pane", 5), 1, 50); List panes = server.panes(); List hits = new ArrayList<>(); + int panesSearched = 0; + boolean workLimited = false; + search: for (Pane pane : panes) { + if (!work.tryStartPane()) { + workLimited = true; + break; + } + panesSearched = work.panes(); int kept = 0; for (String line : Screen.withoutTrailingBlanks(pane.capture())) { if (kept >= perPane) { break; } - boolean matched = compiled == null - ? line.contains(pattern) - : compiled.matcher(line).find(); - if (matched) { + if (!work.trySpend(line)) { + workLimited = true; + break search; + } + if (matcher.matches(line)) { hits.add(new Hit( pane.id().value(), pane.window().session().name(), @@ -135,6 +147,10 @@ static Found search(Call call) { kept++; } } + if (work.expired()) { + workLimited = true; + break; + } } Trim.Trimmed budget = Trim.tail(hits.stream().map(Hit::line).toList(), Trim.lineBudget(call)); List shown = hits.size() > budget.lines().size() @@ -142,23 +158,17 @@ static Found search(Call call) { : List.copyOf(hits); return new Found( shown.size(), - panes.size(), + panesSearched, + work.lines(), + work.bytes(), + work.seconds(), + workLimited, shown, - shown.isEmpty() - ? "No pane is currently showing that. This searches what panes show now, not their " - + "history — text that has scrolled away will not be found." - : null); - } - - private static @Nullable Pattern compile(String pattern, boolean regex) { - if (!regex) { - return null; - } - try { - return Pattern.compile(pattern); - } catch (PatternSyntaxException e) { - throw new IllegalArgumentException("'" + pattern + "' is not a valid regular expression: " - + e.getDescription() + ". Omit 'regex' to search for it as plain text instead"); - } + workLimited + ? "Search stopped at the fixed pane, line, byte or five-second work limit." + : shown.isEmpty() + ? "No pane is currently showing that. This searches what panes show now, not their " + + "history — text that has scrolled away will not be found." + : null); } } diff --git a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/ResourceInvalidations.java b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/ResourceInvalidations.java deleted file mode 100644 index 8b34008..0000000 --- a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/ResourceInvalidations.java +++ /dev/null @@ -1,210 +0,0 @@ -package io.github.libtmux.mcp; - -import io.github.libtmux.PaneId; -import io.github.libtmux.SessionId; -import io.github.libtmux.snapshot.PaneState; -import io.github.libtmux.snapshot.ServerSnapshot; -import io.github.libtmux.snapshot.SessionState; -import io.github.libtmux.snapshot.WindowState; -import java.util.Collections; -import java.util.LinkedHashMap; -import java.util.LinkedHashSet; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.Set; -import java.util.function.Predicate; - -/** Computes exact resource invalidations without reading tmux or sending notifications. */ -final class ResourceInvalidations { - - private ResourceInvalidations() {} - - static Projection project(ServerSnapshot snapshot, Predicate hiddenClient) { - Objects.requireNonNull(snapshot, "snapshot"); - Objects.requireNonNull(hiddenClient, "hiddenClient"); - - Set attached = new LinkedHashSet<>(); - snapshot.clients().stream() - .filter(client -> !hiddenClient.test(client.name())) - .flatMap(client -> client.session().stream()) - .forEach(attached::add); - - List sessions = snapshot.sessions().stream() - .map(session -> session(snapshot, session, attached.contains(session.id()))) - .toList(); - Map sessionsById = new LinkedHashMap<>(); - sessions.forEach(session -> sessionsById.putIfAbsent(session.id(), session)); - - List panes = - snapshot.panes().stream().map(pane -> pane(snapshot, pane)).toList(); - Map panesById = new LinkedHashMap<>(); - for (PaneView pane : panes) { - panesById.putIfAbsent(pane.id(), pane); - } - - return new Projection( - new Counts( - snapshot.sessions().size(), - snapshot.windows().size(), - snapshot.panes().size()), - sessions, - sessionsById, - panes, - panesById); - } - - static Set between(Projection before, Projection after) { - Objects.requireNonNull(before, "before"); - Objects.requireNonNull(after, "after"); - Set changed = new LinkedHashSet<>(); - - if (!before.counts.equals(after.counts)) { - changed.add(Resources.SERVER_URI); - } - if (!before.sessions.equals(after.sessions)) { - changed.add(Resources.SESSIONS_URI); - } - for (SessionId id : union(before.sessionsById.keySet(), after.sessionsById.keySet())) { - SessionView oldSession = before.sessionsById.get(id); - SessionView newSession = after.sessionsById.get(id); - if (!Objects.equals(oldSession, newSession)) { - if (oldSession != null) { - changed.add(Resources.sessionUri(oldSession.name())); - } - if (newSession != null) { - changed.add(Resources.sessionUri(newSession.name())); - } - } - } - - if (!before.panes.equals(after.panes)) { - changed.add(Resources.PANES_URI); - } - for (PaneId id : union(before.panesById.keySet(), after.panesById.keySet())) { - PaneView oldPane = before.panesById.get(id); - PaneView newPane = after.panesById.get(id); - if (!Objects.equals(oldPane, newPane)) { - changed.add(Resources.paneUri(id)); - } - if (oldPane == null - || newPane == null - || !oldPane.size().equals(newPane.size()) - || oldPane.pid() != newPane.pid()) { - changed.add(Resources.paneContentUri(id)); - } - } - return immutable(changed); - } - - static Set allKnown(Projection... projections) { - Set changed = new LinkedHashSet<>(); - changed.add(Resources.SERVER_URI); - changed.add(Resources.SESSIONS_URI); - changed.add(Resources.PANES_URI); - for (Projection projection : projections) { - Objects.requireNonNull(projection, "projection"); - projection.sessionsById.values().stream() - .map(SessionView::name) - .map(Resources::sessionUri) - .forEach(changed::add); - for (PaneId pane : projection.panesById.keySet()) { - changed.add(Resources.paneUri(pane)); - changed.add(Resources.paneContentUri(pane)); - } - } - return immutable(changed); - } - - static Set output(PaneId pane) { - return Set.of(Resources.paneContentUri(Objects.requireNonNull(pane, "pane"))); - } - - static Set droppedOutput(Projection projection) { - Objects.requireNonNull(projection, "projection"); - Set changed = new LinkedHashSet<>(); - projection.panesById.keySet().stream().map(Resources::paneContentUri).forEach(changed::add); - return immutable(changed); - } - - private static SessionView session(ServerSnapshot snapshot, SessionState session, boolean attached) { - List windows = - snapshot.windowsOf(session.id()).stream().map(WindowState::name).toList(); - return new SessionView(session.id(), session.name(), attached, windows.size(), windows); - } - - private static PaneView pane(ServerSnapshot snapshot, PaneState pane) { - String session = snapshot.session(pane.context().session()) - .orElseThrow(() -> new IllegalArgumentException("pane refers to an absent session")) - .name(); - String window = snapshot.window(pane.context()) - .orElseThrow(() -> new IllegalArgumentException("pane refers to an absent window")) - .name(); - return new PaneView( - pane.id(), - session, - window, - pane.context().window().value(), - pane.currentCommand(), - pane.currentPath().toString(), - pane.size().toString(), - pane.pid(), - pane.active()); - } - - private static Set union(Set before, Set after) { - Set union = new LinkedHashSet<>(before); - union.addAll(after); - return union; - } - - private static Set immutable(Set values) { - return values.isEmpty() ? Set.of() : Collections.unmodifiableSet(values); - } - - static final class Projection { - - private final Counts counts; - private final List sessions; - private final Map sessionsById; - private final List panes; - private final Map panesById; - - private Projection( - Counts counts, - List sessions, - Map sessionsById, - List panes, - Map panesById) { - this.counts = counts; - this.sessions = List.copyOf(sessions); - this.sessionsById = unmodifiableMap(sessionsById); - this.panes = List.copyOf(panes); - this.panesById = unmodifiableMap(panesById); - } - } - - private static Map unmodifiableMap(Map values) { - return Collections.unmodifiableMap(new LinkedHashMap<>(values)); - } - - private record Counts(int sessions, int windows, int panes) {} - - private record SessionView(SessionId id, String name, boolean attached, int windows, List windowNames) { - - private SessionView { - windowNames = List.copyOf(windowNames); - } - } - - private record PaneView( - PaneId id, - String session, - String window, - String windowId, - String command, - String path, - String size, - long pid, - boolean active) {} -} diff --git a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Resources.java b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Resources.java index 759c04a..c258547 100644 --- a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Resources.java +++ b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Resources.java @@ -1,154 +1,37 @@ package io.github.libtmux.mcp; import com.fasterxml.jackson.core.JacksonException; -import com.fasterxml.jackson.databind.ObjectMapper; -import io.github.libtmux.Pane; -import io.github.libtmux.PaneId; import io.modelcontextprotocol.server.McpServerFeatures; import io.modelcontextprotocol.spec.McpSchema; import java.util.List; -import java.util.function.Function; -/** - * The same state, addressable rather than asked for. - * - *

A tool is a verb a model has to choose. A resource is a noun a client can hold: it can attach - * {@code tmux://panes/%251/content} to a conversation, refresh it, and show it to a person, none of - * which spends a tool call or a model's decision. Declaring only tools gives that up. - * - *

The templated ones carry the id in the URI, so a client that has listed panes once can address - * every one of them without asking this server how — and a client that supports completion can - * offer the ids that exist as a person types one. - */ +/** The single static disclosure resource exposed by this MCP server. */ final class Resources { - private static final ObjectMapper JSON = Answers.mapper(); - - private static final String JSON_MIME = "application/json"; - - /** - * Terminal text is not JSON and must not be parsed as it. It is what a program drew on a grid, - * and a client that renders it as anything else will make a mess of a progress bar. - */ - private static final String TEXT_MIME = "text/plain"; - - static final String SERVER_URI = "tmux://server"; - - static final String SESSIONS_URI = "tmux://sessions"; - - static final String PANES_URI = "tmux://panes"; - - static final String PANE_TEMPLATE = "tmux://panes/{pane_id}"; - - static final String PANE_CONTENT_TEMPLATE = "tmux://panes/{pane_id}/content"; - - static final String SESSION_TEMPLATE = "tmux://sessions/{session_name}"; + static final String CAPABILITIES_URI = "tmux://capabilities"; private Resources() {} - static String sessionUri(String name) { - return SESSIONS_URI + "/" + Uris.segment(name); - } - - static String paneUri(PaneId pane) { - return PANES_URI + "/" + Uris.segment(pane.value()); - } - - static String paneContentUri(PaneId pane) { - return paneUri(pane) + "/content"; - } - static List fixed(Connection connection) { - return List.of( - resource( - SERVER_URI, - "This tmux server", - "Which server this connection acts on, how much is on it, and which pane this " - + "conversation is coming through.", - () -> Listings.whoami(connection.server(), connection.caller(), connection.ceiling())), - resource( - SESSIONS_URI, - "All sessions", - "Every session on this server, with the windows in each.", - () -> Listings.sessions(connection)), - resource( - PANES_URI, - "All panes", - "Every pane on this server, with the id other tools take as a target.", - () -> { - List panes = connection.server().panes(); - return new Listings.Panes( - panes.size(), Listings.describe(panes, connection.caller()), null); - })); - } - - static List templated(Connection connection) { - return List.of( - jsonTemplate(SESSION_TEMPLATE, "One session", "A session and the windows in it.", values -> { - var found = Listings.session(connection, values.get(0)); - return new Listings.Sessions(1, List.of(found), null); - }), - jsonTemplate( - PANE_TEMPLATE, - "One pane", - "What tmux knows about a pane: what is running in it, where, and how big it is.", - values -> Listings.describe( - List.of(Targets.pane(connection.server(), values.get(0))), connection.caller()) - .get(0)), - template( - PANE_CONTENT_TEMPLATE, - "What a pane is showing", - "The text a pane currently shows, newest last. This is terminal output, not JSON.", - TEXT_MIME, - values -> { - Pane pane = Targets.pane(connection.server(), values.get(0)); - return String.join("\n", Screen.withoutTrailingBlanks(pane.capture())); - })); - } - - // ------------------------------------------------------------------ plumbing - - private static McpServerFeatures.SyncResourceSpecification resource( - String uri, String title, String description, java.util.function.Supplier read) { - McpSchema.Resource declared = McpSchema.Resource.builder(uri, title) - .description(description) - .mimeType(JSON_MIME) + String payload = render(connection.surface().capabilities(connection.server())); + McpSchema.Resource declared = McpSchema.Resource.builder(CAPABILITIES_URI, "tmux capabilities") + .description("The startup-frozen effective tool surface and selected tmux socket.") + .mimeType("application/json") .build(); - return new McpServerFeatures.SyncResourceSpecification( + return List.of(new McpServerFeatures.SyncResourceSpecification( declared, (exchange, request) -> McpSchema.ReadResourceResult.builder( - List.of(McpSchema.TextResourceContents.builder(request.uri(), render(read.get())) - .mimeType(JSON_MIME) - .build())) - .build()); - } - - private static McpServerFeatures.SyncResourceTemplateSpecification jsonTemplate( - String pattern, String title, String description, Function, Object> read) { - return template(pattern, title, description, JSON_MIME, values -> render(read.apply(values))); - } - - private static McpServerFeatures.SyncResourceTemplateSpecification template( - String pattern, String title, String description, String mime, Function, String> read) { - McpSchema.ResourceTemplate declared = McpSchema.ResourceTemplate.builder(pattern, title) - .description(description) - .mimeType(mime) - .build(); - return new McpServerFeatures.SyncResourceTemplateSpecification( - declared, - (exchange, request) -> McpSchema.ReadResourceResult.builder(List.of( - McpSchema.TextResourceContents.builder( - request.uri(), read.apply(Uris.values(pattern, request.uri()))) - .mimeType(mime) + List.of(McpSchema.TextResourceContents.builder(request.uri(), payload) + .mimeType("application/json") .build())) - .build()); + .build())); } private static String render(Object value) { try { - return JSON.writerWithDefaultPrettyPrinter().writeValueAsString(value); + return Answers.mapper().writerWithDefaultPrettyPrinter().writeValueAsString(value); } catch (JacksonException e) { - throw new IllegalStateException("could not render a resource", e); + throw new IllegalStateException("could not render the capabilities resource", e); } } } diff --git a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/RunningCommands.java b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/RunningCommands.java index 70cc8b9..ded454d 100644 --- a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/RunningCommands.java +++ b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/RunningCommands.java @@ -108,11 +108,11 @@ static Ran run(Call call) { return switch (wake) { case TIMED_OUT -> "The command is still running; the output above is what it had printed by the " - + "deadline. Call tmux_wait_for_text or tmux_capture_since on this pane to keep watching, " - + "or tmux_send_keys with 'C-c' to stop it."; + + "deadline. Call wait_for_text or capture_since on this pane to keep watching, " + + "or send_keys with 'C-c' to stop it."; case SERVER_GONE -> "The tmux server ended while the command was running. Nothing this call was " - + "waiting on can be relied on; call tmux_list_servers to see what is left."; + + "waiting on can be relied on; check that this process selected the intended socket."; case SIGNALLED -> framed.exact() ? null @@ -227,9 +227,9 @@ private static void requirePosixShell(Pane pane) { name = name.substring(1); } if (!POSIX_SHELLS.contains(name)) { - throw new IllegalStateException( - "tmux_run requires a POSIX-compatible shell in the target pane; " + "it is running '" + name - + "'. Use tmux_send_keys when typing into another program is intentional"); + throw new IllegalStateException("run_shell_command requires a POSIX-compatible shell in the target pane; " + + "it is running '" + name + + "'. Use send_keys when typing into another program is intentional"); } } } diff --git a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Safety.java b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Safety.java deleted file mode 100644 index ffbc20f..0000000 --- a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Safety.java +++ /dev/null @@ -1,60 +0,0 @@ -package io.github.libtmux.mcp; - -import java.util.Locale; - -/** - * Which classes of tool a server is willing to offer. - * - *

This is an availability ceiling, not an effect annotation. {@link #MUTATING} includes commands - * and pane input whose effects may be destructive; their MCP annotations say so independently. A - * tool above the configured ceiling is not listed at all rather than listed and refused. - * - *

The names are the ones every port of libtmux uses, so an operator who has configured one has - * configured all of them. - */ -public enum Safety { - - /** Offers only tools that read state. */ - READONLY(0), - - /** Also offers tools that change state, run commands, or send input. */ - MUTATING(1), - - /** Also offers dedicated tools that end a pane, session, or server. */ - DESTRUCTIVE(2); - - /** - * How much this level permits, stated rather than taken from the declaration order. Reordering - * the constants must not quietly widen what a server offers. - */ - private final int rank; - - Safety(int rank) { - this.rank = rank; - } - - /** Whether a server holding this ceiling will serve a tool of {@code required} safety. */ - public boolean allows(Safety required) { - return required.rank <= rank; - } - - /** The name an operator writes, which is the lowercase one every port accepts. */ - public String wireName() { - return name().toLowerCase(Locale.ROOT); - } - - /** - * Reads the name an operator wrote. - * - * @throws IllegalArgumentException naming what was accepted, since the caller is a person - */ - public static Safety ofWireName(String name) { - for (Safety safety : values()) { - if (safety.wireName().equals(name)) { - return safety; - } - } - throw new IllegalArgumentException( - "unknown safety level '" + name + "'; expected readonly, mutating or destructive"); - } -} diff --git a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Screen.java b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Screen.java index 4132f84..a00ebe6 100644 --- a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Screen.java +++ b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Screen.java @@ -6,7 +6,6 @@ import io.github.libtmux.batch.OperationResult; import java.util.ArrayList; import java.util.List; -import java.util.Objects; import org.jspecify.annotations.Nullable; /** @@ -36,6 +35,8 @@ final class Screen { */ private static final int SLACK_LINES = 256; + private static final int CURSOR_RECOVERY_LINES = 20_000; + private Screen() {} /** @@ -83,12 +84,17 @@ static Fresh since(Pane pane, @Nullable Cursor from, int budget) { if (answer != null) { return answer; } - // The cursor's line is older than the look reached. Looking as far back as tmux keeps - // anything settles it either way: found, and the pane merely ran ahead; absent, and its - // history really has rolled past what was delivered. A look that started at the oldest line - // there is always answers, which is what makes this terminate. - return Objects.requireNonNull( - resolve(from, look(pane, Integer.MAX_VALUE)), "a look at the whole history always answers"); + Look recovery = look(pane, CURSOR_RECOVERY_LINES); + Fresh resumed = resolve(from, recovery); + if (resumed != null) { + return resumed; + } + List written = recovery.complete(); + return new Fresh(List.copyOf(written), Cursor.of(recovery.serverPid(), paneId, written), false); + } + + static int cursorRecoveryLines() { + return CURSOR_RECOVERY_LINES; } /** diff --git a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/ServerDiscovery.java b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/ServerDiscovery.java deleted file mode 100644 index fd0a9ee..0000000 --- a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/ServerDiscovery.java +++ /dev/null @@ -1,286 +0,0 @@ -package io.github.libtmux.mcp; - -import com.sun.security.auth.module.UnixSystem; -import io.github.libtmux.transport.CommandRequest; -import io.github.libtmux.transport.CommandResult; -import io.github.libtmux.transport.DispatchOutcome; -import io.github.libtmux.transport.ProcessTransport; -import io.github.libtmux.transport.TmuxTimeoutException; -import io.github.libtmux.transport.TmuxTransportException; -import java.io.IOException; -import java.nio.file.DirectoryIteratorException; -import java.nio.file.DirectoryStream; -import java.nio.file.Files; -import java.nio.file.LinkOption; -import java.nio.file.NoSuchFileException; -import java.nio.file.Path; -import java.nio.file.attribute.BasicFileAttributes; -import java.time.Duration; -import java.util.ArrayList; -import java.util.Comparator; -import java.util.LinkedHashSet; -import java.util.List; -import java.util.Set; -import java.util.concurrent.ExecutionException; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.Future; -import org.jspecify.annotations.Nullable; - -/** Finds tmux sockets without constructing a server. */ -final class ServerDiscovery { - - private static final int DEFAULT_CANDIDATE_LIMIT = 32; - private static final int DEFAULT_SCAN_LIMIT = 128; - private static final int DEFAULT_CONCURRENCY = 4; - private static final Duration DEFAULT_PROBE_TIMEOUT = Duration.ofMillis(1_500); - private static final int UNIX_FILE_TYPE_MASK = 0170000; - private static final int UNIX_SOCKET_TYPE = 0140000; - - private final Path socketRoot; - private final UidResolver uidResolver; - private final int candidateLimit; - private final int scanLimit; - private final int maxConcurrency; - private final Duration probeTimeout; - - ServerDiscovery( - Path socketRoot, - UidResolver uidResolver, - int candidateLimit, - int scanLimit, - int maxConcurrency, - Duration probeTimeout) { - if (candidateLimit < 1) { - throw new IllegalArgumentException("candidateLimit is not positive"); - } - if (scanLimit < candidateLimit) { - throw new IllegalArgumentException("scanLimit is below candidateLimit"); - } - if (maxConcurrency < 1) { - throw new IllegalArgumentException("maxConcurrency is not positive"); - } - if (probeTimeout.isZero() || probeTimeout.isNegative()) { - throw new IllegalArgumentException("probeTimeout is not positive"); - } - this.socketRoot = socketRoot.toAbsolutePath().normalize(); - this.uidResolver = uidResolver; - this.candidateLimit = candidateLimit; - this.scanLimit = scanLimit; - this.maxConcurrency = maxConcurrency; - this.probeTimeout = probeTimeout; - } - - static ServerDiscovery system() { - String configured = System.getenv("TMUX_TMPDIR"); - Path root = Path.of(configured == null || configured.isEmpty() ? "/tmp" : configured); - return new ServerDiscovery( - root, - ServerDiscovery::unixUid, - DEFAULT_CANDIDATE_LIMIT, - DEFAULT_SCAN_LIMIT, - DEFAULT_CONCURRENCY, - DEFAULT_PROBE_TIMEOUT); - } - - Result discover(String binary, @Nullable Path currentSocket) { - Scan scan = scan(currentSocket); - if (scan.candidates().isEmpty()) { - return new Result(List.of(), scan.truncated(), scan.note()); - } - - List found = new ArrayList<>(scan.candidates().size()); - try (ProcessTransport transport = new ProcessTransport(maxConcurrency); - ExecutorService probes = Executors.newVirtualThreadPerTaskExecutor()) { - List> futures = scan.candidates().stream() - .map(socket -> probes.submit(() -> probe(transport, binary, socket))) - .toList(); - boolean interrupted = false; - for (int index = 0; index < futures.size(); index++) { - Future future = futures.get(index); - if (interrupted) { - future.cancel(true); - found.add(notProbed(scan.candidates().get(index), "discovery was interrupted")); - continue; - } - try { - found.add(future.get()); - } catch (InterruptedException e) { - interrupted = true; - future.cancel(true); - found.add(notProbed(scan.candidates().get(index), "discovery was interrupted")); - } catch (ExecutionException e) { - found.add(notProbed(scan.candidates().get(index), "tmux could not be probed")); - } - } - if (interrupted) { - Thread.currentThread().interrupt(); - } - } - return new Result(found, scan.truncated(), scan.note()); - } - - private Scan scan(@Nullable Path currentSocket) { - LinkedHashSet candidates = new LinkedHashSet<>(); - Path current = - currentSocket == null ? null : currentSocket.toAbsolutePath().normalize(); - if (current != null) { - candidates.add(current); - } - - long uid; - try { - uid = uidResolver.currentUid(); - if (uid < 0) { - throw new IOException("negative Unix UID"); - } - } catch (IOException | RuntimeException e) { - return scanResult( - candidates, - current, - false, - "The current Unix user could not be identified; standard sockets were not scanned."); - } - - Path directory = socketRoot.resolve("tmux-" + uid); - try { - BasicFileAttributes directoryAttributes = - Files.readAttributes(directory, BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS); - if (!directoryAttributes.isDirectory()) { - return scanResult( - candidates, - current, - false, - "The standard socket path is not a directory, so it was not scanned."); - } - long owner = ((Number) Files.getAttribute(directory, "unix:uid", LinkOption.NOFOLLOW_LINKS)).longValue(); - int mode = ((Number) Files.getAttribute(directory, "unix:mode", LinkOption.NOFOLLOW_LINKS)).intValue(); - if (owner != uid || (mode & 07) != 0) { - return scanResult( - candidates, - current, - false, - "The standard socket directory has unsafe ownership or permissions; it was not scanned."); - } - } catch (NoSuchFileException e) { - return scanResult(candidates, current, false, null); - } catch (IOException | RuntimeException e) { - return scanResult(candidates, current, false, "The standard socket directory could not be read."); - } - - boolean truncated = false; - int inspected = 0; - try (DirectoryStream entries = Files.newDirectoryStream(directory)) { - var iterator = entries.iterator(); - while (iterator.hasNext()) { - if (inspected == scanLimit) { - truncated = true; - break; - } - Path entry = iterator.next().toAbsolutePath().normalize(); - inspected++; - if (!isSocket(entry) || candidates.contains(entry)) { - continue; - } - candidates.add(entry); - } - } catch (IOException | DirectoryIteratorException | SecurityException e) { - return scanResult( - candidates, current, truncated, "The standard socket directory could not be read completely."); - } - return scanResult(candidates, current, truncated, null); - } - - private Scan scanResult(Set candidates, @Nullable Path current, boolean truncated, @Nullable String note) { - int scannedLimit = candidateLimit - (current == null ? 0 : 1); - List bounded = new ArrayList<>(candidateLimit); - candidates.stream() - .filter(candidate -> !candidate.equals(current)) - .sorted(Comparator.comparing(Path::toString)) - .limit(scannedLimit) - .forEach(bounded::add); - if (current != null) { - bounded.add(current); - bounded.sort(Comparator.comparing(Path::toString)); - } - return new Scan(bounded, truncated || candidates.size() > candidateLimit, note); - } - - private static boolean isSocket(Path path) { - try { - int mode = (int) Files.getAttribute(path, "unix:mode", LinkOption.NOFOLLOW_LINKS); - return (mode & UNIX_FILE_TYPE_MASK) == UNIX_SOCKET_TYPE; - } catch (IOException | RuntimeException e) { - return false; - } - } - - private KnownServer probe(ProcessTransport transport, String binary, Path socket) { - try { - CommandResult result = transport.execute(CommandRequest.of( - List.of(binary, "-S", socket.toString()), - List.of("list-sessions", "-F", "#{session_id}"), - probeTimeout)); - if (result.succeeded()) { - return new KnownServer(socket, State.RUNNING, result.stdout().size(), null); - } - return new KnownServer(socket, State.UNREACHABLE, null, "tmux did not answer on this socket"); - } catch (TmuxTimeoutException e) { - if (e.outcome() == DispatchOutcome.NOT_DISPATCHED) { - return notProbed(socket, "tmux could not be started before the probe deadline"); - } - return new KnownServer(socket, State.TIMED_OUT, null, "tmux did not answer before the probe deadline"); - } catch (TmuxTransportException e) { - if (e.outcome() == DispatchOutcome.NOT_DISPATCHED) { - return notProbed(socket, "tmux could not be started before the probe deadline"); - } - return new KnownServer(socket, State.UNREACHABLE, null, "tmux could not be probed"); - } catch (RuntimeException e) { - return notProbed(socket, "tmux could not be started"); - } - } - - private static KnownServer notProbed(Path socket, String note) { - return new KnownServer(socket, State.NOT_PROBED, null, note); - } - - private static long unixUid() throws IOException { - try { - return new UnixSystem().getUid(); - } catch (RuntimeException | LinkageError e) { - throw new IOException("could not read Unix UID", e); - } - } - - enum State { - RUNNING, - UNREACHABLE, - TIMED_OUT, - NOT_PROBED - } - - record KnownServer( - Path socket, - State state, - @Nullable Integer sessions, - @Nullable String note) {} - - record Result( - List servers, - boolean truncated, - @Nullable String scanNote) { - Result { - servers = List.copyOf(servers); - } - } - - @FunctionalInterface - interface UidResolver { - long currentUid() throws IOException; - } - - private record Scan( - List candidates, - boolean truncated, - @Nullable String note) {} -} diff --git a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Shaping.java b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Shaping.java index be5ebc1..02a9ccd 100644 --- a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Shaping.java +++ b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Shaping.java @@ -6,7 +6,6 @@ import io.github.libtmux.Server; import io.github.libtmux.Session; import io.github.libtmux.Window; -import java.nio.file.Path; import java.util.Arrays; import java.util.List; import java.util.Locale; @@ -40,8 +39,6 @@ static Made newSession(Call call) { } Session session = server.newSession(spec -> { spec.named(name); - call.maybe("path").ifPresent(path -> spec.in(Path.of(path))); - call.maybe("command").ifPresent(command -> spec.running("sh", "-c", command)); }); Pane first = session.windows().get(0).panes().get(0); return new Made( @@ -56,8 +53,6 @@ static Made newWindow(Call call) { Session session = Targets.sessionNamed(call.server(), call.string("session")); Window window = session.newWindow(spec -> { call.maybe("name").ifPresent(spec::named); - call.maybe("path").ifPresent(path -> spec.in(Path.of(path))); - call.maybe("command").ifPresent(command -> spec.running("sh", "-c", command)); spec.detached(); }); Pane first = window.panes().get(0); @@ -92,8 +87,6 @@ static Made splitPane(Call call) { if (percent > 0) { spec.percent(Math.clamp(percent, 1, 99)); } - call.maybe("path").ifPresent(path -> spec.in(Path.of(path))); - call.maybe("command").ifPresent(command -> spec.running("sh", "-c", command)); }); return new Made( "pane", diff --git a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/SocketProfile.java b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/SocketProfile.java new file mode 100644 index 0000000..22ac84b --- /dev/null +++ b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/SocketProfile.java @@ -0,0 +1,37 @@ +package io.github.libtmux.mcp; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; + +/** Startup-frozen facts about the one tmux socket this MCP process exposes. */ +record SocketProfile( + String selector, + String selectionProvenance, + String serverState, + String configurationProvenance, + String resolvedSocketPath, + String attachCommand, + boolean defaultTeardown) { + + Map report() { + Map socket = new LinkedHashMap<>(); + socket.put("selector", selector); + socket.put("selectionProvenance", selectionProvenance); + socket.put("serverState", serverState); + socket.put("configurationProvenance", configurationProvenance); + socket.put("namespaceBoundary", "tmux-objects-only"); + return Collections.unmodifiableMap(socket); + } + + Map connection() { + Map connection = new LinkedHashMap<>(); + connection.put("socketSelector", selector); + connection.put("socketProvenance", selectionProvenance); + connection.put("resolvedSocketPath", resolvedSocketPath); + connection.put("serverState", serverState); + connection.put("configurationProvenance", configurationProvenance); + connection.put("attachCommand", attachCommand); + return Collections.unmodifiableMap(connection); + } +} diff --git a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Targets.java b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Targets.java index 3dda57d..52eec13 100644 --- a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Targets.java +++ b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Targets.java @@ -15,7 +15,7 @@ * Finds the thing a model asked for, or says what to do about not finding it. * *

Every failure here names the tool that produces a working target. A model that reads "no pane - * %9" can guess; one that reads "call tmux_list_panes for the ids that exist" cannot get stuck. + * %9" can guess; one that reads "call list_panes for the ids that exist" cannot get stuck. * *

Object targets are ids, never positions. A model works from a listing it read some turns ago, * and indexes move as neighbours come and go, so a positional target would quietly act on a pane @@ -32,8 +32,8 @@ static Pane pane(Server server, String id) { return panes.stream() .filter(pane -> pane.id().equals(wanted)) .findFirst() - .orElseThrow(() -> new ObjectDoesNotExist("no pane " + id - + " on this server; call tmux_list_panes for the " + panes.size() + " that exist")); + .orElseThrow(() -> new ObjectDoesNotExist( + "no pane " + id + " on this server; call list_panes for the " + panes.size() + " that exist")); } static Window window(Server server, String id) { @@ -43,7 +43,7 @@ static Window window(Server server, String id) { .filter(window -> window.id().equals(wanted)) .findFirst() .orElseThrow(() -> new ObjectDoesNotExist("no window " + id - + " on this server; call tmux_list_windows for the " + windows.size() + " that exist")); + + " on this server; call list_windows for the " + windows.size() + " that exist")); } static Session sessionNamed(Server server, String name) { @@ -62,7 +62,7 @@ static Session sessionById(Server server, String id) { .filter(session -> session.id().equals(wanted)) .findFirst() .orElseThrow(() -> new ObjectDoesNotExist("no session " + id - + " on this server; call tmux_list_sessions for the " + sessions.size() + " that exist")); + + " on this server; call list_sessions for the " + sessions.size() + " that exist")); } /** diff --git a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/TextPatterns.java b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/TextPatterns.java new file mode 100644 index 0000000..832c5d8 --- /dev/null +++ b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/TextPatterns.java @@ -0,0 +1,131 @@ +package io.github.libtmux.mcp; + +import com.google.re2j.Pattern; +import com.google.re2j.PatternSyntaxException; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import org.jspecify.annotations.Nullable; + +/** Linear-time matching plus fixed caller-input and search-work bounds. */ +final class TextPatterns { + + static final int MAX_PATTERNS = 32; + static final int MAX_PATTERN_BYTES = 4_096; + static final int MAX_TOTAL_PATTERN_BYTES = 16_384; + static final int MAX_SEARCH_BYTES = 1_000_000; + static final int MAX_SEARCH_PANES = 200; + static final int MAX_SEARCH_LINES = 20_000; + static final Duration MAX_SEARCH_TIME = Duration.ofSeconds(5); + + private TextPatterns() {} + + static List compile(List sources, boolean regex) { + if (sources.size() > MAX_PATTERNS) { + throw new IllegalArgumentException("at most " + MAX_PATTERNS + " patterns are allowed"); + } + int total = 0; + List matchers = new ArrayList<>(sources.size()); + for (String source : sources) { + int bytes = utf8Bytes(source); + if (bytes > MAX_PATTERN_BYTES) { + throw new IllegalArgumentException("one pattern exceeds " + MAX_PATTERN_BYTES + " UTF-8 bytes"); + } + total = Math.addExact(total, bytes); + if (total > MAX_TOTAL_PATTERN_BYTES) { + throw new IllegalArgumentException("patterns exceed " + MAX_TOTAL_PATTERN_BYTES + " total UTF-8 bytes"); + } + matchers.add(Matcher.of(source, regex)); + } + return List.copyOf(matchers); + } + + static Matcher compileOne(String source, boolean regex) { + return compile(List.of(source), regex).getFirst(); + } + + static WorkBudget searchBudget() { + return new WorkBudget(); + } + + private static int utf8Bytes(String value) { + return value.getBytes(StandardCharsets.UTF_8).length; + } + + record Matcher(String source, @Nullable Pattern compiled) { + + static Matcher of(String source, boolean regex) { + if (!regex) { + return new Matcher(source, null); + } + try { + return new Matcher(source, Pattern.compile(source)); + } catch (PatternSyntaxException e) { + throw new IllegalArgumentException("'" + source + "' is not a supported regular expression: " + + e.getDescription() + ". Omit 'regex' to match it as plain text instead"); + } + } + + boolean matches(String line) { + return compiled == null + ? line.contains(source) + : compiled.matcher(line).find(); + } + } + + static final class WorkBudget { + + private final long started = System.nanoTime(); + private final long deadline = started + MAX_SEARCH_TIME.toNanos(); + private int remainingBytes = MAX_SEARCH_BYTES; + private int remainingPanes = MAX_SEARCH_PANES; + private int remainingLines = MAX_SEARCH_LINES; + private int panes; + private int lines; + private int bytes; + + private WorkBudget() {} + + boolean tryStartPane() { + if (expired() || remainingPanes == 0) { + return false; + } + remainingPanes--; + panes++; + return true; + } + + boolean trySpend(String text) { + int size = utf8Bytes(text); + if (expired() || remainingLines == 0 || size > remainingBytes) { + return false; + } + remainingLines--; + remainingBytes -= size; + lines++; + bytes += size; + return true; + } + + boolean expired() { + return System.nanoTime() >= deadline; + } + + int panes() { + return panes; + } + + int lines() { + return lines; + } + + int bytes() { + return bytes; + } + + double seconds() { + return Math.round((System.nanoTime() - started) / 10_000_000.0) / 100.0; + } + } +} diff --git a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/TmuxMcpServer.java b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/TmuxMcpServer.java index abbce99..c9a40fa 100644 --- a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/TmuxMcpServer.java +++ b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/TmuxMcpServer.java @@ -14,7 +14,6 @@ import java.io.OutputStream; import java.util.Map; import java.util.Objects; -import java.util.concurrent.atomic.AtomicBoolean; import org.jspecify.annotations.Nullable; /** @@ -24,12 +23,9 @@ * what gets tested against real tmux; this class describes them to a client and turns their answers * into protocol. * - *

Synchronous, and deliberately. The SDK runs a synchronous handler on - * {@code Schedulers.boundedElastic} rather than on the thread reading the transport, so a tool that - * blocks for a minute does not stop the connection answering anything else — measured at twenty - * interleaved calls served during one six-second call. Writing the same handlers as reactive - * pipelines measured worse: a {@code Mono.fromCallable} that blocks pins the single reactor thread - * and serves nothing at all until it lets go. + *

The SDK dispatches synchronous handlers on its bounded worker scheduler, not the transport I/O + * thread. Core operations remain typed and synchronous; wait and channel operations retain their + * own cancellation and timeout contracts. */ public final class TmuxMcpServer { @@ -49,7 +45,7 @@ private static String version() { /** Serves a tmux server over stdin and stdout, which is how an MCP client launches a tool. */ public static McpSyncServer overStdio(Server server) { - return overStdio(server, System.in, Safety.MUTATING, false); + return overStdio(server, System.in, ToolSurface.resolve(System.getenv()), () -> {}); } /** @@ -61,26 +57,24 @@ public static McpSyncServer overStdio(Server server) { *

The returned server owns and closes the input when startup fails, either protocol stream * disconnects, or the server closes. * - * @param watching whether to attach a control client and push notifications as tmux changes */ - public static McpSyncServer overStdio(Server server, InputStream in, Safety ceiling, boolean watching) { - return overStdio(server, in, ceiling, watching, () -> {}); + static McpSyncServer overStdio(Server server, InputStream in, ToolSurface surface) { + return overStdio(server, in, surface, () -> {}); } - static McpSyncServer overStdio( - Server server, InputStream in, Safety ceiling, boolean watching, Runnable onSessionEnd) { - return overStdio(server, in, System.out, ceiling, watching, onSessionEnd); + static McpSyncServer overStdio(Server server, InputStream in, ToolSurface surface, Runnable onSessionEnd) { + return overStdio(server, in, System.out, surface, onSessionEnd); } static McpSyncServer overStdio( - Server server, InputStream in, OutputStream out, Safety ceiling, boolean watching, Runnable onSessionEnd) { + Server server, InputStream in, OutputStream out, ToolSurface surface, Runnable onSessionEnd) { SessionLifetime lifetime = new SessionLifetime(onSessionEnd); lifetime.own(in); try { var provider = new StdioServerTransportProvider( new JacksonMcpJsonMapper(new ObjectMapper()), in, lifetime.observe(out)); lifetime.own(provider::close); - return serving(server, ceiling, watching, lifetime.observe(provider), lifetime); + return serving(server, surface, lifetime.observe(provider), lifetime); } catch (RuntimeException | Error failure) { lifetime.endAfter(failure); throw failure; @@ -93,52 +87,28 @@ static McpSyncServer overStdio( *

The returned server owns the transport. Ownership transfers on entry, so failed startup * closes it too. */ - public static McpSyncServer serving(Server server, Safety ceiling, McpServerTransportProvider transport) { - return serving(server, ceiling, false, transport); + public static McpSyncServer serving(Server server, McpServerTransportProvider transport) { + return serving(server, ToolSurface.resolve(System.getenv()), transport); } - /** - * Serves a tmux server over a caller-supplied transport, optionally watching it for changes. - * - *

The returned server owns the transport. Ownership transfers on entry, so failed startup - * closes it too. - */ - public static McpSyncServer serving( - Server server, Safety ceiling, boolean watching, McpServerTransportProvider transport) { - return serving(server, ceiling, watching, transport, null); + static McpSyncServer serving(Server server, ToolSurface surface, McpServerTransportProvider transport) { + return serving(server, surface, transport, null); } private static McpSyncServer serving( Server server, - Safety ceiling, - boolean watching, + ToolSurface surface, McpServerTransportProvider transport, @Nullable SessionLifetime lifetime) { Objects.requireNonNull(transport, "transport"); - @Nullable Watches watches = null; @Nullable McpSyncServer built = null; try { - Connection connection = Connection.to(server, ceiling); - if (watching) { - watches = Watches.prepare(connection); - if (lifetime != null) { - lifetime.own(watches); - } - } - built = build(connection, watching, transport); - if (watches == null) { - return built; - } - WatchedMcpServer owned = new WatchedMcpServer(built, watches); - watches.start(new McpNotifier(owned)); - return owned; + Connection connection = Connection.to(server, surface); + built = build(connection, transport); + return built; } catch (RuntimeException | Error failure) { Cleanup cleanup = new Cleanup(failure); if (lifetime == null) { - if (watches != null) { - Watches prepared = watches; - cleanup.run(prepared::close); - } if (built == null) { cleanup.run(transport::close); } else { @@ -153,72 +123,24 @@ private static McpSyncServer serving( } } - private static McpSyncServer build(Connection connection, boolean watching, McpServerTransportProvider transport) { + private static McpSyncServer build(Connection connection, McpServerTransportProvider transport) { var specification = McpServer.sync(new SerializedTransportProvider(transport)) .serverInfo("libtmux", version()) - .instructions(Instructions.forServer(connection.ceiling(), watching)) + .instructions(Instructions.forServer(connection)) .capabilities(McpSchema.ServerCapabilities.builder() - .tools(true) - // Subscription is offered only when something is actually watching tmux. - // Advertising it otherwise invites a client to subscribe and wait forever for - // an update nothing will ever send. - .resources(watching, false) - .prompts(false) - .completions() + .tools(false) + .resources(false, false) .logging() .build()) - .resources(Resources.fixed(connection)) - .resourceTemplates(Resources.templated(connection)) - .prompts(Prompts.all()) - .completions(Completions.all(connection)); + .resources(Resources.fixed(connection)); - for (ToolSpec tool : Catalog.offered(connection.ceiling()).values()) { + for (ToolSpec tool : connection.surface().tools().values()) { specification = specification.toolCall( tool.describe(), (exchange, request) -> answer(connection, tool, exchange, request)); } return specification.build(); } - /** Sends watcher output only after the owned MCP server exists. */ - private record McpNotifier(McpSyncServer target) implements Watches.Notifier { - - @Override - public void updated(String uri) { - target.notifyResourcesUpdated(new McpSchema.ResourcesUpdatedNotification(uri)); - } - } - - /** Couples the watcher lifecycle to the MCP server lifecycle for embedded callers. */ - private static final class WatchedMcpServer extends McpSyncServer { - - private final Watches watches; - private final AtomicBoolean closed = new AtomicBoolean(); - - WatchedMcpServer(McpSyncServer delegate, Watches watches) { - super(delegate.getAsyncServer()); - this.watches = watches; - } - - @Override - public void closeGracefully() { - closeBoth(() -> super.closeGracefully()); - } - - @Override - public void close() { - closeBoth(() -> super.close()); - } - - private void closeBoth(Runnable closeServer) { - if (closed.compareAndSet(false, true)) { - Cleanup cleanup = new Cleanup(); - cleanup.run(watches::close); - cleanup.run(closeServer); - cleanup.throwIfFailed(); - } - } - } - /** * Runs one tool and turns whatever happens into something a model can act on. * @@ -229,9 +151,13 @@ private void closeBoth(Runnable closeServer) { private static McpSchema.CallToolResult answer( Connection connection, ToolSpec tool, McpSyncServerExchange exchange, McpSchema.CallToolRequest request) { try { + connection.surface().require(tool.name()); Map arguments = request.arguments() == null ? Map.of() : request.arguments(); + tool.validateArguments(arguments); Call call = connection.call(arguments, progress(exchange, request)); - return Answers.ok(tool.answer().apply(call)); + Object value = tool.answer().apply(call); + tool.validateOutput(value); + return Answers.ok(value); } catch (LibTmuxException | IllegalArgumentException | IllegalStateException e) { return Answers.failure(String.valueOf(e.getMessage())); } diff --git a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/ToolSpec.java b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/ToolSpec.java index 5149baa..5b107e8 100644 --- a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/ToolSpec.java +++ b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/ToolSpec.java @@ -1,70 +1,476 @@ package io.github.libtmux.mcp; import io.modelcontextprotocol.spec.McpSchema; +import java.util.Collections; +import java.util.EnumSet; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; import java.util.function.Function; -/** - * One tool: what a model is told about it, what an operator offers, and what it does. - * - *

The operator's {@link Safety} ceiling and the protocol's {@link Effect} hint are separate: a - * tool may remain useful at a mutating ceiling while still warning a client that its update can be - * destructive. - * - * @param name the wire name, prefixed {@code tmux_} so it reads unambiguously beside other servers' - * @param title what a client shows a person - * @param description what a model reads to decide whether this is the tool it wants - * @param safety the narrowest operator ceiling that offers it - * @param effect how it may update its environment - * @param arguments what it takes - * @param answer what it does, given the arguments a model sent - */ +/** One authoritative structured-tool definition. */ record ToolSpec( String name, String title, String description, - Safety safety, - Effect effect, + Toolset toolset, + ProcessReach processReach, + Set effects, + Set outputClasses, + boolean mayExposeSecrets, + boolean mayReturnUntrustedContent, + boolean amplifiesFutureInput, + Annotations annotations, List arguments, + Map> inputSinks, + Map inputLiteralization, + Set nestedAuthority, + OutputSchema output, Function answer) { - enum Effect { - READ_ONLY, - ADDITIVE, - DESTRUCTIVE + static final String CAPABILITY_META_KEY = "com.git-pull.libtmux-mcp/capability"; + + ToolSpec { + name = requireText(name, "name"); + title = requireText(title, "title"); + description = requireText(description, "description"); + Objects.requireNonNull(toolset, "toolset"); + Objects.requireNonNull(processReach, "processReach"); + effects = immutableEnums(effects, "effects"); + outputClasses = immutableEnumSet(outputClasses, "outputClasses"); + Objects.requireNonNull(annotations, "annotations"); + arguments = List.copyOf(arguments); + inputSinks = immutableSinks(inputSinks); + inputLiteralization = Collections.unmodifiableMap(new LinkedHashMap<>(inputLiteralization)); + nestedAuthority = Collections.unmodifiableSet(new LinkedHashSet<>(nestedAuthority)); + Objects.requireNonNull(output, "output"); + Objects.requireNonNull(answer, "answer"); + } + + enum Toolset { + INSPECT("inspect"), + MANAGE("manage"), + EXECUTE("execute"), + TEARDOWN("teardown"); + + private final String wireName; + + Toolset(String wireName) { + this.wireName = wireName; + } + + String wireName() { + return wireName; + } + + static Toolset ofWireName(String name) { + for (Toolset toolset : values()) { + if (toolset.wireName.equals(name)) { + return toolset; + } + } + throw new IllegalArgumentException( + "unknown toolset '" + name + "'; expected inspect, manage, execute or teardown"); + } + } + + enum ProcessReach { + NONE("none"), + CONFIGURED_PROCESS("configured-process"), + PANE_INPUT("pane-input"), + PANE_COMMAND("pane-command"), + HOST_COMMAND("host-command"); + + private final String wireName; + + ProcessReach(String wireName) { + this.wireName = wireName; + } + + String wireName() { + return wireName; + } } - static ToolSpec of( + enum TmuxEffect { + OBSERVE("observe"), + CHANGE("change"), + DELETE("delete"); + + private final String wireName; + + TmuxEffect(String wireName) { + this.wireName = wireName; + } + + String wireName() { + return wireName; + } + } + + enum OutputClass { + TMUX_METADATA("tmux-metadata"), + TERMINAL_CONTENT("terminal-content"), + PROCESS_ENVIRONMENT("process-environment"), + CONFIGURED_COMMAND("configured-command"); + + private final String wireName; + + OutputClass(String wireName) { + this.wireName = wireName; + } + + String wireName() { + return wireName; + } + } + + enum InputSink { + NONE("none"), + TMUX_LOOKUP("tmux-lookup"), + TMUX_STATE("tmux-state"), + PANE_INPUT("pane-input"), + SHELL_COMMAND("shell-command"), + PROCESS_ARGV("process-argv"), + REGEX("regex"), + NESTED_TOOL("nested-tool"), + TMUX_FORMAT("tmux-format"); + + private final String wireName; + + InputSink(String wireName) { + this.wireName = wireName; + } + + String wireName() { + return wireName; + } + } + + record Annotations(boolean readOnlyHint, boolean destructiveHint, boolean idempotentHint, boolean openWorldHint) { + + McpSchema.ToolAnnotations describe(String title) { + return new McpSchema.ToolAnnotations( + title, readOnlyHint, destructiveHint, idempotentHint, openWorldHint, null); + } + } + + static ToolSpec define( String name, String title, - String description, - Safety safety, - Effect effect, + String details, + Toolset toolset, + ProcessReach processReach, + Set effects, + Set outputClasses, + boolean mayExposeSecrets, + boolean mayReturnUntrustedContent, + Annotations annotations, List arguments, + Map> inputSinks, + Set nestedAuthority, + OutputSchema output, Function answer) { - return new ToolSpec(name, title, description, safety, effect, List.copyOf(arguments), answer); + String description = opener(name, toolset, processReach, outputClasses) + " " + details; + return new ToolSpec( + name, + title, + description, + toolset, + processReach, + effects, + outputClasses, + mayExposeSecrets, + mayReturnUntrustedContent, + false, + annotations, + arguments, + inputSinks, + Map.of(), + nestedAuthority, + output, + answer); } - /** - * The tool as the protocol describes it. - * - *

A client uses these hints to decide what to confirm with a person. {@link Safety} cannot - * supply them: it controls availability, while {@link Effect} describes the updates a call may - * make. - */ + /** The tool as a client sees it during discovery. */ McpSchema.Tool describe() { - McpSchema.ToolAnnotations annotations = new McpSchema.ToolAnnotations( - title, - effect == Effect.READ_ONLY, - effect == Effect.DESTRUCTIVE, - effect == Effect.READ_ONLY, - // tmux is a world this server does not own: another client may change it between two - // calls, and a pane's contents come from programs nobody here started. - true, - null); - return McpSchema.Tool.builder(name, Argument.objectSchema(arguments)) + return McpSchema.Tool.builder(name, inputSchema()) .description(description) - .annotations(annotations) + .annotations(annotations.describe(title)) + .meta(Map.of(CAPABILITY_META_KEY, capability())) + .outputSchema(outputSchema()) .build(); } + + Map capability() { + Map row = new LinkedHashMap<>(); + row.put("name", name); + row.put("title", title); + row.put("description", description); + row.put("toolset", toolset.wireName()); + row.put("processReach", processReach.wireName()); + row.put("tmuxEffects", wireNames(effects)); + row.put("outputClasses", wireNames(outputClasses)); + row.put("mayExposeSecrets", mayExposeSecrets); + row.put("mayReturnUntrustedContent", mayReturnUntrustedContent); + row.put("amplifiesFutureInput", amplifiesFutureInput); + row.put( + "annotations", + Map.of( + "readOnlyHint", annotations.readOnlyHint(), + "destructiveHint", annotations.destructiveHint(), + "idempotentHint", annotations.idempotentHint(), + "openWorldHint", annotations.openWorldHint())); + row.put("inputSchema", inputSchema()); + row.put("outputSchema", outputSchema()); + row.put("inputLiteralization", inputLiteralization); + row.put("nestedAuthority", List.copyOf(nestedAuthority)); + return Collections.unmodifiableMap(row); + } + + String controlledOpener() { + return opener(name, toolset, processReach, outputClasses); + } + + Map inputSchema() { + Map schema = new LinkedHashMap<>(Argument.objectSchema(arguments)); + if (!name.equals("call_read_tools_batch")) { + return Collections.unmodifiableMap(schema); + } + @SuppressWarnings("unchecked") + Map originalProperties = (Map) schema.get("properties"); + Map properties = new LinkedHashMap<>(originalProperties); + Map catalog = new LinkedHashMap<>(); + for (ToolSpec tool : Catalog.tools()) { + catalog.put(tool.name(), tool); + } + List> alternatives = nestedAuthority.stream() + .map(nested -> { + Map operationProperties = new LinkedHashMap<>(); + operationProperties.put("tool", Map.of("type", "string", "const", nested)); + ToolSpec selected = Objects.requireNonNull(catalog.get(nested), nested); + operationProperties.put("arguments", selected.inputSchema()); + Map branch = new LinkedHashMap<>(); + branch.put("type", "object"); + branch.put("properties", operationProperties); + branch.put("required", List.of("tool")); + branch.put("additionalProperties", false); + return Collections.unmodifiableMap(branch); + }) + .toList(); + Map items = alternatives.isEmpty() ? Map.of("not", Map.of()) : Map.of("oneOf", alternatives); + properties.put( + "operations", + Map.of( + "type", + "array", + "items", + items, + "minItems", + 1, + "maxItems", + 16, + "description", + "Typed calls within the disclosed nested authority.")); + @SuppressWarnings("unchecked") + Map onError = new LinkedHashMap<>((Map) properties.get("onError")); + onError.put("enum", List.of("stop", "continue")); + properties.put("onError", Collections.unmodifiableMap(onError)); + schema.put("properties", Collections.unmodifiableMap(properties)); + return Collections.unmodifiableMap(schema); + } + + void validateArguments(Map values) { + Argument.validate(arguments, values); + } + + Map outputSchema() { + return output.wireSchema(); + } + + void validateOutput(Object value) { + output.validate(name, value); + } + + private static String opener( + String name, Toolset toolset, ProcessReach processReach, Set outputClasses) { + return switch (toolset) { + case INSPECT -> inspectOpener(outputClasses); + case MANAGE -> "Change tmux state; no client-supplied executable input."; + case EXECUTE -> + switch (processReach) { + case CONFIGURED_PROCESS -> "Start a pane's configured process; accepts no command payload."; + case PANE_INPUT -> + "Send input to a pane's program; a shell that receives it runs it with your user's permissions."; + case PANE_COMMAND -> "Run a shell command in a pane with your user's permissions."; + case NONE -> "Change tmux state; no client-supplied executable input."; + case HOST_COMMAND -> + throw new IllegalStateException( + name + " has no controlled opener for " + processReach.wireName()); + }; + case TEARDOWN -> "Delete tmux state; accepts no command payload."; + }; + } + + private static String inspectOpener(Set outputClasses) { + if (outputClasses.contains(OutputClass.TERMINAL_CONTENT)) { + return "Read pane output; accepts no client-supplied executable input. " + + "Returned content may be sensitive or untrusted."; + } + if (outputClasses.contains(OutputClass.PROCESS_ENVIRONMENT)) { + return "Read the tmux environment; accepts no client-supplied executable input. " + + "Returned values may contain secrets."; + } + if (outputClasses.contains(OutputClass.CONFIGURED_COMMAND)) { + return "Read configured tmux commands; accepts no client-supplied executable input. " + + "Returned values may contain executable configuration."; + } + return "Inspect tmux metadata; accepts no client-supplied executable input."; + } + + ToolSpec withInputLiteralization(Map literalization) { + Map> classifiedSinks = new LinkedHashMap<>(inputSinks); + literalization.forEach((input, strategy) -> { + Set current = Objects.requireNonNull(classifiedSinks.get(input), input); + Set expanded = EnumSet.copyOf(current); + expanded.add( + switch (strategy) { + case "double-hash-once" -> InputSink.TMUX_STATE; + case "validated-variable-name" -> InputSink.TMUX_LOOKUP; + default -> + throw new IllegalArgumentException("unknown input literalization '" + strategy + "'"); + }); + classifiedSinks.put(input, expanded); + }); + return new ToolSpec( + name, + title, + description, + toolset, + processReach, + effects, + outputClasses, + mayExposeSecrets, + mayReturnUntrustedContent, + amplifiesFutureInput, + annotations, + arguments, + classifiedSinks, + literalization, + nestedAuthority, + output, + answer); + } + + ToolSpec amplifyingFutureInput() { + return new ToolSpec( + name, + title, + description, + toolset, + processReach, + effects, + outputClasses, + mayExposeSecrets, + mayReturnUntrustedContent, + true, + annotations, + arguments, + inputSinks, + inputLiteralization, + nestedAuthority, + output, + answer); + } + + ToolSpec withNestedAuthority(Set effectiveNestedAuthority, Map catalog) { + Set aggregateEffects = EnumSet.of(TmuxEffect.OBSERVE); + Set aggregateOutputs = EnumSet.noneOf(OutputClass.class); + boolean aggregateSecrets = false; + boolean aggregateUntrusted = false; + for (String nested : effectiveNestedAuthority) { + ToolSpec selected = Objects.requireNonNull(catalog.get(nested), nested); + aggregateEffects.addAll(selected.effects()); + aggregateOutputs.addAll(selected.outputClasses()); + aggregateSecrets |= selected.mayExposeSecrets(); + aggregateUntrusted |= selected.mayReturnUntrustedContent(); + } + String body = description.substring(controlledOpener().length()).stripLeading(); + String aggregateDescription = opener(name, toolset, processReach, aggregateOutputs) + " " + body; + return new ToolSpec( + name, + title, + aggregateDescription, + toolset, + processReach, + aggregateEffects, + aggregateOutputs, + aggregateSecrets, + aggregateUntrusted, + amplifiesFutureInput, + annotations, + arguments, + inputSinks, + inputLiteralization, + effectiveNestedAuthority, + output, + answer); + } + + private static String requireText(String value, String field) { + Objects.requireNonNull(value, field); + if (value.isBlank()) { + throw new IllegalArgumentException(field + " is blank"); + } + return value; + } + + private static > Set immutableEnums(Set values, String field) { + Objects.requireNonNull(values, field); + if (values.isEmpty()) { + throw new IllegalArgumentException(field + " is empty"); + } + return Collections.unmodifiableSet(EnumSet.copyOf(values)); + } + + private static > Set immutableEnumSet(Set values, String field) { + Objects.requireNonNull(values, field); + if (values.isEmpty()) { + return Set.of(); + } + return Collections.unmodifiableSet(EnumSet.copyOf(values)); + } + + private static Map> immutableSinks(Map> declared) { + Objects.requireNonNull(declared, "inputSinks"); + Map> copied = new LinkedHashMap<>(); + for (Map.Entry> entry : declared.entrySet()) { + String input = requireText(entry.getKey(), "input sink key"); + Set sinks = immutableEnums(entry.getValue(), input + " sinks"); + if (copied.put(input, sinks) != null) { + throw new IllegalArgumentException("duplicate input sink '" + input + "'"); + } + } + return Collections.unmodifiableMap(copied); + } + + private static > List wireNames(Set values) { + return values.stream() + .map(value -> { + if (value instanceof TmuxEffect effect) { + return effect.wireName(); + } + if (value instanceof OutputClass output) { + return output.wireName(); + } + if (value instanceof InputSink sink) { + return sink.wireName(); + } + throw new IllegalArgumentException("unsupported capability enum " + value); + }) + .toList(); + } } diff --git a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/ToolSurface.java b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/ToolSurface.java new file mode 100644 index 0000000..8eac084 --- /dev/null +++ b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/ToolSurface.java @@ -0,0 +1,277 @@ +package io.github.libtmux.mcp; + +import io.github.libtmux.Server; +import io.github.libtmux.ServerEndpoint; +import java.util.ArrayList; +import java.util.Collections; +import java.util.EnumSet; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.jspecify.annotations.Nullable; + +/** The immutable advertised and callable tool surface resolved once at process startup. */ +final class ToolSurface { + + static final String TOOLSETS_ENV = "LIBTMUX_TOOLSETS"; + static final String TOOLS_ENV = "LIBTMUX_TOOLS"; + static final String EXCLUDE_TOOLS_ENV = "LIBTMUX_EXCLUDE_TOOLS"; + static final String LEGACY_SAFETY_ENV = "LIBTMUX_SAFETY"; + static final String LEGACY_WATCH_ENV = "LIBTMUX_WATCH"; + + private static final Set INHERITED_DEFAULTS = Collections.unmodifiableSet( + EnumSet.of(ToolSpec.Toolset.INSPECT, ToolSpec.Toolset.MANAGE, ToolSpec.Toolset.EXECUTE)); + + private final Set toolsets; + private final Set inclusions; + private final Set exclusions; + private final Map tools; + private final @Nullable SocketProfile socketProfile; + + private ToolSurface( + Set toolsets, + Set inclusions, + Set exclusions, + Map tools, + @Nullable SocketProfile socketProfile) { + EnumSet copiedToolsets = EnumSet.noneOf(ToolSpec.Toolset.class); + copiedToolsets.addAll(toolsets); + this.toolsets = Collections.unmodifiableSet(copiedToolsets); + this.inclusions = Collections.unmodifiableSet(new LinkedHashSet<>(inclusions)); + this.exclusions = Collections.unmodifiableSet(new LinkedHashSet<>(exclusions)); + this.tools = Collections.unmodifiableMap(new LinkedHashMap<>(tools)); + this.socketProfile = socketProfile; + } + + static ToolSurface defaults() { + return resolve(Map.of()); + } + + static ToolSurface resolve(Map environment) { + return resolveInternal(environment, null); + } + + static ToolSurface resolve(Map environment, SocketProfile socketProfile) { + return resolveInternal(environment, socketProfile); + } + + private static ToolSurface resolveInternal(Map environment, @Nullable SocketProfile socketProfile) { + if (environment.containsKey(LEGACY_SAFETY_ENV)) { + throw new IllegalArgumentException( + LEGACY_SAFETY_ENV + " was retired; select unordered toolsets with " + TOOLSETS_ENV); + } + if (environment.containsKey(LEGACY_WATCH_ENV)) { + throw new IllegalArgumentException( + LEGACY_WATCH_ENV + + " was retired; use wait_for_text, wait_for_channel, or capture_since; Java applications can use ControlClient"); + } + + Set selected = environment.containsKey(TOOLSETS_ENV) + ? parseToolsets(environment.get(TOOLSETS_ENV)) + : defaults(socketProfile); + Set included = parseToolNames(environment.get(TOOLS_ENV), TOOLS_ENV); + Set excluded = parseToolNames(environment.get(EXCLUDE_TOOLS_ENV), EXCLUDE_TOOLS_ENV); + + Map all = catalogByName(); + rejectUnknownTools(included, all, TOOLS_ENV); + rejectUnknownTools(excluded, all, EXCLUDE_TOOLS_ENV); + + Map effective = new LinkedHashMap<>(); + for (ToolSpec tool : Catalog.tools()) { + if (selected.contains(tool.toolset()) || included.contains(tool.name())) { + effective.put(tool.name(), tool); + } + } + excluded.forEach(effective::remove); + Map bounded = new LinkedHashMap<>(); + for (ToolSpec tool : effective.values()) { + Set nested = new LinkedHashSet<>(tool.nestedAuthority()); + nested.removeAll(excluded); + bounded.put( + tool.name(), nested.equals(tool.nestedAuthority()) ? tool : tool.withNestedAuthority(nested, all)); + } + return new ToolSurface(selected, included, excluded, bounded, socketProfile); + } + + Map tools() { + return tools; + } + + ToolSpec require(String name) { + ToolSpec tool = tools.get(name); + if (tool == null) { + throw new IllegalArgumentException("tool '" + name + "' is not enabled on this server"); + } + return tool; + } + + Set toolsets() { + return toolsets; + } + + Set inclusions() { + return inclusions; + } + + Set exclusions() { + return exclusions; + } + + List toolsetNames() { + return toolsets.stream().map(ToolSpec.Toolset::wireName).toList(); + } + + /** Static disclosure captured while the MCP server is built. */ + Map capabilities(Server server) { + Map report = new LinkedHashMap<>(); + report.put("schemaVersion", 1); + report.put("frozen", true); + report.put( + "boundary", + Map.of( + "oneSocketPerProcess", true, + "perCallSocketSelection", false, + "hostCommandExecution", false, + "dynamicResources", false)); + Map socket = socketReport(server); + report.put("socket", socket); + report.put("connection", connectionReport(server, socket)); + report.put("toolsets", toolsetNames()); + report.put("includedTools", List.copyOf(inclusions)); + report.put("excludedTools", List.copyOf(exclusions)); + report.put( + "selection", + Map.of( + "toolsets", toolsetNames(), + "includedTools", List.copyOf(inclusions), + "excludedTools", List.copyOf(exclusions))); + report.put("toolCount", tools.size()); + report.put("effectiveTools", List.copyOf(tools.keySet())); + report.put("tools", tools.values().stream().map(ToolSurface::capability).toList()); + report.put("hostCommandTools", 0); + report.put("toolFilteringBoundary", "interface-shaping-not-authorization"); + report.put("executionAuthority", "tmux-user"); + report.put("operatingSystemBoundary", "none"); + return Collections.unmodifiableMap(report); + } + + private static Map capability(ToolSpec tool) { + return tool.capability(); + } + + static Map socket(Server server) { + ServerEndpoint endpoint = server.config().endpoint(); + String selection; + String selector; + if (endpoint instanceof ServerEndpoint.Default) { + selection = "inherited"; + selector = "inherit"; + } else if (endpoint instanceof ServerEndpoint.NamedSocket named) { + selection = "operator-current"; + selector = "name:" + named.name(); + } else if (endpoint instanceof ServerEndpoint.SocketPath path) { + selection = "operator-current"; + selector = "path:" + path.path(); + } else { + throw new IllegalStateException("unrecognized server endpoint " + endpoint); + } + Map socket = new LinkedHashMap<>(); + socket.put("selector", selector); + socket.put("selectionProvenance", selection); + socket.put("serverState", server.isAlive() ? "existing" : "unknown"); + socket.put("configurationProvenance", "unknown"); + socket.put("namespaceBoundary", "tmux-objects-only"); + return Collections.unmodifiableMap(socket); + } + + Map socketReport(Server server) { + return socketProfile == null ? socket(server) : socketProfile.report(); + } + + Map connectionReport(Server server, Map socket) { + if (socketProfile != null) { + return socketProfile.connection(); + } + ServerEndpoint endpoint = server.config().endpoint(); + String path = endpoint instanceof ServerEndpoint.SocketPath selected + ? selected.path().toString() + : ""; + StringBuilder attach = new StringBuilder(shellQuote(server.config().binaryPath())).append(" -N"); + if (!path.isBlank()) { + attach.append(" -S ").append(shellQuote(path)); + } else if (endpoint instanceof ServerEndpoint.NamedSocket named) { + attach.append(" -L ").append(shellQuote(named.name())); + } + attach.append(" attach"); + Map connection = new LinkedHashMap<>(); + connection.put("socketSelector", socket.get("selector")); + connection.put("socketProvenance", socket.get("selectionProvenance")); + connection.put("resolvedSocketPath", path); + connection.put("serverState", socket.get("serverState")); + connection.put("configurationProvenance", socket.get("configurationProvenance")); + connection.put("attachCommand", attach.toString()); + return Collections.unmodifiableMap(connection); + } + + private static String shellQuote(String value) { + return "'" + value.replace("'", "'\"'\"'") + "'"; + } + + private static Set defaults(@Nullable SocketProfile socketProfile) { + if (socketProfile == null || !socketProfile.defaultTeardown()) { + return INHERITED_DEFAULTS; + } + return Collections.unmodifiableSet(EnumSet.allOf(ToolSpec.Toolset.class)); + } + + private static Set parseToolsets(String configured) { + if (configured.isEmpty()) { + return EnumSet.noneOf(ToolSpec.Toolset.class); + } + Set selected = EnumSet.noneOf(ToolSpec.Toolset.class); + for (String name : tokens(configured, TOOLSETS_ENV)) { + selected.add(ToolSpec.Toolset.ofWireName(name)); + } + return selected; + } + + private static Set parseToolNames(@Nullable String configured, String variable) { + if (configured == null) { + return Set.of(); + } + return Collections.unmodifiableSet(new LinkedHashSet<>(tokens(configured, variable))); + } + + private static List tokens(String configured, String variable) { + List names = new ArrayList<>(); + for (String token : configured.split(",", -1)) { + String name = token.trim(); + if (name.isEmpty()) { + throw new IllegalArgumentException(variable + " contains an empty name"); + } + names.add(name); + } + return names; + } + + private static Map catalogByName() { + Map all = new LinkedHashMap<>(); + for (ToolSpec tool : Catalog.tools()) { + if (all.put(tool.name(), tool) != null) { + throw new IllegalStateException("duplicate catalog tool '" + tool.name() + "'"); + } + } + return all; + } + + private static void rejectUnknownTools(Set selected, Map all, String variable) { + Set unknown = new LinkedHashSet<>(selected); + unknown.removeAll(all.keySet()); + if (!unknown.isEmpty()) { + throw new IllegalArgumentException( + variable + " contains unknown tool(s) " + unknown + "; valid tools: " + all.keySet()); + } + } +} diff --git a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Typing.java b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Typing.java index 298d932..139e367 100644 --- a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Typing.java +++ b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Typing.java @@ -1,7 +1,7 @@ package io.github.libtmux.mcp; import io.github.libtmux.Pane; -import java.util.ArrayList; +import java.util.Comparator; import java.util.List; import org.jspecify.annotations.Nullable; @@ -20,6 +20,7 @@ record Sent( String paneId, int keys, boolean literal, + List resolvedPaneIds, @Nullable String note) {} record Pasted( @@ -43,19 +44,33 @@ static Sent sendKeys(Call call) { "'keys' is empty; give the key names to send, such as [\"C-c\"] or [\"q\"]"); } boolean literal = call.flag("literal", false); - List argv = new ArrayList<>(List.of("send-keys")); - if (literal) { - argv.add("-l"); - } - argv.addAll(List.of("-t", pane.id().value())); - argv.addAll(keys); - call.server().run(argv); + return sendKeys(pane, keys, literal); + } + + static Sent sendKeys(Pane pane, List keys, boolean literal) { + List resolved = resolvedPaneIds(pane); + pane.sendKeys(keys, literal); return new Sent( pane.id().value(), keys.size(), literal, - "Sent, not waited for. Call tmux_capture_since or tmux_wait_for_text on this pane to see " - + "what it did."); + resolved, + "Sent, not waited for. Call capture_since or wait_for_text on this pane to see " + "what it did."); + } + + private static List resolvedPaneIds(Pane pane) { + boolean synchronizedPanes = pane.window() + .options() + .get("synchronize-panes") + .map(value -> value.equals("on") || value.equals("1")) + .orElse(false); + if (!synchronizedPanes) { + return List.of(pane.id().value()); + } + return pane.window().panes().stream() + .map(candidate -> candidate.id().value()) + .sorted(Comparator.naturalOrder()) + .toList(); } /** diff --git a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Uris.java b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Uris.java deleted file mode 100644 index dd24cc8..0000000 --- a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Uris.java +++ /dev/null @@ -1,120 +0,0 @@ -package io.github.libtmux.mcp; - -import java.io.ByteArrayOutputStream; -import java.nio.ByteBuffer; -import java.nio.charset.CharacterCodingException; -import java.nio.charset.CodingErrorAction; -import java.nio.charset.StandardCharsets; -import java.util.List; -import java.util.Objects; - -/** - * Reads the values out of a templated resource URI. - * - *

Only the one shape MCP resource templates use here: a prefix, one {@code {name}} path segment, - * and an optional suffix. A general RFC 6570 implementation would accept forms this server never - * produces. - */ -final class Uris { - - private static final char[] HEX = "0123456789ABCDEF".toCharArray(); - - private Uris() {} - - /** - * The values a URI supplies for a template's variables, in the order the template names them. - * - * @throws IllegalArgumentException if the URI does not fit the template - */ - static List values(String template, String uri) { - int open = template.indexOf('{'); - int close = open < 0 ? -1 : template.indexOf('}', open + 1); - if (open < 0 - || close < 0 - || close == open + 1 - || template.indexOf('{', close + 1) >= 0 - || template.indexOf('}', close + 1) >= 0) { - throw new IllegalStateException("resource template must contain one named segment: " + template); - } - - String before = template.substring(0, open); - String after = template.substring(close + 1); - if (!uri.startsWith(before) || !uri.endsWith(after)) { - throw mismatch(template, uri); - } - int end = uri.length() - after.length(); - if (end <= before.length()) { - throw mismatch(template, uri); - } - String encoded = uri.substring(before.length(), end); - if (encoded.indexOf('/') >= 0) { - throw mismatch(template, uri); - } - String value = decodeSegment(encoded, template, uri); - if (!encoded.equals(segment(value))) { - throw mismatch(template, uri); - } - return List.of(value); - } - - /** Encodes one value for the simple path-segment expansion used by the resource templates. */ - static String segment(String value) { - Objects.requireNonNull(value, "value"); - StringBuilder encoded = new StringBuilder(value.length()); - for (byte octet : value.getBytes(StandardCharsets.UTF_8)) { - int unsigned = Byte.toUnsignedInt(octet); - if (unreserved(unsigned)) { - encoded.append((char) unsigned); - } else { - encoded.append('%').append(HEX[unsigned >>> 4]).append(HEX[unsigned & 0x0f]); - } - } - return encoded.toString(); - } - - private static String decodeSegment(String encoded, String template, String uri) { - ByteArrayOutputStream decoded = new ByteArrayOutputStream(encoded.length()); - for (int index = 0; index < encoded.length(); ) { - int codePoint = encoded.codePointAt(index); - if (codePoint == '%') { - if (index + 2 >= encoded.length()) { - throw mismatch(template, uri); - } - int high = Character.digit(encoded.charAt(index + 1), 16); - int low = Character.digit(encoded.charAt(index + 2), 16); - if (high < 0 || low < 0) { - throw mismatch(template, uri); - } - decoded.write((high << 4) | low); - index += 3; - } else { - decoded.writeBytes(new String(Character.toChars(codePoint)).getBytes(StandardCharsets.UTF_8)); - index += Character.charCount(codePoint); - } - } - try { - return StandardCharsets.UTF_8 - .newDecoder() - .onMalformedInput(CodingErrorAction.REPORT) - .onUnmappableCharacter(CodingErrorAction.REPORT) - .decode(ByteBuffer.wrap(decoded.toByteArray())) - .toString(); - } catch (CharacterCodingException e) { - throw mismatch(template, uri); - } - } - - private static boolean unreserved(int octet) { - return (octet >= 'a' && octet <= 'z') - || (octet >= 'A' && octet <= 'Z') - || (octet >= '0' && octet <= '9') - || octet == '-' - || octet == '.' - || octet == '_' - || octet == '~'; - } - - private static IllegalArgumentException mismatch(String template, String uri) { - return new IllegalArgumentException("'" + uri + "' is not a " + template); - } -} diff --git a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/WaitingForText.java b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/WaitingForText.java index 3a4ff2d..1e3eff3 100644 --- a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/WaitingForText.java +++ b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/WaitingForText.java @@ -2,9 +2,8 @@ import io.github.libtmux.Pane; import java.time.Duration; +import java.util.ArrayList; import java.util.List; -import java.util.regex.Pattern; -import java.util.regex.PatternSyntaxException; import org.jspecify.annotations.Nullable; /** @@ -49,8 +48,13 @@ record Waited( static Waited waitFor(Call call) { Pane pane = Targets.pane(call.server(), call.string("pane_id")); Duration timeout = Waits.requested(call); - List wanted = matchers(call.strings("patterns"), call.flag("regex", false)); - List stops = matchers(call.strings("stop"), call.flag("regex", false)); + List wantedSources = nonempty(call.strings("patterns")); + List stopSources = nonempty(call.strings("stop")); + List allSources = new ArrayList<>(wantedSources); + allSources.addAll(stopSources); + List all = TextPatterns.compile(allSources, call.flag("regex", false)); + List wanted = all.subList(0, wantedSources.size()); + List stops = all.subList(wantedSources.size(), all.size()); int budget = Trim.lineBudget(call); Cursor cursor = call.maybe("cursor") @@ -61,7 +65,7 @@ static Waited waitFor(Call call) { long deadline = started + timeout.toNanos(); String outcome = "TIMED_OUT"; - Matcher hit = null; + TextPatterns.Matcher hit = null; String hitLine = null; while (true) { @@ -123,7 +127,8 @@ static Waited waitFor(Call call) { note(outcome, wanted, stops)); } - private static @Nullable String note(String outcome, List wanted, List stops) { + private static @Nullable String note( + String outcome, List wanted, List stops) { if ("TIMED_OUT".equals(outcome)) { return stops.isEmpty() ? "Nothing matched before the deadline. Pass 'cursor' to carry on from here without " @@ -141,11 +146,11 @@ static Waited waitFor(Call call) { return wanted.isEmpty() ? "Matched on any new output, because no patterns were given." : null; } - private record Found(Matcher matcher, String line) {} + private record Found(TextPatterns.Matcher matcher, String line) {} - private static @Nullable Found find(List matchers, List lines) { + private static @Nullable Found find(List matchers, List lines) { for (String line : lines) { - for (Matcher matcher : matchers) { + for (TextPatterns.Matcher matcher : matchers) { if (matcher.matches(line)) { return new Found(matcher, line); } @@ -164,32 +169,7 @@ private static boolean sleep() { } } - private static List matchers(List sources, boolean regex) { - return sources.stream() - .filter(source -> !source.isEmpty()) - .map(source -> Matcher.of(source, regex)) - .toList(); - } - - /** One thing to look for, and the text a caller asked for so a result can name it back. */ - private record Matcher(String source, @Nullable Pattern compiled) { - - static Matcher of(String source, boolean regex) { - if (!regex) { - return new Matcher(source, null); - } - try { - return new Matcher(source, Pattern.compile(source)); - } catch (PatternSyntaxException e) { - throw new IllegalArgumentException("'" + source + "' is not a valid regular expression: " - + e.getDescription() + ". Omit 'regex' to match it as plain text instead"); - } - } - - boolean matches(String line) { - return compiled == null - ? line.contains(source) - : compiled.matcher(line).find(); - } + private static List nonempty(List sources) { + return sources.stream().filter(source -> !source.isEmpty()).toList(); } } diff --git a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/WatchAttachment.java b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/WatchAttachment.java deleted file mode 100644 index bf480f2..0000000 --- a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/WatchAttachment.java +++ /dev/null @@ -1,195 +0,0 @@ -package io.github.libtmux.mcp; - -import io.github.libtmux.SessionId; -import io.github.libtmux.control.ControlClient; -import io.github.libtmux.control.ControlEvent; -import io.github.libtmux.control.EventSubscription; -import io.github.libtmux.control.PaneOutput; -import java.util.Optional; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicBoolean; -import org.jspecify.annotations.Nullable; - -/** Owns the control client and loss-aware subscription drains for one tmux session. */ -final class WatchAttachment implements AutoCloseable { - - private static final String PANE_STATE = String.join( - ",", "#{pane_current_command}", "#{pane_current_path}", "#{pane_width}x#{pane_height}", "#{pane_active}"); - private static final int EVENT_CAPACITY = 128; - private static final long JOIN_MILLIS = 5_000; - - private final Watches owner; - private final Connection connection; - private final SessionId session; - private final ControlClient client; - private final EventSubscription output; - private final EventSubscription events; - private final @Nullable String clientName; - private final AtomicBoolean started = new AtomicBoolean(); - private final AtomicBoolean closed = new AtomicBoolean(); - private final Thread outputConsumer; - private final Thread eventConsumer; - - private WatchAttachment( - Watches owner, - Connection connection, - SessionId session, - ControlClient client, - EventSubscription output, - EventSubscription events, - @Nullable String clientName) { - this.owner = owner; - this.connection = connection; - this.session = session; - this.client = client; - this.output = output; - this.events = events; - this.clientName = clientName; - this.outputConsumer = Thread.ofVirtual().unstarted(this::consumeOutput); - this.outputConsumer.setName("libtmux-mcp-output-" + session.value()); - this.eventConsumer = Thread.ofVirtual().unstarted(this::consumeEvents); - this.eventConsumer.setName("libtmux-mcp-events-" + session.value()); - } - - static WatchAttachment open(Watches owner, Connection connection, SessionId session) { - return connection.changeClients(() -> openWhileHidden(owner, connection, session)); - } - - private static WatchAttachment openWhileHidden(Watches owner, Connection connection, SessionId session) { - ControlClient client = ControlClient.attach( - connection.server().config(), - session, - connection.server().config().defaultTimeout()); - String name = null; - try { - name = client.send("display-message", "-p", "#{client_name}").lines().stream() - .filter(value -> !value.isBlank()) - .findFirst() - .orElse(null); - if (name != null) { - connection.hide(name); - } - EventSubscription output = client.subscribeOutput(EVENT_CAPACITY); - EventSubscription events = client.subscribeEvents(EVENT_CAPACITY); - if (!client.watch("state", "%*", PANE_STATE).succeeded()) { - throw new IllegalStateException("tmux refused the pane-state watch"); - } - return new WatchAttachment(owner, connection, session, client, output, events, name); - } catch (RuntimeException | Error failure) { - Cleanup cleanup = new Cleanup(failure); - cleanup.run(client::close); - if (name != null) { - String hidden = name; - cleanup.run(() -> connection.reveal(hidden)); - } - throw failure; - } - } - - SessionId session() { - return session; - } - - void start() { - if (started.compareAndSet(false, true)) { - outputConsumer.start(); - eventConsumer.start(); - } - } - - boolean isAlive() { - return !closed.get() && client.isAlive(); - } - - private void consumeOutput() { - long dropped = 0; - try { - while (!closed.get()) { - Optional next = output.next(); - long nowDropped = output.droppedCount(); - if (nowDropped != dropped) { - dropped = nowDropped; - owner.outputDropped(); - } - if (next.isEmpty()) { - return; - } - owner.output(next.orElseThrow().pane()); - } - } catch (InterruptedException e) { - if (!closed.get()) { - Thread.currentThread().interrupt(); - } - } finally { - owner.ended(this); - } - } - - private void consumeEvents() { - long dropped = 0; - try { - while (!closed.get()) { - Optional next = events.next(); - long nowDropped = events.droppedCount(); - if (nowDropped != dropped) { - dropped = nowDropped; - owner.stateLost(); - } - if (next.isEmpty()) { - return; - } - owner.stateChanged(); - } - } catch (InterruptedException e) { - if (!closed.get()) { - Thread.currentThread().interrupt(); - } - } finally { - owner.ended(this); - } - } - - @Override - public void close() { - if (!closed.compareAndSet(false, true)) { - return; - } - Cleanup cleanup = new Cleanup(); - cleanup.run(output::close); - cleanup.run(events::close); - cleanup.run(outputConsumer::interrupt); - cleanup.run(eventConsumer::interrupt); - cleanup.run(() -> connection.changeClients(() -> { - Cleanup clientCleanup = new Cleanup(); - clientCleanup.run(client::close); - if (clientName != null) { - clientCleanup.run(() -> connection.reveal(clientName)); - } - clientCleanup.throwIfFailed(); - })); - if (started.get()) { - cleanup.run(() -> join(outputConsumer)); - cleanup.run(() -> join(eventConsumer)); - } - cleanup.throwIfFailed(); - } - - private static void join(Thread thread) { - boolean interrupted = Thread.interrupted(); - long deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(JOIN_MILLIS); - while (thread.isAlive()) { - long remaining = deadline - System.nanoTime(); - if (remaining <= 0) { - break; - } - try { - thread.join(Math.max(1, TimeUnit.NANOSECONDS.toMillis(remaining))); - } catch (InterruptedException e) { - interrupted = true; - } - } - if (interrupted) { - Thread.currentThread().interrupt(); - } - } -} diff --git a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Watches.java b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Watches.java deleted file mode 100644 index 2b79220..0000000 --- a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Watches.java +++ /dev/null @@ -1,350 +0,0 @@ -package io.github.libtmux.mcp; - -import io.github.libtmux.LibTmuxException; -import io.github.libtmux.PaneId; -import io.github.libtmux.SessionId; -import io.github.libtmux.snapshot.ServerSnapshot; -import java.time.Duration; -import java.util.ArrayList; -import java.util.LinkedHashSet; -import java.util.Map; -import java.util.Set; -import java.util.concurrent.ArrayBlockingQueue; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicBoolean; -import java.util.concurrent.atomic.AtomicReference; -import org.jspecify.annotations.Nullable; - -/** Keeps MCP resource subscriptions aligned with a changing tmux server. */ -final class Watches implements AutoCloseable { - private static final int SIGNAL_CAPACITY = 64; - private static final int NOTIFICATION_CAPACITY = 256; - private static final Duration RETRY_DELAY = Duration.ofMillis(250); - private static final Duration MAX_RETRY_DELAY = Duration.ofSeconds(8); - private static final long JOIN_MILLIS = 5_000; - - /** What tmux pushes and what the protocol sends can be tested independently. */ - interface Notifier { - - /** Says a resource is no longer what a client last read. */ - void updated(String uri); - } - - private enum Signal { - STATE, - OUTPUT_GAP, - GENERATION_GAP, - NOTIFY, - RETRY, - STOP - } - - private final Connection connection; - private final Map attachments = new ConcurrentHashMap<>(); - private final ArrayBlockingQueue signals = new ArrayBlockingQueue<>(SIGNAL_CAPACITY); - private final NotificationBuffer notifications = new NotificationBuffer(NOTIFICATION_CAPACITY); - private final AtomicBoolean generationGap = new AtomicBoolean(); - private final AtomicBoolean outputGap = new AtomicBoolean(); - private final AtomicBoolean outageAnnounced = new AtomicBoolean(); - private final AtomicBoolean started = new AtomicBoolean(); - private final AtomicBoolean closed = new AtomicBoolean(); - private final AtomicReference<@Nullable Notifier> notifier = new AtomicReference<>(); - private final Thread supervisor; - private volatile ResourceInvalidations.Projection projection; - - private Watches(Connection connection, ResourceInvalidations.Projection projection) { - this.connection = connection; - this.projection = projection; - this.supervisor = Thread.ofVirtual().unstarted(this::supervise); - this.supervisor.setName("libtmux-mcp-watch-supervisor"); - } - - /** Attaches and buffers before the MCP server is built, without notifying an unbound target. */ - static Watches prepare(Connection connection) { - ServerSnapshot snapshot = connection.server().snapshot(); - if (snapshot.sessions().isEmpty()) { - throw new IllegalStateException("watching requires a tmux session to attach to"); - } - Watches watches = new Watches(connection, ResourceInvalidations.project(snapshot, connection::isOurs)); - try { - if (!watches.reconcile(snapshot)) { - throw new IllegalStateException("could not attach every tmux session"); - } - ServerSnapshot current = connection.server().snapshot(); - if (!watches.reconcile(current)) { - throw new IllegalStateException("could not attach every current tmux session"); - } - watches.projection = ResourceInvalidations.project(current, connection::isOurs); - return watches; - } catch (RuntimeException | Error e) { - Cleanup cleanup = new Cleanup(e); - cleanup.run(watches::close); - throw new IllegalStateException("could not start the requested tmux watcher", e); - } - } - - /** Starts watching after a notifier has a live MCP server to target. */ - void start(Notifier target) { - if (closed.get()) { - throw new IllegalStateException("tmux watcher is closed"); - } - if (!notifier.compareAndSet(null, target) || !started.compareAndSet(false, true)) { - throw new IllegalStateException("tmux watcher is already started"); - } - attachments.values().forEach(WatchAttachment::start); - supervisor.start(); - signal(Signal.STATE); - } - - /** Convenience entry point for callers that already have a notifier. */ - static Watches start(Connection connection, Notifier notifier) { - Watches watches = prepare(connection); - try { - watches.start(notifier); - return watches; - } catch (RuntimeException | Error e) { - Cleanup cleanup = new Cleanup(e); - cleanup.run(watches::close); - throw e; - } - } - - private void supervise() { - boolean retry = false; - RetryBackoff backoff = new RetryBackoff(); - try { - while (!closed.get()) { - Signal first = takeSignal(retry, backoff.delay()); - if (first == Signal.STOP) { - return; - } - boolean timedRetry = first == Signal.RETRY; - if (!timedRetry) { - backoff.reset(); - } - boolean state = first == Signal.STATE || first == Signal.GENERATION_GAP; - boolean lostOutput = first == Signal.OUTPUT_GAP; - boolean lostState = first == Signal.GENERATION_GAP; - Signal next; - while ((next = signals.poll()) != null) { - if (next == Signal.STOP) { - return; - } - state |= next == Signal.STATE || next == Signal.GENERATION_GAP; - lostOutput |= next == Signal.OUTPUT_GAP; - lostState |= next == Signal.GENERATION_GAP; - } - lostOutput |= outputGap.getAndSet(false); - lostState |= generationGap.getAndSet(false); - if (lostOutput) { - announce(ResourceInvalidations.droppedOutput(projection)); - } - announce(notifications.drain()); - if (state || lostState || retry) { - retry = !refresh(lostState); - if (!retry) { - backoff.reset(); - } else if (timedRetry) { - backoff.failedRetry(); - } - } - } - } catch (InterruptedException e) { - if (!closed.get()) { - Thread.currentThread().interrupt(); - } - } - } - - private Signal takeSignal(boolean retry, Duration delay) throws InterruptedException { - if (!retry) { - return signals.take(); - } - Signal signal = signals.poll(delay.toNanos(), TimeUnit.NANOSECONDS); - return signal == null ? Signal.RETRY : signal; - } - - private boolean refresh(boolean lostState) { - ServerSnapshot snapshot; - ResourceInvalidations.Projection fresh; - try { - snapshot = connection.server().snapshot(); - fresh = ResourceInvalidations.project(snapshot, connection::isOurs); - } catch (RuntimeException e) { - if (outageAnnounced.compareAndSet(false, true)) { - announce(ResourceInvalidations.allKnown(projection)); - } - generationGap.set(true); - return false; - } - - outageAnnounced.set(false); - boolean reconciled = reconcile(snapshot); - announce( - lostState - ? ResourceInvalidations.allKnown(projection, fresh) - : ResourceInvalidations.between(projection, fresh)); - projection = fresh; - return reconciled; - } - - private boolean reconcile(ServerSnapshot snapshot) { - Set wanted = new LinkedHashSet<>(); - snapshot.sessions().forEach(session -> wanted.add(session.id())); - for (Map.Entry entry : new ArrayList<>(attachments.entrySet())) { - if (!wanted.contains(entry.getKey()) || !entry.getValue().isAlive()) { - if (attachments.remove(entry.getKey(), entry.getValue())) { - entry.getValue().close(); - } - } - } - for (SessionId session : wanted) { - if (closed.get()) { - return false; - } - if (!attachments.containsKey(session)) { - try { - WatchAttachment added = attach(session); - if (started.get()) { - added.start(); - } - } catch (LibTmuxException | IllegalStateException e) { - generationGap.set(true); - } - } - } - return !attachments.isEmpty() - && attachments.keySet().containsAll(wanted) - && attachments.values().stream().allMatch(WatchAttachment::isAlive); - } - - private WatchAttachment attach(SessionId session) { - WatchAttachment added = WatchAttachment.open(this, connection, session); - WatchAttachment existing = attachments.putIfAbsent(session, added); - if (existing != null) { - added.close(); - return existing; - } - if (closed.get()) { - attachments.remove(session, added); - added.close(); - throw new IllegalStateException("tmux watcher is closed"); - } - return added; - } - - void output(PaneId pane) { - String uri = Resources.paneContentUri(pane); - if (notifications.offer(uri)) { - signal(Signal.NOTIFY); - } else { - signal(Signal.OUTPUT_GAP); - } - } - - void outputDropped() { - signal(Signal.OUTPUT_GAP); - } - - void stateChanged() { - signal(Signal.STATE); - } - - void stateLost() { - signal(Signal.GENERATION_GAP); - } - - void ended(WatchAttachment attachment) { - if (!closed.get() && attachments.get(attachment.session()) == attachment) { - signal(Signal.GENERATION_GAP); - } - } - - private void signal(Signal signal) { - if (closed.get() && signal != Signal.STOP) { - return; - } - if (!signals.offer(signal)) { - if (signal == Signal.OUTPUT_GAP) { - outputGap.set(true); - } else { - generationGap.set(true); - } - } - } - - private void announce(Set uris) { - Notifier target = notifier.get(); - if (target == null) { - return; - } - for (String uri : uris) { - try { - target.updated(uri); - } catch (RuntimeException e) { - // A client that stopped listening does not end later notifications. - } - } - } - - /** Whether every currently attached control client is still up. */ - boolean isAlive() { - return !closed.get() - && !attachments.isEmpty() - && attachments.values().stream().allMatch(WatchAttachment::isAlive); - } - - @Override - public void close() { - if (!closed.compareAndSet(false, true)) { - return; - } - Cleanup cleanup = new Cleanup(); - cleanup.run(() -> signals.offer(Signal.STOP)); - cleanup.run(supervisor::interrupt); - attachments.values().forEach(cleanup::close); - attachments.clear(); - if (supervisor.getState() != Thread.State.NEW && !Thread.currentThread().equals(supervisor)) { - cleanup.run(() -> join(supervisor)); - } - cleanup.throwIfFailed(); - } - - private static void join(Thread thread) { - boolean interrupted = Thread.interrupted(); - long deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(JOIN_MILLIS); - while (thread.isAlive()) { - long remaining = deadline - System.nanoTime(); - if (remaining <= 0) { - break; - } - try { - thread.join(Math.max(1, TimeUnit.NANOSECONDS.toMillis(remaining))); - } catch (InterruptedException e) { - interrupted = true; - } - } - if (interrupted) { - Thread.currentThread().interrupt(); - } - } - - static final class RetryBackoff { - - private Duration delay = RETRY_DELAY; - - Duration delay() { - return delay; - } - - void failedRetry() { - long doubled = Math.min(MAX_RETRY_DELAY.toNanos(), delay.toNanos() * 2); - delay = Duration.ofNanos(doubled); - } - - void reset() { - delay = RETRY_DELAY; - } - } -} diff --git a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Workspaces.java b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Workspaces.java deleted file mode 100644 index 631f2b9..0000000 --- a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Workspaces.java +++ /dev/null @@ -1,71 +0,0 @@ -package io.github.libtmux.mcp; - -import io.github.libtmux.Pane; -import io.github.libtmux.Session; -import io.github.libtmux.Window; -import io.github.libtmux.workspace.Workspace; -import io.github.libtmux.workspace.WorkspaceBuilder; -import java.util.List; - -/** - * Builds a whole session from one description. - * - *

A three-window, six-pane workspace built call by call is a dozen tool calls, each of which can - * half-succeed and leave the model reasoning about what it has. Built from a document it is one - * call, and the description is checked while it is still text — a layout tmux would refuse is - * refused before any session exists to leave half-made. - * - *

The document is the shape tmuxp uses, so a file somebody already has is one a model can send. - */ -final class Workspaces { - - private Workspaces() {} - - record BuiltPane(String id, String window) {} - - record Built(String session, String sessionId, int windows, int panes, List paneIds, String note) {} - - static Built apply(Call call) { - String document = call.string("workspace"); - Workspace workspace = WorkspaceBuilder.parse(document); - if (call.server().hasSession(workspace.sessionName())) { - throw new IllegalArgumentException("a session named '" + workspace.sessionName() - + "' is already there; rename it in the document, or kill the one that exists first"); - } - Session session = WorkspaceBuilder.build(call.server(), workspace); - List windows = session.windows(); - List panes = windows.stream() - .flatMap(window -> window.panes().stream() - .map(pane -> new BuiltPane(pane.id().value(), window.name()))) - .toList(); - return new Built( - session.name(), - session.id().value(), - windows.size(), - panes.size(), - panes, - "Built detached, so nothing a person is looking at changed. Every pane's commands were sent, " - + "not waited for; call tmux_wait_for_text on one to see whether it came up."); - } - - /** What a caller is shown when it asks how to write one. */ - static String example() { - return """ - session_name: api-work - windows: - - window_name: editor - panes: - - nvim - - window_name: services - layout: even-horizontal - panes: - - npm run dev - - docker compose logs -f - """; - } - - /** Panes in the order the document described them, which is the order their ids come back in. */ - static List paneIds(List panes) { - return panes.stream().map(pane -> pane.id().value()).toList(); - } -} diff --git a/libtmux-mcp/src/main/resources/io/github/libtmux/mcp/minimal.conf b/libtmux-mcp/src/main/resources/io/github/libtmux/mcp/minimal.conf new file mode 100644 index 0000000..7db478e --- /dev/null +++ b/libtmux-mcp/src/main/resources/io/github/libtmux/mcp/minimal.conf @@ -0,0 +1,4 @@ +# libtmux-mcp dedicated daemon configuration +set -g exit-empty off +set -g status off +set -g @libtmux_mcp_owner "__LIBTMUX_MCP_OWNER_NONCE__" diff --git a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/CapabilityRegistryTest.java b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/CapabilityRegistryTest.java new file mode 100644 index 0000000..d05085f --- /dev/null +++ b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/CapabilityRegistryTest.java @@ -0,0 +1,996 @@ +package io.github.libtmux.mcp; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import io.github.libtmux.Server; +import io.github.libtmux.ServerConfig; +import io.github.libtmux.transport.CommandRequest; +import io.github.libtmux.transport.CommandResult; +import io.github.libtmux.transport.TmuxTransport; +import io.modelcontextprotocol.spec.McpSchema; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; +import org.jspecify.annotations.Nullable; +import org.junit.jupiter.api.Test; + +/** The capability registry's client-visible claims, selection, and disclosure contract. */ +final class CapabilityRegistryTest { + + private static final List CATALOG_ORDER = List.of( + "list_sessions", + "list_windows", + "list_panes", + "get_server_info", + "get_session_info", + "get_window_info", + "get_pane_info", + "capture_pane", + "capture_since", + "snapshot_pane", + "search_panes", + "find_pane_by_position", + "wait_for_text", + "get_tmux_variables", + "show_option", + "show_environment", + "show_hooks", + "call_read_tools_batch", + "rename_session", + "rename_window", + "select_window", + "select_pane", + "select_layout", + "resize_window", + "resize_pane", + "move_window", + "swap_pane", + "set_pane_title", + "enter_copy_mode", + "exit_copy_mode", + "wait_for_channel", + "signal_channel", + "set_mouse_enabled", + "set_history_limit", + "create_session", + "create_window", + "split_window", + "respawn_pane", + "run_shell_command", + "send_keys", + "send_keys_batch", + "paste_text", + "set_synchronize_panes", + "clear_pane_scrollback", + "kill_pane", + "kill_window", + "kill_session"); + + private static final Map> EXPECTED_TOOLSETS = Map.of( + "inspect", + Set.of( + "list_sessions", + "list_windows", + "list_panes", + "get_server_info", + "get_session_info", + "get_window_info", + "get_pane_info", + "capture_pane", + "capture_since", + "snapshot_pane", + "search_panes", + "find_pane_by_position", + "wait_for_text", + "get_tmux_variables", + "show_option", + "show_environment", + "show_hooks", + "call_read_tools_batch"), + "manage", + Set.of( + "rename_session", + "rename_window", + "select_window", + "select_pane", + "select_layout", + "resize_window", + "resize_pane", + "move_window", + "swap_pane", + "set_pane_title", + "enter_copy_mode", + "exit_copy_mode", + "wait_for_channel", + "signal_channel", + "set_mouse_enabled", + "set_history_limit"), + "execute", + Set.of( + "create_session", + "create_window", + "split_window", + "respawn_pane", + "run_shell_command", + "send_keys", + "send_keys_batch", + "paste_text", + "set_synchronize_panes"), + "teardown", + Set.of("clear_pane_scrollback", "kill_pane", "kill_window", "kill_session")); + + @Test + void everyManifestRowDrivesConservativeRegistrationMetadataAndSinkValidation() { + assertEquals(CATALOG_ORDER, Catalog.tools().stream().map(ToolSpec::name).toList()); + Catalog.validate(Catalog.tools()); + ToolSurface all = ToolSurface.resolve(Map.of(ToolSurface.TOOLSETS_ENV, "inspect,manage,execute,teardown")); + + for (ToolSpec tool : Catalog.tools()) { + McpSchema.Tool described = tool.describe(); + assertTrue( + Objects.requireNonNull(described.description(), "description") + .startsWith(tool.controlledOpener()), + tool.name()); + McpSchema.ToolAnnotations annotations = Objects.requireNonNull(described.annotations(), "annotations"); + assertEquals(false, annotations.readOnlyHint(), tool.name()); + assertEquals(true, annotations.destructiveHint(), tool.name()); + assertEquals(false, annotations.idempotentHint(), tool.name()); + assertEquals(true, annotations.openWorldHint(), tool.name()); + assertEquals( + tool.arguments().stream().map(Argument::name).collect(java.util.stream.Collectors.toSet()), + tool.inputSinks().keySet(), + tool.name()); + assertTrue(tool.effects().size() >= 1, tool.name()); + assertTrue(tool.outputClasses().size() >= 1, tool.name()); + assertFalse(tool.outputSchema().isEmpty(), tool.name()); + assertFalse(tool.processReach() == ToolSpec.ProcessReach.HOST_COMMAND, tool.name()); + assertEquals(tool.name().equals("set_synchronize_panes"), tool.amplifiesFutureInput(), tool.name()); + assertEquals( + Set.of("com.git-pull.libtmux-mcp/capability"), + described.meta().keySet(), + tool.name()); + @SuppressWarnings("unchecked") + Map metadata = (Map) Objects.requireNonNull( + described.meta().get("com.git-pull.libtmux-mcp/capability"), "capability metadata"); + assertEquals( + Set.of( + "name", + "title", + "description", + "toolset", + "processReach", + "tmuxEffects", + "outputClasses", + "mayExposeSecrets", + "mayReturnUntrustedContent", + "amplifiesFutureInput", + "annotations", + "inputSchema", + "outputSchema", + "inputLiteralization", + "nestedAuthority"), + metadata.keySet(), + tool.name()); + assertEquals(wireNames(tool.effects()), metadata.get("tmuxEffects"), tool.name()); + assertEquals(tool.outputSchema(), described.outputSchema(), tool.name()); + assertEquals(tool.outputSchema(), metadata.get("outputSchema"), tool.name()); + assertEquals(tool.amplifiesFutureInput(), metadata.get("amplifiesFutureInput"), tool.name()); + Set formatInputs = tool.inputSinks().entrySet().stream() + .filter(entry -> entry.getValue().contains(ToolSpec.InputSink.TMUX_FORMAT)) + .map(Map.Entry::getKey) + .collect(java.util.stream.Collectors.toSet()); + assertEquals(formatInputs, tool.inputLiteralization().keySet(), tool.name()); + assertTrue( + tool.inputLiteralization().values().stream() + .allMatch(Set.of("double-hash-once", "validated-variable-name")::contains), + tool.name()); + tool.inputLiteralization() + .forEach((input, strategy) -> assertTrue( + Objects.requireNonNull(tool.inputSinks().get(input), input) + .contains( + strategy.equals("double-hash-once") + ? ToolSpec.InputSink.TMUX_STATE + : ToolSpec.InputSink.TMUX_LOOKUP), + tool.name() + "." + input)); + assertSame(tool, all.require(tool.name())); + } + + assertEquals(47, Catalog.tools().size()); + assertEquals( + Map.of("inspect", 18L, "manage", 16L, "execute", 9L, "teardown", 4L), + Catalog.tools().stream() + .collect(java.util.stream.Collectors.groupingBy( + tool -> tool.toolset().wireName(), java.util.stream.Collectors.counting()))); + + for (String creator : List.of("create_session", "create_window", "split_window", "respawn_pane")) { + ToolSpec tool = byName(creator); + assertEquals(ToolSpec.ProcessReach.CONFIGURED_PROCESS, tool.processReach(), creator); + assertTrue( + tool.arguments().stream() + .noneMatch(argument -> argument.name().equals("command")), + creator); + assertTrue( + tool.arguments().stream() + .noneMatch(argument -> argument.name().equals("environment")), + creator); + } + assertEquals( + Set.of("session_name", "window_name", "start_directory", "width", "height"), + schemaKeys("create_session")); + assertEquals( + Set.of("session_id", "window_name", "start_directory", "attach", "direction"), + schemaKeys("create_window")); + assertEquals(Set.of("pane_id", "direction", "percent", "start_directory"), schemaKeys("split_window")); + assertEquals(Set.of("pane_id", "start_directory"), schemaKeys("respawn_pane")); + assertEquals( + Set.of("new_name"), + byName("rename_session").inputLiteralization().keySet()); + assertEquals( + Set.of("session_name", "window_name", "start_directory"), + byName("create_session").inputLiteralization().keySet()); + assertEquals( + Map.of("names", "validated-variable-name"), + byName("get_tmux_variables").inputLiteralization()); + assertEquals( + Set.of(ToolSpec.InputSink.TMUX_LOOKUP, ToolSpec.InputSink.TMUX_FORMAT), + byName("get_tmux_variables").inputSinks().get("names")); + assertEquals( + Set.of(ToolSpec.InputSink.TMUX_LOOKUP), + byName("get_tmux_variables").inputSinks().get("pane")); + assertEquals(Set.of("names", "pane"), schemaKeys("get_tmux_variables")); + @SuppressWarnings("unchecked") + Map variableProperties = (Map) Objects.requireNonNull( + byName("get_tmux_variables").inputSchema().get("properties")); + @SuppressWarnings("unchecked") + Map namesSchema = (Map) Objects.requireNonNull(variableProperties.get("names")); + assertEquals(32, namesSchema.get("maxItems")); + assertEquals(Set.of("operations", "onError"), schemaKeys("send_keys_batch")); + ToolSpec batch = byName("call_read_tools_batch"); + assertEquals(16, batch.nestedAuthority().size()); + assertTrue(batch.nestedAuthority().contains("show_environment")); + assertFalse(batch.nestedAuthority().contains("wait_for_text")); + assertTrue(batch.outputClasses().contains(ToolSpec.OutputClass.PROCESS_ENVIRONMENT)); + assertTrue(batch.controlledOpener().startsWith("Read pane output")); + assertEquals(Set.of(ToolSpec.TmuxEffect.OBSERVE, ToolSpec.TmuxEffect.CHANGE), batch.effects()); + assertEquals(Set.of(ToolSpec.InputSink.NESTED_TOOL), batch.inputSinks().get("operations")); + assertTrue(batch.description().contains("no separate approval")); + assertTrue(batch.description().contains("1 MiB")); + assertTrue(byName("set_synchronize_panes").description().contains("subsequent input is copied to every pane")); + for (String removed : List.of( + "tmux_whoami", + "tmux_list_servers", + "tmux_apply_workspace", + "tmux_set_option", + "tmux_drain_channel", + "tmux_kill")) { + assertFalse(CATALOG_ORDER.contains(removed), removed); + } + assertThrows(UnsupportedOperationException.class, () -> Catalog.tools().clear()); + assertThrows( + IllegalArgumentException.class, + () -> Catalog.validate(List.of(byName("get_server_info"), byName("get_server_info")))); + } + + @Test + void exactEffectRowsAndExclusionPrunedBatchUnionsStayAligned() { + Map> expected = Map.ofEntries( + Map.entry("capture_since", Set.of(ToolSpec.TmuxEffect.OBSERVE, ToolSpec.TmuxEffect.CHANGE)), + Map.entry("create_session", Set.of(ToolSpec.TmuxEffect.OBSERVE, ToolSpec.TmuxEffect.CHANGE)), + Map.entry("enter_copy_mode", Set.of(ToolSpec.TmuxEffect.OBSERVE, ToolSpec.TmuxEffect.CHANGE)), + Map.entry("kill_pane", Set.of(ToolSpec.TmuxEffect.OBSERVE, ToolSpec.TmuxEffect.DELETE)), + Map.entry( + "respawn_pane", + Set.of(ToolSpec.TmuxEffect.OBSERVE, ToolSpec.TmuxEffect.CHANGE, ToolSpec.TmuxEffect.DELETE)), + Map.entry("run_shell_command", Set.of(ToolSpec.TmuxEffect.OBSERVE, ToolSpec.TmuxEffect.CHANGE)), + Map.entry("set_history_limit", Set.of(ToolSpec.TmuxEffect.CHANGE)), + Map.entry("set_mouse_enabled", Set.of(ToolSpec.TmuxEffect.CHANGE)), + Map.entry("set_synchronize_panes", Set.of(ToolSpec.TmuxEffect.CHANGE)), + Map.entry("wait_for_channel", Set.of(ToolSpec.TmuxEffect.CHANGE))); + expected.forEach((name, effects) -> assertEquals(effects, byName(name).effects(), name)); + assertEquals( + Set.of(ToolSpec.OutputClass.CONFIGURED_COMMAND), + byName("show_hooks").outputClasses()); + + ToolSurface pruned = ToolSurface.resolve(Map.of( + ToolSurface.TOOLSETS_ENV, + "", + ToolSurface.TOOLS_ENV, + "call_read_tools_batch", + ToolSurface.EXCLUDE_TOOLS_ENV, + "capture_since,show_environment,show_hooks,show_option,get_tmux_variables")); + ToolSpec batch = pruned.require("call_read_tools_batch"); + assertEquals(Set.of(ToolSpec.TmuxEffect.OBSERVE), batch.effects()); + assertEquals( + Set.of(ToolSpec.OutputClass.TMUX_METADATA, ToolSpec.OutputClass.TERMINAL_CONTENT), + batch.outputClasses()); + assertTrue(batch.controlledOpener().startsWith("Read pane output")); + + ToolSurface empty = ToolSurface.resolve(Map.of( + ToolSurface.TOOLSETS_ENV, + "", + ToolSurface.TOOLS_ENV, + "call_read_tools_batch", + ToolSurface.EXCLUDE_TOOLS_ENV, + String.join(",", byName("call_read_tools_batch").nestedAuthority()))); + ToolSpec emptyBatch = empty.require("call_read_tools_batch"); + assertEquals(Set.of(ToolSpec.TmuxEffect.OBSERVE), emptyBatch.effects()); + assertEquals(Set.of(), emptyBatch.outputClasses()); + assertFalse(emptyBatch.mayExposeSecrets()); + assertFalse(emptyBatch.mayReturnUntrustedContent()); + } + + @Test + void allSixteenUnorderedToolsetSubsetsResolveExactlyAndDeterministically() { + List names = List.of("inspect", "manage", "execute", "teardown"); + for (int mask = 0; mask < 16; mask++) { + List selected = new ArrayList<>(); + Set expectedNames = new LinkedHashSet<>(); + for (int bit = 0; bit < names.size(); bit++) { + if ((mask & (1 << bit)) != 0) { + String name = names.get(bit); + selected.add(name); + expectedNames.addAll(EXPECTED_TOOLSETS.get(name)); + } + } + List expected = + CATALOG_ORDER.stream().filter(expectedNames::contains).toList(); + ToolSurface surface = ToolSurface.resolve(Map.of(ToolSurface.TOOLSETS_ENV, String.join(",", selected))); + + assertEquals(expected, List.copyOf(surface.tools().keySet()), "mask=" + mask); + assertEquals( + expected, + surface.tools().keySet().stream() + .map(surface::require) + .map(ToolSpec::name) + .toList()); + } + + assertEquals( + ToolSurface.resolve(Map.of(ToolSurface.TOOLSETS_ENV, "inspect,manage")) + .tools() + .keySet(), + ToolSurface.resolve(Map.of(ToolSurface.TOOLSETS_ENV, "manage,inspect")) + .tools() + .keySet()); + assertFalse(ToolSurface.defaults().tools().containsKey("kill_session")); + } + + @Test + void namedSelectionIsFailClosedAndExclusionAlwaysWins() { + ToolSurface selected = ToolSurface.resolve(Map.of( + ToolSurface.TOOLSETS_ENV, + "", + ToolSurface.TOOLS_ENV, + "run_shell_command,kill_session", + ToolSurface.EXCLUDE_TOOLS_ENV, + "run_shell_command")); + assertEquals(List.of("kill_session"), List.copyOf(selected.tools().keySet())); + assertThrows(IllegalArgumentException.class, () -> selected.require("run_shell_command")); + + for (String malformed : List.of(",inspect", "inspect,", "inspect,,manage", " ")) { + assertThrows( + IllegalArgumentException.class, + () -> ToolSurface.resolve(Map.of(ToolSurface.TOOLSETS_ENV, malformed)), + malformed); + } + for (String variable : List.of(ToolSurface.TOOLS_ENV, ToolSurface.EXCLUDE_TOOLS_ENV)) { + assertThrows(IllegalArgumentException.class, () -> ToolSurface.resolve(Map.of(variable, "")), variable); + assertThrows( + IllegalArgumentException.class, + () -> ToolSurface.resolve(Map.of(variable, "run_shell_command,")), + variable); + assertThrows( + IllegalArgumentException.class, + () -> ToolSurface.resolve(Map.of(variable, "not_a_real_tool")), + variable); + } + assertThrows( + IllegalArgumentException.class, + () -> ToolSurface.resolve(Map.of(ToolSurface.TOOLSETS_ENV, "inspect,admin"))); + IllegalArgumentException legacy = assertThrows( + IllegalArgumentException.class, () -> ToolSurface.resolve(Map.of(ToolSurface.LEGACY_SAFETY_ENV, ""))); + assertTrue(String.valueOf(legacy.getMessage()).contains(ToolSurface.TOOLSETS_ENV)); + assertThrows(UnsupportedOperationException.class, () -> selected.tools().clear()); + + ToolSurface pruned = ToolSurface.resolve( + Map.of(ToolSurface.TOOLSETS_ENV, "inspect", ToolSurface.EXCLUDE_TOOLS_ENV, "capture_pane")); + assertTrue(pruned.tools().containsKey("call_read_tools_batch")); + assertFalse(pruned.require("call_read_tools_batch").nestedAuthority().contains("capture_pane")); + assertFalse( + pruned.require("call_read_tools_batch").inputSchema().toString().contains("capture_pane")); + assertTrue( + pruned.require("call_read_tools_batch").inputSchema().toString().contains("show_environment")); + + ToolSurface aggregateOnly = ToolSurface.resolve( + Map.of(ToolSurface.TOOLSETS_ENV, "", ToolSurface.TOOLS_ENV, "call_read_tools_batch")); + assertEquals( + 16, + aggregateOnly.require("call_read_tools_batch").nestedAuthority().size()); + assertEquals(16, batchMaxItems(aggregateOnly.require("call_read_tools_batch"))); + ToolSurface emptyAggregate = ToolSurface.resolve(Map.of( + ToolSurface.TOOLSETS_ENV, + "", + ToolSurface.TOOLS_ENV, + "call_read_tools_batch", + ToolSurface.EXCLUDE_TOOLS_ENV, + String.join(",", aggregateOnly.require("call_read_tools_batch").nestedAuthority()))); + assertEquals( + 0, + emptyAggregate + .require("call_read_tools_batch") + .nestedAuthority() + .size()); + assertEquals(16, batchMaxItems(emptyAggregate.require("call_read_tools_batch"))); + assertFalse(emptyAggregate + .require("call_read_tools_batch") + .inputSchema() + .toString() + .contains("oneOf=[]")); + } + + @Test + void theRetiredWatchVariableFailsWithCurrentAlternatives() { + IllegalArgumentException refused = + assertThrows(IllegalArgumentException.class, () -> ToolSurface.resolve(Map.of("LIBTMUX_WATCH", ""))); + + String message = String.valueOf(refused.getMessage()); + for (String replacement : List.of("wait_for_text", "wait_for_channel", "capture_since", "ControlClient")) { + assertTrue(message.contains(replacement), message); + } + } + + @Test + void invalidCatalogClaimsFailClosed() { + ToolSpec whoami = byName("get_server_info"); + Argument unexpected = Argument.required("unexpected", "A test-only input."); + assertThrows( + IllegalArgumentException.class, + () -> Catalog.validate(List.of(replace( + whoami, + whoami.processReach(), + List.of(unexpected), + Map.of(), + Set.of(), + whoami.description())))); + assertThrows( + IllegalArgumentException.class, + () -> Catalog.validate(List.of(replace( + whoami, + whoami.processReach(), + List.of(), + Map.of("unexpected", Set.of(ToolSpec.InputSink.NONE)), + Set.of(), + whoami.description())))); + assertThrows( + IllegalArgumentException.class, + () -> Catalog.validate(List.of(replace( + whoami, + ToolSpec.ProcessReach.HOST_COMMAND, + whoami.arguments(), + whoami.inputSinks(), + Set.of(), + whoami.description())))); + + ToolSpec windows = byName("list_windows"); + assertThrows( + IllegalArgumentException.class, + () -> Catalog.validate(List.of(replace( + windows, + windows.processReach(), + windows.arguments(), + Map.of("session", Set.of(ToolSpec.InputSink.TMUX_FORMAT)), + Set.of(), + windows.description())))); + + ToolSpec rename = byName("rename_session"); + Map> understated = new java.util.LinkedHashMap<>(rename.inputSinks()); + understated.put("new_name", Set.of(ToolSpec.InputSink.TMUX_FORMAT)); + assertThrows( + IllegalArgumentException.class, + () -> Catalog.validate(List.of(replace( + rename, + rename.processReach(), + rename.arguments(), + understated, + Set.of(), + rename.description())))); + + ToolSpec creator = byName("create_session"); + assertThrows( + IllegalArgumentException.class, + () -> Catalog.validate(List.of(replace( + creator, + ToolSpec.ProcessReach.CONFIGURED_PROCESS, + creator.arguments(), + Map.of("name", Set.of(ToolSpec.InputSink.PROCESS_ARGV)), + Set.of(), + creator.description())))); + assertThrows( + IllegalArgumentException.class, + () -> Catalog.validate(List.of(replace( + whoami, + whoami.processReach(), + whoami.arguments(), + whoami.inputSinks(), + Set.of("missing_nested_tool"), + whoami.description())))); + assertThrows( + IllegalArgumentException.class, + () -> Catalog.validate(List.of(replace( + whoami, + whoami.processReach(), + whoami.arguments(), + whoami.inputSinks(), + Set.of(), + "Uncontrolled description.")))); + } + + @Test + void nestedCallsUseTheSameClosedTypedSchemaAsTopLevelCalls() { + ToolSpec option = byName("show_option"); + assertThrows(IllegalArgumentException.class, () -> option.validateArguments(Map.of())); + assertThrows( + IllegalArgumentException.class, + () -> option.validateArguments(Map.of("name", "status", "unexpected", true))); + assertThrows(IllegalArgumentException.class, () -> option.validateArguments(Map.of("name", 7))); + option.validateArguments(Map.of("name", "status")); + + ToolSpec wait = byName("wait_for_channel"); + assertThrows( + IllegalArgumentException.class, + () -> wait.validateArguments(Map.of("channel", "ready", "timeout", "soon"))); + } + + @Test + void outputSchemasRequireKnownFieldsAndTypeNestedArrays() { + for (ToolSpec tool : Catalog.tools()) { + Map schema = tool.outputSchema(); + Map properties = object(schema.get("properties"), tool.name() + " properties"); + @SuppressWarnings("unchecked") + List required = (List) Objects.requireNonNull(schema.get("required"), tool.name()); + assertFalse(required.isEmpty(), tool.name()); + assertTrue(properties.keySet().containsAll(required), tool.name()); + } + + Map captureProperties = + object(byName("capture_pane").outputSchema().get("properties"), "capture properties"); + Map content = object(captureProperties.get("content"), "content"); + assertEquals(Map.of("type", "string"), content.get("items")); + + Map searchProperties = + object(byName("search_panes").outputSchema().get("properties"), "search properties"); + Map matches = object(searchProperties.get("matches"), "matches"); + Map hit = object(matches.get("items"), "match item"); + Map hitProperties = object(hit.get("properties"), "match properties"); + assertEquals(Set.of("pane_id", "session", "window", "line"), hitProperties.keySet()); + + Map batchProperties = + object(byName("call_read_tools_batch").outputSchema().get("properties"), "batch properties"); + Map results = object(batchProperties.get("results"), "results"); + Map row = object(results.get("items"), "result item"); + Map rowProperties = object(row.get("properties"), "result properties"); + assertEquals(Set.of("index", "tool", "success", "error", "result", "resultTruncated"), rowProperties.keySet()); + assertEquals(rowProperties.keySet(), new LinkedHashSet<>(strings(row.get("required"), "result required"))); + Map envelopeOrNull = object(rowProperties.get("result"), "result envelope"); + assertTrue(envelopeOrNull.containsKey("oneOf")); + + Map sendProperties = + object(byName("send_keys_batch").outputSchema().get("properties"), "send properties"); + Map sends = object(sendProperties.get("results"), "send results"); + Map sendRow = object(sends.get("items"), "send result item"); + assertEquals("object", sendRow.get("type")); + + Object nil = com.fasterxml.jackson.databind.node.NullNode.getInstance(); + Map malformedRow = Map.of( + "index", + "zero", + "tool", + "get_server_info", + "success", + true, + "error", + nil, + "result", + nil, + "resultTruncated", + false); + Map malformedBatch = Map.of( + "results", + List.of(malformedRow), + "succeeded", + 1, + "failed", + 0, + "stoppedAt", + nil, + "truncated", + false, + "truncatedBytes", + 0, + "onError", + "stop"); + assertThrows( + IllegalStateException.class, + () -> byName("call_read_tools_batch").validateOutput(malformedBatch)); + } + + @Test + void readmeInventoryIsGeneratedFromTheAuthoritativeCatalog() throws IOException { + Path workingDirectory = Path.of(System.getProperty("user.dir")); + Path readme = workingDirectory.resolve("libtmux-mcp/README.md"); + if (!Files.isRegularFile(readme)) { + readme = workingDirectory.resolve("README.md"); + } + String documentation = Files.readString(readme); + String start = ""; + String end = ""; + int first = documentation.indexOf(start); + int last = documentation.indexOf(end); + assertTrue(first >= 0 && last > first, "generated inventory markers are missing"); + assertEquals(generatedInventory(), documentation.substring(first, last + end.length())); + } + + @Test + void anAggregateIncludedAloneDispatchesRepeatedNestedCallsWithoutAdvertisingThem() { + ToolSurface surface = ToolSurface.resolve( + Map.of(ToolSurface.TOOLSETS_ENV, "", ToolSurface.TOOLS_ENV, "call_read_tools_batch")); + TmuxTransport absent = new TmuxTransport() { + @Override + public CommandResult execute(CommandRequest request) { + return new CommandResult(1, List.of(), List.of("absent")); + } + + @Override + public void close() {} + }; + List> operations = java.util.stream.IntStream.range(0, 16) + .mapToObj(ignored -> Map.of("tool", "get_server_info")) + .toList(); + try (Server server = Server.using(ServerConfig.builder().build(), absent)) { + Connection connection = new Connection(server, Caller.nowhere(), surface); + @SuppressWarnings("unchecked") + Map result = (Map) Operations.callReadToolsBatch( + connection.call(Map.of("operations", operations), Call.Progress.SILENT)); + + assertEquals(16, result.get("succeeded")); + assertEquals(0, result.get("failed")); + assertEquals(false, result.get("truncated")); + assertEquals(0, result.get("truncatedBytes")); + assertEquals(com.fasterxml.jackson.databind.node.NullNode.getInstance(), result.get("stoppedAt")); + assertEquals("stop", result.get("onError")); + @SuppressWarnings("unchecked") + List> rows = + (List>) Objects.requireNonNull(result.get("results"), "results"); + @SuppressWarnings("unchecked") + Map envelope = + (Map) Objects.requireNonNull(rows.getFirst().get("result"), "result"); + assertEquals(0, rows.getFirst().get("index")); + assertEquals(true, rows.getFirst().get("success")); + assertEquals(false, rows.getFirst().get("resultTruncated")); + assertTrue(rows.getFirst().containsKey("error")); + assertEquals( + com.fasterxml.jackson.databind.node.NullNode.getInstance(), + rows.getFirst().get("error")); + assertTrue(envelope.get("content") instanceof List); + assertTrue(envelope.get("structuredContent") instanceof Map); + assertEquals(false, envelope.get("isError")); + assertEquals(Set.of("call_read_tools_batch"), surface.tools().keySet()); + } + } + + @Test + void readBatchUsesCamelCaseOnErrorForDispatchAndDisclosure() { + ToolSurface surface = ToolSurface.resolve( + Map.of(ToolSurface.TOOLSETS_ENV, "", ToolSurface.TOOLS_ENV, "call_read_tools_batch")); + TmuxTransport absent = new TmuxTransport() { + @Override + public CommandResult execute(CommandRequest request) { + return new CommandResult(1, List.of(), List.of("absent")); + } + + @Override + public void close() {} + }; + try (Server server = Server.using(ServerConfig.builder().build(), absent)) { + Connection connection = new Connection(server, Caller.nowhere(), surface); + @SuppressWarnings("unchecked") + Map result = (Map) Operations.callReadToolsBatch(connection.call( + Map.of( + "operations", + List.of( + Map.of("tool", "get_pane_info", "arguments", Map.of("pane_id", "%404")), + Map.of("tool", "get_server_info")), + "onError", + "continue"), + Call.Progress.SILENT)); + + assertEquals("continue", result.get("onError")); + @SuppressWarnings("unchecked") + List> rows = (List>) Objects.requireNonNull(result.get("results")); + assertEquals(2, rows.size()); + assertEquals(false, rows.getFirst().get("success")); + assertTrue(rows.getFirst().get("error") instanceof String); + assertEquals(true, rows.get(1).get("success")); + + @SuppressWarnings("unchecked") + Map properties = (Map) Objects.requireNonNull( + surface.require("call_read_tools_batch").inputSchema().get("properties")); + assertTrue(properties.containsKey("onError")); + assertFalse(properties.containsKey("on_error")); + } + } + + @Test + void aggregateOutputStopsAtOneMiBAndReportsTruncation() throws Exception { + ToolSurface surface = ToolSurface.resolve( + Map.of(ToolSurface.TOOLSETS_ENV, "", ToolSurface.TOOLS_ENV, "call_read_tools_batch")); + TmuxTransport oversizedEnvironment = new TmuxTransport() { + @Override + public CommandResult execute(CommandRequest request) { + return new CommandResult(0, List.of("VALUE=" + "x".repeat(1_048_576)), List.of()); + } + + @Override + public void close() {} + }; + try (Server server = Server.using(ServerConfig.builder().build(), oversizedEnvironment)) { + Connection connection = new Connection(server, Caller.nowhere(), surface); + ToolSpec environment = byName("show_environment"); + Object nested = environment.answer().apply(connection.call(Map.of(), Call.Progress.SILENT)); + int expectedRemovedBytes = Answers.JSON.writeValueAsBytes(Answers.envelope(Answers.ok(nested))).length + - Answers.JSON.writeValueAsBytes(com.fasterxml.jackson.databind.node.NullNode.getInstance()).length; + @SuppressWarnings("unchecked") + Map result = (Map) Operations.callReadToolsBatch(connection.call( + Map.of("operations", List.of(Map.of("tool", "show_environment"))), Call.Progress.SILENT)); + + byName("call_read_tools_batch").validateOutput(result); + assertEquals(1, result.get("succeeded")); + assertEquals(0, result.get("failed")); + assertEquals(true, result.get("truncated")); + assertEquals(expectedRemovedBytes, result.get("truncatedBytes")); + assertEquals(com.fasterxml.jackson.databind.node.NullNode.getInstance(), result.get("stoppedAt")); + assertEquals("stop", result.get("onError")); + @SuppressWarnings("unchecked") + List> rows = + (List>) Objects.requireNonNull(result.get("results"), "results"); + assertEquals(1, rows.size()); + assertEquals(true, rows.getFirst().get("resultTruncated")); + assertEquals( + com.fasterxml.jackson.databind.node.NullNode.getInstance(), + rows.getFirst().get("result")); + assertTrue( + Answers.JSON.writeValueAsBytes(Answers.envelope(Answers.ok(result))).length <= 1_048_576, + "the complete duplicated MCP tool result must fit the cap"); + } + } + + @Test + void aggregateOverflowRollsBackOnlyTheRowThatCrossedTheCap() { + ToolSurface surface = ToolSurface.resolve( + Map.of(ToolSurface.TOOLSETS_ENV, "", ToolSurface.TOOLS_ENV, "call_read_tools_batch")); + AtomicInteger calls = new AtomicInteger(); + TmuxTransport secondResultIsOversized = new TmuxTransport() { + @Override + public CommandResult execute(CommandRequest request) { + return calls.getAndIncrement() == 0 + ? new CommandResult(1, List.of(), List.of("absent")) + : new CommandResult(0, List.of("VALUE=" + "x".repeat(1_048_576)), List.of()); + } + + @Override + public void close() {} + }; + try (Server server = Server.using(ServerConfig.builder().build(), secondResultIsOversized)) { + Connection connection = new Connection(server, Caller.nowhere(), surface); + @SuppressWarnings("unchecked") + Map result = (Map) Operations.callReadToolsBatch(connection.call( + Map.of( + "operations", + List.of(Map.of("tool", "get_server_info"), Map.of("tool", "show_environment"))), + Call.Progress.SILENT)); + @SuppressWarnings("unchecked") + List> rows = + (List>) Objects.requireNonNull(result.get("results"), "results"); + + assertEquals(2, rows.size()); + assertTrue(rows.get(0).get("result") instanceof Map); + assertEquals(false, rows.get(0).get("resultTruncated")); + assertEquals( + com.fasterxml.jackson.databind.node.NullNode.getInstance(), + rows.get(1).get("result")); + assertEquals(true, rows.get(1).get("resultTruncated")); + } + } + + @Test + void theOnlyResourceDisclosesTheEffectiveSurfaceAndCurrentSocketHonestly() { + TmuxTransport absent = new TmuxTransport() { + @Override + public CommandResult execute(CommandRequest request) { + return new CommandResult(1, List.of(), List.of("absent")); + } + + @Override + public void close() {} + }; + ToolSurface surface = ToolSurface.resolve(Map.of(ToolSurface.TOOLSETS_ENV, "inspect,teardown")); + try (Server server = Server.using(ServerConfig.builder().build(), absent)) { + Map report = surface.capabilities(server); + @SuppressWarnings("unchecked") + Map socket = (Map) Objects.requireNonNull(report.get("socket"), "socket"); + assertEquals("inherit", socket.get("selector")); + assertEquals("inherited", socket.get("selectionProvenance")); + assertEquals("unknown", socket.get("serverState")); + assertEquals("unknown", socket.get("configurationProvenance")); + assertEquals(surface.tools().size(), report.get("toolCount")); + assertEquals("tmux-user", report.get("executionAuthority")); + assertEquals("none", report.get("operatingSystemBoundary")); + assertEquals( + Map.of( + "oneSocketPerProcess", true, + "perCallSocketSelection", false, + "hostCommandExecution", false, + "dynamicResources", false), + report.get("boundary")); + @SuppressWarnings("unchecked") + Map connectionFacts = + (Map) Objects.requireNonNull(report.get("connection"), "connection"); + assertEquals( + Set.of( + "socketSelector", + "socketProvenance", + "resolvedSocketPath", + "serverState", + "configurationProvenance", + "attachCommand"), + connectionFacts.keySet()); + @SuppressWarnings("unchecked") + List> reportedTools = + (List>) Objects.requireNonNull(report.get("tools"), "tools"); + assertEquals( + List.copyOf(surface.tools().keySet()), + reportedTools.stream().map(tool -> tool.get("name")).toList()); + for (ToolSpec tool : surface.tools().values()) { + @SuppressWarnings("unchecked") + Map advertised = (Map) Objects.requireNonNull( + tool.describe().meta().get(ToolSpec.CAPABILITY_META_KEY), "advertised capability"); + Map reported = reportedTools.stream() + .filter(row -> tool.name().equals(row.get("name"))) + .findFirst() + .orElseThrow(); + assertEquals(reported, advertised, tool.name()); + } + + Connection connection = new Connection(server, Caller.nowhere(), surface); + var resources = Resources.fixed(connection); + assertEquals(1, resources.size()); + assertEquals( + Resources.CAPABILITIES_URI, resources.getFirst().resource().uri()); + } + } + + @Test + void capabilityReportFreezesOneSocketObservation() { + AtomicInteger probes = new AtomicInteger(); + TmuxTransport changing = new TmuxTransport() { + @Override + public CommandResult execute(CommandRequest request) { + return probes.getAndIncrement() == 0 + ? new CommandResult(0, List.of("123"), List.of()) + : new CommandResult(1, List.of(), List.of("gone")); + } + + @Override + public void close() {} + }; + try (Server server = Server.using(ServerConfig.builder().build(), changing)) { + Map report = ToolSurface.defaults().capabilities(server); + @SuppressWarnings("unchecked") + Map socket = (Map) Objects.requireNonNull(report.get("socket")); + @SuppressWarnings("unchecked") + Map connection = (Map) Objects.requireNonNull(report.get("connection")); + + assertEquals(socket.get("serverState"), connection.get("serverState")); + assertEquals(1, probes.get()); + } + } + + private static ToolSpec byName(String name) { + return Catalog.tools().stream() + .filter(tool -> tool.name().equals(name)) + .findFirst() + .orElseThrow(); + } + + @SuppressWarnings("unchecked") + private static int batchMaxItems(ToolSpec batch) { + Map properties = + (Map) Objects.requireNonNull(batch.inputSchema().get("properties"), "properties"); + Map operations = + (Map) Objects.requireNonNull(properties.get("operations"), "operations"); + return (Integer) Objects.requireNonNull(operations.get("maxItems"), "maxItems"); + } + + private static List wireNames(Set effects) { + return effects.stream().map(ToolSpec.TmuxEffect::wireName).toList(); + } + + @SuppressWarnings("unchecked") + private static Map object(@Nullable Object value, String name) { + if (!(value instanceof Map)) { + throw new AssertionError(name + " is not an object: " + value); + } + return (Map) value; + } + + @SuppressWarnings("unchecked") + private static List strings(@Nullable Object value, String name) { + if (!(value instanceof List)) { + throw new AssertionError(name + " is not an array: " + value); + } + return (List) value; + } + + private static String generatedInventory() { + StringBuilder inventory = new StringBuilder() + .append("\n") + .append("The complete frozen inventory below is generated from the code registry.\n\n") + .append("| toolset | public tools |\n") + .append("| --- | --- |\n"); + for (ToolSpec.Toolset toolset : ToolSpec.Toolset.values()) { + String tools = Catalog.tools().stream() + .filter(tool -> tool.toolset() == toolset) + .map(tool -> "`" + tool.name() + "`") + .collect(java.util.stream.Collectors.joining(" · ")); + inventory + .append("| `") + .append(toolset.wireName()) + .append("` | ") + .append(tools) + .append(" |\n"); + } + return inventory.append("").toString(); + } + + private static Set schemaKeys(String name) { + return byName(name).arguments().stream().map(Argument::name).collect(java.util.stream.Collectors.toSet()); + } + + private static ToolSpec replace( + ToolSpec source, + ToolSpec.ProcessReach reach, + List arguments, + Map> sinks, + Set nestedAuthority, + String description) { + return new ToolSpec( + source.name(), + source.title(), + description, + source.toolset(), + reach, + source.effects(), + source.outputClasses(), + source.mayExposeSecrets(), + source.mayReturnUntrustedContent(), + source.amplifiesFutureInput(), + source.annotations(), + arguments, + sinks, + source.inputLiteralization(), + nestedAuthority, + source.output(), + source.answer()); + } +} diff --git a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/CatalogTest.java b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/CatalogTest.java deleted file mode 100644 index 85f6ad1..0000000 --- a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/CatalogTest.java +++ /dev/null @@ -1,152 +0,0 @@ -package io.github.libtmux.mcp; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import io.modelcontextprotocol.spec.McpSchema; -import java.util.HashSet; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.Set; -import org.junit.jupiter.api.Test; - -/** - * The promises the tool surface makes to a model, checked without tmux. - * - *

A description is the only documentation a model gets and a hint is what a client decides to - * confirm with a person on. Both are easy to leave off a new tool and impossible to notice missing. - */ -final class CatalogTest { - - @Test - void everyToolIsNamedForThisServerAndNamedOnlyOnce() { - Set seen = new HashSet<>(); - - for (ToolSpec tool : Catalog.tools()) { - assertTrue(tool.name().startsWith("tmux_"), tool.name() + " must say which server it belongs to"); - assertTrue(seen.add(tool.name()), tool.name() + " is declared twice"); - } - } - - @Test - void everyToolTellsAModelWhatItIsFor() { - for (ToolSpec tool : Catalog.tools()) { - assertFalse(tool.description().isBlank(), tool.name() + " has no description"); - assertTrue( - tool.description().length() > 40, - tool.name() + " describes itself in too few words for a model to choose it"); - assertFalse(tool.title().isBlank(), tool.name() + " has no title for a person to read"); - } - } - - /** A client decides what to confirm with a person from these, so they may never be absent. */ - @Test - void whatAToolCanDestroyIsDeclaredToTheClient() { - for (ToolSpec tool : Catalog.tools()) { - McpSchema.ToolAnnotations annotations = tool.describe().annotations(); - assertNotNull(annotations, tool.name() + " carries no annotations"); - assertEquals( - tool.safety() == Safety.READONLY, - annotations.readOnlyHint(), - tool.name() + " disagrees with its ceiling about being read-only"); - assertEquals( - tool.effect() == ToolSpec.Effect.DESTRUCTIVE, - annotations.destructiveHint(), - tool.name() + " disagrees with its declared effect about being destructive"); - } - } - - @Test - void toolsThatCanReplaceOrRemoveStateDeclareTheirFullEffect() { - for (String name : List.of( - "tmux_run", - "tmux_wait_for_channel", - "tmux_signal_channel", - "tmux_drain_channel", - "tmux_send_keys", - "tmux_paste_text", - "tmux_new_session", - "tmux_new_window", - "tmux_split_pane", - "tmux_apply_workspace", - "tmux_rename", - "tmux_select", - "tmux_select_layout", - "tmux_resize_pane", - "tmux_set_option", - "tmux_kill")) { - ToolSpec tool = - Objects.requireNonNull(Catalog.offered(Safety.DESTRUCTIVE).get(name), name); - McpSchema.ToolAnnotations annotations = - Objects.requireNonNull(tool.describe().annotations(), name); - - assertEquals(false, annotations.readOnlyHint(), name); - assertEquals(true, annotations.destructiveHint(), name); - assertEquals(false, annotations.idempotentHint(), name); - assertEquals(true, annotations.openWorldHint(), name); - } - } - - @Test - void everyArgumentIsDescribedAndTheRequiredOnesAreListed() { - for (ToolSpec tool : Catalog.tools()) { - Map schema = Argument.objectSchema(tool.arguments()); - @SuppressWarnings("unchecked") - Map properties = - (Map) Objects.requireNonNull(schema.get("properties"), "properties"); - @SuppressWarnings("unchecked") - List required = (List) Objects.requireNonNull(schema.get("required"), "required"); - - assertEquals(tool.arguments().size(), properties.size(), tool.name() + " lost an argument in its schema"); - for (Argument argument : tool.arguments()) { - assertFalse( - argument.description().isBlank(), - tool.name() + " does not say what '" + argument.name() + "' is"); - assertEquals( - argument.required(), - required.contains(argument.name()), - tool.name() + " disagrees with itself about whether '" + argument.name() + "' is required"); - } - } - } - - /** - * The point of the ceiling: a tool above it is never listed, so a model is not offered something - * it will only be refused. - */ - @Test - void aCeilingRemovesToolsRatherThanRefusingThem() { - Map readonly = Catalog.offered(Safety.READONLY); - Map mutating = Catalog.offered(Safety.MUTATING); - Map everything = Catalog.offered(Safety.DESTRUCTIVE); - - assertTrue(readonly.values().stream().allMatch(tool -> tool.safety() == Safety.READONLY)); - assertFalse(readonly.containsKey("tmux_run"), "running a command changes the pane"); - assertFalse(mutating.containsKey("tmux_kill"), "killing is not something a mutating server offers"); - assertTrue(everything.containsKey("tmux_kill")); - assertTrue(readonly.size() < mutating.size() && mutating.size() < everything.size()); - assertEquals(everything.size(), Catalog.tools().size(), "the widest ceiling offers everything declared"); - } - - /** The tools a model needs before it can do anything else must survive the strictest ceiling. */ - @Test - void findingOutWhatIsThereIsAlwaysOffered() { - Map readonly = Catalog.offered(Safety.READONLY); - - assertTrue(readonly.containsKey("tmux_whoami")); - assertTrue(readonly.containsKey("tmux_list_panes")); - assertTrue(readonly.containsKey("tmux_capture_pane")); - assertTrue(readonly.containsKey("tmux_wait_for_text"), "watching is reading, whatever it waits for"); - } - - @Test - void consumingAChannelSignalIsNotAdvertisedAsReadOnly() { - assertFalse(Catalog.offered(Safety.READONLY).containsKey("tmux_wait_for_channel")); - ToolSpec wait = - Objects.requireNonNull(Catalog.offered(Safety.MUTATING).get("tmux_wait_for_channel"), "wait tool"); - assertEquals(Safety.MUTATING, wait.safety()); - } -} diff --git a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/ConnectionTest.java b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/ConnectionTest.java deleted file mode 100644 index a0ca6a4..0000000 --- a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/ConnectionTest.java +++ /dev/null @@ -1,69 +0,0 @@ -package io.github.libtmux.mcp; - -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import io.github.libtmux.Server; -import io.github.libtmux.ServerConfig; -import io.github.libtmux.transport.CommandRequest; -import io.github.libtmux.transport.CommandResult; -import io.github.libtmux.transport.TmuxTransport; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.Executors; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.TimeoutException; -import org.junit.jupiter.api.Test; - -final class ConnectionTest { - - @Test - void aClientListingCannotObserveAHalfHiddenWatcher() throws Exception { - TmuxTransport unused = new TmuxTransport() { - @Override - public CommandResult execute(CommandRequest request) { - throw new AssertionError("this test dispatches no tmux command"); - } - - @Override - public void close() {} - }; - try (Server server = Server.using(ServerConfig.builder().build(), unused); - var tasks = Executors.newVirtualThreadPerTaskExecutor()) { - Connection connection = - new Connection(server, Caller.nowhere(), Safety.MUTATING, ConcurrentHashMap.newKeySet()); - CountDownLatch attaching = new CountDownLatch(1); - CountDownLatch hide = new CountDownLatch(1); - CountDownLatch listing = new CountDownLatch(1); - - var attachment = tasks.submit(() -> connection.changeClients(() -> { - attaching.countDown(); - await(hide); - connection.hide("watcher"); - })); - assertTrue(attaching.await(1, TimeUnit.SECONDS)); - - var observed = tasks.submit(() -> { - listing.countDown(); - return connection.withStableClients(() -> connection.isOurs("watcher")); - }); - assertTrue(listing.await(1, TimeUnit.SECONDS)); - try { - assertThrows(TimeoutException.class, () -> observed.get(100, TimeUnit.MILLISECONDS)); - } finally { - hide.countDown(); - } - attachment.get(1, TimeUnit.SECONDS); - assertTrue(observed.get(1, TimeUnit.SECONDS), "the listing ran before the watcher was hidden"); - } - } - - private static void await(CountDownLatch latch) { - try { - latch.await(); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - throw new IllegalStateException("interrupted while arranging the test", e); - } - } -} diff --git a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/MainTest.java b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/MainTest.java index db91ca4..26a1c9f 100644 --- a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/MainTest.java +++ b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/MainTest.java @@ -4,10 +4,19 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +import io.github.libtmux.Server; import io.github.libtmux.ServerConfig; import io.github.libtmux.ServerEndpoint; +import io.github.libtmux.transport.CommandRequest; +import io.github.libtmux.transport.CommandResult; +import io.github.libtmux.transport.TmuxTransport; +import java.io.IOException; +import java.nio.file.Files; import java.nio.file.Path; import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.UUID; import org.junit.jupiter.api.Test; /** @@ -17,10 +26,13 @@ final class MainTest { @Test - void nothingSaidLeavesTmuxToFindItsOwnServer() { + void nothingSaidPinsTheDedicatedMinimalServer() throws IOException { ServerConfig config = Main.configure(List.of()); - assertEquals(ServerEndpoint.defaultSocket(), config.endpoint()); + assertEquals(ServerEndpoint.namedSocket("libtmux-mcp"), config.endpoint()); + Path minimal = config.configFile().orElseThrow(); + assertTrue(!minimal.equals(Path.of("/dev/null"))); + assertTrue(Files.readString(minimal).contains("libtmux-mcp")); assertEquals("tmux", config.binary()); } @@ -47,13 +59,11 @@ void theBinaryCanBeChosenAlongsideTheServer() { assertEquals("/usr/local/bin/tmux", config.binary()); } - /** Both spellings are documented, so both can be given. Last wins, as a shell caller expects. */ @Test - void theLastEndpointNamedIsTheOneUsed() { - ServerConfig config = - Main.configure(List.of("--socket", "/tmp/libtmux-java-dev/probe/s", "--socket-name", "work")); - - assertEquals(ServerEndpoint.namedSocket("work"), config.endpoint()); + void socketFlagsAreMutuallyExclusive() { + assertThrows( + IllegalArgumentException.class, + () -> Main.configure(List.of("--socket", "/tmp/libtmux-java-dev/probe/s", "--socket-name", "work"))); } @Test @@ -88,35 +98,218 @@ void aValueIsTakenLiterallyEvenWhenItLooksLikeAFlag() { assertEquals(ServerEndpoint.namedSocket("--tmux"), config.endpoint()); } - /** - * The ceiling decides which tools exist at all, so a launcher that quietly ignored the flag would - * hand a model the power to kill things its operator meant to withhold. - */ @Test - void theSafetyCeilingIsReadFromTheFlag() { - assertEquals(Safety.READONLY, Main.safety(List.of("--safety", "readonly"))); - assertEquals(Safety.DESTRUCTIVE, Main.safety(List.of("--socket-name", "work", "--safety", "destructive"))); + void theRetiredSafetyFlagNamesItsReplacement() { + IllegalArgumentException refused = + assertThrows(IllegalArgumentException.class, () -> Main.configure(List.of("--safety", "readonly"))); + + assertTrue(String.valueOf(refused.getMessage()).contains("LIBTMUX_TOOLSETS")); } - /** Left unsaid, a server offers changes but not the dedicated kill tool. */ @Test - void theCeilingLeftUnsaidStopsShortOfDestroying() { - assertEquals(Safety.MUTATING, Main.safety(List.of("--socket-name", "work"))); + void socketAndConfigurationEnvironmentAreResolvedOnceAtStartup() { + LaunchConfiguration named = + LaunchConfiguration.resolve(List.of(), Map.of(LaunchConfiguration.SOCKET_ENV, "work")); + assertEquals(ServerEndpoint.namedSocket("work"), named.config().endpoint()); + assertTrue(named.config().configFile().isEmpty()); + assertEquals("unknown", named.declaredConfigurationProvenance()); + assertEquals(false, named.defaultDedicated()); + + LaunchConfiguration path = LaunchConfiguration.resolve( + List.of(), + Map.of( + LaunchConfiguration.SOCKET_PATH_ENV, + "/tmp/libtmux-java-dev/probe/s", + LaunchConfiguration.CONFIG_ENV, + "/tmp/libtmux-java-dev/minimal.conf")); + assertEquals( + ServerEndpoint.socketPath(Path.of("/tmp/libtmux-java-dev/probe/s")), + path.config().endpoint()); + assertEquals( + Path.of("/tmp/libtmux-java-dev/minimal.conf"), + path.config().configFile().orElseThrow()); + assertEquals("user-configured", path.declaredConfigurationProvenance()); + SocketProfile absent = absentProfile(path); + assertEquals("absent", absent.serverState()); + assertEquals("user-configured", absent.configurationProvenance()); + assertThrows( + IllegalArgumentException.class, + () -> LaunchConfiguration.resolve( + List.of(), + Map.of( + LaunchConfiguration.SOCKET_ENV, + "work", + LaunchConfiguration.SOCKET_PATH_ENV, + "/tmp/libtmux-java-dev/probe/s"))); } @Test - void aCeilingNobodyRecognisesStopsTheLauncher() { - IllegalArgumentException refused = - assertThrows(IllegalArgumentException.class, () -> Main.safety(List.of("--safety", "yolo"))); + void onlyANewDefaultMinimalSocketEnablesTeardownByDefault() { + LaunchConfiguration launch = LaunchConfiguration.resolve(List.of(), Map.of()); - assertTrue(String.valueOf(refused.getMessage()).contains("readonly"), String.valueOf(refused.getMessage())); + SocketProfile created = profile(launch, Objects.requireNonNull(launch.ownerNonce())); + SocketProfile existing = profile(launch, "another-launch"); + assertEquals(true, created.defaultTeardown()); + assertEquals("minimal", created.configurationProvenance()); + assertEquals("created", created.serverState()); + assertEquals(false, existing.defaultTeardown()); + assertEquals("unknown", existing.configurationProvenance()); + assertEquals("existing", existing.serverState()); + + assertEquals(47, ToolSurface.resolve(Map.of(), created).tools().size()); + assertEquals(false, ToolSurface.resolve(Map.of(), existing).tools().containsKey("kill_session")); + assertEquals( + List.of("kill_pane", "kill_window", "kill_session"), + ToolSurface.resolve(Map.of(ToolSurface.TOOLSETS_ENV, "teardown"), existing).tools().keySet().stream() + .filter(name -> name.startsWith("kill_")) + .toList()); } - /** The endpoint parser has to know the flag exists, or it would reject a launch that is correct. */ @Test - void theSafetyFlagDoesNotConfuseTheEndpointParser() { - ServerConfig config = Main.configure(List.of("--safety", "readonly", "--socket-name", "work")); + void aPrivateLaunchNonceProvesCreationWithoutEnteringTheTmuxEnvironment() throws IOException { + LaunchConfiguration launch = LaunchConfiguration.resolve(List.of(), Map.of()); + String config = Files.readString(launch.config().configFile().orElseThrow()); - assertEquals(ServerEndpoint.namedSocket("work"), config.endpoint()); + assertTrue(config.contains(launch.ownerNonce())); + assertTrue(config.contains("set -g exit-empty off")); + assertTrue(!config.contains("set-environment")); + assertTrue(!config.contains("LIBTMUX_")); + } + + @Test + void onlyTheLaunchWhosePrivateConfigCreatedTheDaemonOwnsIt() throws IOException { + Path root = Path.of("/tmp/libtmux-java-test"); + Files.createDirectories(root); + Path socket = root.resolve("mcp-owner-" + UUID.randomUUID()); + LaunchConfiguration first = dedicatedOn(LaunchConfiguration.resolve(List.of(), Map.of()), socket); + LaunchConfiguration second = dedicatedOn(LaunchConfiguration.resolve(List.of(), Map.of()), socket); + + try (Server firstServer = Server.open(first.config())) { + try { + SocketProfile created = first.profile(firstServer); + assertEquals("created", created.serverState()); + assertTrue(created.defaultTeardown()); + + try (Server secondServer = Server.open(second.config())) { + SocketProfile existing = second.profile(secondServer); + assertEquals("existing", existing.serverState()); + assertTrue(!existing.defaultTeardown()); + CommandResult environment = secondServer.cmd("show-environment", "-g"); + assertTrue(environment.succeeded(), environment.stderr().toString()); + assertTrue(environment.stdout().stream() + .noneMatch(line -> line.contains(Objects.requireNonNull(first.ownerNonce())) + || line.contains(Objects.requireNonNull(second.ownerNonce())))); + } + } finally { + if (firstServer.isAlive()) { + firstServer.killServer(); + } + } + } + } + + @Test + void missingStartupMetadataFailsClosed() { + LaunchConfiguration launch = LaunchConfiguration.resolve(List.of(), Map.of()); + TmuxTransport broken = new TmuxTransport() { + @Override + public CommandResult execute(CommandRequest request) { + List command = request.commands().getFirst(); + return command.getFirst().equals("start-server") + ? new CommandResult(0, List.of(), List.of()) + : new CommandResult(1, List.of(), List.of("metadata unavailable")); + } + + @Override + public void close() {} + }; + try (Server server = Server.using(launch.config(), broken)) { + assertThrows(IllegalStateException.class, () -> launch.profile(server)); + } + } + + @Test + void explicitSocketMetadataErrorsAreNotMisreportedAsAnAbsentServer() { + LaunchConfiguration launch = LaunchConfiguration.resolve( + List.of(), Map.of(LaunchConfiguration.SOCKET_PATH_ENV, "/tmp/libtmux-java-dev/denied/s")); + TmuxTransport denied = new TmuxTransport() { + @Override + public CommandResult execute(CommandRequest request) { + return new CommandResult(1, List.of(), List.of("permission denied")); + } + + @Override + public void close() {} + }; + try (Server server = Server.using(launch.config(), denied)) { + assertThrows(IllegalStateException.class, () -> launch.profile(server)); + } + } + + @Test + void malformedSocketAndConfigurationSelectorsFailClosed() { + assertThrows( + IllegalArgumentException.class, + () -> LaunchConfiguration.resolve( + List.of(), Map.of(LaunchConfiguration.SOCKET_PATH_ENV, "relative/socket"))); + assertThrows( + IllegalArgumentException.class, + () -> LaunchConfiguration.resolve(List.of(), Map.of(LaunchConfiguration.CONFIG_ENV, "relative.conf"))); + assertThrows( + IllegalArgumentException.class, + () -> LaunchConfiguration.resolve(List.of(), Map.of(LaunchConfiguration.CONFIG_ENV, "minimal"))); + assertThrows( + IllegalArgumentException.class, + () -> LaunchConfiguration.resolve(List.of(), Map.of(LaunchConfiguration.CONFIG_ENV, ""))); + } + + private static SocketProfile profile(LaunchConfiguration launch, String marker) { + TmuxTransport transport = new TmuxTransport() { + @Override + public CommandResult execute(CommandRequest request) { + List command = request.commands().getFirst(); + if (command.getFirst().equals("start-server")) { + return new CommandResult(0, List.of(), List.of()); + } + if (command.getFirst().equals("display-message")) { + return new CommandResult( + 0, List.of(marker + "\t/tmp/libtmux-java-dev/libtmux-mcp.sock"), List.of()); + } + throw new AssertionError(command); + } + + @Override + public void close() {} + }; + try (Server server = Server.using(launch.config(), transport)) { + return launch.profile(server); + } + } + + private static LaunchConfiguration dedicatedOn(LaunchConfiguration launch, Path socket) { + return new LaunchConfiguration( + launch.config().toBuilder() + .endpoint(ServerEndpoint.socketPath(socket)) + .build(), + "path:" + socket, + "default-dedicated", + launch.declaredConfigurationProvenance(), + true, + launch.ownerNonce()); + } + + private static SocketProfile absentProfile(LaunchConfiguration launch) { + TmuxTransport absent = new TmuxTransport() { + @Override + public CommandResult execute(CommandRequest request) { + return new CommandResult(1, List.of(), List.of("no server running on /tmp/libtmux-java-dev/probe/s")); + } + + @Override + public void close() {} + }; + try (Server server = Server.using(launch.config(), absent)) { + return launch.profile(server); + } } } diff --git a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/McpLauncherTest.java b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/McpLauncherTest.java index 9486971..87df573 100644 --- a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/McpLauncherTest.java +++ b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/McpLauncherTest.java @@ -6,7 +6,6 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import com.fasterxml.jackson.databind.ObjectMapper; -import io.github.libtmux.PaneId; import io.github.libtmux.Server; import io.github.libtmux.ServerConfig; import io.github.libtmux.ServerEndpoint; @@ -45,21 +44,16 @@ final class McpLauncherTest { private static final int PATIENCE_SECONDS = 60; @Test - void failedWatchStartupDoesNotLeaveTheLauncherAlive(Server server, TmuxSocketPath socket) throws Exception { - assertTrue(server.cmd("set-option", "-g", "exit-empty", "off").succeeded()); - server.sessions().getFirst().kill(); - assertTrue(server.sessions().isEmpty(), "the fixture still had a session for the watcher to attach to"); - + void theRemovedWatchFlagDoesNotLeaveTheLauncherAlive(Server server, TmuxSocketPath socket) throws Exception { Process launcher = rawLauncher(socket.path(), ProcessBuilder.Redirect.DISCARD, ProcessBuilder.Redirect.PIPE, "--watch"); try { assertTrue( - launcher.waitFor(5, TimeUnit.SECONDS), - "watch startup failed, but the launcher stayed alive on its tmux transport threads"); - assertTrue(launcher.exitValue() != 0, "failed watch startup reported success"); + launcher.waitFor(5, TimeUnit.SECONDS), "the rejected launch stayed alive on its transport threads"); + assertTrue(launcher.exitValue() != 0, "the removed watch flag reported success"); String diagnostic = new String(launcher.getErrorStream().readAllBytes(), StandardCharsets.UTF_8); assertTrue( - diagnostic.contains("watching requires a tmux session to attach to"), + diagnostic.contains("unknown argument '--watch'"), "the launcher failed for the wrong reason: " + diagnostic); } finally { stop(launcher); @@ -114,7 +108,7 @@ void aLaunchedServerAnswersAboutTheSocketItWasGiven(Server server, TmuxSocketPat client.initialize(); String listed = textOf(client.callTool( - McpSchema.CallToolRequest.builder("tmux_list_sessions").build())); + McpSchema.CallToolRequest.builder("list_sessions").build())); assertTrue(listed.contains("libtmux"), "the launcher did not find the fixture's session: " + listed); assertTrue(listed.contains("editor"), "the launcher answered about a different server: " + listed); @@ -133,43 +127,11 @@ void everyToolTheReadmeNamesIsOffered(Server server, TmuxSocketPath socket) { assertTrue( offered.containsAll(List.of( - "tmux_list_sessions", - "tmux_list_panes", - "tmux_capture_pane", - "tmux_run", - "tmux_new_window")), + "list_sessions", "list_panes", "capture_pane", "run_shell_command", "create_window")), offered.toString()); } } - /** - * The filter is the one argument that is a structured document rather than a string, so it is - * the one that can be mangled between the model and tmux without anything noticing. - */ - @Test - @Timeout(PATIENCE_SECONDS) - void aFilterSentAsADocumentNarrowsWhatComesBack(Server server, TmuxSocketPath socket) { - String running = server.panes().get(0).currentCommand(); - - try (McpSyncClient client = launch(socket.path())) { - client.initialize(); - - String matching = textOf(client.callTool(McpSchema.CallToolRequest.builder("tmux_list_panes") - .arguments(Map.of("filter", filterOn(running))) - .build())); - String missing = textOf(client.callTool(McpSchema.CallToolRequest.builder("tmux_list_panes") - .arguments(Map.of("filter", filterOn("no-such-command-anywhere"))) - .build())); - - assertTrue(matching.contains("\"id\":\"%"), "the filter excluded the pane that matches it: " + matching); - assertTrue(missing.contains("\"count\":0"), "a filter matching nothing returned panes: " + missing); - assertFalse(missing.contains("\"id\":\"%"), "a filter matching nothing must not return everything"); - assertTrue( - missing.contains("without 'filter'"), - "an empty answer has to say whether the server was empty or the filter was wrong: " + missing); - } - } - /** * The surface a model meets before it calls anything: what this server is for, and which tool to * reach for. A description drifting from what the tools do sends every model the same wrong way. @@ -182,113 +144,87 @@ void theServerTellsAModelHowToUseItBeforeItCallsAnything(Server server, TmuxSock String instructions = String.valueOf(initialized.instructions()); assertTrue(instructions.contains("WAIT, DO NOT POLL"), instructions); - assertTrue(instructions.contains("tmux_whoami"), "a model has to be told how to find its own pane"); + assertTrue(instructions.contains("get_server_info"), "a model has to be told how to identify the server"); assertTrue(instructions.contains("Do NOT use them for browser tabs"), "anti-triggers must be stated"); - assertTrue(instructions.contains("SAFETY"), "and what it is not allowed to do"); + assertTrue(instructions.contains("Tool filtering"), "the interface boundary must be stated"); } } - /** Resources, prompts and completion are all advertised, or a client will never ask for them. */ + /** Capabilities are the only resource; no second prompt authority is advertised. */ @Test @Timeout(PATIENCE_SECONDS) - void theOtherHalvesOfTheProtocolAreOfferedToo(Server server, TmuxSocketPath socket) { + void theCapabilityReportIsTheOnlyResource(Server server, TmuxSocketPath socket) { try (McpSyncClient client = launch(socket.path())) { client.initialize(); List resources = client.listResources().resources().stream() .map(McpSchema.Resource::uri) .toList(); - List templates = client.listResourceTemplates().resourceTemplates().stream() - .map(McpSchema.ResourceTemplate::uriTemplate) - .toList(); - List prompts = client.listPrompts().prompts().stream() - .map(McpSchema.Prompt::name) - .toList(); - - assertTrue(resources.contains("tmux://panes"), resources.toString()); - assertTrue(templates.contains("tmux://panes/{pane_id}/content"), templates.toString()); - assertTrue(prompts.contains("run_and_wait"), prompts.toString()); - } - } - - /** A pane resource is the pane's own text, addressable without spending a tool call on it. */ - @Test - @Timeout(PATIENCE_SECONDS) - void aPaneCanBeReadAsAResource(Server server, TmuxSocketPath socket) { - String pane = server.panes().get(0).id().value(); - - try (McpSyncClient client = launch(socket.path())) { - client.initialize(); - - McpSchema.ReadResourceResult read = client.readResource( - McpSchema.ReadResourceRequest.builder(Resources.paneContentUri(new PaneId(pane))) - .build()); - - assertEquals(1, read.contents().size()); - assertEquals( - "text/plain", - ((McpSchema.TextResourceContents) read.contents().get(0)).mimeType(), - "terminal text is not JSON and must not be labelled as it"); - } - } - - /** - * Completion answered from tmux rather than from a fixed list. Without it, finding a pane id - * costs a listing call, a read of that listing, and a choice. - */ - @Test - @Timeout(PATIENCE_SECONDS) - void completionOffersThePaneIdsThatActuallyExist(Server server, TmuxSocketPath socket) { - String pane = server.panes().get(0).id().value(); - - try (McpSyncClient client = launch(socket.path())) { - client.initialize(); - - McpSchema.CompleteResult completed = client.completeCompletion(McpSchema.CompleteRequest.builder( - new McpSchema.ResourceReference("tmux://panes/{pane_id}"), - new McpSchema.CompleteRequest.CompleteArgument("pane_id", "%")) - .build()); - - assertTrue( - completed.completion().values().contains(pane), - "the pane that exists was not offered: " - + completed.completion().values()); + assertEquals(List.of(Resources.CAPABILITIES_URI), resources); } } /** What a client decides to confirm with a person on comes from these, so they have to arrive. */ @Test @Timeout(PATIENCE_SECONDS) - void everyToolArrivesWithItsRiskDeclared(Server server, TmuxSocketPath socket) { + void everyToolArrivesWithItsRiskDeclared(Server server, TmuxSocketPath socket) throws Exception { try (McpSyncClient client = launch(socket.path())) { client.initialize(); - for (McpSchema.Tool tool : client.listTools().tools()) { + List tools = client.listTools().tools(); + McpSchema.ReadResourceResult resource = + client.readResource(McpSchema.ReadResourceRequest.builder(Resources.CAPABILITIES_URI) + .build()); + McpSchema.TextResourceContents contents = + (McpSchema.TextResourceContents) resource.contents().getFirst(); + @SuppressWarnings("unchecked") + Map report = new ObjectMapper().readValue(contents.text(), Map.class); + @SuppressWarnings("unchecked") + List> rows = (List>) + java.util.Objects.requireNonNull(report.get("tools"), "capability rows"); + + for (McpSchema.Tool tool : tools) { assertTrue(tool.annotations() != null, tool.name() + " arrived with no annotations"); + assertEquals( + java.util.Set.of(ToolSpec.CAPABILITY_META_KEY), + tool.meta().keySet(), + tool.name() + " arrived without its capability row"); + Map row = rows.stream() + .filter(candidate -> tool.name().equals(candidate.get("name"))) + .findFirst() + .orElseThrow(); + assertEquals( + row, + tool.meta().get(ToolSpec.CAPABILITY_META_KEY), + tool.name() + " metadata differs from tmux://capabilities"); } - McpSchema.Tool reading = named(client, "tmux_capture_pane"); - McpSchema.Tool running = named(client, "tmux_run"); + McpSchema.Tool reading = named(client, "capture_pane"); + McpSchema.Tool running = named(client, "run_shell_command"); - assertEquals(true, reading.annotations().readOnlyHint(), "reading a pane changes nothing"); + assertEquals( + false, + reading.annotations().readOnlyHint(), + "unknown configuration provenance requires conservative whole-call annotations"); assertEquals(false, running.annotations().readOnlyHint(), "running a command does"); assertEquals(true, running.annotations().destructiveHint(), "a shell command may delete data"); } } - /** A ceiling removes tools rather than refusing them, and this is where that reaches a client. */ + /** A toolset selection removes tools rather than refusing them, and this reaches the client. */ @Test @Timeout(PATIENCE_SECONDS) - void aReadOnlyLauncherDoesNotEvenOfferTheToolsThatChangeThings(Server server, TmuxSocketPath socket) { - try (McpSyncClient client = launch(socket.path(), "--safety", "readonly")) { + void anInspectOnlyLauncherDoesNotOfferToolsThatChangeThings(Server server, TmuxSocketPath socket) { + try (McpSyncClient client = + launch("--socket", socket.path().toString(), Map.of(ToolSurface.TOOLSETS_ENV, "inspect"))) { client.initialize(); List offered = client.listTools().tools().stream() .map(McpSchema.Tool::name) .toList(); - assertTrue(offered.contains("tmux_capture_pane"), offered.toString()); - assertFalse(offered.contains("tmux_run"), "a read-only server must not offer to run commands"); - assertFalse(offered.contains("tmux_kill"), offered.toString()); + assertTrue(offered.contains("capture_pane"), offered.toString()); + assertFalse(offered.contains("run_shell_command"), "an inspect-only server must not offer execution"); + assertFalse(offered.contains("kill_session"), offered.toString()); } } @@ -310,7 +246,7 @@ void aCommandRunsAndItsExitStatusComesBack(Server server, TmuxSocketPath socket) try (McpSyncClient client = launch(socket.path())) { client.initialize(); - McpSchema.CallToolResult ran = client.callTool(McpSchema.CallToolRequest.builder("tmux_run") + McpSchema.CallToolResult ran = client.callTool(McpSchema.CallToolRequest.builder("run_shell_command") .arguments(Map.of("pane_id", pane, "command", "echo over-the-wire; exit 7", "timeout", 30)) .build()); @@ -338,10 +274,10 @@ void oneValueIsAcceptedWhereAListIsWanted(Server server, TmuxSocketPath socket) try (McpSyncClient client = launch(socket.path())) { client.initialize(); - McpSchema.CallToolResult keys = client.callTool(McpSchema.CallToolRequest.builder("tmux_send_keys") + McpSchema.CallToolResult keys = client.callTool(McpSchema.CallToolRequest.builder("send_keys") .arguments(Map.of("pane_id", pane, "keys", "q")) .build()); - McpSchema.CallToolResult waited = client.callTool(McpSchema.CallToolRequest.builder("tmux_wait_for_text") + McpSchema.CallToolResult waited = client.callTool(McpSchema.CallToolRequest.builder("wait_for_text") .arguments(Map.of("pane_id", pane, "patterns", "never-appears-here", "timeout", 1)) .build()); @@ -350,33 +286,6 @@ void oneValueIsAcceptedWhereAListIsWanted(Server server, TmuxSocketPath socket) } } - /** - * The tool a model is told to call first, on a socket no server is listening on. Everything else - * here needs a server to answer, so this one has to answer without one. - */ - @Test - @Timeout(PATIENCE_SECONDS) - void whoamiAnswersWhenNoServerIsRunning(@TempDir Path directory) { - Path absent = directory.resolve("nothing-here"); - - try (McpSyncClient client = launch(absent)) { - client.initialize(); - - McpSchema.CallToolResult whoami = client.callTool( - McpSchema.CallToolRequest.builder("tmux_whoami").build()); - McpSchema.CallToolResult panes = client.callTool( - McpSchema.CallToolRequest.builder("tmux_list_panes").build()); - - assertEquals(false, whoami.isError(), "asking where we are must not fail: " + textOf(whoami)); - assertTrue(textOf(whoami).contains("No tmux server is running"), textOf(whoami)); - assertTrue(textOf(whoami).contains("tmux_list_servers"), "and where to look instead"); - assertEquals(false, panes.isError(), textOf(panes)); - assertTrue( - textOf(panes).contains("No tmux server is running"), - "an empty listing has to say whether the server was empty or absent: " + textOf(panes)); - } - } - private static McpSchema.Tool named(McpSyncClient client, String name) { return client.listTools().tools().stream() .filter(tool -> tool.name().equals(name)) @@ -394,7 +303,7 @@ void aTargetThatIsNotThereComesBackAsAToolErrorAndTheServerLivesOn(Server server try (McpSyncClient client = launch(socket.path())) { client.initialize(); - McpSchema.CallToolResult refused = client.callTool(McpSchema.CallToolRequest.builder("tmux_capture_pane") + McpSchema.CallToolResult refused = client.callTool(McpSchema.CallToolRequest.builder("capture_pane") .arguments(Map.of("pane_id", "%999")) .build()); @@ -403,7 +312,7 @@ void aTargetThatIsNotThereComesBackAsAToolErrorAndTheServerLivesOn(Server server // The same client keeps working, which is what separates a tool error from a crash. assertFalse( - textOf(client.callTool(McpSchema.CallToolRequest.builder("tmux_list_sessions") + textOf(client.callTool(McpSchema.CallToolRequest.builder("list_sessions") .build())) .isEmpty(), "the launcher died on a bad target instead of reporting it"); @@ -423,8 +332,8 @@ void aServerAddressedByNameIsFoundToo(@TempDir Path directory) throws Exception try (McpSyncClient client = launch("--socket-name", name, Map.of("TMUX_TMPDIR", tmuxTmpDir()))) { client.initialize(); - String listed = textOf(client.callTool(McpSchema.CallToolRequest.builder("tmux_list_sessions") - .build())); + String listed = textOf(client.callTool( + McpSchema.CallToolRequest.builder("list_sessions").build())); assertTrue(listed.contains("by-name"), "the launcher did not find the named server: " + listed); } @@ -435,17 +344,6 @@ void aServerAddressedByNameIsFoundToo(@TempDir Path directory) throws Exception } } - /** The versioned document a model sends, built here rather than pasted, so it cannot drift. */ - private static Map filterOn(String command) { - return Map.of( - "schema", - "libtmux.filter/1", - "model", - "pane", - "expr", - Map.of("node", "compare", "field", "pane_current_command", "op", "starts_with", "value", command)); - } - private static String textOf(McpSchema.CallToolResult result) { return result.content().stream() .filter(McpSchema.TextContent.class::isInstance) diff --git a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/NotificationBufferTest.java b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/NotificationBufferTest.java deleted file mode 100644 index 41afc67..0000000 --- a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/NotificationBufferTest.java +++ /dev/null @@ -1,25 +0,0 @@ -package io.github.libtmux.mcp; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.util.Set; -import org.junit.jupiter.api.Test; - -final class NotificationBufferTest { - - @Test - void repeatedUpdatesCoalesceAndDistinctUpdatesStayBounded() { - NotificationBuffer notifications = new NotificationBuffer(2); - - assertTrue(notifications.offer("one")); - assertTrue(notifications.offer("one")); - assertTrue(notifications.offer("two")); - assertFalse(notifications.offer("three")); - assertEquals(2, notifications.size()); - assertEquals(Set.of("one", "two"), notifications.drain()); - assertEquals(0, notifications.size()); - assertTrue(notifications.offer("three")); - } -} diff --git a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/ReadingTest.java b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/ReadingTest.java index e9ba091..6c2b336 100644 --- a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/ReadingTest.java +++ b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/ReadingTest.java @@ -65,7 +65,7 @@ void aPaneThatHasStoppedChangingCostsNothingToWatchAgain(Server server) { * Reads until the pane stops producing lines and answers the cursor that reached that point. * *

A command's own output is not the last thing a pane draws: the shell redraws its prompt - * afterwards, and tmux_run returns on the completion signal rather than waiting for that. So a + * afterwards, and run_shell_command returns on the completion signal rather than waiting for that. So a * cursor taken the instant a command finishes legitimately has one more line coming. */ private static String settled(Server server, String pane) { @@ -287,6 +287,12 @@ void aCaptureIsCappedAndSaysWhatItDropped(Server server) { assertTrue(String.valueOf(captured.note()).contains("most recent"), String.valueOf(captured.note())); } + @Test + void cursorRecoveryHasAFixedHistoryCeiling() { + assertEquals(20_000, Screen.cursorRecoveryLines()); + assertTrue(Screen.cursorRecoveryLines() < Integer.MAX_VALUE); + } + @Test void searchingFindsThePaneShowingSomething(Server server) { String first = server.panes().get(0).id().value(); diff --git a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/ResourceInvalidationsTest.java b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/ResourceInvalidationsTest.java deleted file mode 100644 index 45c040f..0000000 --- a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/ResourceInvalidationsTest.java +++ /dev/null @@ -1,289 +0,0 @@ -package io.github.libtmux.mcp; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; - -import io.github.libtmux.Dimensions; -import io.github.libtmux.PaneEdges; -import io.github.libtmux.PaneId; -import io.github.libtmux.SessionId; -import io.github.libtmux.WindowId; -import io.github.libtmux.WindowIndex; -import io.github.libtmux.snapshot.ClientState; -import io.github.libtmux.snapshot.PaneState; -import io.github.libtmux.snapshot.ServerSnapshot; -import io.github.libtmux.snapshot.SessionState; -import io.github.libtmux.snapshot.WindowContext; -import io.github.libtmux.snapshot.WindowState; -import java.nio.file.Path; -import java.time.Instant; -import java.util.List; -import java.util.Optional; -import java.util.Set; -import org.junit.jupiter.api.Test; - -final class ResourceInvalidationsTest { - - private static final Instant WHEN = Instant.parse("2026-08-28T00:00:00Z"); - private static final SessionId SESSION = new SessionId("$1"); - private static final WindowContext WINDOW = new WindowContext(SESSION, new WindowIndex(0), new WindowId("@1")); - private static final WindowContext OTHER_WINDOW = - new WindowContext(SESSION, new WindowIndex(1), new WindowId("@2")); - private static final PaneId PANE = new PaneId("%1"); - private static final PaneId OTHER_PANE = new PaneId("%2"); - private static final Dimensions SIZE = new Dimensions(80, 24); - - @Test - void sessionRenameInvalidatesTheOldAndNewAddresses() { - ServerSnapshot before = onePane(WHEN, "alpha", false, "zsh", SIZE, List.of()); - ServerSnapshot after = onePane(WHEN.plusSeconds(1), "renamed", false, "zsh", SIZE, List.of()); - - assertEquals( - Set.of( - "tmux://sessions", - "tmux://sessions/alpha", - "tmux://sessions/renamed", - "tmux://panes", - "tmux://panes/%251"), - changed(before, after)); - } - - @Test - void paneMetadataChangeInvalidatesOnlyPaneMetadata() { - ServerSnapshot before = onePane(WHEN, "alpha", false, "zsh", SIZE, List.of()); - ServerSnapshot after = onePane(WHEN.plusSeconds(1), "alpha", false, "nvim", SIZE, List.of()); - - assertEquals(Set.of("tmux://panes", "tmux://panes/%251"), changed(before, after)); - } - - @Test - void addingOrRemovingAPaneInvalidatesItsAddressesAndServerCount() { - PaneState first = pane(WINDOW, PANE, "zsh", SIZE); - PaneState second = pane(WINDOW, OTHER_PANE, 1, "tail", SIZE); - ServerSnapshot before = snapshot(WHEN, "alpha", false, List.of(window(WINDOW, "shell", 1)), List.of(first)); - ServerSnapshot after = snapshot( - WHEN.plusSeconds(1), "alpha", false, List.of(window(WINDOW, "shell", 2)), List.of(first, second)); - Set expected = - Set.of("tmux://server", "tmux://panes", "tmux://panes/%252", "tmux://panes/%252/content"); - - assertEquals(expected, changed(before, after)); - assertEquals(expected, changed(after, before)); - } - - @Test - void addingOrRemovingASessionInvalidatesItsAddressAndServerCount() { - ServerSnapshot empty = ServerSnapshot.of(WHEN, List.of(), List.of(), List.of(), List.of()); - ServerSnapshot present = ServerSnapshot.of( - WHEN.plusSeconds(1), - List.of(new SessionState(SESSION, "alpha", false, 0)), - List.of(), - List.of(), - List.of()); - Set expected = Set.of("tmux://server", "tmux://sessions", "tmux://sessions/alpha"); - - assertEquals(expected, changed(empty, present)); - assertEquals(expected, changed(present, empty)); - } - - @Test - void paneDimensionsAlsoInvalidateContent() { - ServerSnapshot before = onePane(WHEN, "alpha", false, "zsh", SIZE, List.of()); - ServerSnapshot after = onePane(WHEN.plusSeconds(1), "alpha", false, "zsh", new Dimensions(120, 40), List.of()); - - assertEquals(Set.of("tmux://panes", "tmux://panes/%251", "tmux://panes/%251/content"), changed(before, after)); - } - - @Test - void replacingAPaneProcessAlsoInvalidatesItsContent() { - PaneState beforePane = pane(WINDOW, PANE, "zsh", SIZE, 11L); - PaneState afterPane = pane(WINDOW, PANE, "sleep", SIZE, 12L); - ServerSnapshot before = - snapshot(WHEN, "alpha", false, List.of(window(WINDOW, "shell", 1)), List.of(beforePane)); - ServerSnapshot after = - snapshot(WHEN.plusSeconds(1), "alpha", false, List.of(window(WINDOW, "shell", 1)), List.of(afterPane)); - - assertEquals(Set.of("tmux://panes", "tmux://panes/%251", "tmux://panes/%251/content"), changed(before, after)); - } - - @Test - void hierarchyCountsInvalidateTheServerResource() { - PaneState pane = pane(WINDOW, PANE, "zsh", SIZE); - ServerSnapshot before = snapshot(WHEN, "alpha", false, List.of(window(WINDOW, "shell", 1)), List.of(pane)); - ServerSnapshot after = snapshot( - WHEN.plusSeconds(1), - "alpha", - false, - List.of(window(WINDOW, "shell", 1), window(OTHER_WINDOW, "logs", 0)), - List.of(pane)); - - assertEquals(Set.of("tmux://server", "tmux://sessions", "tmux://sessions/alpha"), changed(before, after)); - } - - @Test - void aGenerationGapInvalidatesAllOldAndNewKnownResources() { - ServerSnapshot before = onePane(WHEN, "alpha", false, "zsh", SIZE, List.of()); - PaneState replacement = pane(WINDOW, OTHER_PANE, "nvim", SIZE); - ServerSnapshot after = snapshot( - WHEN.plusSeconds(1), "renamed", false, List.of(window(WINDOW, "shell", 1)), List.of(replacement)); - - assertEquals( - Set.of( - "tmux://server", - "tmux://sessions", - "tmux://panes", - "tmux://sessions/alpha", - "tmux://sessions/renamed", - "tmux://panes/%251", - "tmux://panes/%251/content", - "tmux://panes/%252", - "tmux://panes/%252/content"), - ResourceInvalidations.allKnown(project(before), project(after))); - } - - @Test - void paneOutputInvalidatesOnlyThatPanesContent() { - assertEquals(Set.of("tmux://panes/%251/content"), ResourceInvalidations.output(PANE)); - } - - @Test - void droppedOutputInvalidatesEveryKnownPaneContentOnce() { - WindowContext linked = new WindowContext(SESSION, new WindowIndex(1), new WindowId("@1")); - PaneState first = pane(WINDOW, PANE, "zsh", SIZE); - PaneState duplicate = pane(linked, PANE, "zsh", SIZE); - PaneState second = pane(WINDOW, OTHER_PANE, 1, "tail", SIZE); - ServerSnapshot snapshot = snapshot( - WHEN, - "alpha", - false, - List.of(window(WINDOW, "shell", 2), window(linked, "linked", 1)), - List.of(first, duplicate, second)); - - assertEquals( - Set.of("tmux://panes/%251/content", "tmux://panes/%252/content"), - ResourceInvalidations.droppedOutput(project(snapshot))); - } - - @Test - void capturedTimeAloneProducesNoInvalidation() { - ServerSnapshot before = onePane(WHEN, "alpha", false, "zsh", SIZE, List.of()); - ServerSnapshot after = onePane(WHEN.plusSeconds(1), "alpha", false, "zsh", SIZE, List.of()); - - assertEquals(Set.of(), changed(before, after)); - } - - @Test - void hiddenClientsDoNotMakeSessionsAppearAttached() { - ServerSnapshot before = onePane(WHEN, "alpha", false, "zsh", SIZE, List.of()); - ServerSnapshot after = onePane( - WHEN.plusSeconds(1), - "alpha", - true, - "zsh", - SIZE, - List.of(new ClientState("watcher", Optional.of(SESSION)))); - - assertEquals( - Set.of(), - ResourceInvalidations.between( - ResourceInvalidations.project(before, Set.of("watcher")::contains), - ResourceInvalidations.project(after, Set.of("watcher")::contains))); - } - - @Test - void visibleClientsMakeSessionsAppearAttached() { - ServerSnapshot before = onePane(WHEN, "alpha", false, "zsh", SIZE, List.of()); - ServerSnapshot after = onePane( - WHEN.plusSeconds(1), - "alpha", - true, - "zsh", - SIZE, - List.of(new ClientState("terminal", Optional.of(SESSION)))); - - assertEquals(Set.of("tmux://sessions", "tmux://sessions/alpha"), changed(before, after)); - } - - @Test - void changedUriSetsAreImmutable() { - ServerSnapshot before = onePane(WHEN, "alpha", false, "zsh", SIZE, List.of()); - ServerSnapshot after = onePane(WHEN.plusSeconds(1), "alpha", false, "nvim", SIZE, List.of()); - Set changed = changed(before, after); - - assertThrows(UnsupportedOperationException.class, () -> changed.add("tmux://server")); - } - - private static Set changed(ServerSnapshot before, ServerSnapshot after) { - return ResourceInvalidations.between(project(before), project(after)); - } - - private static ResourceInvalidations.Projection project(ServerSnapshot snapshot) { - return ResourceInvalidations.project(snapshot, ignored -> false); - } - - private static ServerSnapshot onePane( - Instant when, - String sessionName, - boolean attached, - String command, - Dimensions size, - List clients) { - return ServerSnapshot.of( - when, - List.of(new SessionState(SESSION, sessionName, attached, 1)), - List.of(window(WINDOW, "shell", 1)), - List.of(pane(WINDOW, PANE, command, size)), - clients); - } - - private static ServerSnapshot snapshot( - Instant when, String sessionName, boolean attached, List windows, List panes) { - return snapshot(when, sessionName, attached, windows, panes, List.of()); - } - - private static ServerSnapshot snapshot( - Instant when, - String sessionName, - boolean attached, - List windows, - List panes, - List clients) { - return ServerSnapshot.of( - when, - List.of(new SessionState(SESSION, sessionName, attached, windows.size())), - windows, - panes, - clients); - } - - private static WindowState window(WindowContext context, String name, int panes) { - return new WindowState(context, name, true, panes, false, SIZE, "layout"); - } - - private static PaneState pane(WindowContext context, PaneId id, String command, Dimensions size) { - return pane(context, id, 0, command, size, 1L); - } - - private static PaneState pane(WindowContext context, PaneId id, String command, Dimensions size, long pid) { - return pane(context, id, 0, command, size, pid); - } - - private static PaneState pane(WindowContext context, PaneId id, int index, String command, Dimensions size) { - return pane(context, id, index, command, size, 1L); - } - - private static PaneState pane( - WindowContext context, PaneId id, int index, String command, Dimensions size, long pid) { - return new PaneState( - context, - id, - index, - true, - command, - size, - "title", - Path.of("/work"), - pid, - new PaneEdges(true, true, true, true), - Optional.of(false)); - } -} diff --git a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/RunningCommandsTest.java b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/RunningCommandsTest.java index 9dfbe74..1d6a57b 100644 --- a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/RunningCommandsTest.java +++ b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/RunningCommandsTest.java @@ -322,7 +322,7 @@ void aPaneThatIsNotThereSaysWhichToolFindsOne(Server server) { () -> RunningCommands.run(TestCalls.on(server, "pane_id", "%999", "command", "true"))); String message = String.valueOf(refused.getMessage()); - assertTrue(message.contains("tmux_list_panes"), message); + assertTrue(message.contains("list_panes"), message); } @Test diff --git a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/SafetyTest.java b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/SafetyTest.java deleted file mode 100644 index 7f58c3a..0000000 --- a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/SafetyTest.java +++ /dev/null @@ -1,47 +0,0 @@ -package io.github.libtmux.mcp; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import org.junit.jupiter.api.Test; - -/** What a ceiling permits, and the names an operator writes. */ -final class SafetyTest { - - @Test - void aCeilingPermitsItselfAndEverythingBelowIt() { - assertTrue(Safety.READONLY.allows(Safety.READONLY)); - assertFalse(Safety.READONLY.allows(Safety.MUTATING)); - assertFalse(Safety.READONLY.allows(Safety.DESTRUCTIVE)); - - assertTrue(Safety.MUTATING.allows(Safety.READONLY)); - assertTrue(Safety.MUTATING.allows(Safety.MUTATING)); - assertFalse(Safety.MUTATING.allows(Safety.DESTRUCTIVE)); - - assertTrue(Safety.DESTRUCTIVE.allows(Safety.DESTRUCTIVE)); - } - - /** The same three words every port of libtmux takes, so one configuration covers them all. */ - @Test - void theNamesAreTheOnesEveryPortAccepts() { - assertEquals("readonly", Safety.READONLY.wireName()); - assertEquals("mutating", Safety.MUTATING.wireName()); - assertEquals("destructive", Safety.DESTRUCTIVE.wireName()); - - for (Safety safety : Safety.values()) { - assertEquals(safety, Safety.ofWireName(safety.wireName())); - } - } - - @Test - void aNameNobodyRecognisesSaysWhatWasExpected() { - IllegalArgumentException refused = - assertThrows(IllegalArgumentException.class, () -> Safety.ofWireName("safe")); - - String message = String.valueOf(refused.getMessage()); - assertTrue(message.contains("readonly"), message); - assertTrue(message.contains("destructive"), message); - } -} diff --git a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/ServerDiscoveryTest.java b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/ServerDiscoveryTest.java deleted file mode 100644 index a4bb69d..0000000 --- a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/ServerDiscoveryTest.java +++ /dev/null @@ -1,373 +0,0 @@ -package io.github.libtmux.mcp; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.io.IOException; -import java.net.StandardProtocolFamily; -import java.net.UnixDomainSocketAddress; -import java.nio.channels.ServerSocketChannel; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.attribute.PosixFilePermissions; -import java.time.Duration; -import java.util.ArrayList; -import java.util.List; -import java.util.Objects; -import java.util.concurrent.FutureTask; -import java.util.function.BooleanSupplier; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; - -@SuppressWarnings("try") -final class ServerDiscoveryTest { - - private static final String STRICT_PROBE = """ - #!/bin/sh - [ "$#" -eq 5 ] || exit 90 - [ "$1" = '-S' ] || exit 91 - [ "$3" = 'list-sessions' ] || exit 92 - [ "$4" = '-F' ] || exit 93 - [ "$5" = '#{session_id}' ] || exit 94 - printf '%s\n' "$*" >> "${2}.calls" - """; - - @Test - void staleSocketIsUnreachableAfterOneStrictProbe(@TempDir Path root) throws Exception { - Path socket = standard(root, "stale"); - staleSocket(socket); - Path binary = binary(root, "exit 1\n"); - - ServerDiscovery.Result result = - discovery(root, 8, 32, 2, Duration.ofSeconds(1)).discover(binary.toString(), null); - - assertEquals(1, result.servers().size()); - ServerDiscovery.KnownServer known = result.servers().get(0); - assertEquals(socket, known.socket()); - assertEquals(ServerDiscovery.State.UNREACHABLE, known.state()); - assertNull(known.sessions()); - assertEquals(1, Files.readAllLines(callLog(socket)).size(), "one candidate gets one strict probe"); - } - - @Test - void candidateIsNotProbedWhenTmuxCannotStart(@TempDir Path root) throws Exception { - Path socket = standard(root, "socket"); - try (SocketSet ignored = sockets(List.of(socket))) { - Path missingBinary = root.resolve("missing-tmux"); - - ServerDiscovery.Result result = - discovery(root, 8, 32, 1, Duration.ofSeconds(1)).discover(missingBinary.toString(), null); - - assertEquals( - ServerDiscovery.State.NOT_PROBED, result.servers().get(0).state()); - assertNull(result.servers().get(0).sessions()); - } - } - - @Test - void slowSocketHasATypedDeadline(@TempDir Path root) throws Exception { - Path socket = standard(root, "slow"); - try (SocketSet ignored = sockets(List.of(socket))) { - Path binary = binary(root, "exec sleep 10\n"); - ServerDiscovery discovery = discovery(root, 8, 32, 1, Duration.ofMillis(200)); - - long started = System.nanoTime(); - ServerDiscovery.Result result = discovery.discover(binary.toString(), null); - Duration elapsed = Duration.ofNanos(System.nanoTime() - started); - - assertEquals( - ServerDiscovery.State.TIMED_OUT, result.servers().get(0).state()); - assertTrue(elapsed.compareTo(Duration.ofSeconds(2)) < 0, "per-candidate deadline was " + elapsed); - assertEquals(1, Files.readAllLines(callLog(socket)).size()); - } - } - - @Test - void candidateCapReportsTruncation(@TempDir Path root) throws Exception { - List candidates = - List.of(standard(root, "a"), standard(root, "b"), standard(root, "c"), standard(root, "d")); - try (SocketSet ignored = sockets(candidates)) { - Path binary = binary(root, "printf '%%1\\n'\n"); - - ServerDiscovery.Result result = - discovery(root, 2, 16, 2, Duration.ofSeconds(1)).discover(binary.toString(), null); - - assertEquals(2, result.servers().size()); - assertTrue(result.truncated()); - assertTrue(result.servers().stream().allMatch(server -> server.state() == ServerDiscovery.State.RUNNING)); - assertTrue(result.servers().stream() - .allMatch(server -> Integer.valueOf(1).equals(server.sessions()))); - } - } - - @Test - void candidateCapSelectsLexicallyFromTheInspectedEntries(@TempDir Path root) throws Exception { - List candidates = - List.of(standard(root, "d"), standard(root, "c"), standard(root, "b"), standard(root, "a")); - try (SocketSet ignored = sockets(candidates)) { - Path binary = binary(root, "printf '%%1\\n'\n"); - - ServerDiscovery.Result result = - discovery(root, 2, 16, 2, Duration.ofSeconds(1)).discover(binary.toString(), null); - - assertEquals( - List.of(standard(root, "a"), standard(root, "b")), - result.servers().stream() - .map(ServerDiscovery.KnownServer::socket) - .toList()); - assertTrue(result.truncated()); - } - } - - @Test - void processConcurrencyNeverExceedsTheConfiguredBound(@TempDir Path root) throws Exception { - List candidates = - List.of(standard(root, "a"), standard(root, "b"), standard(root, "c"), standard(root, "d")); - try (SocketSet ignored = sockets(candidates)) { - Path binary = binary(root, """ - : > "${2}.started" - while [ ! -e "${2}.release" ]; do - sleep 0.01 - done - printf '%%1\n' - """); - ServerDiscovery discovery = discovery(root, 4, 16, 2, Duration.ofSeconds(2)); - FutureTask task = - new FutureTask<>(() -> discovery.discover(binary.toString(), null)); - Thread worker = Thread.startVirtualThread(task); - - await(() -> started(candidates) >= 2, Duration.ofSeconds(2)); - Thread.sleep(150); - int firstWave = started(candidates); - releaseStarted(candidates); - assertEquals(2, firstWave, "a third process started before one of the first two left"); - await(() -> started(candidates) == 4, Duration.ofSeconds(2)); - releaseStarted(candidates); - - ServerDiscovery.Result result = task.get(); - worker.join(); - assertTrue(result.servers().stream().allMatch(server -> server.state() == ServerDiscovery.State.RUNNING)); - } - } - - @Test - void uidFailureDoesNotScanTheRootFallbackDirectory(@TempDir Path root) throws Exception { - Path rootFallback = root.resolve("tmux-0/would-have-been-probed"); - try (SocketSet ignored = sockets(List.of(rootFallback))) { - Path binary = binary(root, "printf '%%1\\n'\n"); - ServerDiscovery discovery = new ServerDiscovery( - root, - () -> { - throw new IOException("uid unavailable"); - }, - 8, - 32, - 2, - Duration.ofSeconds(1)); - - ServerDiscovery.Result result = discovery.discover(binary.toString(), null); - - assertTrue(result.servers().isEmpty()); - assertTrue(Objects.requireNonNull(result.scanNote()).contains("user")); - assertFalse(Files.exists(callLog(rootFallback)), "UID failure must not become uid 0"); - } - } - - @Test - void currentCustomSocketOutsideTheStandardDirectoryIsIncludedOnce(@TempDir Path root) throws Exception { - Path standard = standard(root, "standard"); - Path custom = root.resolve("custom/current"); - try (SocketSet ignored = sockets(List.of(standard, custom))) { - Path binary = binary(root, "printf '%%1\\n'\n"); - - ServerDiscovery.Result result = - discovery(root, 8, 32, 2, Duration.ofSeconds(1)).discover(binary.toString(), custom); - - assertEquals(2, result.servers().size()); - assertEquals( - 1, - result.servers().stream() - .filter(server -> server.socket().equals(custom)) - .count()); - assertEquals(1, Files.readAllLines(callLog(custom)).size()); - } - } - - @Test - void candidateCapReservesTheCurrentSocket(@TempDir Path root) throws Exception { - Path first = standard(root, "a"); - Path second = standard(root, "b"); - Path current = root.resolve("z-custom/current"); - try (SocketSet ignored = sockets(List.of(first, second, current))) { - Path binary = binary(root, "printf '%%1\\n'\n"); - - ServerDiscovery.Result result = - discovery(root, 2, 16, 2, Duration.ofSeconds(1)).discover(binary.toString(), current); - - assertEquals( - List.of(first, current), - result.servers().stream() - .map(ServerDiscovery.KnownServer::socket) - .toList()); - assertTrue(result.truncated()); - } - } - - @Test - void currentSocketFoundByTheDirectoryScanIsNotProbedTwice(@TempDir Path root) throws Exception { - Path current = standard(root, "current"); - try (SocketSet ignored = sockets(List.of(current))) { - Path binary = binary(root, "printf '%%1\\n'\n"); - - ServerDiscovery.Result result = - discovery(root, 8, 32, 2, Duration.ofSeconds(1)).discover(binary.toString(), current); - - assertEquals(1, result.servers().size()); - assertEquals(1, Files.readAllLines(callLog(current)).size()); - } - } - - @Test - void symlinkedSocketEntriesAreNotProbed(@TempDir Path root) throws Exception { - Path outside = root.resolve("outside/socket"); - try (SocketSet ignored = sockets(List.of(outside))) { - Path directory = standardDirectory(root); - Files.createSymbolicLink(directory.resolve("alias"), outside); - Path binary = binary(root, "printf '%%1\\n'\n"); - - ServerDiscovery.Result result = - discovery(root, 8, 32, 2, Duration.ofSeconds(1)).discover(binary.toString(), null); - - assertTrue(result.servers().isEmpty()); - assertFalse(Files.exists(callLog(outside))); - } - } - - @Test - void fifoEntriesAreNotProbedAsSockets(@TempDir Path root) throws Exception { - Path directory = standardDirectory(root); - Path fifo = directory.resolve("not-a-socket"); - Process process = new ProcessBuilder("mkfifo", fifo.toString()).start(); - assertEquals(0, process.waitFor()); - Path binary = binary(root, "printf '%%1\\n'\n"); - - ServerDiscovery.Result result = - discovery(root, 8, 32, 2, Duration.ofSeconds(1)).discover(binary.toString(), null); - - assertTrue(result.servers().isEmpty()); - assertFalse(Files.exists(callLog(fifo))); - } - - @Test - void unsafeStandardSocketDirectoryIsNotScanned(@TempDir Path root) throws Exception { - Path socket = standard(root, "untrusted"); - try (SocketSet ignored = sockets(List.of(socket))) { - Files.setPosixFilePermissions(socket.getParent(), PosixFilePermissions.fromString("rwx---r-x")); - Path binary = binary(root, "printf '%%1\\n'\n"); - - ServerDiscovery.Result result = - discovery(root, 8, 32, 2, Duration.ofSeconds(1)).discover(binary.toString(), null); - - assertTrue(result.servers().isEmpty()); - assertTrue(Objects.requireNonNull(result.scanNote()).contains("unsafe")); - assertFalse(Files.exists(callLog(socket))); - } - } - - private static ServerDiscovery discovery( - Path root, int candidateLimit, int scanLimit, int maxConcurrency, Duration timeout) throws IOException { - return new ServerDiscovery(root, () -> uid(root), candidateLimit, scanLimit, maxConcurrency, timeout); - } - - private static Path standard(Path root, String name) throws IOException { - return standardDirectory(root).resolve(name); - } - - private static Path standardDirectory(Path root) throws IOException { - Path directory = Files.createDirectories(root.resolve("tmux-" + uid(root))); - Files.setPosixFilePermissions(directory, PosixFilePermissions.fromString("rwx------")); - return directory; - } - - private static long uid(Path path) throws IOException { - return ((Number) Files.getAttribute(path, "unix:uid")).longValue(); - } - - private static Path binary(Path root, String behavior) throws IOException { - Path binary = root.resolve("fake-tmux-" + System.nanoTime()); - Files.writeString(binary, STRICT_PROBE + behavior); - Files.setPosixFilePermissions(binary, PosixFilePermissions.fromString("rwx------")); - return binary; - } - - private static SocketSet sockets(List paths) throws IOException { - List channels = new ArrayList<>(); - try { - for (Path path : paths) { - Files.createDirectories(path.getParent()); - ServerSocketChannel channel = ServerSocketChannel.open(StandardProtocolFamily.UNIX); - channel.bind(UnixDomainSocketAddress.of(path)); - channels.add(channel); - } - return new SocketSet(channels); - } catch (IOException | RuntimeException e) { - new SocketSet(channels).close(); - throw e; - } - } - - private static void staleSocket(Path path) throws IOException { - try (SocketSet ignored = sockets(List.of(path))) { - // Closing the listener deliberately leaves its socket node behind. - } - } - - private static Path callLog(Path socket) { - return Path.of(socket + ".calls"); - } - - private static int started(List sockets) { - return (int) sockets.stream() - .filter(socket -> Files.exists(Path.of(socket + ".started"))) - .count(); - } - - private static void releaseStarted(List sockets) throws IOException { - for (Path socket : sockets) { - if (Files.exists(Path.of(socket + ".started"))) { - Files.writeString(Path.of(socket + ".release"), ""); - } - } - } - - private static void await(BooleanSupplier condition, Duration timeout) throws InterruptedException { - long deadline = System.nanoTime() + timeout.toNanos(); - while (!condition.getAsBoolean() && System.nanoTime() < deadline) { - Thread.sleep(10); - } - assertTrue(condition.getAsBoolean(), "condition did not become true within " + timeout); - } - - private record SocketSet(List channels) implements AutoCloseable { - @Override - public void close() throws IOException { - IOException failure = null; - for (ServerSocketChannel channel : channels) { - try { - channel.close(); - } catch (IOException e) { - if (failure == null) { - failure = e; - } else { - failure.addSuppressed(e); - } - } - } - if (failure != null) { - throw failure; - } - } - } -} diff --git a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/ServerRequestTimeoutTest.java b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/ServerRequestTimeoutTest.java index fe4aeb8..6c9b45d 100644 --- a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/ServerRequestTimeoutTest.java +++ b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/ServerRequestTimeoutTest.java @@ -32,7 +32,7 @@ final class ServerRequestTimeoutTest { @Test void serverRequestsRetainTheSdkDefaultBound(Server server) { CapturingProvider provider = new CapturingProvider(); - McpSyncServer mcp = TmuxMcpServer.serving(server, Safety.MUTATING, provider); + McpSyncServer mcp = TmuxMcpServer.serving(server, provider); RecordingScheduler scheduler = new RecordingScheduler(); Schedulers.Snapshot snapshot = Schedulers.setFactoryWithSnapshot(scheduler); try { diff --git a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/TestCalls.java b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/TestCalls.java index 6efc5ca..dd057bc 100644 --- a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/TestCalls.java +++ b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/TestCalls.java @@ -20,8 +20,7 @@ static Call on(Server server, Object... pairs) { for (int index = 0; index + 1 < pairs.length; index += 2) { arguments.put(pairs[index].toString(), pairs[index + 1]); } - Connection connection = new Connection( - server, Caller.nowhere(), Safety.DESTRUCTIVE, java.util.concurrent.ConcurrentHashMap.newKeySet()); + Connection connection = new Connection(server, Caller.nowhere(), ToolSurface.defaults()); return new Call(connection, arguments, Call.Progress.SILENT); } @@ -44,11 +43,7 @@ static Call withEnvironment(Server server, Map environment, Obje } private static Call withEnvironment(Server server, Map environment, Map arguments) { - Connection connection = new Connection( - server, - Caller.of(server, environment), - Safety.DESTRUCTIVE, - java.util.concurrent.ConcurrentHashMap.newKeySet()); + Connection connection = new Connection(server, Caller.of(server, environment), ToolSurface.defaults()); return new Call(connection, arguments, Call.Progress.SILENT); } diff --git a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/TextPatternsTest.java b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/TextPatternsTest.java new file mode 100644 index 0000000..1047a64 --- /dev/null +++ b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/TextPatternsTest.java @@ -0,0 +1,60 @@ +package io.github.libtmux.mcp; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import org.junit.jupiter.api.Test; + +final class TextPatternsTest { + + @Test + void patternInputsAndSearchWorkAreFixedBeforeMatching() { + assertThrows( + IllegalArgumentException.class, + () -> TextPatterns.compile(List.of("x".repeat(TextPatterns.MAX_PATTERN_BYTES + 1)), false)); + + List tooMany = new ArrayList<>(); + for (int index = 0; index <= TextPatterns.MAX_PATTERNS; index++) { + tooMany.add("p" + index); + } + assertThrows(IllegalArgumentException.class, () -> TextPatterns.compile(tooMany, false)); + + assertThrows( + IllegalArgumentException.class, + () -> TextPatterns.compile(java.util.Collections.nCopies(TextPatterns.MAX_PATTERNS + 1, ""), false)); + assertTrue(TextPatterns.compileOne("", false).matches("anything")); + + assertThrows(IllegalArgumentException.class, () -> TextPatterns.compile(List.of("(a+)\\1"), true)); + + TextPatterns.WorkBudget budget = TextPatterns.searchBudget(); + for (int index = 0; index < TextPatterns.MAX_SEARCH_PANES; index++) { + assertTrue(budget.tryStartPane()); + } + assertFalse(budget.tryStartPane()); + assertTrue(budget.trySpend("x".repeat(TextPatterns.MAX_SEARCH_BYTES))); + assertFalse(budget.trySpend("x")); + TextPatterns.WorkBudget lines = TextPatterns.searchBudget(); + for (int index = 0; index < TextPatterns.MAX_SEARCH_LINES; index++) { + assertTrue(lines.trySpend("")); + } + assertFalse(lines.trySpend("")); + assertTrue(TextPatterns.MAX_SEARCH_TIME.toSeconds() > 0); + assertTrue(TextPatterns.MAX_SEARCH_TIME.toSeconds() <= 5); + + assertTrue(input("search_panes", "pattern").containsKey("maxLength")); + assertTrue(input("wait_for_text", "patterns").containsKey("maxItems")); + assertTrue(input("wait_for_text", "patterns").toString().contains("maxLength")); + } + + @SuppressWarnings("unchecked") + private static Map input(String tool, String name) { + Map properties = (Map) + Objects.requireNonNull(Catalog.named(tool).inputSchema().get("properties"), "properties"); + return (Map) Objects.requireNonNull(properties.get(name), name); + } +} diff --git a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/TmuxMcpServerTest.java b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/TmuxMcpServerTest.java index 3f8c6d3..213adea 100644 --- a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/TmuxMcpServerTest.java +++ b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/TmuxMcpServerTest.java @@ -6,13 +6,8 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import com.fasterxml.jackson.databind.ObjectMapper; -import io.github.libtmux.Pane; -import io.github.libtmux.Pane_; import io.github.libtmux.Server; -import io.github.libtmux.jackson.FilterJson; -import io.github.libtmux.jackson.LibTmuxModels; import io.github.libtmux.junit5.TmuxExtension; -import io.github.libtmux.query.FilterExpr; import io.modelcontextprotocol.json.jackson2.JacksonMcpJsonMapper; import io.modelcontextprotocol.server.McpSyncServer; import io.modelcontextprotocol.server.transport.StdioServerTransportProvider; @@ -26,7 +21,6 @@ import java.io.PipedOutputStream; import java.io.PrintStream; import java.nio.charset.StandardCharsets; -import java.time.Duration; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; @@ -43,50 +37,6 @@ @ExtendWith(TmuxExtension.class) final class TmuxMcpServerTest { - @Test - void theFilterExampleShownToAModelIsOneTheLibraryReads() { - FilterExpr parsed = FilterJson.readString(Catalog.EXAMPLE_FILTER, LibTmuxModels.pane()); - - assertEquals( - Pane_.command().startsWith("nvim").describe(), - parsed.describe(), - "the example must mean what it appears to mean"); - } - - /** And it has to select on a real server, not merely parse. */ - @Test - void theFilterExampleSelectsAgainstRealTmux(Server server) { - FilterExpr parsed = FilterJson.readString(Catalog.EXAMPLE_FILTER, LibTmuxModels.pane()); - - assertTrue( - server.panes().stream().noneMatch(parsed), - "the fixture runs a shell, so nothing should match a filter for nvim"); - assertEquals(1, server.panes().size(), "and the unfiltered listing still sees the pane"); - } - - @Test - void closingAnEmbeddedMcpServerClosesItsWatcher(Server server) throws Exception { - PipedInputStream input = new PipedInputStream(); - try (PipedOutputStream client = new PipedOutputStream(input)) { - client.flush(); - StdioServerTransportProvider transport = new StdioServerTransportProvider( - new JacksonMcpJsonMapper(new ObjectMapper()), input, new ByteArrayOutputStream()); - McpSyncServer mcp = TmuxMcpServer.serving(server, Safety.MUTATING, true, transport); - try { - assertTrue(await(() -> !server.clients().isEmpty()), "the watcher never attached"); - - mcp.close(); - - assertTrue(await(() -> server.clients().isEmpty()), "closing MCP left its watcher attached"); - } finally { - mcp.close(); - for (var attached : server.clients()) { - server.cmd("detach-client", "-t", attached.name()); - } - } - } - } - @Test void brokenOutputEndsAStdioSessionEvenWhileInputRemainsOpen(Server server) throws Exception { CountDownLatch ended = new CountDownLatch(1); @@ -94,7 +44,7 @@ void brokenOutputEndsAStdioSessionEvenWhileInputRemainsOpen(Server server) throw PipedInputStream input = new PipedInputStream(); try (PipedOutputStream client = new PipedOutputStream(input); PrintStream output = new PrintStream(brokenOutput(), true, StandardCharsets.UTF_8)) { - McpSyncServer mcp = TmuxMcpServer.overStdio(server, input, output, Safety.MUTATING, false, () -> { + McpSyncServer mcp = TmuxMcpServer.overStdio(server, input, output, ToolSurface.defaults(), () -> { endCalls.incrementAndGet(); ended.countDown(); }); @@ -114,7 +64,7 @@ void brokenOutputEndsAStdioSessionEvenWhileInputRemainsOpen(Server server) throw void closingAStdioServerUnblocksItsInputReader(Server server) throws Exception { BlockingInput input = new BlockingInput(); McpSyncServer mcp = - TmuxMcpServer.overStdio(server, input, new ByteArrayOutputStream(), Safety.MUTATING, false, () -> {}); + TmuxMcpServer.overStdio(server, input, new ByteArrayOutputStream(), ToolSurface.defaults(), () -> {}); try { assertTrue(input.reading.await(3, TimeUnit.SECONDS), "the protocol reader never started"); @@ -128,76 +78,37 @@ void closingAStdioServerUnblocksItsInputReader(Server server) throws Exception { } } - @Test - void failedStdioStartupClosesItsOwnedInput(Server server) throws Exception { - server.sessions().getFirst().kill(); - BlockingInput input = new BlockingInput(); - try { - assertThrows( - IllegalStateException.class, - () -> TmuxMcpServer.overStdio( - server, input, new ByteArrayOutputStream(), Safety.MUTATING, true, () -> {})); - - assertTrue(input.closed.await(1, TimeUnit.SECONDS), "failed startup left its input stream open"); - } finally { - input.close(); - } - } - @Test void failedStartupClosesAnyAcceptedTransport(Server server) throws Exception { - for (boolean watching : new boolean[] {false, true}) { - AtomicInteger closes = new AtomicInteger(); - IllegalStateException startupFailure = new IllegalStateException("session factory failed"); - McpServerTransportProvider transport = new McpServerTransportProvider() { - @Override - public void setSessionFactory(McpServerSession.Factory factory) { - throw startupFailure; - } + AtomicInteger closes = new AtomicInteger(); + IllegalStateException startupFailure = new IllegalStateException("session factory failed"); + McpServerTransportProvider transport = new McpServerTransportProvider() { + @Override + public void setSessionFactory(McpServerSession.Factory factory) { + throw startupFailure; + } - @Override - public Mono notifyClients(String method, Object params) { - return Mono.empty(); - } + @Override + public Mono notifyClients(String method, Object params) { + return Mono.empty(); + } - @Override - public Mono closeGracefully() { - return Mono.empty(); - } + @Override + public Mono closeGracefully() { + return Mono.empty(); + } - @Override - public void close() { - closes.incrementAndGet(); - } - }; + @Override + public void close() { + closes.incrementAndGet(); + } + }; - IllegalStateException thrown = assertThrows( - IllegalStateException.class, - () -> TmuxMcpServer.serving(server, Safety.MUTATING, watching, transport), - "watching=" + watching); + IllegalStateException thrown = assertThrows( + IllegalStateException.class, () -> TmuxMcpServer.serving(server, ToolSurface.defaults(), transport)); - assertSame(startupFailure, thrown); - assertEquals(1, closes.get(), "accepted transport was not closed exactly once"); - assertTrue(await(() -> server.clients().isEmpty()), "failed startup left a watcher attached"); - } - } - - @Test - void brokenOutputDetachesTheOwnedWatcherWhileInputRemainsOpen(Server server) throws Exception { - PipedInputStream input = new PipedInputStream(); - try (PipedOutputStream client = new PipedOutputStream(input); - PrintStream output = new PrintStream(brokenOutput(), true, StandardCharsets.UTF_8)) { - McpSyncServer mcp = TmuxMcpServer.overStdio(server, input, output, Safety.MUTATING, true, () -> {}); - try { - assertTrue(await(() -> !server.clients().isEmpty()), "the watcher never attached"); - client.write(initialize()); - client.flush(); - - assertTrue(await(() -> server.clients().isEmpty()), "stdout failed but the watcher stayed attached"); - } finally { - mcp.close(); - } - } + assertSame(startupFailure, thrown); + assertEquals(1, closes.get(), "accepted transport was not closed exactly once"); } @Test @@ -208,7 +119,7 @@ void oversizedStdioInputEndsTheSessionBeforeNewline(Server server) throws Except try (PipedOutputStream client = new PipedOutputStream(input)) { var transport = new StdioServerTransportProvider( new JacksonMcpJsonMapper(new ObjectMapper()), input, new ByteArrayOutputStream(), 64); - McpSyncServer mcp = TmuxMcpServer.serving(server, Safety.MUTATING, lifetime.observe(transport)); + McpSyncServer mcp = TmuxMcpServer.serving(server, ToolSurface.defaults(), lifetime.observe(transport)); try { client.write("x".repeat(65).getBytes(StandardCharsets.UTF_8)); client.flush(); @@ -270,15 +181,4 @@ public void close() { closed.countDown(); } } - - private static boolean await(java.util.function.BooleanSupplier condition) throws InterruptedException { - long deadline = System.nanoTime() + Duration.ofSeconds(5).toNanos(); - while (System.nanoTime() < deadline) { - if (condition.getAsBoolean()) { - return true; - } - Thread.sleep(25); - } - return condition.getAsBoolean(); - } } diff --git a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/ToolsAgainstTmuxTest.java b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/ToolsAgainstTmuxTest.java index 9b7ea08..59b834d 100644 --- a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/ToolsAgainstTmuxTest.java +++ b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/ToolsAgainstTmuxTest.java @@ -1,23 +1,16 @@ package io.github.libtmux.mcp; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import io.github.libtmux.ObjectDoesNotExist; import io.github.libtmux.Server; -import io.github.libtmux.ServerConfig; import io.github.libtmux.WakeReason; import io.github.libtmux.junit5.TmuxExtension; -import io.github.libtmux.transport.CommandRequest; -import io.github.libtmux.transport.CommandResult; -import io.github.libtmux.transport.ProcessTransport; -import io.github.libtmux.transport.TmuxTransport; import java.util.List; import java.util.Map; -import java.util.concurrent.atomic.AtomicInteger; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -38,63 +31,6 @@ void panesAreListedWithTheIdOtherToolsTake(Server server) { assertNull(panes.panes().get(0).caller(), "this process is not running inside the fixture"); } - /** - * A filter that selects nothing is the case a model cannot tell from an empty server, so the - * answer says which it was. - */ - @Test - void aFilterMatchingNothingSaysHowManyThereWere(Server server) { - Object filter = java.util.Map.of( - "schema", - "libtmux.filter/1", - "model", - "pane", - "expr", - java.util.Map.of( - "node", "compare", "field", "pane_current_command", "op", "starts_with", "value", "nvim")); - - Listings.Panes panes = Listings.panes(TestCalls.on(server, "filter", filter)); - - assertEquals(0, panes.count()); - assertTrue(String.valueOf(panes.note()).contains("without 'filter'"), String.valueOf(panes.note())); - } - - /** - * A model that guesses the filter's shape gets told the shape, not only that its guess was - * wrong. Measured against a real agent, which guessed a plain field map first and had to spend a - * call finding out. - */ - @Test - void aFilterThatWillNotReadSaysWhatOneLooksLike(Server server) { - IllegalArgumentException refused = assertThrows( - IllegalArgumentException.class, - () -> Listings.panes(TestCalls.on(server, "filter", java.util.Map.of("window_name", "build")))); - - String message = String.valueOf(refused.getMessage()); - assertTrue(message.contains("libtmux.filter/1"), message); - assertTrue(message.contains("\"node\":\"compare\""), "the shape to copy has to be in it: " + message); - assertTrue(message.contains("pane_current_command"), "and the fields it may name: " + message); - } - - /** A field the pane model does not have is named alongside the ones it does. */ - @Test - void aFieldThePaneModelLacksSaysWhichItHas(Server server) { - Object document = java.util.Map.of( - "schema", - "libtmux.filter/1", - "model", - "pane", - "expr", - java.util.Map.of("node", "compare", "field", "window_name", "op", "equals", "value", "build")); - - IllegalArgumentException refused = assertThrows( - IllegalArgumentException.class, () -> Listings.panes(TestCalls.on(server, "filter", document))); - - String message = String.valueOf(refused.getMessage()); - assertTrue(message.contains("pane_active"), message); - assertTrue(message.contains("list the panes"), "and where to look instead: " + message); - } - /** Ending a container that holds the caller's pane names the ones that could be ended instead. */ @Test void refusingToEndAContainerNamesWhatCanBeEnded(Server server) { @@ -111,17 +47,6 @@ void refusingToEndAContainerNamesWhatCanBeEnded(Server server) { assertEquals(2, server.panes().size()); } - @Test - void whoamiOnAServerThatIsNotRunningSaysSoRatherThanFailing(Server server) { - server.killServer(); - - Listings.Whoami whoami = Listings.whoami(server, Caller.nowhere(), Safety.MUTATING); - - assertTrue(whoami.note().contains("No tmux server is running"), whoami.note()); - assertTrue(whoami.note().contains("tmux_list_servers"), "and where to look instead: " + whoami.note()); - assertEquals(0, whoami.panes()); - } - /** An empty listing has to say whether the server was empty or absent; a count cannot. */ @Test void anEmptyListingSaysWhetherThereIsAServerAtAll(Server server) { @@ -135,76 +60,6 @@ void anEmptyListingSaysWhetherThereIsAServerAtAll(Server server) { assertTrue(String.valueOf(gone.note()).contains("No tmux server is running"), String.valueOf(gone.note())); } - @Test - void whoamiSaysWhichServerAndThatNoPaneIsSpecial(Server server) { - Listings.Whoami whoami = Listings.whoami(server, Caller.nowhere(), Safety.MUTATING); - - assertEquals(1, whoami.sessions()); - assertEquals(1, whoami.panes()); - assertEquals("mutating", whoami.safety()); - assertNull(whoami.callerPane()); - assertTrue(whoami.note().contains("no pane here is special"), whoami.note()); - assertNotNull(whoami.socket()); - } - - @Test - void serverDiscoveryReservesTheLiveSocketForAnAmbientEndpoint() { - String liveSocket = "/tmp/libtmux-java-test/ambient-custom"; - TmuxTransport reportsSocket = new TmuxTransport() { - @Override - public CommandResult execute(CommandRequest request) { - return new CommandResult(0, List.of(liveSocket), List.of()); - } - - @Override - public void close() {} - }; - ServerConfig config = ServerConfig.builder().binary("/bin/false").build(); - - try (Server ambient = Server.using(config, reportsSocket)) { - Listings.Servers servers = Listings.servers(ambient); - - assertTrue( - servers.servers().stream().anyMatch(found -> found.socket().equals(liveSocket))); - assertTrue(servers.note().contains(liveSocket), servers.note()); - } - } - - @Test - void whoamiCapturesTheHierarchyOnceInsteadOfTraversingLiveHandles(Server server) { - AtomicInteger commands = new AtomicInteger(); - try (ProcessTransport processes = new ProcessTransport()) { - TmuxTransport counting = new TmuxTransport() { - @Override - public CommandResult execute(CommandRequest request) { - commands.incrementAndGet(); - return processes.execute(request); - } - - @Override - public void close() {} - }; - try (Server measured = Server.using(server.config(), counting)) { - Listings.Whoami whoami = Listings.whoami(measured, Caller.nowhere(), Safety.MUTATING); - - assertEquals(1, whoami.sessions()); - assertEquals(3, commands.get(), "one identity read, the listings as one group, one socket path"); - } - } - } - - /** And when this process really is inside a pane, that pane is named as the one to protect. */ - @Test - void whoamiNamesTheCallersOwnPaneWhenThereIsOne(Server server) { - String pane = server.panes().get(0).id().value(); - Call call = TestCalls.asCaller(server, pane); - - Listings.Whoami whoami = Listings.whoami(server, call.caller(), Safety.DESTRUCTIVE); - - assertEquals(pane, whoami.callerPane()); - assertTrue(whoami.note().contains("confirm_self"), whoami.note()); - } - @Test void panesAreMarkedWhenTheyAreTheCallersOwn(Server server) { String pane = server.panes().get(0).id().value(); @@ -228,11 +83,16 @@ void windowsAndSessionsAreListedWithTheirIds(Server server) { } @Test - void nothingAttachedIsReportedAsNobodyWatching(Server server) { - Listings.Clients clients = Listings.clients(TestCalls.on(server)); + void validatedTmuxVariablesCanUseOnePaneContext(Server server) { + String pane = server.panes().getFirst().id().value(); - assertEquals(0, clients.count()); - assertTrue(String.valueOf(clients.note()).contains("no person is watching"), String.valueOf(clients.note())); + @SuppressWarnings("unchecked") + Map result = (Map) Operations.tmuxVariables( + TestCalls.on(server, "names", List.of("pane_id", "session_name"), "pane", pane)); + @SuppressWarnings("unchecked") + Map values = (Map) result.get("values"); + + assertEquals(Map.of("pane_id", pane, "session_name", "libtmux"), values); } // ---------------------------------------------------------------- refusing to end the conversation @@ -383,42 +243,6 @@ void aDirectionNobodyRecognisesSaysWhichOnesExist(Server server) { assertTrue(String.valueOf(refused.getMessage()).contains("below"), refused.getMessage()); } - /** One document, one call, and the ids of everything it built. */ - @Test - void aWholeSessionIsBuiltFromOneDocument(Server server) { - String document = """ - session_name: built-from-a-document - windows: - - window_name: editor - panes: - - echo editing - - window_name: services - layout: even-horizontal - panes: - - echo one - - echo two - """; - - Workspaces.Built built = Workspaces.apply(TestCalls.on(server, "workspace", document)); - - assertEquals("built-from-a-document", built.session()); - assertEquals(2, built.windows()); - assertEquals(3, built.panes()); - assertTrue(built.paneIds().stream().allMatch(pane -> pane.id().startsWith("%"))); - assertTrue(server.hasSession("built-from-a-document")); - } - - @Test - void aWorkspaceNamingASessionThatExistsIsRefusedBeforeAnythingIsBuilt(Server server) { - String document = "session_name: libtmux\nwindows:\n - window_name: w\n panes:\n - echo hi\n"; - - IllegalArgumentException refused = assertThrows( - IllegalArgumentException.class, () -> Workspaces.apply(TestCalls.on(server, "workspace", document))); - - assertTrue(String.valueOf(refused.getMessage()).contains("already there"), refused.getMessage()); - assertEquals(1, server.sessions().size()); - } - // ---------------------------------------------------------------- channels /** A signal outlives the moment it was sent, which is what draining exists to undo. */ @@ -482,7 +306,7 @@ void hooksAreReadableAndSayWhyTheyAreNotWritable(Server server) { void aTargetThatIsNotThereNamesTheToolThatFindsOne(Server server) { ObjectDoesNotExist missing = assertThrows(ObjectDoesNotExist.class, () -> Targets.window(server, "@999")); - assertTrue(String.valueOf(missing.getMessage()).contains("tmux_list_windows"), missing.getMessage()); + assertTrue(String.valueOf(missing.getMessage()).contains("list_windows"), missing.getMessage()); } /** tmux would read a bare number as an index, acting on a real but unintended pane. */ diff --git a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/TypingTest.java b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/TypingTest.java index 9fe5631..607aa12 100644 --- a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/TypingTest.java +++ b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/TypingTest.java @@ -9,6 +9,7 @@ import io.github.libtmux.LibTmuxException; import io.github.libtmux.Server; +import io.github.libtmux.SplitSpec; import io.github.libtmux.TmuxVersion; import io.github.libtmux.junit5.TmuxExtension; import io.github.libtmux.transport.CommandRequest; @@ -16,6 +17,7 @@ import io.github.libtmux.transport.ProcessTransport; import io.github.libtmux.transport.TmuxTransport; import java.util.List; +import java.util.Set; import java.util.concurrent.CountDownLatch; import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; @@ -38,9 +40,22 @@ void keysAreSentByNameSoAnInterruptInterrupts(Server server) { assertEquals(1, sent.keys()); assertFalse(sent.literal()); + assertEquals(List.of(pane), sent.resolvedPaneIds()); assertTrue(String.valueOf(sent.note()).contains("not waited for"), String.valueOf(sent.note())); } + @Test + void synchronizedInputDisclosesEveryResolvedPane(Server server) { + var source = server.panes().getFirst(); + var other = source.split(SplitSpec.builder().build()); + source.window().setSynchronizePanes(true); + + Typing.Sent sent = Typing.sendKeys( + TestCalls.on(server, "pane_id", source.id().value(), "keys", List.of("q"), "literal", true)); + + assertEquals(Set.of(source.id().value(), other.id().value()), Set.copyOf(sent.resolvedPaneIds())); + } + @Test void sendingNoKeysAtAllSaysWhatWasWanted(Server server) { String pane = server.panes().get(0).id().value(); diff --git a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/UrisTest.java b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/UrisTest.java deleted file mode 100644 index 7a7076c..0000000 --- a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/UrisTest.java +++ /dev/null @@ -1,48 +0,0 @@ -package io.github.libtmux.mcp; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; - -import io.github.libtmux.PaneId; -import java.util.List; -import org.junit.jupiter.api.Test; - -final class UrisTest { - - @Test - void resourceIdentifiersEncodeOnePathSegment() { - assertEquals("tmux://panes/%251", Resources.paneUri(new PaneId("%1"))); - assertEquals("tmux://sessions/a%2Fb%20%25%3F%23", Resources.sessionUri("a/b %?#")); - } - - @Test - void templateValuesDecodeOnePathSegment() { - assertEquals(List.of("%1"), Uris.values(Resources.PANE_TEMPLATE, "tmux://panes/%251")); - assertEquals(List.of("a/b é"), Uris.values(Resources.SESSION_TEMPLATE, "tmux://sessions/a%2Fb%20%C3%A9")); - } - - @Test - void aTemplateMustConsumeTheWholeUri() { - assertThrows( - IllegalArgumentException.class, - () -> Uris.values(Resources.PANE_CONTENT_TEMPLATE, "tmux://panes/%251/content/extra")); - assertThrows( - IllegalArgumentException.class, () -> Uris.values(Resources.PANE_TEMPLATE, "tmux://panes/%251/extra")); - assertThrows(IllegalArgumentException.class, () -> Uris.values(Resources.PANE_TEMPLATE, "tmux://panes/")); - } - - @Test - void malformedEscapesAreRejected() { - assertThrows(IllegalArgumentException.class, () -> Uris.values(Resources.PANE_TEMPLATE, "tmux://panes/%1")); - } - - @Test - void equivalentButNoncanonicalUrisAreRejected() { - assertThrows( - IllegalArgumentException.class, () -> Uris.values(Resources.SESSION_TEMPLATE, "tmux://sessions/f%6Fo")); - assertThrows( - IllegalArgumentException.class, () -> Uris.values(Resources.SESSION_TEMPLATE, "tmux://sessions/a%2fb")); - assertThrows( - IllegalArgumentException.class, () -> Uris.values(Resources.SESSION_TEMPLATE, "tmux://sessions/a?b")); - } -} diff --git a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/WatchesTest.java b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/WatchesTest.java deleted file mode 100644 index c8139bc..0000000 --- a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/WatchesTest.java +++ /dev/null @@ -1,299 +0,0 @@ -package io.github.libtmux.mcp; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import io.github.libtmux.Pane; -import io.github.libtmux.PaneId; -import io.github.libtmux.Server; -import io.github.libtmux.junit5.TmuxExtension; -import java.time.Duration; -import java.util.List; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.CopyOnWriteArrayList; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicBoolean; -import java.util.concurrent.atomic.AtomicReference; -import java.util.function.BooleanSupplier; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; - -/** - * Being told what changed, rather than a model asking whether anything did. - * - *

tmux does the comparing on its own timer and pushes only differences, so a client that - * subscribes spends nothing while the server is idle. What is tested here is that the right resource - * is named — a notification about the wrong URI is worse than none, because a client acts on it. - */ -@ExtendWith(TmuxExtension.class) -final class WatchesTest { - - /** Records what would have gone out over the protocol. */ - private static final class Heard implements Watches.Notifier { - - private final List updated = new CopyOnWriteArrayList<>(); - - @Override - public void updated(String uri) { - updated.add(uri); - } - - void clear() { - updated.clear(); - } - } - - @Test - void aWindowAppearingTellsTheClientTheListingIsStale(Server server) throws Exception { - Connection connection = Connection.to(server, Safety.MUTATING); - Heard heard = new Heard(); - - try (Watches watching = Watches.start(connection, heard)) { - assertTrue(watching.isAlive(), "the control client stayed up"); - server.sessions().get(0).newWindow("appeared"); - - assertTrue(await(() -> heard.updated.contains("tmux://sessions")), heard.updated.toString()); - assertTrue(heard.updated.contains("tmux://panes")); - assertTrue(heard.updated.contains("tmux://server")); - assertTrue(heard.updated.contains("tmux://sessions/libtmux")); - String pane = server.windows().stream() - .filter(window -> window.name().equals("appeared")) - .findFirst() - .orElseThrow() - .panes() - .getFirst() - .id() - .value(); - assertTrue(heard.updated.contains(Resources.paneUri(new PaneId(pane)))); - assertTrue(heard.updated.contains(Resources.paneContentUri(new PaneId(pane)))); - } - } - - /** Output in a pane invalidates that pane's content, and names the pane it happened in. */ - @Test - void outputInAPaneNamesThatPanesContentAsStale(Server server) throws Exception { - Connection connection = Connection.to(server, Safety.MUTATING); - String pane = server.panes().get(0).id().value(); - Heard heard = new Heard(); - - try (Watches watching = Watches.start(connection, heard)) { - assertTrue(watching.isAlive()); - server.run(List.of("send-keys", "-l", "-t", pane, "echo watched-output")); - server.run(List.of("send-keys", "-t", pane, "Enter")); - - assertTrue( - await(() -> heard.updated.contains(Resources.paneContentUri(new PaneId(pane)))), - "the pane that produced output is the one named: " + heard.updated); - } - } - - @Test - void anInPlaceRedrawInvalidatesContentEvenWhenTheCursorDoesNotMove(Server server) throws Exception { - Connection connection = Connection.to(server, Safety.MUTATING); - Pane pane = server.panes().getFirst(); - String content = Resources.paneContentUri(pane.id()); - Heard heard = new Heard(); - - try (Watches watching = Watches.start(connection, heard)) { - assertTrue(watching.isAlive()); - assertTrue(await(() -> heard.updated.contains(content)), "the initial subscription never settled"); - heard.clear(); - pane.sendLine("printf first; sleep 4; printf '\\rother'; sleep 2"); - assertTrue(await(() -> heard.updated.contains(content)), "the first output was not observed"); - Thread.sleep(1_200); - heard.clear(); - - assertTrue(await(() -> heard.updated.contains(content)), "the in-place redraw was missed"); - } - } - - @Test - void outputInASecondSessionIsWatched(Server server) throws Exception { - Pane second = - server.newSession("watched-second").windows().getFirst().panes().getFirst(); - String content = Resources.paneContentUri(second.id()); - Heard heard = new Heard(); - - try (Watches watching = Watches.start(Connection.to(server, Safety.MUTATING), heard)) { - assertTrue(watching.isAlive()); - second.sendLine("echo second-session-output"); - - assertTrue(await(() -> heard.updated.contains(content)), "the second session was not covered"); - } - } - - @Test - void concurrentProducersNeverCallTheProtocolNotifierConcurrently(Server server) throws Exception { - CountDownLatch entered = new CountDownLatch(1); - CountDownLatch release = new CountDownLatch(1); - CountDownLatch secondReturned = new CountDownLatch(1); - AtomicBoolean notifying = new AtomicBoolean(); - AtomicBoolean concurrent = new AtomicBoolean(); - Watches.Notifier slow = uri -> { - if (!notifying.compareAndSet(false, true)) { - concurrent.set(true); - } - entered.countDown(); - await(release); - notifying.set(false); - }; - - try (Watches watching = Watches.start(Connection.to(server, Safety.MUTATING), slow)) { - Thread first = Thread.ofVirtual().start(() -> watching.output(new PaneId("%900"))); - assertTrue(entered.await(5, TimeUnit.SECONDS), "the first notification never started"); - Thread second = Thread.ofVirtual().start(() -> { - watching.output(new PaneId("%901")); - secondReturned.countDown(); - }); - - assertTrue(secondReturned.await(2, TimeUnit.SECONDS), "a slow client blocked a producer"); - assertFalse(concurrent.get(), "the protocol notifier was entered concurrently"); - release.countDown(); - first.join(); - second.join(); - } finally { - release.countDown(); - } - } - - @Test - void outageRetriesBackOffToACapAndRealActivityResetsThem() { - Watches.RetryBackoff backoff = new Watches.RetryBackoff(); - - assertEquals(Duration.ofMillis(250), backoff.delay()); - backoff.failedRetry(); - assertEquals(Duration.ofMillis(500), backoff.delay()); - for (int attempt = 0; attempt < 20; attempt++) { - backoff.failedRetry(); - } - assertEquals(Duration.ofSeconds(8), backoff.delay()); - backoff.reset(); - assertEquals(Duration.ofMillis(250), backoff.delay()); - } - - @Test - void aNewPanesFirstInvalidationComesAfterItsWatcherIsAttached(Server server) throws Exception { - CountDownLatch inspect = new CountDownLatch(1); - CountDownLatch contentAnnounced = new CountDownLatch(1); - AtomicReference content = new AtomicReference<>(); - AtomicReference session = new AtomicReference<>(); - AtomicBoolean attachedAtAnnouncement = new AtomicBoolean(); - Watches.Notifier notifier = uri -> { - await(inspect); - if (uri.equals(content.get())) { - attachedAtAnnouncement.set(server.snapshot().clients().stream() - .anyMatch(client -> - client.session().filter(session.get()::equals).isPresent())); - contentAnnounced.countDown(); - } - }; - - try (Watches watching = Watches.start(Connection.to(server, Safety.MUTATING), notifier)) { - assertTrue(watching.isAlive()); - var addedSession = server.newSession("attached-before-announced"); - Pane added = addedSession.windows().getFirst().panes().getFirst(); - session.set(addedSession.id()); - content.set(Resources.paneContentUri(added.id())); - inspect.countDown(); - - assertTrue(contentAnnounced.await(10, TimeUnit.SECONDS), "the new pane was not invalidated"); - assertTrue(attachedAtAnnouncement.get(), "a client could refresh before output watching was active"); - } finally { - inspect.countDown(); - } - } - - @Test - void watchingRecoversAfterTheTmuxServerRestarts(Server server) throws Exception { - Heard heard = new Heard(); - - try (Watches watching = Watches.start(Connection.to(server, Safety.MUTATING), heard)) { - server.killServer(); - // tmux answers a socket whose server is still exiting with "server exited unexpectedly", - // and leaves the socket file behind either way, so the replacement is retried rather - // than assumed. What the watcher does once one exists is the subject here. - assertTrue(await(() -> restarted(server)), "no replacement server could be started"); - Pane reborn = - server.sessions().getFirst().windows().getFirst().panes().getFirst(); - String content = Resources.paneContentUri(reborn.id()); - reborn.sendLine("echo after-restart"); - - assertTrue(await(watching::isAlive), "the watcher never reattached"); - assertTrue(await(() -> heard.updated.contains(content)), "output after restart was not observed"); - } - } - - /** - * Watching attaches a client, and an attached client is exactly what "is anybody looking at this" - * is answered with. This server's own watcher must not be mistaken for a person. - */ - @Test - void theWatchersOwnClientIsNotReportedAsSomebodyWatching(Server server) throws Exception { - Connection connection = Connection.to(server, Safety.MUTATING); - - try (Watches watching = Watches.start(connection, new Heard())) { - assertTrue(watching.isAlive()); - assertTrue(await(() -> !server.clients().isEmpty()), "the control client really did attach"); - - Listings.Clients clients = Listings.clients(new Call(connection, java.util.Map.of(), Call.Progress.SILENT)); - Listings.Sessions sessions = Listings.sessions(connection); - - assertEquals(0, clients.count(), "our own watcher is not a person watching"); - assertTrue(String.valueOf(clients.note()).contains("no person is watching")); - assertFalse(sessions.sessions().getFirst().attached(), "our own watcher did not attach a person"); - } - } - - /** A connection nothing is watching hides nothing, so a real client still counts. */ - @Test - void withoutAWatcherEveryAttachedClientIsReported(Server server) { - Connection connection = - new Connection(server, Caller.nowhere(), Safety.MUTATING, ConcurrentHashMap.newKeySet()); - - Listings.Clients clients = Listings.clients(new Call(connection, java.util.Map.of(), Call.Progress.SILENT)); - - assertEquals(server.clients().size(), clients.count()); - } - - @Test - void explicitlyRequestedWatchingFailsLoudlyWhenNothingCanBeAttached(Server server) { - Connection connection = Connection.to(server, Safety.MUTATING); - server.killSession(server.sessions().get(0).name()); - - IllegalStateException refused = - assertThrows(IllegalStateException.class, () -> Watches.start(connection, new Heard())); - - assertTrue(String.valueOf(refused.getMessage()).contains("session"), refused.getMessage()); - } - - private static boolean restarted(Server server) { - try { - server.newSession("reborn"); - return true; - } catch (RuntimeException stillExiting) { - return false; - } - } - - private static boolean await(BooleanSupplier condition) throws InterruptedException { - // tmux checks a subscription about once a second, so this has to outlast that. - for (int attempt = 0; attempt < 100; attempt++) { - if (condition.getAsBoolean()) { - return true; - } - Thread.sleep(100); - } - return false; - } - - private static void await(CountDownLatch latch) { - try { - latch.await(); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } - } -} diff --git a/scripts/mcp_swap.py b/scripts/mcp_swap.py index 42a61e9..ea3053f 100755 --- a/scripts/mcp_swap.py +++ b/scripts/mcp_swap.py @@ -40,7 +40,7 @@ ``` ```console -$ uv run scripts/mcp_swap.py use --socket /tmp/libtmux-java-dev/demo/s --safety destructive +$ uv run scripts/mcp_swap.py use --socket /tmp/libtmux-java-dev/demo/s ``` ```console @@ -160,10 +160,6 @@ def launcher(args: argparse.Namespace) -> tuple[str, list[str]]: flags += ["--socket-name", args.socket_name] if args.tmux: flags += ["--tmux", args.tmux] - if args.safety: - flags += ["--safety", args.safety] - if args.watch: - flags += ["--watch"] # Gradle takes the server's own flags as one --args string. if args.source == "gradle": @@ -306,8 +302,6 @@ def shared(sub: argparse.ArgumentParser) -> None: use.add_argument("--socket", help="tmux socket path to serve") use.add_argument("--socket-name", help="tmux socket name to serve") use.add_argument("--tmux", help="which tmux binary the server should run") - use.add_argument("--safety", choices=("readonly", "mutating", "destructive")) - use.add_argument("--watch", action="store_true", help="push notifications as tmux changes") use.set_defaults(run=cmd_use) revert = commands.add_parser("revert", help="restore each config from its backup") From 567dd73b1462f4ee76a1f8db17dc00d39dc20ee1 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 5 Sep 2026 04:54:34 -0500 Subject: [PATCH 03/65] Mcp(fix[batch]): Bound complete wire response why: The aggregate capped its duplicated tool result at one MiB before JSON-RPC added the response id and line framing. what: - Apply the 1,000,000-byte cap to the complete outgoing response - Retain every executed row and mark removed nested payloads - Cover the real stdio envelope, id, and newline --- libtmux-mcp/README.md | 7 + .../java/io/github/libtmux/mcp/Catalog.java | 2 +- .../io/github/libtmux/mcp/Operations.java | 54 +------ .../libtmux/mcp/ReadBatchResponses.java | 138 ++++++++++++++++++ .../mcp/SerializedTransportProvider.java | 3 +- .../libtmux/mcp/CapabilityRegistryTest.java | 23 +-- .../github/libtmux/mcp/TmuxMcpServerTest.java | 122 ++++++++++++++++ 7 files changed, 288 insertions(+), 61 deletions(-) create mode 100644 libtmux-mcp/src/main/java/io/github/libtmux/mcp/ReadBatchResponses.java diff --git a/libtmux-mcp/README.md b/libtmux-mcp/README.md index 9bc74e3..6f9ef06 100644 --- a/libtmux-mcp/README.md +++ b/libtmux-mcp/README.md @@ -186,6 +186,13 @@ Existing callers from earlier alpha releases must also migrate tool names: `list_panes` reads metadata — what is *running*, and where. `search_panes` reads content — what is *displayed*. "Which pane mentions the error" is a search. +Batch rows retain the nested MCP envelope rather than flattening its text or +structured content. The complete JSON-RPC response, including line framing, is +capped at 1,000,000 bytes. A row that would cross that boundary remains in +order with `result: null` and +`resultTruncated: true`; the outer result sets `truncated` and reports the +removed byte count in `truncatedBytes`. + ### Waiting | tool | for | diff --git a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Catalog.java b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Catalog.java index 7d95fba..978ca79 100644 --- a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Catalog.java +++ b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Catalog.java @@ -488,7 +488,7 @@ private static void inspect(List tools) { tools.add(tool( "call_read_tools_batch", "Call read tools in a batch", - "Calls up to sixteen eligible inspect tools serially; inner tools receive no separate approval, and its nested authority is disclosed. The full serialized outer MCP result is capped at 1 MiB; a removed nested envelope is marked on its row and counted in truncatedBytes.", + "Calls up to sixteen eligible inspect tools serially; inner tools receive no separate approval, and its nested authority is disclosed. The complete JSON-RPC response is capped at 1,000,000 bytes; a removed nested envelope is marked on its row and counted in truncatedBytes.", INSPECT, NONE, effects(OBSERVE, CHANGE), diff --git a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Operations.java b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Operations.java index 95e11e3..e599be7 100644 --- a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Operations.java +++ b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Operations.java @@ -21,8 +21,6 @@ /** Small typed adapters for the capability-model inventory. */ final class Operations { - private static final int MAX_READ_BATCH_BYTES = 1_048_576; - private Operations() {} static Object serverInfo(Call call) { @@ -156,8 +154,6 @@ static Object callReadToolsBatch(Call call) { validated.add(new ReadOperation(name, nested, arguments)); } List> results = new ArrayList<>(); - boolean truncated = false; - int truncatedBytes = 0; @Nullable Integer stoppedAt = null; for (int index = 0; index < validated.size(); index++) { ReadOperation operation = validated.get(index); @@ -190,36 +186,15 @@ static Object callReadToolsBatch(Call call) { if (!success && !keepGoing) { stoppedAt = index; } - while (outerBytes(batchResult(results, stoppedAt, truncated, truncatedBytes, onError)) - > MAX_READ_BATCH_BYTES) { - int row = resultRow(results); - if (row < 0) { - throw new IllegalStateException("read batch accounting exceeds its fixed response limit"); - } - Map original = results.get(row); - int removed = Math.subtractExact( - encodedBytes(java.util.Objects.requireNonNull(original.get("result"))), - encodedBytes(com.fasterxml.jackson.databind.node.NullNode.getInstance())); - truncatedBytes = Math.addExact(truncatedBytes, removed); - Map shortened = new LinkedHashMap<>(original); - shortened.put("result", com.fasterxml.jackson.databind.node.NullNode.getInstance()); - shortened.put("resultTruncated", true); - results.set(row, Collections.unmodifiableMap(shortened)); - truncated = true; - } if (stoppedAt != null) { break; } } - return batchResult(results, stoppedAt, truncated, truncatedBytes, onError); + return batchResult(results, stoppedAt, onError); } private static Map batchResult( - List> results, - @Nullable Integer stoppedAt, - boolean truncated, - int truncatedBytes, - String onError) { + List> results, @Nullable Integer stoppedAt, String onError) { long succeeded = results.stream() .filter(row -> Boolean.TRUE.equals(row.get("success"))) .count(); @@ -233,35 +208,14 @@ private static Map batchResult( "stoppedAt", stoppedAt == null ? com.fasterxml.jackson.databind.node.NullNode.getInstance() : stoppedAt, "truncated", - truncated, + false, "truncatedBytes", - truncatedBytes, + 0, "onError", onError)); return Collections.unmodifiableMap(result); } - private static int resultRow(List> results) { - for (int index = results.size() - 1; index >= 0; index--) { - if (!Boolean.TRUE.equals(results.get(index).get("resultTruncated"))) { - return index; - } - } - return -1; - } - - private static int outerBytes(Object value) { - return encodedBytes(Answers.envelope(Answers.ok(value))); - } - - private static int encodedBytes(Object value) { - try { - return Answers.JSON.writeValueAsBytes(value).length; - } catch (com.fasterxml.jackson.core.JacksonException failure) { - throw new IllegalStateException("could not measure a batch result", failure); - } - } - private record ReadOperation(String name, ToolSpec tool, Map arguments) {} static Object renameSession(Call call) { diff --git a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/ReadBatchResponses.java b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/ReadBatchResponses.java new file mode 100644 index 0000000..d48731d --- /dev/null +++ b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/ReadBatchResponses.java @@ -0,0 +1,138 @@ +package io.github.libtmux.mcp; + +import com.fasterxml.jackson.core.JacksonException; +import com.fasterxml.jackson.databind.node.NullNode; +import io.modelcontextprotocol.spec.McpSchema; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +/** Applies the read aggregate's exact bound after the JSON-RPC id is known. */ +final class ReadBatchResponses { + + private static final int MAX_WIRE_BYTES = 1_000_000; + private static final Set OUTPUT_FIELDS = + Set.of("results", "succeeded", "failed", "stoppedAt", "truncated", "truncatedBytes", "onError"); + private static final String TRUNCATED_ERROR = "nested tool error was truncated to fit the batch response"; + + private ReadBatchResponses() {} + + static McpSchema.JSONRPCMessage limit(McpSchema.JSONRPCMessage message) { + if (wireBytes(message) <= MAX_WIRE_BYTES || !(message instanceof McpSchema.JSONRPCResponse response)) { + return message; + } + if (!(response.result() instanceof McpSchema.CallToolResult result) + || !(result.structuredContent() instanceof Map untyped) + || !untyped.keySet().equals(OUTPUT_FIELDS)) { + return message; + } + + Map output = stringMap(untyped); + Object untypedRows = output.get("results"); + if (!(untypedRows instanceof List listed)) { + return message; + } + List> rows = new ArrayList<>(listed.size()); + for (Object untypedRow : listed) { + if (!(untypedRow instanceof Map row)) { + return message; + } + rows.add(new LinkedHashMap<>(stringMap(row))); + } + output.put("results", rows); + + while (true) { + McpSchema.JSONRPCResponse bounded = response(response, result, output); + if (wireBytes(bounded) <= MAX_WIRE_BYTES) { + return bounded; + } + int row = largestResult(rows); + if (row >= 0) { + Map shortened = rows.get(row); + Object removed = shortened.put("result", NullNode.getInstance()); + shortened.put("resultTruncated", true); + recordTruncation(output, encodedBytes(removed) - encodedBytes(NullNode.getInstance())); + continue; + } + row = largestError(rows); + if (row >= 0) { + Map shortened = rows.get(row); + Object removed = shortened.put("error", TRUNCATED_ERROR); + shortened.put("resultTruncated", true); + recordTruncation(output, encodedBytes(removed) - encodedBytes(TRUNCATED_ERROR)); + continue; + } + throw new IllegalStateException("read batch metadata exceeds its fixed response limit"); + } + } + + private static McpSchema.JSONRPCResponse response( + McpSchema.JSONRPCResponse response, McpSchema.CallToolResult original, Map output) { + McpSchema.CallToolResult rendered = Answers.ok(output); + McpSchema.CallToolResult result = new McpSchema.CallToolResult( + rendered.content(), rendered.isError(), rendered.structuredContent(), original.meta()); + return new McpSchema.JSONRPCResponse(response.jsonrpc(), response.id(), result, response.error()); + } + + private static int largestResult(List> rows) { + int selected = -1; + int selectedBytes = -1; + for (int index = 0; index < rows.size(); index++) { + Object result = rows.get(index).get("result"); + if (result == null || result instanceof NullNode) { + continue; + } + int bytes = encodedBytes(result); + if (bytes > selectedBytes) { + selected = index; + selectedBytes = bytes; + } + } + return selected; + } + + private static int largestError(List> rows) { + int selected = -1; + int selectedBytes = encodedBytes(TRUNCATED_ERROR); + for (int index = 0; index < rows.size(); index++) { + Object error = rows.get(index).get("error"); + if (!(error instanceof String) || TRUNCATED_ERROR.equals(error)) { + continue; + } + int bytes = encodedBytes(error); + if (bytes > selectedBytes) { + selected = index; + selectedBytes = bytes; + } + } + return selected; + } + + private static void recordTruncation(Map output, int removedBytes) { + int alreadyRemoved = + ((Number) Objects.requireNonNull(output.get("truncatedBytes"), "truncatedBytes")).intValue(); + output.put("truncated", true); + output.put("truncatedBytes", Math.addExact(alreadyRemoved, Math.max(0, removedBytes))); + } + + private static int wireBytes(McpSchema.JSONRPCMessage message) { + return Math.addExact(encodedBytes(message), 1); + } + + private static int encodedBytes(Object value) { + try { + return Answers.JSON.writeValueAsBytes(value).length; + } catch (JacksonException failure) { + throw new IllegalStateException("could not measure a read batch response", failure); + } + } + + private static Map stringMap(Map untyped) { + Map typed = new LinkedHashMap<>(); + untyped.forEach((key, value) -> typed.put(String.valueOf(key), value)); + return typed; + } +} diff --git a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/SerializedTransportProvider.java b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/SerializedTransportProvider.java index b97acae..239d8d9 100644 --- a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/SerializedTransportProvider.java +++ b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/SerializedTransportProvider.java @@ -79,7 +79,8 @@ public Mono sendMessage(McpSchema.JSONRPCMessage message) { return Mono.create(sink -> { PendingSend added; try { - added = new PendingSend(message, sink, encodedBytes(message)); + McpSchema.JSONRPCMessage bounded = ReadBatchResponses.limit(message); + added = new PendingSend(bounded, sink, encodedBytes(bounded)); } catch (RuntimeException failure) { sink.error(failure); return; diff --git a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/CapabilityRegistryTest.java b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/CapabilityRegistryTest.java index d05085f..3f72f2a 100644 --- a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/CapabilityRegistryTest.java +++ b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/CapabilityRegistryTest.java @@ -265,7 +265,7 @@ void everyManifestRowDrivesConservativeRegistrationMetadataAndSinkValidation() { assertEquals(Set.of(ToolSpec.TmuxEffect.OBSERVE, ToolSpec.TmuxEffect.CHANGE), batch.effects()); assertEquals(Set.of(ToolSpec.InputSink.NESTED_TOOL), batch.inputSinks().get("operations")); assertTrue(batch.description().contains("no separate approval")); - assertTrue(batch.description().contains("1 MiB")); + assertTrue(batch.description().contains("1,000,000 bytes")); assertTrue(byName("set_synchronize_panes").description().contains("subsequent input is copied to every pane")); for (String removed : List.of( "tmux_whoami", @@ -732,7 +732,7 @@ public void close() {} } @Test - void aggregateOutputStopsAtOneMiBAndReportsTruncation() throws Exception { + void aggregateWireLimiterReportsTruncation() throws Exception { ToolSurface surface = ToolSurface.resolve( Map.of(ToolSurface.TOOLSETS_ENV, "", ToolSurface.TOOLS_ENV, "call_read_tools_batch")); TmuxTransport oversizedEnvironment = new TmuxTransport() { @@ -751,8 +751,8 @@ public void close() {} int expectedRemovedBytes = Answers.JSON.writeValueAsBytes(Answers.envelope(Answers.ok(nested))).length - Answers.JSON.writeValueAsBytes(com.fasterxml.jackson.databind.node.NullNode.getInstance()).length; @SuppressWarnings("unchecked") - Map result = (Map) Operations.callReadToolsBatch(connection.call( - Map.of("operations", List.of(Map.of("tool", "show_environment"))), Call.Progress.SILENT)); + Map result = boundedBatch(Operations.callReadToolsBatch(connection.call( + Map.of("operations", List.of(Map.of("tool", "show_environment"))), Call.Progress.SILENT))); byName("call_read_tools_batch").validateOutput(result); assertEquals(1, result.get("succeeded")); @@ -769,9 +769,6 @@ public void close() {} assertEquals( com.fasterxml.jackson.databind.node.NullNode.getInstance(), rows.getFirst().get("result")); - assertTrue( - Answers.JSON.writeValueAsBytes(Answers.envelope(Answers.ok(result))).length <= 1_048_576, - "the complete duplicated MCP tool result must fit the cap"); } } @@ -794,11 +791,11 @@ public void close() {} try (Server server = Server.using(ServerConfig.builder().build(), secondResultIsOversized)) { Connection connection = new Connection(server, Caller.nowhere(), surface); @SuppressWarnings("unchecked") - Map result = (Map) Operations.callReadToolsBatch(connection.call( + Map result = boundedBatch(Operations.callReadToolsBatch(connection.call( Map.of( "operations", List.of(Map.of("tool", "get_server_info"), Map.of("tool", "show_environment"))), - Call.Progress.SILENT)); + Call.Progress.SILENT))); @SuppressWarnings("unchecked") List> rows = (List>) Objects.requireNonNull(result.get("results"), "results"); @@ -813,6 +810,14 @@ public void close() {} } } + @SuppressWarnings("unchecked") + private static Map boundedBatch(Object output) { + McpSchema.JSONRPCResponse response = McpSchema.JSONRPCResponse.result("read-batch", Answers.ok(output)); + McpSchema.JSONRPCResponse bounded = (McpSchema.JSONRPCResponse) ReadBatchResponses.limit(response); + McpSchema.CallToolResult result = (McpSchema.CallToolResult) bounded.result(); + return (Map) result.structuredContent(); + } + @Test void theOnlyResourceDisclosesTheEffectiveSurfaceAndCurrentSocketHonestly() { TmuxTransport absent = new TmuxTransport() { diff --git a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/TmuxMcpServerTest.java b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/TmuxMcpServerTest.java index 213adea..8fe8e69 100644 --- a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/TmuxMcpServerTest.java +++ b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/TmuxMcpServerTest.java @@ -7,7 +7,11 @@ import com.fasterxml.jackson.databind.ObjectMapper; import io.github.libtmux.Server; +import io.github.libtmux.ServerConfig; import io.github.libtmux.junit5.TmuxExtension; +import io.github.libtmux.transport.CommandRequest; +import io.github.libtmux.transport.CommandResult; +import io.github.libtmux.transport.TmuxTransport; import io.modelcontextprotocol.json.jackson2.JacksonMcpJsonMapper; import io.modelcontextprotocol.server.McpSyncServer; import io.modelcontextprotocol.server.transport.StdioServerTransportProvider; @@ -21,6 +25,8 @@ import java.io.PipedOutputStream; import java.io.PrintStream; import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; @@ -37,6 +43,84 @@ @ExtendWith(TmuxExtension.class) final class TmuxMcpServerTest { + @Test + void readBatchBoundsTheCompleteJsonRpcLine() throws Exception { + String payload = "x".repeat(140_000); + TmuxTransport environment = new TmuxTransport() { + @Override + public CommandResult execute(CommandRequest request) { + return new CommandResult(0, List.of("VALUE=" + payload), List.of()); + } + + @Override + public void close() {} + }; + ToolSurface surface = ToolSurface.resolve( + Map.of(ToolSurface.TOOLSETS_ENV, "", ToolSurface.TOOLS_ENV, "call_read_tools_batch")); + String requestId = "batch-response"; + byte[] request = (Answers.JSON.writeValueAsString(Map.of( + "jsonrpc", + "2.0", + "id", + requestId, + "method", + "tools/call", + "params", + Map.of( + "name", + "call_read_tools_batch", + "arguments", + Map.of( + "operations", + List.of( + Map.of("tool", "show_environment"), + Map.of("tool", "show_environment")))))) + + "\n") + .getBytes(StandardCharsets.UTF_8); + WireOutput output = new WireOutput(); + + try (Server server = Server.using(ServerConfig.builder().build(), environment); + PipedInputStream input = new PipedInputStream(); + PipedOutputStream client = new PipedOutputStream(input)) { + McpSyncServer mcp = TmuxMcpServer.overStdio(server, input, output, surface, () -> {}); + try { + client.write(initialize()); + client.flush(); + assertTrue(output.first.await(3, TimeUnit.SECONDS), "initialization did not answer"); + + client.write("{\"jsonrpc\":\"2.0\",\"method\":\"notifications/initialized\"}\n" + .getBytes(StandardCharsets.UTF_8)); + client.write(request); + client.flush(); + assertTrue(output.second.await(5, TimeUnit.SECONDS), "read batch did not answer"); + } finally { + mcp.close(); + } + } + + String response = output.lines().stream() + .filter(line -> line.contains(requestId)) + .findFirst() + .orElseThrow(() -> new AssertionError("read batch response is absent")); + assertTrue( + response.getBytes(StandardCharsets.UTF_8).length + 1 <= 1_000_000, + "the complete JSON-RPC response, including its newline, exceeds 1,000,000 bytes"); + + var result = Answers.JSON.readTree(response).path("result").path("structuredContent"); + assertEquals(2, result.path("results").size(), "an executed row was dropped"); + boolean explicitlyTruncated = false; + for (var row : result.path("results")) { + assertEquals(true, row.path("success").asBoolean()); + if (row.path("resultTruncated").asBoolean()) { + explicitlyTruncated = true; + assertTrue(row.path("result").isNull()); + } + } + assertTrue(explicitlyTruncated, "the oversized nested payloads were not marked as truncated"); + assertEquals(true, result.path("truncated").asBoolean()); + assertTrue(result.path("truncatedBytes").asInt() > 0); + } + @Test void brokenOutputEndsAStdioSessionEvenWhileInputRemainsOpen(Server server) throws Exception { CountDownLatch ended = new CountDownLatch(1); @@ -181,4 +265,42 @@ public void close() { closed.countDown(); } } + + private static final class WireOutput extends OutputStream { + + private final ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + private final CountDownLatch first = new CountDownLatch(1); + private final CountDownLatch second = new CountDownLatch(1); + private int lines; + + @Override + public synchronized void write(int value) { + bytes.write(value); + count(value); + } + + @Override + public synchronized void write(byte[] values, int offset, int length) { + bytes.write(values, offset, length); + for (int index = offset; index < offset + length; index++) { + count(values[index]); + } + } + + synchronized List lines() { + return bytes.toString(StandardCharsets.UTF_8).lines().toList(); + } + + private void count(int value) { + if (value != '\n') { + return; + } + lines++; + if (lines == 1) { + first.countDown(); + } else if (lines == 2) { + second.countDown(); + } + } + } } From 746fb9a7bbea5ac6d61dd4a8008a45e530783d86 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 5 Sep 2026 04:56:11 -0500 Subject: [PATCH 04/65] Mcp(fix[effects]): Classify cursor reads why: Cursor capture observes retained pane output; it does not change tmux state under the capability contract. what: - Publish capture_since as observe-only - Derive the read aggregate's observe-only union --- .../java/io/github/libtmux/mcp/Catalog.java | 4 ++-- .../libtmux/mcp/CapabilityRegistryTest.java | 18 ++++++++++++++++-- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Catalog.java b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Catalog.java index 978ca79..a70070b 100644 --- a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Catalog.java +++ b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Catalog.java @@ -279,7 +279,7 @@ private static void inspect(List tools) { "Returns pane output produced after a cursor.", INSPECT, NONE, - effects(OBSERVE, CHANGE), + effects(OBSERVE), outputs(TERMINAL_CONTENT, TMUX_METADATA), true, true, @@ -491,7 +491,7 @@ private static void inspect(List tools) { "Calls up to sixteen eligible inspect tools serially; inner tools receive no separate approval, and its nested authority is disclosed. The complete JSON-RPC response is capped at 1,000,000 bytes; a removed nested envelope is marked on its row and counted in truncatedBytes.", INSPECT, NONE, - effects(OBSERVE, CHANGE), + effects(OBSERVE), outputs(TMUX_METADATA, TERMINAL_CONTENT, PROCESS_ENVIRONMENT, CONFIGURED_COMMAND), true, true, diff --git a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/CapabilityRegistryTest.java b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/CapabilityRegistryTest.java index 3f72f2a..e86507a 100644 --- a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/CapabilityRegistryTest.java +++ b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/CapabilityRegistryTest.java @@ -262,7 +262,7 @@ void everyManifestRowDrivesConservativeRegistrationMetadataAndSinkValidation() { assertFalse(batch.nestedAuthority().contains("wait_for_text")); assertTrue(batch.outputClasses().contains(ToolSpec.OutputClass.PROCESS_ENVIRONMENT)); assertTrue(batch.controlledOpener().startsWith("Read pane output")); - assertEquals(Set.of(ToolSpec.TmuxEffect.OBSERVE, ToolSpec.TmuxEffect.CHANGE), batch.effects()); + assertEquals(Set.of(ToolSpec.TmuxEffect.OBSERVE), batch.effects()); assertEquals(Set.of(ToolSpec.InputSink.NESTED_TOOL), batch.inputSinks().get("operations")); assertTrue(batch.description().contains("no separate approval")); assertTrue(batch.description().contains("1,000,000 bytes")); @@ -285,7 +285,8 @@ void everyManifestRowDrivesConservativeRegistrationMetadataAndSinkValidation() { @Test void exactEffectRowsAndExclusionPrunedBatchUnionsStayAligned() { Map> expected = Map.ofEntries( - Map.entry("capture_since", Set.of(ToolSpec.TmuxEffect.OBSERVE, ToolSpec.TmuxEffect.CHANGE)), + Map.entry("capture_since", Set.of(ToolSpec.TmuxEffect.OBSERVE)), + Map.entry("call_read_tools_batch", Set.of(ToolSpec.TmuxEffect.OBSERVE)), Map.entry("create_session", Set.of(ToolSpec.TmuxEffect.OBSERVE, ToolSpec.TmuxEffect.CHANGE)), Map.entry("enter_copy_mode", Set.of(ToolSpec.TmuxEffect.OBSERVE, ToolSpec.TmuxEffect.CHANGE)), Map.entry("kill_pane", Set.of(ToolSpec.TmuxEffect.OBSERVE, ToolSpec.TmuxEffect.DELETE)), @@ -302,6 +303,19 @@ void exactEffectRowsAndExclusionPrunedBatchUnionsStayAligned() { Set.of(ToolSpec.OutputClass.CONFIGURED_COMMAND), byName("show_hooks").outputClasses()); + String everythingButCaptureSince = byName("call_read_tools_batch").nestedAuthority().stream() + .filter(name -> !name.equals("capture_since")) + .collect(java.util.stream.Collectors.joining(",")); + ToolSpec captureOnlyBatch = ToolSurface.resolve(Map.of( + ToolSurface.TOOLSETS_ENV, + "", + ToolSurface.TOOLS_ENV, + "call_read_tools_batch", + ToolSurface.EXCLUDE_TOOLS_ENV, + everythingButCaptureSince)) + .require("call_read_tools_batch"); + assertEquals(Set.of(ToolSpec.TmuxEffect.OBSERVE), captureOnlyBatch.effects()); + ToolSurface pruned = ToolSurface.resolve(Map.of( ToolSurface.TOOLSETS_ENV, "", From 9e9f851fb01b0def6d42d855b72050946190ba8b Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 5 Sep 2026 04:59:39 -0500 Subject: [PATCH 05/65] Mcp(fix[protocol]): Reject oversized request IDs why: The SDK accepts request lines large enough for an ID to exceed the complete batch-response budget before a tool runs. what: - Cap serialized stdio request IDs at 512 KiB before SDK dispatch - Write a bounded id-null invalid-request response under the output lock - Cover the accepted boundary and rejected inspect call over real stdio --- libtmux-mcp/README.md | 3 + .../libtmux/mcp/StdioRequestFilter.java | 126 ++++++++++++++++++ .../io/github/libtmux/mcp/TmuxMcpServer.java | 5 +- .../github/libtmux/mcp/TmuxMcpServerTest.java | 84 ++++++++++++ 4 files changed, 217 insertions(+), 1 deletion(-) create mode 100644 libtmux-mcp/src/main/java/io/github/libtmux/mcp/StdioRequestFilter.java diff --git a/libtmux-mcp/README.md b/libtmux-mcp/README.md index 6f9ef06..c606c6e 100644 --- a/libtmux-mcp/README.md +++ b/libtmux-mcp/README.md @@ -193,6 +193,9 @@ order with `result: null` and `resultTruncated: true`; the outer result sets `truncated` and reports the removed byte count in `truncatedBytes`. +A serialized request ID may use at most 524,288 bytes; a larger ID returns an +`id: null` invalid-request error before any tool runs. + ### Waiting | tool | for | diff --git a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/StdioRequestFilter.java b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/StdioRequestFilter.java new file mode 100644 index 0000000..006a7a9 --- /dev/null +++ b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/StdioRequestFilter.java @@ -0,0 +1,126 @@ +package io.github.libtmux.mcp; + +import com.fasterxml.jackson.core.JacksonException; +import com.fasterxml.jackson.databind.JsonNode; +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; +import java.util.Objects; +import org.jspecify.annotations.Nullable; + +/** Rejects request IDs that would consume the bounded response line. */ +final class StdioRequestFilter extends InputStream { + + static final int REQUEST_ID_MAX_BYTES = 512 * 1024; + // Preserve the pinned SDK transport's line ceiling while filtering before it. + private static final int INPUT_MAX_CHARS = 16 * 1024 * 1024; + private static final byte[] OVERSIZED_ID_ERROR = ("{\"jsonrpc\":\"2.0\",\"id\":null,\"error\":{" + + "\"code\":-32600,\"message\":\"request id exceeds " + + REQUEST_ID_MAX_BYTES + + " bytes\"}}\n") + .getBytes(StandardCharsets.UTF_8); + + private final BufferedReader input; + private final OutputStream output; + private final byte[] one = new byte[1]; + private byte[] pending = new byte[0]; + private int offset; + + StdioRequestFilter(InputStream input, OutputStream output) { + this.input = new BufferedReader(new InputStreamReader(Objects.requireNonNull(input), StandardCharsets.UTF_8)); + this.output = Objects.requireNonNull(output); + } + + @Override + public int read() throws IOException { + int read = read(one, 0, 1); + return read < 0 ? -1 : Byte.toUnsignedInt(one[0]); + } + + @Override + public int read(byte[] bytes, int destination, int length) throws IOException { + Objects.checkFromIndexSize(destination, length, bytes.length); + if (length == 0) { + return 0; + } + while (offset == pending.length && !refill()) { + return -1; + } + int copied = Math.min(length, pending.length - offset); + System.arraycopy(pending, offset, bytes, destination, copied); + offset += copied; + return copied; + } + + @Override + public void close() throws IOException { + input.close(); + } + + private boolean refill() throws IOException { + while (true) { + String line = readLine(); + if (line == null) { + return false; + } + if (oversizedRequestId(line)) { + rejectOversizedRequestId(); + continue; + } + pending = (line + "\n").getBytes(StandardCharsets.UTF_8); + offset = 0; + return true; + } + } + + private @Nullable String readLine() throws IOException { + StringBuilder line = new StringBuilder(); + int value; + while ((value = input.read()) != -1) { + if (value == '\n') { + return line.toString(); + } + if (value == '\r') { + input.mark(1); + int next = input.read(); + if (next != '\n' && next != -1) { + input.reset(); + } + return line.toString(); + } + if (line.length() >= INPUT_MAX_CHARS) { + throw new IOException("JSON-RPC input exceeds " + INPUT_MAX_CHARS + " characters"); + } + line.append((char) value); + } + return line.isEmpty() ? null : line.toString(); + } + + private static boolean oversizedRequestId(String line) { + try { + JsonNode message = Answers.JSON.readTree(line); + if (message == null + || !message.isObject() + || !message.path("method").isTextual()) { + return false; + } + JsonNode id = message.get("id"); + if (id == null || !(id.isTextual() || id.isIntegralNumber())) { + return false; + } + return Answers.JSON.writeValueAsBytes(id).length > REQUEST_ID_MAX_BYTES; + } catch (JacksonException ignored) { + return false; + } + } + + private void rejectOversizedRequestId() throws IOException { + synchronized (output) { + output.write(OVERSIZED_ID_ERROR); + output.flush(); + } + } +} diff --git a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/TmuxMcpServer.java b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/TmuxMcpServer.java index c9a40fa..3b0d94e 100644 --- a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/TmuxMcpServer.java +++ b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/TmuxMcpServer.java @@ -71,8 +71,11 @@ static McpSyncServer overStdio( SessionLifetime lifetime = new SessionLifetime(onSessionEnd); lifetime.own(in); try { + OutputStream protocolOutput = lifetime.observe(out); var provider = new StdioServerTransportProvider( - new JacksonMcpJsonMapper(new ObjectMapper()), in, lifetime.observe(out)); + new JacksonMcpJsonMapper(new ObjectMapper()), + new StdioRequestFilter(in, protocolOutput), + protocolOutput); lifetime.own(provider::close); return serving(server, surface, lifetime.observe(provider), lifetime); } catch (RuntimeException | Error failure) { diff --git a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/TmuxMcpServerTest.java b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/TmuxMcpServerTest.java index 8fe8e69..4dae4dd 100644 --- a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/TmuxMcpServerTest.java +++ b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/TmuxMcpServerTest.java @@ -121,6 +121,65 @@ public void close() {} assertTrue(result.path("truncatedBytes").asInt() > 0); } + @Test + void oversizedRequestIdFailsBeforeToolDispatch() throws Exception { + AtomicInteger calls = new AtomicInteger(); + TmuxTransport environment = new TmuxTransport() { + @Override + public CommandResult execute(CommandRequest request) { + calls.incrementAndGet(); + return new CommandResult(0, List.of("VALUE=kept"), List.of()); + } + + @Override + public void close() {} + }; + ToolSurface surface = + ToolSurface.resolve(Map.of(ToolSurface.TOOLSETS_ENV, "", ToolSurface.TOOLS_ENV, "show_environment")); + String acceptedId = "i".repeat(StdioRequestFilter.REQUEST_ID_MAX_BYTES - 2); + assertEquals(StdioRequestFilter.REQUEST_ID_MAX_BYTES, Answers.JSON.writeValueAsBytes(acceptedId).length); + WireOutput output = new WireOutput(); + + try (Server server = Server.using(ServerConfig.builder().build(), environment); + PipedInputStream input = new PipedInputStream(); + PipedOutputStream client = new PipedOutputStream(input)) { + McpSyncServer mcp = TmuxMcpServer.overStdio(server, input, output, surface, () -> {}); + try { + client.write(initialize()); + client.flush(); + assertTrue(output.first.await(3, TimeUnit.SECONDS), "initialization did not answer"); + calls.set(0); + + client.write("{\"jsonrpc\":\"2.0\",\"method\":\"notifications/initialized\"}\n" + .getBytes(StandardCharsets.UTF_8)); + client.write(toolCall(acceptedId)); + client.flush(); + assertTrue(output.second.await(5, TimeUnit.SECONDS), "near-bound request ID did not answer"); + assertEquals(1, calls.get(), "near-bound request ID did not dispatch exactly once"); + assertEquals( + acceptedId, + Answers.JSON.readTree(output.lines().get(1)).path("id").textValue(), + "near-bound request ID did not round trip"); + + client.write(toolCall("i".repeat(1_000_000))); + client.flush(); + assertTrue(output.awaitLines(3, 5, TimeUnit.SECONDS), "oversized request ID did not answer"); + } finally { + mcp.close(); + } + } + + String rejectedLine = output.lines().get(2); + var rejected = Answers.JSON.readTree(rejectedLine); + boolean idNull = rejected.has("id") && rejected.path("id").isNull(); + int code = rejected.path("error").path("code").asInt(); + int wireBytes = rejectedLine.getBytes(StandardCharsets.UTF_8).length + 1; + assertTrue( + idNull && code == -32600 && wireBytes <= 1_000_000 && calls.get() == 1, + () -> "oversized response = (" + wireBytes + " bytes, id null " + idNull + ", code " + code + ", calls " + + calls.get() + ")"); + } + @Test void brokenOutputEndsAStdioSessionEvenWhileInputRemainsOpen(Server server) throws Exception { CountDownLatch ended = new CountDownLatch(1); @@ -231,6 +290,20 @@ private static byte[] initialize() { return request.getBytes(StandardCharsets.UTF_8); } + private static byte[] toolCall(String id) throws IOException { + return (Answers.JSON.writeValueAsString(Map.of( + "jsonrpc", + "2.0", + "id", + id, + "method", + "tools/call", + "params", + Map.of("name", "show_environment", "arguments", Map.of()))) + + "\n") + .getBytes(StandardCharsets.UTF_8); + } + private static final class BlockingInput extends java.io.InputStream { private final CountDownLatch reading = new CountDownLatch(1); @@ -291,11 +364,22 @@ synchronized List lines() { return bytes.toString(StandardCharsets.UTF_8).lines().toList(); } + synchronized boolean awaitLines(int expected, long timeout, TimeUnit unit) throws InterruptedException { + long remaining = unit.toNanos(timeout); + long end = System.nanoTime() + remaining; + while (lines < expected && remaining > 0) { + TimeUnit.NANOSECONDS.timedWait(this, remaining); + remaining = end - System.nanoTime(); + } + return lines >= expected; + } + private void count(int value) { if (value != '\n') { return; } lines++; + notifyAll(); if (lines == 1) { first.countDown(); } else if (lines == 2) { From 75f1ec245fa88dc750687bb7aa438ef4c976aa54 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 5 Sep 2026 05:01:49 -0500 Subject: [PATCH 06/65] Docs(docs[mcp]): Preserve capability workflows --- .github/WRITING.md | 17 +++++---- docs/guide/filtering.md | 11 +++--- docs/guide/mcp.md | 76 +++++++++++++++++++++++------------------ scripts/README.md | 12 +++++-- 4 files changed, 65 insertions(+), 51 deletions(-) diff --git a/.github/WRITING.md b/.github/WRITING.md index 980f2d0..75da82b 100644 --- a/.github/WRITING.md +++ b/.github/WRITING.md @@ -73,15 +73,14 @@ An entry opens with a bold clause naming what changed, then gives the prose that makes it decidable: ```markdown -- **`tmux_whoami` failed on a socket with no server behind it.** It is the tool - the instructions tell a model to call first, and it asked tmux for its - version, which needs a running server. It now says there is no server and - points at `tmux_list_servers`. +- **`capture_since` now reports an observe-only tmux effect.** Reading from a + cursor leaves tmux state unchanged, so the capability metadata no longer + overstates what the call changes. ``` -Name identifiers literally: `Pane.capture`, `LIBTMUX_WATCH`, `--rerun-tasks`, -`tmux://panes/{pane}`. Lead with a concrete verb — add, fix, remove, reject, -`now`, `no longer`. +Name identifiers literally: `Pane.capture`, `LIBTMUX_TOOLSETS`, +`--rerun-tasks`, `tmux://capabilities`. Lead with a concrete verb: add, fix, +remove, reject, `now`, or `no longer`. State a changed default explicitly, and an incompatibility more explicitly still, with the way forward in the same entry. @@ -354,8 +353,8 @@ has nothing but the string: "JDK 21" both appear upstream — this project writes **JDK 21**. - A **pane**, **window**, **session**, and **server** are what tmux calls them. Do not introduce a synonym for one. -- Write the identifier, not a description of it: `LIBTMUX_WATCH=true`, not - "the watch environment variable"; `--rerun-tasks`, not "the rerun flag"; +- Write the identifier, not a description of it: `LIBTMUX_TOOLSETS=inspect`, + not "the toolset environment variable"; `--rerun-tasks`, not "the rerun flag"; `/tmp/libtmux-java-test/`, not "the test socket directory". Treat AI slop as review-hostile noise. The goal is information density: diff --git a/docs/guide/filtering.md b/docs/guide/filtering.md index 678d36e..0d47514 100644 --- a/docs/guide/filtering.md +++ b/docs/guide/filtering.md @@ -98,8 +98,8 @@ Java names. The `matches` operand is the exception: its syntax and numeric flags are those of `java.util.regex.Pattern`. A non-Java consumer must reproduce those semantics or reject that operator. -`libtmux-mcp` is the worked example. Its `tmux_list_panes` tool takes an optional -`filter`, which is one of these documents: +An application that stores a pane predicate is the worked example. The wire +form is one of these documents: ```json {"schema": "libtmux.filter/1", "model": "pane", @@ -107,9 +107,10 @@ semantics or reject that operator. "op": "starts_with", "value": "nvim"}} ``` -A model cannot write Java, so this is the only way it can say what it wants -narrowed. What it gets back costs the same one capture the unfiltered listing -would have, because the filter runs over what that capture returned. +Java applications can read that document with the matching `FilterModel` and +apply it to a captured hierarchy. `libtmux-mcp` deliberately does not accept +this open expression format: `list_panes` returns bounded typed metadata for a +client to filter, while `search_panes` searches only rendered terminal text. ## Filters that arrive as strings diff --git a/docs/guide/mcp.md b/docs/guide/mcp.md index e061de5..dfbbcd0 100644 --- a/docs/guide/mcp.md +++ b/docs/guide/mcp.md @@ -6,6 +6,9 @@ Every Java snippet here is executed by `ExamplesTest`. [module README](../../libtmux-mcp/README.md) is how to run it; this page is why it is shaped the way it is, and what was measured to decide. +Commands here use `libtmux-mcp` as the installed launcher name; the module +README gives its full path. + ## The thing an agent actually spends Not tmux commands. Context, and turns. @@ -24,19 +27,19 @@ loop as a polling cycle, where it costs a call per look and has no ceiling at al Four waits, cheapest first. -**You wrote the command: `tmux_run`.** It sends the command, waits for it, and +**You wrote the command: `run_shell_command`.** It sends the command, waits for it, and returns the output with an exit status in one call. -**You wrote it but want it composed yourself: `tmux_wait_for_channel`.** Append +**You wrote it but want it composed yourself: `wait_for_channel`.** Append `; tmux wait-for -S mychannel` to whatever you send, then block on the channel. This is the only wait that infers nothing — tmux blocks inside the server and returns on the signal itself. -**You did not write it: `tmux_wait_for_text`.** A daemon, a dev server, a build +**You did not write it: `wait_for_text`.** A daemon, a dev server, a build someone else started. There is no command to append a signal to, so the screen is all there is to read. This is the only one that is a heuristic. -**You want to keep watching: `tmux_capture_since`.** It returns a cursor; pass it +**You want to keep watching: `capture_since`.** It returns a cursor; pass it back and you get the lines added since, not the screen again. ### Why every wait is bounded @@ -73,7 +76,7 @@ work bounded. ## Telling output apart from the plumbing -`tmux_run` has to know when a command finished and what it exited with. The shell +`run_shell_command` has to know when a command finished and what it exited with. The shell in a pane will not tell anyone, so the command is followed by two things it runs afterwards — one recording the status in a pane option, one signalling a private tmux channel — and the wait is tmux's own `wait-for`. @@ -101,13 +104,13 @@ Two consequences worth knowing, both pinned by tests: - The command runs in a **subshell**, so a `cd` or an `export` in it does not outlive the call — and neither does an `exit`, which is what keeps `exit 3` from closing the pane. -- `tmux_run` returns on the completion signal, which happens *before* the shell - redraws its prompt. A following `tmux_capture_since` legitimately reports that +- `run_shell_command` returns on the completion signal, which happens *before* the shell + redraws its prompt. A following `capture_since` legitimately reports that prompt as new output. ## A cursor, so watching is not re-reading -`tmux_capture_since` takes an opaque cursor and returns the lines added since it, +`capture_since` takes an opaque cursor and returns the lines added since it, plus the next one. The tenth look at a build log costs the few lines it added, not the nine screens already read. @@ -141,11 +144,8 @@ tmux can push. A control client that has attached is told when a window appears or a session is renamed, and `refresh-client -B` registers a format tmux re-expands on its own one-second timer and reports **only when the value differs**. -That is a change detector inside the server. With `--watch`, this server turns -those into MCP `notifications/resources/updated`, so a client holding -`tmux://panes/%251/content` refreshes when pane `%1` changes and never otherwise. - -The same mechanism is available to any Java caller: +That is a change detector inside the server. The Java library exposes it to +applications directly: ```java @@ -161,8 +161,10 @@ try (ControlClient client = ControlClient.attach(server.config(), session.id()); ``` Watching costs one attached client, which is a real change to a server somebody -may be looking at — so it is off unless asked for, and the client it attaches is -hidden from `tmux_list_clients` so it cannot be mistaken for a person. +may be looking at. The MCP process therefore does not attach one implicitly or +turn it into dynamic resource notifications. An agent uses `wait_for_text`, +`wait_for_channel`, and cursor-based `capture_since`; an embedding application +that chooses the control client owns its lifetime explicitly. For a sibling design that was measured and rejected: tapping the pty with `pipe-pane` gives an event source too, but tmux keeps a single pipe per pane, so @@ -171,25 +173,31 @@ pipe carries raw pty bytes rather than the rendered grid. ## What a model may do -Three tiers, and they decide which tools exist rather than which are refused. +Four unordered toolsets decide which tools exist rather than which are refused. -```java -Safety.READONLY.allows(Safety.MUTATING); // → false -Safety.DESTRUCTIVE.allows(Safety.MUTATING); // → true -Safety.ofWireName("readonly"); // → READONLY +```console +$ LIBTMUX_TOOLSETS=inspect,execute \ + LIBTMUX_EXCLUDE_TOOLS=run_shell_command \ + libtmux-mcp --socket-name project ``` -The ceiling filters the tool catalog; it does not confine effects. `MUTATING` -includes `tmux_run`, key input, and pasted text, so it can run programs or -delete data in a pane. Use a separate OS account, socket permissions, or a -container when effects must be contained. MCP effect hints are declared -separately, so a tool can remain available at this ceiling while warning that -its update may be destructive. +`inspect`, `manage`, `execute`, and `teardown` are independent capabilities, not +increasing trust levels. Exact names can add tools, exclusions remove them last, +and an empty toolset selection starts with none. A newly created dedicated +minimal daemon defaults to all four; existing or operator-selected daemons omit +teardown unless it is requested explicitly. + +The selection filters the catalog; it does not confine effects. `execute` +includes authored shell commands, key input, and pasted text, so it can run +programs or delete data in a pane. Use a separate OS account, socket permissions, +or a container when effects must be contained. -A tool above the ceiling is never listed. A model cannot be tempted by a tool it -never saw, and an error it can do nothing about is context spent for nothing. The -server's instructions say plainly what is absent and how an operator would enable -it, so the model does not spend a turn hunting for another way. +A hidden tool is never listed and is not callable. Every visible tool also +publishes process reach, tmux effects, output classes, one +`inputLiteralization` map, schemas, nested authority, and conservative MCP +effect hints in one capability row. Detailed interpreter-sink tables remain +internal validation data. `tmux://capabilities` reports those same rows and why +this process selected them. ### The pane you are speaking through @@ -202,8 +210,8 @@ server — so the socket is checked too, by resolving both paths, before that pa is believed to be the caller's own. Unprovable means not the caller's: a wrong "yes" disarms a guard, while a wrong "no" merely declines to help. -`tmux_whoami` names it. `tmux_kill` refuses it, and the window and session holding -it, unless `confirm_self` is passed. +`list_panes` marks it as the caller. `kill_pane`, `kill_window`, and +`kill_session` refuse it and its containers unless `confirm_self` is passed. ## Reading costs context @@ -227,11 +235,11 @@ exist. Errors work the same way. A failure comes back as a tool error rather than an exception, because a transport-level exception never reaches the model — and the model is the one participant able to choose a different pane. Each one names the -recovery: `no pane %9 on this server; call tmux_list_panes for the 3 that exist`. +recovery: `no pane %9 on this server; call list_panes for the 3 that exist`. ## Further reading - [`libtmux-mcp` README](../../libtmux-mcp/README.md) — running it, and the tool list -- [Filtering](filtering.md) — the expression model a `filter` argument carries +- [Filtering](filtering.md) — the expression model Java applications can use outside MCP - [Watching output as it happens](streaming.md) — the control client directly - [Control-mode subscriptions](../spikes/23-control-subscriptions.md) — what was measured diff --git a/scripts/README.md b/scripts/README.md index b236b0e..63c34f1 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -55,11 +55,17 @@ $ uv run scripts/mcp_swap.py use --dry-run ```console $ uv run scripts/mcp_swap.py use \ - --socket /tmp/libtmux-java-dev/demo/s \ - --safety destructive \ - --watch + --socket /tmp/libtmux-java-dev/demo/s ``` +The swapper records the executable and connection selector. Set +`LIBTMUX_TOOLSETS`, `LIBTMUX_TOOLS`, and `LIBTMUX_EXCLUDE_TOOLS` in the MCP +client's launch environment when the default capability surface is not the one +you want. + +The retired `--safety` and `--watch` swapper arguments are rejected; the +[module README](../libtmux-mcp/README.md#run-it) maps their replacements. + Put them back: ```console From fe2e9e205c74622e8318d6d6eb345fe761b91601 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 5 Sep 2026 05:03:22 -0500 Subject: [PATCH 07/65] Docs(docs[changelog]): Record MCP capability sync --- CHANGELOG.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e5994a9..3017482 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,28 @@ production. ### Changed +- **`libtmux-mcp` now exposes a fixed 47-tool capability surface.** One native + registry drives tool registration, schemas, trust metadata, selection, and + the static `tmux://capabilities` resource. Unordered toolsets and named + include/exclude lists replace safety tiers; the retired `LIBTMUX_SAFETY` + variable and `--safety` option now fail with migration guidance. The retired + `LIBTMUX_WATCH` variable and `--watch` option also fail; use bounded wait and + capture tools instead of dynamic resource notifications. The server defaults + to the dedicated `libtmux-mcp` socket, supports separate socket-name and + absolute socket-path selectors, and enables teardown by default only for a + newly created minimal daemon. +- **The MCP guide maps every earlier public tool, resource URI, prompt workflow, + and completion path.** Each retired name now points to its current typed + route, composed workflow, or explicit no-replacement boundary. +- **MCP searches and read batches now have fixed work ceilings.** Search stops + after 200 panes, 20,000 lines, 1,000,000 bytes of matching input, or five + seconds. Read batches validate each nested call and cap the complete JSON-RPC + response, including line framing, at 1,000,000 bytes without dropping an + executed row. Request IDs over 512 KiB now fail before dispatch rather than + consuming that response budget. +- **`capture_since` and `call_read_tools_batch` now advertise observe-only tmux + effects.** Cursor capture and every batch-eligible inspect operation leave + tmux state unchanged. - **`Pane.findWindowByName` and `Pane.findWindowByContent` are removed.** Use `findWindow` with `inName()` or `inContent()`. (#6) - **`Server.waitFor`, `waitForWithSignalCapacity`, `signal`, and `drain` move @@ -128,6 +150,9 @@ production. ### Removed +- **MCP prompts, completions, watches, dynamic resources, per-call server + discovery, and workspace tools are removed.** Use the fixed tool surface and + its static `tmux://capabilities` resource. - **`ExecutionMode`, `ControlTransport`, `VirtualThreadTransport`, `LIBTMUX_MODE`, and their benchmark surface are removed.** `Server` uses process execution; use `ControlClient` for event streams and batches or From f74bf40c4e83ce6455e08dcee17f373eb2ca37b4 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 5 Sep 2026 11:09:21 -0500 Subject: [PATCH 08/65] Mcp(fix[surface]): Exclude copy-mode tools why: Copy mode is attached-client modal state. MCP can read pane text through bounded capture without taking ownership of a person's mode. what: - Remove copy-mode enter and exit from the MCP catalog and handlers - Keep manifest tests and documentation aligned at 45 tools - Preserve the core Pane copy-mode API for Java callers - Add module-local guidance for future MCP changes --- libtmux-mcp/AGENTS.md | 18 ++++++++++++++++++ libtmux-mcp/CLAUDE.md | 1 + libtmux-mcp/README.md | 11 ++++++++--- .../java/io/github/libtmux/mcp/Catalog.java | 17 ----------------- .../java/io/github/libtmux/mcp/Operations.java | 12 ------------ .../libtmux/mcp/CapabilityRegistryTest.java | 9 ++------- .../java/io/github/libtmux/mcp/MainTest.java | 2 +- 7 files changed, 30 insertions(+), 40 deletions(-) create mode 100644 libtmux-mcp/AGENTS.md create mode 120000 libtmux-mcp/CLAUDE.md diff --git a/libtmux-mcp/AGENTS.md b/libtmux-mcp/AGENTS.md new file mode 100644 index 0000000..28a76c3 --- /dev/null +++ b/libtmux-mcp/AGENTS.md @@ -0,0 +1,18 @@ +# MCP surface boundary + +Follow the repository-level [`AGENTS.md`](../AGENTS.md) and its writing and +contribution guides. + +- Treat MCP as a curated, semantic, detached-safe surface. Library parity does + not imply MCP parity. +- Exclude modal human-client interfaces when a capture or noninteractive + equivalent exists. Copy mode, clock mode, choose-tree, prompts, menus, + popups, and mouse gestures are exclusion signals. +- Read text through bounded capture, history, snapshot, search, and + `capture_since` operations. Report an active mode or read its screen instead + of entering or cancelling it. +- Avoid operations that require paired cleanup, have unclear ownership, or + depend on key tables, a mouse, a clipboard, or UI timing. +- Retain typed core library APIs even when MCP omits their commands. +- Assign every public tool to exactly one ADR toolset. The authoritative + catalog drives runtime registration, documentation, and tests. diff --git a/libtmux-mcp/CLAUDE.md b/libtmux-mcp/CLAUDE.md new file mode 120000 index 0000000..47dc3e3 --- /dev/null +++ b/libtmux-mcp/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/libtmux-mcp/README.md b/libtmux-mcp/README.md index c606c6e..61fd5b3 100644 --- a/libtmux-mcp/README.md +++ b/libtmux-mcp/README.md @@ -126,7 +126,7 @@ The complete frozen inventory below is generated from the code registry. | toolset | public tools | | --- | --- | | `inspect` | `list_sessions` · `list_windows` · `list_panes` · `get_server_info` · `get_session_info` · `get_window_info` · `get_pane_info` · `capture_pane` · `capture_since` · `snapshot_pane` · `search_panes` · `find_pane_by_position` · `wait_for_text` · `get_tmux_variables` · `show_option` · `show_environment` · `show_hooks` · `call_read_tools_batch` | -| `manage` | `rename_session` · `rename_window` · `select_window` · `select_pane` · `select_layout` · `resize_window` · `resize_pane` · `move_window` · `swap_pane` · `set_pane_title` · `enter_copy_mode` · `exit_copy_mode` · `wait_for_channel` · `signal_channel` · `set_mouse_enabled` · `set_history_limit` | +| `manage` | `rename_session` · `rename_window` · `select_window` · `select_pane` · `select_layout` · `resize_window` · `resize_pane` · `move_window` · `swap_pane` · `set_pane_title` · `wait_for_channel` · `signal_channel` · `set_mouse_enabled` · `set_history_limit` | | `execute` | `create_session` · `create_window` · `split_window` · `respawn_pane` · `run_shell_command` · `send_keys` · `send_keys_batch` · `paste_text` · `set_synchronize_panes` | | `teardown` | `clear_pane_scrollback` · `kill_pane` · `kill_window` · `kill_session` | @@ -186,6 +186,11 @@ Existing callers from earlier alpha releases must also migrate tool names: `list_panes` reads metadata — what is *running*, and where. `search_panes` reads content — what is *displayed*. "Which pane mentions the error" is a search. +Copy mode is an attached-client interface, not a prerequisite for reading pane +text. Set `history: true` on `capture_pane` or `snapshot_pane` for bounded +scrollback, use `search_panes` to locate displayed text, and continue from a +cursor with `capture_since` instead of entering or cancelling a person's mode. + Batch rows retain the nested MCP envelope rather than flattening its text or structured content. The complete JSON-RPC response, including line framing, is capped at 1,000,000 bytes. A row that would cross that boundary remains in @@ -209,8 +214,8 @@ A serialized request ID may use at most 524,288 bytes; a larger ID returns an `rename_session` · `rename_window` · `select_window` · `select_pane` · `select_layout` · `resize_window` · `resize_pane` · `move_window` · `swap_pane` · -`set_pane_title` · `enter_copy_mode` · `exit_copy_mode` · `set_mouse_enabled` · -`set_history_limit` · `create_session` · `create_window` · `split_window` · +`set_pane_title` · `set_mouse_enabled` · `set_history_limit` · `create_session` · +`create_window` · `split_window` · `respawn_pane` · `send_keys` · `send_keys_batch` · `paste_text` · `set_synchronize_panes` diff --git a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Catalog.java b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Catalog.java index a70070b..a749bad 100644 --- a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Catalog.java +++ b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Catalog.java @@ -606,23 +606,6 @@ private static void manage(List tools) { PANE_OUTPUT, Operations::setPaneTitle), "title")); - tools.add(manageTool( - "enter_copy_mode", - "Enter copy mode", - "Puts a pane into copy mode.", - List.of(paneId()), - sinks(input("pane_id", TMUX_LOOKUP)), - shape(field("pane_id", STRING), field("mode", STRING)), - Operations::enterCopyMode)); - tools.add(manageTool( - "exit_copy_mode", - "Exit copy mode", - "Leaves the pane's current mode.", - List.of(paneId()), - sinks(input("pane_id", TMUX_LOOKUP)), - shape(field("pane_id", STRING), field("mode", STRING)), - Operations::exitCopyMode)); - List channelWait = List.of( required("channel", "A server-wide tmux channel name."), seconds("timeout", "Seconds to wait before giving up.", 30), diff --git a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Operations.java b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Operations.java index e599be7..a4e0c82 100644 --- a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Operations.java +++ b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Operations.java @@ -269,18 +269,6 @@ static Object setPaneTitle(Call call) { return pane(Targets.pane(call.server(), call.string("pane_id")).retitle(call.string("title"))); } - static Object enterCopyMode(Call call) { - Pane pane = Targets.pane(call.server(), call.string("pane_id")); - pane.copyMode(); - return values("pane_id", pane.id().value(), "mode", "copy-mode"); - } - - static Object exitCopyMode(Call call) { - Pane pane = Targets.pane(call.server(), call.string("pane_id")); - pane.exitMode(); - return values("pane_id", pane.id().value(), "mode", "normal"); - } - static Object setMouseEnabled(Call call) { boolean enabled = call.flag("enabled", false); call.server().setMouseEnabled(enabled); diff --git a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/CapabilityRegistryTest.java b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/CapabilityRegistryTest.java index e86507a..90577e0 100644 --- a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/CapabilityRegistryTest.java +++ b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/CapabilityRegistryTest.java @@ -57,8 +57,6 @@ final class CapabilityRegistryTest { "move_window", "swap_pane", "set_pane_title", - "enter_copy_mode", - "exit_copy_mode", "wait_for_channel", "signal_channel", "set_mouse_enabled", @@ -110,8 +108,6 @@ final class CapabilityRegistryTest { "move_window", "swap_pane", "set_pane_title", - "enter_copy_mode", - "exit_copy_mode", "wait_for_channel", "signal_channel", "set_mouse_enabled", @@ -206,9 +202,9 @@ void everyManifestRowDrivesConservativeRegistrationMetadataAndSinkValidation() { assertSame(tool, all.require(tool.name())); } - assertEquals(47, Catalog.tools().size()); + assertEquals(45, Catalog.tools().size()); assertEquals( - Map.of("inspect", 18L, "manage", 16L, "execute", 9L, "teardown", 4L), + Map.of("inspect", 18L, "manage", 14L, "execute", 9L, "teardown", 4L), Catalog.tools().stream() .collect(java.util.stream.Collectors.groupingBy( tool -> tool.toolset().wireName(), java.util.stream.Collectors.counting()))); @@ -288,7 +284,6 @@ void exactEffectRowsAndExclusionPrunedBatchUnionsStayAligned() { Map.entry("capture_since", Set.of(ToolSpec.TmuxEffect.OBSERVE)), Map.entry("call_read_tools_batch", Set.of(ToolSpec.TmuxEffect.OBSERVE)), Map.entry("create_session", Set.of(ToolSpec.TmuxEffect.OBSERVE, ToolSpec.TmuxEffect.CHANGE)), - Map.entry("enter_copy_mode", Set.of(ToolSpec.TmuxEffect.OBSERVE, ToolSpec.TmuxEffect.CHANGE)), Map.entry("kill_pane", Set.of(ToolSpec.TmuxEffect.OBSERVE, ToolSpec.TmuxEffect.DELETE)), Map.entry( "respawn_pane", diff --git a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/MainTest.java b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/MainTest.java index 26a1c9f..9e2f77e 100644 --- a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/MainTest.java +++ b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/MainTest.java @@ -156,7 +156,7 @@ void onlyANewDefaultMinimalSocketEnablesTeardownByDefault() { assertEquals("unknown", existing.configurationProvenance()); assertEquals("existing", existing.serverState()); - assertEquals(47, ToolSurface.resolve(Map.of(), created).tools().size()); + assertEquals(45, ToolSurface.resolve(Map.of(), created).tools().size()); assertEquals(false, ToolSurface.resolve(Map.of(), existing).tools().containsKey("kill_session")); assertEquals( List.of("kill_pane", "kill_window", "kill_session"), From 145f7d2d1f94c54e6544800d6b61caaf9f5a1ef3 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 5 Sep 2026 11:10:39 -0500 Subject: [PATCH 09/65] Docs(docs[changelog]): Record MCP mode boundary why: The public manifest contains 45 tools and keeps modal copy-mode ownership in the core Java library. what: - Correct the fixed MCP tool count - Document the capture-based route and retained Pane APIs --- CHANGELOG.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3017482..5aad711 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,7 +26,7 @@ production. ### Changed -- **`libtmux-mcp` now exposes a fixed 47-tool capability surface.** One native +- **`libtmux-mcp` now exposes a fixed 45-tool capability surface.** One native registry drives tool registration, schemas, trust metadata, selection, and the static `tmux://capabilities` resource. Unordered toolsets and named include/exclude lists replace safety tiers; the retired `LIBTMUX_SAFETY` @@ -36,6 +36,10 @@ production. to the dedicated `libtmux-mcp` socket, supports separate socket-name and absolute socket-path selectors, and enables teardown by default only for a newly created minimal daemon. +- **Copy-mode entry and exit remain library-only.** The MCP surface reads pane + text through `capture_pane` history, `snapshot_pane`, `search_panes`, or + `capture_since` without taking ownership of an attached client's modal + interface. Java callers retain `Pane.copyMode` and `Pane.exitMode`. - **The MCP guide maps every earlier public tool, resource URI, prompt workflow, and completion path.** Each retired name now points to its current typed route, composed workflow, or explicit no-replacement boundary. From 3fe7b52a3e1bcbcc99f3cf209a407544479eae60 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 5 Sep 2026 12:56:24 -0500 Subject: [PATCH 10/65] Mcp(fix[command]): Isolate command framing why: Interactive shell state can suppress completion markers, abort the pane, collide with command status bookkeeping, or reroute a relative client/socket. what: - Emit framed status and completion from an outer subshell exit trap - Pin an absolute executable and the live server's absolute socket - Preserve inherited command state inside a separate inner subshell - Document and cover the explicit shell/server trust boundaries --- docs/guide/mcp.md | 19 +- libtmux-mcp/README.md | 8 + .../java/io/github/libtmux/mcp/Catalog.java | 3 +- .../github/libtmux/mcp/PaneCommandFrame.java | 97 ++++++ .../github/libtmux/mcp/RunningCommands.java | 46 +-- .../io/github/libtmux/mcp/ToolSurface.java | 6 + .../libtmux/mcp/CapabilityRegistryTest.java | 2 + .../libtmux/mcp/PaneCommandFrameTest.java | 297 ++++++++++++++++++ .../libtmux/mcp/RunningCommandsTest.java | 179 ++++++++++- 9 files changed, 618 insertions(+), 39 deletions(-) create mode 100644 libtmux-mcp/src/main/java/io/github/libtmux/mcp/PaneCommandFrame.java create mode 100644 libtmux-mcp/src/test/java/io/github/libtmux/mcp/PaneCommandFrameTest.java diff --git a/docs/guide/mcp.md b/docs/guide/mcp.md index dfbbcd0..69d9dad 100644 --- a/docs/guide/mcp.md +++ b/docs/guide/mcp.md @@ -76,10 +76,10 @@ work bounded. ## Telling output apart from the plumbing -`run_shell_command` has to know when a command finished and what it exited with. The shell -in a pane will not tell anyone, so the command is followed by two things it runs -afterwards — one recording the status in a pane option, one signalling a private -tmux channel — and the wait is tmux's own `wait-for`. +`run_shell_command` has to know when a command finished and what it exited with. An +outer subshell therefore arms an exit trap before starting the command. The trap sends +the numeric status marker and signals a private tmux channel; the wait is tmux's own +`wait-for`. The catch is that a shell echoes everything typed at it, so that plumbing lands on screen amongst the output. Matching it by its shape does not work: in a narrow @@ -91,7 +91,7 @@ So the command is framed instead. It is bracketed by two lines that print a rand nonce, and only lines strictly between them are returned: ``` - echo lt3fa9-s; ( pytest -q ); lt3fa9=$?; echo lt3fa9-e; tmux … wait-for -S ch_lt3fa9 + ( \trap '/usr/bin/tmux -S /tmp/tmux.sock display-message -p lt3fa9-e:"$?"; /usr/bin/tmux -S /tmp/tmux.sock wait-for -S ch_lt3fa9; \exit 0' 0; /usr/bin/tmux -S /tmp/tmux.sock display-message -p lt3fa9-s; ( \eval 'pytest -q' ) ) ``` The echo of that whole line *contains* both markers. No echo is ever *equal* to @@ -108,6 +108,15 @@ Two consequences worth knowing, both pinned by tests: redraws its prompt. A following `capture_since` legitimately reports that prompt as new output. +The command's inner subshell inherits the pane's ordinary environment, options, +traps, and functions. The outer frame uses one absolute client and the server's +resolved `-S` socket, so output-command aliases and functions, a `tmux` basename +function, pane `PATH`, and pane socket variables do not own completion. Pre-existing +functions named `trap`, `eval`, `exit`, or exactly like that resolved client are not +a supported hostile-shell case. The marker `display-message` calls still use the +trusted server's normal command path, including configured command aliases and +`after-display-message` hooks. + ## A cursor, so watching is not re-reading `capture_since` takes an opaque cursor and returns the lines added since it, diff --git a/libtmux-mcp/README.md b/libtmux-mcp/README.md index 61fd5b3..e36096d 100644 --- a/libtmux-mcp/README.md +++ b/libtmux-mcp/README.md @@ -249,6 +249,14 @@ wait. Those mean different things, and only the first makes `exit_status` meaningful — tmux reports a server that died under a waiter as a *successful* wake, so "it worked" is never the answer on its own. +Completion runs inside the pane's trusted POSIX shell: an inherited inner +subshell contains the authored command, while an outer exit trap emits its status +and signals through one absolute tmux client and the server's resolved `-S` +socket. Ordinary output aliases and functions are tolerated; pre-existing +functions named `trap`, `eval`, `exit`, or exactly like that resolved client are +outside this boundary. Marker `display-message` calls honor the selected trusted +server's command aliases and hooks. + **You did not write it.** Always pass `stop`: ```json diff --git a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Catalog.java b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Catalog.java index a749bad..1bc4535 100644 --- a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Catalog.java +++ b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Catalog.java @@ -761,7 +761,8 @@ private static void execute(List tools) { tools.add(tool( "run_shell_command", "Run a shell command", - "Runs one authored command in a pane and waits for its framed completion.", + "Runs one authored command in a trusted pane shell and waits for framed completion; marker " + + "display-message commands honor the selected trusted server's command aliases and hooks.", EXECUTE, PANE_COMMAND, effects(OBSERVE, CHANGE), diff --git a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/PaneCommandFrame.java b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/PaneCommandFrame.java new file mode 100644 index 0000000..7c602d1 --- /dev/null +++ b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/PaneCommandFrame.java @@ -0,0 +1,97 @@ +package io.github.libtmux.mcp; + +import io.github.libtmux.ServerConfig; +import io.github.libtmux.transport.CommandResult; +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.function.Supplier; + +/** The exact tmux client and live socket used by one pane-command frame. */ +record PaneCommandFrame(List client) { + + PaneCommandFrame { + client = List.copyOf(client); + } + + static PaneCommandFrame resolve(Call call) { + return resolve( + call.server().config(), + call.surface().resolvedSocketPath(), + System.getenv(), + Path.of("").toAbsolutePath(), + () -> call.server().cmd("display-message", "-p", "#{socket_path}")); + } + + static PaneCommandFrame resolve( + ServerConfig config, + Optional suppliedSocket, + Map environment, + Path workingDirectory, + Supplier socketQuery) { + String executable = resolveExecutable(config.binary(), environment, workingDirectory); + String socket = resolveSocket(suppliedSocket, socketQuery); + return new PaneCommandFrame(List.of(executable, "-S", socket)); + } + + static String resolveExecutable(String configured, Map environment, Path workingDirectory) { + if (configured.indexOf(File.separatorChar) >= 0) { + Path selected = Path.of(configured); + return requireExecutable(selected.isAbsolute() ? selected : workingDirectory.resolve(selected), configured); + } + + String path = environment.get("PATH"); + if (path == null) { + throw new IllegalArgumentException("PATH is required to resolve tmux executable '" + configured + "'"); + } + for (String entry : path.split(java.util.regex.Pattern.quote(File.pathSeparator), -1)) { + Path directory = entry.isEmpty() ? workingDirectory : Path.of(entry); + if (!directory.isAbsolute()) { + directory = workingDirectory.resolve(directory); + } + Path candidate = directory.resolve(configured); + if (Files.isRegularFile(candidate) && Files.isExecutable(candidate)) { + return requireExecutable(candidate, configured); + } + } + throw new IllegalArgumentException("tmux executable '" + configured + "' was not found on PATH"); + } + + private static String requireExecutable(Path selected, String configured) { + if (!Files.isRegularFile(selected) || !Files.isExecutable(selected)) { + throw new IllegalArgumentException("tmux executable '" + configured + "' is not a regular executable file"); + } + try { + Path resolved = selected.toRealPath(); + if (!resolved.isAbsolute() || !Files.isRegularFile(resolved) || !Files.isExecutable(resolved)) { + throw new IllegalArgumentException( + "tmux executable '" + configured + "' did not resolve to an absolute executable file"); + } + return resolved.toString(); + } catch (IOException failure) { + throw new IllegalArgumentException("tmux executable '" + configured + "' could not be resolved", failure); + } + } + + static String resolveSocket(Optional supplied, Supplier socketQuery) { + if (supplied.isPresent() && !supplied.orElseThrow().isBlank()) { + return requireAbsoluteSocket(supplied.orElseThrow()); + } + CommandResult result = socketQuery.get(); + if (!result.succeeded() || result.stdout().size() != 1) { + throw new IllegalArgumentException("tmux did not report exactly one socket path"); + } + return requireAbsoluteSocket(result.stdout().getFirst()); + } + + private static String requireAbsoluteSocket(String socket) { + if (socket.isBlank() || !Path.of(socket).isAbsolute()) { + throw new IllegalArgumentException("tmux socket path must be nonblank and absolute"); + } + return socket; + } +} diff --git a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/RunningCommands.java b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/RunningCommands.java index ded454d..03c3d2a 100644 --- a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/RunningCommands.java +++ b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/RunningCommands.java @@ -21,10 +21,11 @@ * *

How completion is known

* - *

The command is followed by two things the shell runs after it: an end marker carrying the exit - * status, and a signal on a private tmux channel. Waiting is then tmux's own {@code wait-for}, which - * blocks server-side and returns on the signal itself — completion is not inferred from what the - * screen looks like. + *

An inner subshell evaluates the command with the pane shell's inherited environment, options, + * traps, and functions. An outer subshell arms an exit trap first; that trap prints the numeric end + * marker and signals a private channel through one absolute tmux executable and the live server's + * exact {@code -S} socket. Waiting is tmux's own {@code wait-for}, so completion is not inferred + * from the screen. * *

How the output is separated from the plumbing

* @@ -33,6 +34,9 @@ * nonce, and only lines strictly between them are returned. The echo of the whole payload contains * the nonce, but never as a complete start marker or an end marker followed only by a numeric * status. Matching those forms separates the two even when the echo wraps across several rows. + * Ordinary aliases and functions such as {@code echo}, {@code printf}, or {@code tmux} cannot own + * the marker path. A parent shell that already replaces {@code trap}, {@code eval}, {@code exit}, + * or the exact resolved executable with a same-name function is outside the supported boundary. */ final class RunningCommands { @@ -72,6 +76,7 @@ static Ran run(Call call) { String command = call.string("command"); Duration timeout = Waits.requested(call); boolean suppressHistory = call.flag("suppress_history", true); + PaneCommandFrame commandFrame = PaneCommandFrame.resolve(call); String nonce = "lt" + HexFormat.of().formatHex(bytes()); String startMark = nonce + "-s"; @@ -79,7 +84,7 @@ static Ran run(Call call) { String channel = "ch_" + nonce; Cursor before = Screen.from(pane).cursor(); - String typed = payload(server, command, nonce, startMark, endMark, channel, suppressHistory); + String typed = payload(commandFrame, command, startMark, endMark, channel, suppressHistory); pane.sendLine(typed); long started = System.nanoTime(); @@ -132,27 +137,26 @@ static Ran run(Call call) { * neither records the line like any other. */ private static String payload( - Server server, + PaneCommandFrame frame, String command, - String nonce, String startMark, String endMark, String channel, boolean suppressHistory) { - // The config file is left off: it is read when a server starts and means nothing to a command - // sent to one already running. Everything typed here is echoed by the shell onto the pane a - // person may be watching, so the shortest correct command line is the kindest one. - // A resolved path, not the name: the pane resolves a name against the user's PATH, and a - // client from another release than this server is dropped without delivering the signal. - List tmux = new ArrayList<>(List.of(server.config().binaryPath())); - tmux.addAll(server.config().endpoint().flags()); - - String finish = Shell.quoteAll(append(tmux, "wait-for", "-S", channel)); - - // The status is held in a shell variable named for the nonce, so nothing this types can - // collide with a variable the person using the pane already had. - return (suppressHistory ? " " : "") + "echo " + startMark + "; ( eval " + Shell.quote(command) + " ); " + nonce - + "=$?; echo " + endMark + ":\"$" + nonce + "\"; " + finish; + List tmux = frame.client(); + String start = Shell.quoteAll(append(tmux, "display-message", "-p", startMark)); + String end = + Shell.quoteAll(append(tmux, "display-message", "-p")) + " " + Shell.quote(endMark + ":") + "\"$?\""; + String signal = Shell.quoteAll(append(tmux, "wait-for", "-S", channel)); + String finish = end + "; " + signal + "; \\exit 0"; + return (suppressHistory ? " " : "") + + "( \\trap " + + Shell.quote(finish) + + " 0; " + + start + + "; ( \\eval " + + Shell.quote(command) + + " ) )"; } private static List append(List base, String... more) { diff --git a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/ToolSurface.java b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/ToolSurface.java index 8eac084..3cf1ed3 100644 --- a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/ToolSurface.java +++ b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/ToolSurface.java @@ -9,6 +9,7 @@ import java.util.LinkedHashSet; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.Set; import org.jspecify.annotations.Nullable; @@ -119,6 +120,11 @@ Set exclusions() { return exclusions; } + /** Startup-frozen socket path, when this surface was built by the MCP launcher. */ + Optional resolvedSocketPath() { + return socketProfile == null ? Optional.empty() : Optional.of(socketProfile.resolvedSocketPath()); + } + List toolsetNames() { return toolsets.stream().map(ToolSpec.Toolset::wireName).toList(); } diff --git a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/CapabilityRegistryTest.java b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/CapabilityRegistryTest.java index 90577e0..f588332 100644 --- a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/CapabilityRegistryTest.java +++ b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/CapabilityRegistryTest.java @@ -263,6 +263,8 @@ void everyManifestRowDrivesConservativeRegistrationMetadataAndSinkValidation() { assertTrue(batch.description().contains("no separate approval")); assertTrue(batch.description().contains("1,000,000 bytes")); assertTrue(byName("set_synchronize_panes").description().contains("subsequent input is copied to every pane")); + assertTrue(byName("run_shell_command").description().contains("trusted pane shell")); + assertTrue(byName("run_shell_command").description().contains("command aliases and hooks")); for (String removed : List.of( "tmux_whoami", "tmux_list_servers", diff --git a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/PaneCommandFrameTest.java b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/PaneCommandFrameTest.java new file mode 100644 index 0000000..5a0ba62 --- /dev/null +++ b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/PaneCommandFrameTest.java @@ -0,0 +1,297 @@ +package io.github.libtmux.mcp; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import io.github.libtmux.Server; +import io.github.libtmux.ServerConfig; +import io.github.libtmux.ServerEndpoint; +import io.github.libtmux.transport.CommandRequest; +import io.github.libtmux.transport.CommandResult; +import io.github.libtmux.transport.ProcessTransport; +import io.github.libtmux.transport.TmuxTransport; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.BooleanSupplier; +import java.util.stream.Stream; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +final class PaneCommandFrameTest { + + private static final String TMUX = System.getProperty("libtmux.tmux", "tmux"); + + @Test + void resolvesConfiguredAndSearchedExecutables(@TempDir Path temporary) throws Exception { + Path working = Files.createDirectory(temporary.resolve("working")); + Path direct = + executable(Files.createDirectories(working.resolve("tools")).resolve("tmux")); + Path relativePath = + executable(Files.createDirectories(working.resolve("bin")).resolve("tmux")); + Path emptyPath = executable(working.resolve("tmux")); + Path absolute = executable(temporary.resolve("absolute-tmux")); + + assertEquals( + direct.toRealPath().toString(), PaneCommandFrame.resolveExecutable("./tools/tmux", Map.of(), working)); + assertEquals( + relativePath.toRealPath().toString(), + PaneCommandFrame.resolveExecutable("tmux", Map.of("PATH", "bin"), working)); + assertEquals( + emptyPath.toRealPath().toString(), + PaneCommandFrame.resolveExecutable("tmux", Map.of("PATH", ":elsewhere"), working)); + assertEquals( + absolute.toRealPath().toString(), + PaneCommandFrame.resolveExecutable(absolute.toString(), Map.of(), working)); + } + + @Test + void rejectsExecutableFallbacks(@TempDir Path temporary) throws Exception { + Path directory = Files.createDirectory(temporary.resolve("directory")); + Path ordinary = Files.writeString(temporary.resolve("ordinary"), "not executable"); + + assertThrows( + IllegalArgumentException.class, () -> PaneCommandFrame.resolveExecutable("tmux", Map.of(), temporary)); + assertThrows( + IllegalArgumentException.class, + () -> PaneCommandFrame.resolveExecutable("tmux", Map.of("PATH", "missing"), temporary)); + assertThrows( + IllegalArgumentException.class, + () -> PaneCommandFrame.resolveExecutable("./missing", Map.of(), temporary)); + assertThrows( + IllegalArgumentException.class, + () -> PaneCommandFrame.resolveExecutable(directory.toString(), Map.of(), temporary)); + assertThrows( + IllegalArgumentException.class, + () -> PaneCommandFrame.resolveExecutable(ordinary.toString(), Map.of(), temporary)); + } + + @Test + void everyEndpointUsesTheAuthoritativeSocket(@TempDir Path temporary) throws Exception { + Path executable = executable(temporary.resolve("tmux")); + AtomicInteger queries = new AtomicInteger(); + List endpoints = List.of( + ServerEndpoint.defaultSocket(), + ServerEndpoint.namedSocket("chosen"), + ServerEndpoint.socketPath(Path.of("/tmp/configured.sock"))); + for (ServerEndpoint endpoint : endpoints) { + ServerConfig config = config(executable, endpoint); + PaneCommandFrame frame = PaneCommandFrame.resolve(config, Optional.empty(), Map.of(), temporary, () -> { + queries.incrementAndGet(); + return result("/tmp/authoritative.sock"); + }); + assertEquals(List.of(executable.toRealPath().toString(), "-S", "/tmp/authoritative.sock"), frame.client()); + } + assertEquals(3, queries.get()); + } + + @Test + void aRetainedSocketAvoidsDiscoveryWhileBlankDoesNot() { + AtomicInteger queries = new AtomicInteger(); + String retained = PaneCommandFrame.resolveSocket(Optional.of("/tmp/retained.sock"), () -> { + throw new AssertionError("a retained socket must avoid discovery"); + }); + String discovered = PaneCommandFrame.resolveSocket(Optional.of(""), () -> { + queries.incrementAndGet(); + return result("/tmp/discovered.sock"); + }); + + assertEquals("/tmp/retained.sock", retained); + assertEquals("/tmp/discovered.sock", discovered); + assertEquals(1, queries.get()); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("malformedSockets") + void malformedSocketRoutesFailClosed(String label, Optional supplied, CommandResult result) { + assertThrows( + IllegalArgumentException.class, () -> PaneCommandFrame.resolveSocket(supplied, () -> result), label); + } + + @ParameterizedTest + @MethodSource("routeKinds") + void framedCommandsStayOnTheResolvedEndpoint(String kind, @TempDir Path temporary) throws Exception { + Path workingDirectory = Path.of("").toAbsolutePath(); + String realTmux = PaneCommandFrame.resolveExecutable(TMUX, System.getenv(), workingDirectory); + Path intendedRoot = Files.createDirectory(temporary.resolve("intended-root")); + Path decoyRoot = Files.createDirectory(temporary.resolve("decoy-root")); + Path intendedSocket = temporary.resolve("intended.sock"); + Path wrapper = temporary.resolve("bin").resolve("tmux-wrapper"); + Files.createDirectories(wrapper.getParent()); + + String socketName = "frame-" + temporary.getFileName(); + ServerEndpoint endpoint = + switch (kind) { + case "default" -> ServerEndpoint.defaultSocket(); + case "named" -> ServerEndpoint.namedSocket(socketName); + case "path" -> ServerEndpoint.socketPath(intendedSocket); + default -> throw new AssertionError(kind); + }; + writeWrapper(wrapper, kind, realTmux, intendedRoot, decoyRoot, intendedSocket); + String configured = kind.equals("default") + ? workingDirectory.relativize(wrapper.toAbsolutePath()).toString() + : wrapper.toString(); + Path configFile = Files.writeString(temporary.resolve("tmux.conf"), ""); + ServerConfig config = ServerConfig.builder() + .binary(configured) + .endpoint(endpoint) + .configFile(configFile) + .build(); + AtomicReference payload = new AtomicReference<>(""); + + try (ProcessTransport processes = new ProcessTransport()) { + TmuxTransport recording = recording(processes, payload); + try (Server server = Server.using(config, recording)) { + try { + CommandResult created = server.cmd("new-session", "-d", "-s", "route", "/bin/dash"); + assertTrue(created.succeeded(), created.stderr().toString()); + String socket = server.cmd("display-message", "-p", "#{socket_path}") + .stdout() + .getFirst(); + + var pane = server.panes().getFirst(); + Path changed = Files.createDirectory(temporary.resolve("changed")); + Path panePath = Files.createDirectory(temporary.resolve("pane-path")); + Path ready = temporary.resolve("pane-ready"); + pane.sendLine("LIBTMUX_FRAME_PANE=pane; export LIBTMUX_FRAME_PANE; unset TMUX; PATH=" + + Shell.quote(panePath.toString()) + + "; TMUX_TMPDIR=" + + Shell.quote(decoyRoot.toString()) + + "; export PATH TMUX_TMPDIR; cd " + + Shell.quote(changed.toString()) + + "; : > " + + Shell.quote(ready.toString())); + assertTrue(await(() -> Files.exists(ready)), "the pane-route setup did not finish"); + + RunningCommands.Ran ran = RunningCommands.run(TestCalls.on( + server, + "pane_id", + pane.id().value(), + "command", + "printf 'route-" + kind + "\\n'; exit 9", + "timeout", + 5)); + + assertEquals("SIGNALLED", ran.outcome()); + assertEquals(9, ran.exitStatus()); + assertEquals(List.of("route-" + kind), ran.output()); + assertTrue(ran.framed()); + assertEquals( + 3, occurrences(payload.get(), wrapper.toRealPath().toString()), payload.get()); + assertEquals(3, occurrences(payload.get(), socket), payload.get()); + } finally { + if (server.isAlive()) { + server.killServer(); + } + } + } + } + } + + private static Stream routeKinds() { + return Stream.of("default", "named", "path"); + } + + private static Stream malformedSockets() { + return Stream.of( + Arguments.of("nonzero", Optional.empty(), new CommandResult(1, List.of(), List.of("no server"))), + Arguments.of("zero lines", Optional.empty(), new CommandResult(0, List.of(), List.of())), + Arguments.of( + "multiple lines", + Optional.empty(), + new CommandResult(0, List.of("/tmp/one", "/tmp/two"), List.of())), + Arguments.of("blank", Optional.empty(), new CommandResult(0, List.of(" "), List.of())), + Arguments.of("relative", Optional.empty(), new CommandResult(0, List.of("relative.sock"), List.of())), + Arguments.of( + "relative retained", + Optional.of("relative.sock"), + new CommandResult(0, List.of("/tmp/unused.sock"), List.of()))); + } + + private static Path executable(Path path) throws IOException { + Files.writeString(path, "#!/bin/sh\nexit 0\n"); + assertTrue(path.toFile().setExecutable(true), "could not make test executable"); + return path; + } + + private static ServerConfig config(Path executable, ServerEndpoint endpoint) { + return ServerConfig.builder() + .binary(executable.toString()) + .endpoint(endpoint) + .build(); + } + + private static CommandResult result(String socket) { + return new CommandResult(0, List.of(socket), List.of()); + } + + private static void writeWrapper( + Path wrapper, String kind, String realTmux, Path intendedRoot, Path decoyRoot, Path socket) + throws IOException { + String javaRoute = + switch (kind) { + case "default" -> + "exec " + Shell.quote(realTmux) + " -S " + Shell.quote(socket.toString()) + " \"$@\""; + case "named" -> + "TMUX_TMPDIR=" + Shell.quote(intendedRoot.toString()) + "; export TMUX_TMPDIR; exec " + + Shell.quote(realTmux) + " \"$@\""; + case "path" -> "exec " + Shell.quote(realTmux) + " \"$@\""; + default -> throw new AssertionError(kind); + }; + String script = """ + #!/bin/sh + set -eu + if [ "${LIBTMUX_FRAME_PANE-}" = pane ]; then + for argument in "$@"; do + if [ "$argument" = -S ]; then exec %s "$@"; fi + done + TMUX_TMPDIR=%s; export TMUX_TMPDIR + exec %s "$@" + fi + %s + """.formatted( + Shell.quote(realTmux), Shell.quote(decoyRoot.toString()), Shell.quote(realTmux), javaRoute); + Files.writeString(wrapper, script); + assertTrue(wrapper.toFile().setExecutable(true), "could not make route wrapper executable"); + } + + private static TmuxTransport recording(ProcessTransport processes, AtomicReference payload) { + return new TmuxTransport() { + @Override + public CommandResult execute(CommandRequest request) { + request.commands().stream() + .flatMap(List::stream) + .filter(argument -> argument.contains("display-message") && argument.contains("ch_lt")) + .forEach(payload::set); + return processes.execute(request); + } + + @Override + public void close() {} + }; + } + + private static boolean await(BooleanSupplier condition) throws InterruptedException { + long deadline = System.nanoTime() + java.time.Duration.ofSeconds(5).toNanos(); + while (System.nanoTime() < deadline) { + if (condition.getAsBoolean()) { + return true; + } + Thread.sleep(10); + } + return condition.getAsBoolean(); + } + + private static int occurrences(String text, String needle) { + return (text.length() - text.replace(needle, "").length()) / needle.length(); + } +} diff --git a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/RunningCommandsTest.java b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/RunningCommandsTest.java index 1d6a57b..406b213 100644 --- a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/RunningCommandsTest.java +++ b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/RunningCommandsTest.java @@ -8,6 +8,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import io.github.libtmux.ObjectDoesNotExist; +import io.github.libtmux.Pane; import io.github.libtmux.Server; import io.github.libtmux.junit5.TmuxExtension; import io.github.libtmux.transport.CommandRequest; @@ -18,13 +19,20 @@ import io.github.libtmux.transport.TmuxTransportException; import java.nio.file.Files; import java.nio.file.Path; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.CountDownLatch; import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; import java.util.function.BooleanSupplier; +import java.util.regex.MatchResult; +import java.util.regex.Pattern; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; /** * Running a command and knowing how it ended, against real tmux. @@ -36,6 +44,8 @@ @ExtendWith(TmuxExtension.class) final class RunningCommandsTest { + private static final Pattern NONCE = Pattern.compile("\\blt[0-9a-f]{32}\\b"); + /** * The signal is sent by the pane, so it is the pane's PATH that decides which tmux sends it. A * client from another release than this server is dropped without delivering it. @@ -72,6 +82,103 @@ void aCommandThatSucceedsComesBackWithItsOutputAndStatus(Server server) { assertTrue(ran.framed(), "the plumbing was cut out exactly"); } + @ParameterizedTest + @ValueSource(strings = {"echo(){ :; }; alias printf=:", "alias echo=:; printf(){ :; }"}) + void preexistingOutputShadowsCannotHideCompletion(String shadow, Server server, @TempDir Path temporary) + throws Exception { + Pane pane = shellPane(server, "output-shadow", "/bin/dash"); + ready( + pane, + temporary.resolve("output-shadow"), + "frame_value=kept; frame_helper(){ test \"$frame_value\" = kept; }; " + shadow); + + RunningCommands.Ran ran = RunningCommands.run(TestCalls.on( + server, "pane_id", pane.id().value(), "command", "frame_helper || exit 91; exit 7", "timeout", 5)); + + assertCompleted(ran, 7); + } + + @Test + void ordinaryClientNameShadowsDoNotOwnFraming(Server server, @TempDir Path temporary) throws Exception { + Pane pane = shellPane(server, "client-shadow", "/bin/dash"); + String client = PaneCommandFrame.resolve(TestCalls.on(server)).client().getFirst(); + ready(pane, temporary.resolve("client-shadow"), "tmux(){ :; }; alias " + Shell.quote(client + "=:")); + + RunningCommands.Ran ran = + RunningCommands.run(TestCalls.on(server, "pane_id", pane.id().value(), "command", "exit 4")); + + assertCompleted(ran, 4); + } + + @Test + void commandDefinedFramingNamesStayInTheInnerShell(Server server) { + Pane pane = shellPane(server, "defined-frame", "/bin/bash", "--noprofile", "--norc"); + String client = PaneCommandFrame.resolve(TestCalls.on(server)).client().getFirst(); + String definitions = "function trap { :; }; function eval { :; }; function exit { :; }; function " + + client + + " { :; }; false"; + + RunningCommands.Ran defined = + RunningCommands.run(TestCalls.on(server, "pane_id", pane.id().value(), "command", definitions)); + RunningCommands.Ran after = + RunningCommands.run(TestCalls.on(server, "pane_id", pane.id().value(), "command", "true")); + + assertCompleted(defined, 1); + assertCompleted(after, 0); + } + + @Test + void inheritedErrexitAndXtraceKeepThePaneAlive(Server server, @TempDir Path temporary) throws Exception { + Pane pane = shellPane(server, "errexit", "/bin/bash", "--noprofile", "--norc"); + Path forbidden = temporary.resolve("must-not-exist"); + ready(pane, temporary.resolve("errexit"), "set -ex"); + + String command = "false; : > " + Shell.quote(forbidden.toString()); + RunningCommands.Ran ran = RunningCommands.run( + TestCalls.on(server, "pane_id", pane.id().value(), "command", command, "timeout", 5)); + + assertCompleted(ran, 1); + assertFalse(Files.exists(forbidden), "errexit did not stop the authored sequence"); + + pane.sendLine("printf 'parent-alive:%s\\n' \"$-\""); + assertTrue(await(() -> pane.capture().stream() + .map(String::trim) + .anyMatch(line -> line.matches("parent-alive:.*e.*x.*|parent-alive:.*x.*e.*")))); + } + + @Test + void framingUsesNoPaneStatusVariableAndIgnoresReadonlyCollision(Server server) throws Exception { + Pane pane = server.panes().get(0); + CopyOnWriteArrayList nonces = new CopyOnWriteArrayList<>(); + try (ProcessTransport processes = new ProcessTransport()) { + TmuxTransport recording = borrowing(request -> { + nonce(request) + .filter(nonces::addIfAbsent) + .filter(value -> nonces.size() == 2) + .ifPresent(value -> armReadonly(pane, value)); + return processes.execute(request); + }); + try (Server measured = Server.using(server.config(), recording)) { + RunningCommands.Ran first = RunningCommands.run( + TestCalls.on(measured, "pane_id", pane.id().value(), "command", "exit 5")); + assertCompleted(first, 5); + + String captured = nonces.getFirst(); + String inspect = "if [ \"${" + captured + "+set}\" = set ]; then " + + "printf 'set\\n'; else printf 'unset\\n'; fi"; + RunningCommands.Ran inspected = RunningCommands.run( + TestCalls.on(server, "pane_id", pane.id().value(), "command", inspect)); + assertEquals(List.of("unset"), inspected.output(), "the frame leaked its status name"); + + RunningCommands.Ran collided = RunningCommands.run( + TestCalls.on(measured, "pane_id", pane.id().value(), "command", "exit 6", "timeout", 5)); + + assertEquals(2, nonces.size(), "the collision nonce was not captured"); + assertCompleted(collided, 6); + } + } + } + @Test void aPaneNotRunningAPosixShellIsRefused(Server server) { server.cmd("new-window", "-d", "-n", "not-a-shell", "cat"); @@ -178,7 +285,7 @@ void exitStatusSurvivesWhenTheStartMarkerRolledOutOfHistory(Server server) { */ @Test void aCommandStillRunningAtTheDeadlineSaysSoAndHandsBackWhatItHas(Server server) { - String pane = server.panes().get(0).id().value(); + String pane = shellPane(server, "timed-output", "/bin/dash").id().value(); RunningCommands.Ran ran = RunningCommands.run( TestCalls.on(server, "pane_id", pane, "command", "echo started; sleep 30", "timeout", 6)); @@ -283,36 +390,45 @@ void aCommandPrintingMoreThanAskedForKeepsTheNewestAndSaysItDropped(Server serve * the command by hand — pinned here because the tool's description promises it. */ @Test - void aCommandCannotChangeThePanesShellAndCannotEndIt(Server server) { + void aCommandCannotChangeThePanesShellAndCannotEndIt(Server server, @TempDir Path temporary) { String pane = server.panes().get(0).id().value(); - RunningCommands.Ran exited = - RunningCommands.run(TestCalls.on(server, "pane_id", pane, "command", "mine=set; cd /; exit 3")); + String changed = Shell.quote(temporary.toString()); + String mutation = "mine=set; mine_helper(){ :; }; trap 'mine_trap=ran' 0; cd " + changed + + "; export mine_export=set; exit 3"; + RunningCommands.Ran exited = RunningCommands.run(TestCalls.on(server, "pane_id", pane, "command", mutation)); RunningCommands.Ran commented = RunningCommands.run(TestCalls.on(server, "pane_id", pane, "command", "echo comment-safe # comment")); RunningCommands.Ran parenthesis = RunningCommands.run(TestCalls.on(server, "pane_id", pane, "command", ": ); exit 7; #")); - RunningCommands.Ran after = - RunningCommands.run(TestCalls.on(server, "pane_id", pane, "command", "echo \"[$mine]\"")); + String inspect = "if [ \"${mine+set}\" = set ] || command -v mine_helper >/dev/null 2>&1 " + + "|| [ \"${mine_trap+set}\" = set ] || [ \"${mine_export+set}\" = set ] " + + "|| [ \"$PWD\" = " + changed + " ]; then printf 'leaked\\n'; else printf 'clean\\n'; fi"; + RunningCommands.Ran after = RunningCommands.run(TestCalls.on(server, "pane_id", pane, "command", inspect)); assertEquals(3, exited.exitStatus(), "exit reports from the isolated command"); assertEquals(java.util.List.of("comment-safe"), commented.output(), "a comment cannot hide the framing"); assertEquals("SIGNALLED", parenthesis.outcome(), "a closing parenthesis cannot escape the command"); - assertEquals(java.util.List.of("[]"), after.output(), "the assignment did not escape its subshell"); + assertEquals(java.util.List.of("clean"), after.output(), "authored state escaped its inner subshell"); assertEquals(1, server.panes().size(), "and exiting inside it did not take the pane with it"); } /** A variable a person set in the pane themselves must survive the plumbing running around it. */ @Test - void thePlumbingDoesNotDisturbThePanesOwnShellVariables(Server server) { - String pane = server.panes().get(0).id().value(); - server.run(java.util.List.of("send-keys", "-l", "-t", pane, "theirs=kept")); - server.run(java.util.List.of("send-keys", "-t", pane, "Enter")); + void thePlumbingDoesNotDisturbThePanesOwnShellVariables(Server server, @TempDir Path temporary) throws Exception { + Pane pane = server.panes().get(0); + Path ready = temporary.resolve("parent-state-ready"); + pane.sendLine("theirs=kept; trap 'theirs_trap=kept' USR1; : > " + Shell.quote(ready.toString())); + assertTrue(await(() -> Files.exists(ready)), "the parent-state setup did not finish"); RunningCommands.Ran ran = - RunningCommands.run(TestCalls.on(server, "pane_id", pane, "command", "echo \"$theirs\"")); + RunningCommands.run(TestCalls.on(server, "pane_id", pane.id().value(), "command", "echo \"$theirs\"")); assertEquals(java.util.List.of("kept"), ran.output()); + pane.sendLine("kill -USR1 $$; printf 'parent-trap:%s\\n' \"$theirs_trap\""); + assertTrue( + await(() -> pane.capture().stream().map(String::trim).anyMatch("parent-trap:kept"::equals)), + "the frame disturbed the parent shell's trap"); } @Test @@ -393,4 +509,43 @@ public CommandResult execute(CommandRequest request) { public void close() {} }; } + + private static Pane shellPane(Server server, String name, String... command) { + return server.sessions() + .getFirst() + .newWindow(window -> window.named(name).running(command)) + .panes() + .getFirst(); + } + + private static void ready(Pane pane, Path marker, String setup) throws InterruptedException { + pane.sendLine(setup + "; : > " + Shell.quote(marker.toString())); + assertTrue(await(() -> Files.exists(marker)), "the pane setup did not finish"); + } + + private static void assertCompleted(RunningCommands.Ran ran, int status) { + assertEquals("SIGNALLED", ran.outcome()); + assertEquals(status, ran.exitStatus()); + assertTrue(ran.framed(), "completion was not framed exactly"); + } + + private static void armReadonly(Pane pane, String nonce) { + pane.sendLine("readonly " + nonce + "=held; printf 'nonce-armed\\n'"); + try { + assertTrue( + await(() -> pane.capture().stream().map(String::trim).anyMatch("nonce-armed"::equals)), + "the nonce collision setup did not finish"); + } catch (InterruptedException failure) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("interrupted while arming the nonce collision", failure); + } + } + + private static Optional nonce(CommandRequest request) { + return request.commands().getFirst().stream() + .filter(argument -> argument.contains("ch_lt")) + .flatMap(argument -> NONCE.matcher(argument).results()) + .map(MatchResult::group) + .findFirst(); + } } From 740aa0a33b53eb7ff166e0a5c671a5ca1473f999 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 5 Sep 2026 13:33:09 -0500 Subject: [PATCH 11/65] Mcp(fix[input]): Guard effective pane cohorts why: Pane input can fan out through synchronized panes. Modal, dead, or changed pane state makes delivery unsafe or ambiguous. what: - Resolve strict effective recipient cohorts from one targeted listing - Refuse modal, dead, plural, or changed command targets before input - Preserve resolved batch membership across policy and dispatch failures --- .../io/github/libtmux/mcp/Operations.java | 9 +- .../github/libtmux/mcp/PaneInputCohort.java | 174 ++++++++++++ .../github/libtmux/mcp/RunningCommands.java | 18 +- .../java/io/github/libtmux/mcp/Typing.java | 25 +- .../libtmux/mcp/PaneInputCohortTest.java | 213 ++++++++++++++ .../libtmux/mcp/RunningCommandsTest.java | 259 ++++++++++++++++++ .../io/github/libtmux/mcp/TypingTest.java | 222 +++++++++++++++ 7 files changed, 895 insertions(+), 25 deletions(-) create mode 100644 libtmux-mcp/src/main/java/io/github/libtmux/mcp/PaneInputCohort.java create mode 100644 libtmux-mcp/src/test/java/io/github/libtmux/mcp/PaneInputCohortTest.java diff --git a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Operations.java b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Operations.java index a4e0c82..9609b9c 100644 --- a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Operations.java +++ b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Operations.java @@ -353,18 +353,21 @@ static Object sendKeysBatch(Call call) { for (int index = 0; index < operations.size(); index++) { Map operation = operations.get(index); String paneId = requiredText(operation, "pane_id"); + List resolvedPaneIds = List.of(); try { Pane pane = Targets.pane(call.server(), paneId); + PaneInputCohort.Resolution cohort = PaneInputCohort.resolve(pane); + resolvedPaneIds = cohort.configuredKeyRecipientIds(); List keys = strings(operation.get("keys"), "keys"); boolean literal = booleanValue(operation.get("literal"), false, "literal"); - Typing.Sent sent = Typing.sendKeys(pane, keys, literal); + Typing.sendKeys(pane, keys, literal, cohort); results.add(values( "index", index, "pane_id", paneId, "resolved_pane_ids", - sent.resolvedPaneIds(), + resolvedPaneIds, "success", true)); } catch (RuntimeException failure) { @@ -373,6 +376,8 @@ static Object sendKeysBatch(Call call) { index, "pane_id", paneId, + "resolved_pane_ids", + resolvedPaneIds, "success", false, "error", diff --git a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/PaneInputCohort.java b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/PaneInputCohort.java new file mode 100644 index 0000000..e3fbc1b --- /dev/null +++ b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/PaneInputCohort.java @@ -0,0 +1,174 @@ +package io.github.libtmux.mcp; + +import io.github.libtmux.LibTmuxException; +import io.github.libtmux.Pane; +import io.github.libtmux.format.RowFormat; +import io.github.libtmux.format.TmuxFormatException; +import io.github.libtmux.transport.CommandResult; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** One authoritative view of the panes tmux may receive input through. */ +final class PaneInputCohort { + + private static final long MAX_TMUX_PANE_ID = 4_294_967_295L; + + private static final RowFormat PANES = RowFormat.of( + "pane_id", + "pane_synchronized", + "pane_in_mode", + "pane_dead", + "pane_current_command"); + + private static final String TERMINATOR = + PANES.template().substring(PANES.template().lastIndexOf('}') + 1); + + private PaneInputCohort() {} + + static Resolution resolve(Pane source) { + return parse( + source.id().value(), + source.server().cmd(List.of( + "list-panes", + "-t", + source.id().value(), + "-F", + PANES.template()))); + } + + static Resolution parse(String sourcePaneId, CommandResult answer) { + if (!answer.succeeded()) { + throw new LibTmuxException("tmux could not resolve pane input state"); + } + if (answer.stdout().isEmpty()) { + throw new LibTmuxException("tmux returned no pane input state for " + sourcePaneId); + } + int terminators = validateFraming(answer.stdout()); + List rows = PANES.rows(answer.stdout()); + if (rows.size() != terminators) { + throw new TmuxFormatException("tmux returned an incomplete pane input listing"); + } + + Map members = new LinkedHashMap<>(); + for (RowFormat.Row row : rows) { + Member member = member(row); + if (members.putIfAbsent(member.paneId(), member) != null) { + throw new LibTmuxException("tmux returned duplicate pane input state"); + } + } + Member source = members.get(sourcePaneId); + if (source == null) { + throw new LibTmuxException("tmux returned no pane input state for " + sourcePaneId); + } + List recipients = source.synchronizedPane() + ? members.values().stream() + .filter(Member::synchronizedPane) + .sorted(java.util.Comparator.comparing(Member::paneId)) + .toList() + : List.of(source); + return new Resolution(source, recipients); + } + + private static int validateFraming(List lines) { + int closed = 0; + for (String line : lines) { + int marker = line.indexOf(TERMINATOR); + if (marker < 0) { + continue; + } + if (marker + TERMINATOR.length() != line.length() + || line.indexOf(TERMINATOR, marker + TERMINATOR.length()) >= 0) { + throw new TmuxFormatException("tmux returned a malformed pane input row terminator"); + } + closed++; + } + if (closed == 0) { + throw new TmuxFormatException("tmux returned no terminated pane input rows"); + } + return closed; + } + + private static Member member(RowFormat.Row row) { + String paneId = paneId(row.text("pane_id")); + boolean synchronizedPane = row.flag("pane_synchronized"); + String rawMode = row.text("pane_in_mode"); + long mode = row.count("pane_in_mode"); + if (mode < 0) { + throw new TmuxFormatException("pane_in_mode was negative"); + } + boolean dead = row.flag("pane_dead"); + String command = row.text("pane_current_command"); + if (command.isEmpty()) { + throw new TmuxFormatException("pane_current_command was empty"); + } + return new Member(paneId, synchronizedPane, mode, rawMode.equals("0"), dead, command); + } + + private static String paneId(String value) { + if (!value.startsWith("%") || value.length() == 1) { + throw new TmuxFormatException("pane_id was invalid"); + } + try { + long id = Long.parseLong(value.substring(1)); + if (id < 0 || id > MAX_TMUX_PANE_ID || !value.equals("%" + id)) { + throw new TmuxFormatException("pane_id was invalid"); + } + return value; + } catch (NumberFormatException failure) { + throw new TmuxFormatException("pane_id was invalid", failure); + } + } + + record Member( + String paneId, + boolean synchronizedPane, + long mode, + boolean canonicalZeroMode, + boolean dead, + String currentCommand) { + + boolean writable() { + return mode == 0 && canonicalZeroMode; + } + } + + record Resolution(Member source, List keyRecipients) { + + Resolution { + keyRecipients = List.copyOf(keyRecipients); + } + + List configuredKeyRecipientIds() { + return keyRecipients.stream().map(Member::paneId).toList(); + } + + List requireKeyRecipients(String operation) { + keyRecipients.forEach(member -> requireWritable(operation, member)); + return configuredKeyRecipientIds(); + } + + void requirePasteTarget(String operation) { + requireWritable(operation, source); + } + + String requireSingularCommandPane(String operation) { + keyRecipients.forEach(member -> requireWritable(operation, member)); + if (keyRecipients.size() != 1) { + throw new IllegalStateException(operation + " requires exactly one effective pane; observed " + + configuredKeyRecipientIds()); + } + return source.currentCommand(); + } + + private static void requireWritable(String operation, Member member) { + if (member.dead()) { + throw new IllegalStateException(operation + " refuses dead pane " + member.paneId()); + } + if (!member.writable()) { + throw new IllegalStateException( + operation + " refuses pane " + member.paneId() + " while it is in a human-owned mode"); + } + } + } +} diff --git a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/RunningCommands.java b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/RunningCommands.java index 03c3d2a..8f6e0fc 100644 --- a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/RunningCommands.java +++ b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/RunningCommands.java @@ -72,10 +72,12 @@ record Ran( static Ran run(Call call) { Server server = call.server(); Pane pane = Targets.pane(server, call.string("pane_id")); - requirePosixShell(pane); String command = call.string("command"); Duration timeout = Waits.requested(call); boolean suppressHistory = call.flag("suppress_history", true); + String currentCommand = + PaneInputCohort.resolve(pane).requireSingularCommandPane("run_shell_command"); + requirePosixShell(currentCommand); PaneCommandFrame commandFrame = PaneCommandFrame.resolve(call); String nonce = "lt" + HexFormat.of().formatHex(bytes()); @@ -85,19 +87,24 @@ static Ran run(Call call) { Cursor before = Screen.from(pane).cursor(); String typed = payload(commandFrame, command, startMark, endMark, channel, suppressHistory); - pane.sendLine(typed); + Pane freshPane = Targets.pane(server, pane.id().value()); + String freshCommand = + PaneInputCohort.resolve(freshPane).requireSingularCommandPane("run_shell_command"); + requirePosixShell(freshCommand); + freshPane.sendLine(typed); long started = System.nanoTime(); WakeReason wake = server.channel(channel).await(timeout); double seconds = (System.nanoTime() - started) / 1_000_000_000.0; - Screen.Fresh fresh = wake == WakeReason.SERVER_GONE ? null : Screen.since(pane, before, Trim.lineBudget(call)); + Screen.Fresh fresh = + wake == WakeReason.SERVER_GONE ? null : Screen.since(freshPane, before, Trim.lineBudget(call)); Framed framed = fresh == null ? new Framed(List.of(), false, null) : frame(fresh.lines(), startMark, endMark); Integer status = wake == WakeReason.SIGNALLED ? framed.status() : null; Trim.Trimmed trimmed = Trim.tail(framed.lines(), Trim.lineBudget(call)); return new Ran( - pane.id().value(), + freshPane.id().value(), wake.name(), status, trimmed.lines(), @@ -223,8 +230,7 @@ private static byte[] bytes() { return value; } - private static void requirePosixShell(Pane pane) { - String current = pane.expand("#{pane_current_command}"); + private static void requirePosixShell(String current) { int slash = current.lastIndexOf('/'); String name = slash < 0 ? current : current.substring(slash + 1); if (name.startsWith("-")) { diff --git a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Typing.java b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Typing.java index 139e367..c83c3c7 100644 --- a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Typing.java +++ b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Typing.java @@ -1,7 +1,6 @@ package io.github.libtmux.mcp; import io.github.libtmux.Pane; -import java.util.Comparator; import java.util.List; import org.jspecify.annotations.Nullable; @@ -48,7 +47,13 @@ static Sent sendKeys(Call call) { } static Sent sendKeys(Pane pane, List keys, boolean literal) { - List resolved = resolvedPaneIds(pane); + PaneInputCohort.Resolution cohort = PaneInputCohort.resolve(pane); + return sendKeys(pane, keys, literal, cohort); + } + + static Sent sendKeys( + Pane pane, List keys, boolean literal, PaneInputCohort.Resolution cohort) { + List resolved = cohort.requireKeyRecipients("send_keys"); pane.sendKeys(keys, literal); return new Sent( pane.id().value(), @@ -58,21 +63,6 @@ static Sent sendKeys(Pane pane, List keys, boolean literal) { "Sent, not waited for. Call capture_since or wait_for_text on this pane to see " + "what it did."); } - private static List resolvedPaneIds(Pane pane) { - boolean synchronizedPanes = pane.window() - .options() - .get("synchronize-panes") - .map(value -> value.equals("on") || value.equals("1")) - .orElse(false); - if (!synchronizedPanes) { - return List.of(pane.id().value()); - } - return pane.window().panes().stream() - .map(candidate -> candidate.id().value()) - .sorted(Comparator.naturalOrder()) - .toList(); - } - /** * Puts text into a pane through a paste buffer rather than as keystrokes. * @@ -87,6 +77,7 @@ static Pasted pasteText(Call call) { Pane pane = Targets.pane(call.server(), call.string("pane_id")); String text = call.string("text"); boolean enter = call.flag("enter", false); + PaneInputCohort.resolve(pane).requirePasteTarget("paste_text"); // tmux turns the line feeds in a buffer into carriage returns as it pastes, so a trailing // newline is what submits the text — there is no flag that means "and Enter". pane.paste(enter ? text + "\n" : text); diff --git a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/PaneInputCohortTest.java b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/PaneInputCohortTest.java new file mode 100644 index 0000000..c387f74 --- /dev/null +++ b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/PaneInputCohortTest.java @@ -0,0 +1,213 @@ +package io.github.libtmux.mcp; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import io.github.libtmux.LibTmuxException; +import io.github.libtmux.Server; +import io.github.libtmux.format.RowFormat; +import io.github.libtmux.format.TmuxFormatException; +import io.github.libtmux.junit5.TmuxExtension; +import io.github.libtmux.transport.CommandRequest; +import io.github.libtmux.transport.CommandResult; +import io.github.libtmux.transport.ProcessTransport; +import io.github.libtmux.transport.TmuxTransport; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.stream.Stream; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; + +@ExtendWith(TmuxExtension.class) +final class PaneInputCohortTest { + + private static final RowFormat TOKENS = RowFormat.of("separator"); + private static final String SEPARATOR = TOKENS.separator(); + private static final String TERMINATOR = TOKENS.template().substring("#{separator}".length()); + + @ParameterizedTest(name = "{0}") + @MethodSource("authorityFailures") + void commandAndSourceFailuresFailClosed(String label, String source, CommandResult result) { + assertThrows(LibTmuxException.class, () -> PaneInputCohort.parse(source, result), label); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("malformedRows") + void malformedAuthoritativeRowsFailClosed(String label, List stdout) { + assertThrows( + TmuxFormatException.class, + () -> PaneInputCohort.parse("%0", new CommandResult(0, stdout, List.of())), + label); + } + + @ParameterizedTest + @ValueSource(strings = { + "junk", "$1", " %1", "\n%1", "%00", "%01", "%4294967296", + "%9999999999999999999999999999999999999999" + }) + void noncanonicalPaneIdsFailClosed(String paneId) { + assertThrows( + TmuxFormatException.class, + () -> PaneInputCohort.parse("%0", answer(row("%0", "1", "0", "0", "sh"), + row(paneId, "1", "0", "0", "sh")))); + } + + @Test + void strayPhysicalLineCannotBecomePaneId() { + assertThrows( + TmuxFormatException.class, + () -> PaneInputCohort.parse("%0", answer( + row("%0", "1", "0", "0", "sh"), + "junk", + row("%1", "1", "0", "0", "sh")))); + } + + @Test + void maximumPaneIdPeerIsAccepted() { + var resolved = PaneInputCohort.parse("%0", answer( + row("%0", "1", "0", "0", "sh"), + row("%4294967295", "1", "0", "0", "sh"))); + + assertEquals(List.of("%0", "%4294967295"), resolved.configuredKeyRecipientIds()); + } + + @Test + void sourceFlagDeterminesTheEffectiveCohort() { + var sourceOff = PaneInputCohort.parse("%10", answer( + row("%1", "1", "0", "0", "sh"), + row("%10", "0", "0", "0", "sh"))); + var sourceOn = PaneInputCohort.parse("%10", answer( + row("%10", "1", "0", "0", "sh"), + row("%1", "0", "0", "0", "sh"), + row("%0", "1", "0", "0", "sh"))); + + assertEquals(List.of("%10"), sourceOff.configuredKeyRecipientIds()); + assertEquals(List.of("%0", "%10"), sourceOn.configuredKeyRecipientIds()); + } + + @ParameterizedTest + @ValueSource(strings = {"00", "1", "2"}) + void noncanonicalOrPositiveModesAreModal(String mode) { + var resolved = PaneInputCohort.parse("%0", answer(row("%0", "0", mode, "0", "sh"))); + + IllegalStateException refused = + assertThrows(IllegalStateException.class, () -> resolved.requireKeyRecipients("send_keys")); + assertTrue(String.valueOf(refused.getMessage()).contains("%0")); + } + + @Test + void deadConfiguredMembersFailButDeadNonmembersDoNot() { + var deadSource = PaneInputCohort.parse("%0", answer(row("%0", "0", "0", "1", "sh"))); + var deadPeer = PaneInputCohort.parse("%0", answer( + row("%0", "1", "0", "0", "sh"), + row("%1", "1", "0", "1", "sh"))); + var outside = PaneInputCohort.parse("%0", answer( + row("%0", "0", "0", "0", "sh"), + row("%1", "1", "0", "1", "sh"))); + + assertThrows(IllegalStateException.class, () -> deadSource.requireKeyRecipients("send_keys")); + assertThrows(IllegalStateException.class, () -> deadPeer.requireKeyRecipients("send_keys")); + assertEquals(List.of("%0"), outside.requireKeyRecipients("send_keys")); + } + + @Test + void pasteChecksOnlyTheSourceWhileCommandsRequireOneRecipient() { + var sourceOnly = PaneInputCohort.parse("%0", answer( + row("%0", "0", "0", "0", "/bin/sh"), + row("%1", "1", "2", "1", "cat"))); + var plural = PaneInputCohort.parse("%0", answer( + row("%0", "1", "0", "0", "/bin/sh"), + row("%1", "1", "0", "0", "sh"))); + + sourceOnly.requirePasteTarget("paste_text"); + assertEquals("/bin/sh", sourceOnly.requireSingularCommandPane("run_shell_command")); + assertThrows( + IllegalStateException.class, + () -> plural.requireSingularCommandPane("run_shell_command")); + } + + @Test + void resolutionUsesOneTargetedFiveFieldListing(Server server) { + CopyOnWriteArrayList requests = new CopyOnWriteArrayList<>(); + try (ProcessTransport processes = new ProcessTransport()) { + TmuxTransport recording = new TmuxTransport() { + @Override + public CommandResult execute(CommandRequest request) { + requests.add(request); + return processes.execute(request); + } + + @Override + public void close() {} + }; + try (Server measured = Server.using(server.config(), recording)) { + var pane = measured.panes().getFirst(); + requests.clear(); + + var resolved = PaneInputCohort.resolve(pane); + + assertEquals(List.of(pane.id().value()), resolved.configuredKeyRecipientIds()); + } + } + + List> commands = requests.stream().flatMap(request -> request.commands().stream()).toList(); + assertEquals(1, commands.size()); + List listing = commands.getFirst(); + assertEquals(List.of("list-panes", "-t"), listing.subList(0, 2)); + assertTrue(listing.contains("-F")); + String format = listing.get(listing.indexOf("-F") + 1); + for (String field : List.of( + "pane_id", "pane_synchronized", "pane_in_mode", "pane_dead", "pane_current_command")) { + assertEquals(1, occurrences(format, "#{" + field + "}")); + } + } + + private static Stream authorityFailures() { + return Stream.of( + Arguments.of("command failed", "%0", new CommandResult(1, List.of(), List.of("gone"))), + Arguments.of("no rows", "%0", answer()), + Arguments.of("source absent", "%0", answer(row("%1", "0", "0", "0", "sh"))), + Arguments.of("source duplicated", "%0", answer( + row("%0", "0", "0", "0", "sh"), + row("%0", "0", "0", "0", "sh")))); + } + + private static Stream malformedRows() { + String complete = row("%0", "0", "0", "0", "sh"); + String peerWithoutTerminator = fields("%1", "0", "0", "0", "sh"); + return Stream.of( + Arguments.of("empty pane id", List.of(row("", "0", "0", "0", "sh"))), + Arguments.of("empty current command", List.of(row("%0", "0", "0", "0", ""))), + Arguments.of("too few fields", List.of(fields("%0", "0", "0", "0") + TERMINATOR)), + Arguments.of("no terminator", List.of(fields("%0", "0", "0", "0", "sh"))), + Arguments.of("unterminated final row", List.of(complete, peerWithoutTerminator)), + Arguments.of("data after terminator", List.of(complete + "tail")), + Arguments.of("extra physical data", List.of(complete, "tail")), + Arguments.of("empty mode", List.of(row("%0", "0", "", "0", "sh"))), + Arguments.of("word mode", List.of(row("%0", "0", "on", "0", "sh"))), + Arguments.of("negative mode", List.of(row("%0", "0", "-1", "0", "sh"))), + Arguments.of("word synchronized", List.of(row("%0", "on", "0", "0", "sh"))), + Arguments.of("word dead", List.of(row("%0", "0", "0", "on", "sh")))); + } + + private static CommandResult answer(String... rows) { + return new CommandResult(0, List.of(rows), List.of()); + } + + private static String row(String... fields) { + return fields(fields) + TERMINATOR; + } + + private static String fields(String... fields) { + return String.join(SEPARATOR, fields); + } + + private static int occurrences(String value, String wanted) { + return (value.length() - value.replace(wanted, "").length()) / wanted.length(); + } +} diff --git a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/RunningCommandsTest.java b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/RunningCommandsTest.java index 406b213..78db320 100644 --- a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/RunningCommandsTest.java +++ b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/RunningCommandsTest.java @@ -25,6 +25,7 @@ import java.util.concurrent.CountDownLatch; import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; import java.util.function.BooleanSupplier; import java.util.regex.MatchResult; import java.util.regex.Pattern; @@ -32,6 +33,7 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.io.TempDir; import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; import org.junit.jupiter.params.provider.ValueSource; /** @@ -179,6 +181,119 @@ void framingUsesNoPaneStatusVariableAndIgnoresReadonlyCollision(Server server) t } } + @Test + void aSynchronizedCommandIsRefusedBeforeTyping(Server server) { + Pane source = server.panes().getFirst(); + Pane peer = source.split(); + source.window().setSynchronizePanes(true); + String marker = "synchronized-run-marker"; + + IllegalStateException refused = assertThrows( + IllegalStateException.class, + () -> RunningCommands.run(TestCalls.on( + server, + "pane_id", + source.id().value(), + "command", + "printf '" + marker + "\\n'"))); + + String message = String.valueOf(refused.getMessage()); + assertTrue(message.contains("run_shell_command"), message); + assertTrue(message.contains("one"), message); + assertFalse(capture(server, source.id().value()).contains(marker)); + assertFalse(capture(server, peer.id().value()).contains(marker)); + } + + @Test + void aModalEffectiveCommandRecipientIsRefusedBeforeBaselineOrTyping(Server server) { + Pane source = server.panes().getFirst(); + Pane modal = source.split(); + source.window().setSynchronizePanes(true); + modal.copyMode(); + + assertRefusedBeforeRunWork(server, source, "modal-must-not-run", modal.id().value()); + } + + @Test + void aDeadCommandPaneIsRefusedBeforeBaselineOrTyping(Server server) throws Exception { + Pane source = server.panes().getFirst(); + source.split(); + source.options().set("remain-on-exit", "on"); + source.sendLine("exit"); + assertTrue(await(() -> "1".equals(source.expand("#{pane_dead}"))), "the pane did not become dead"); + + assertRefusedBeforeRunWork(server, source, "dead-must-not-run", "dead", source.id().value()); + } + + @ParameterizedTest(name = "{0}") + @EnumSource(RunTransition.class) + void stateTransitionAfterSetupRefusesRun(RunTransition transition, Server server) throws Exception { + Pane source = server.panes().getFirst(); + prepare(transition, source); + AtomicInteger listings = new AtomicInteger(); + CopyOnWriteArrayList requests = new CopyOnWriteArrayList<>(); + + try (ProcessTransport processes = new ProcessTransport()) { + TmuxTransport changing = borrowing(request -> { + requests.add(request); + if (request.commands().stream().anyMatch(RunningCommandsTest::isCohortListing) + && listings.incrementAndGet() == 2) { + apply(transition, server, source); + } + return processes.execute(request); + }); + try (Server measured = Server.using(server.config(), changing)) { + assertThrows( + RuntimeException.class, + () -> RunningCommands.run(TestCalls.on( + measured, + "pane_id", + source.id().value(), + "command", + "echo transition-must-not-run"))); + } finally { + restore(transition, server, source); + } + } + + assertEquals(2, listings.get(), "the run must attempt both authoritative preflights"); + assertEquals(0, commandCount(requests, "send-keys")); + assertEquals(0, commandCount(requests, "wait-for")); + assertTrue(server.buffers().list().stream().noneMatch(buffer -> buffer.name().startsWith("libtmux-run-"))); + assertTrue(server.panes().stream() + .flatMap(pane -> pane.options().all().keySet().stream()) + .noneMatch(name -> name.startsWith("@st_"))); + } + + @Test + void successfulRunUsesExactlyTwoPreflights(Server server) { + String pane = server.panes().getFirst().id().value(); + CopyOnWriteArrayList requests = new CopyOnWriteArrayList<>(); + + try (ProcessTransport processes = new ProcessTransport()) { + TmuxTransport recording = borrowing(request -> { + requests.add(request); + return processes.execute(request); + }); + try (Server measured = Server.using(server.config(), recording)) { + RunningCommands.Ran ran = RunningCommands.run( + TestCalls.on(measured, "pane_id", pane, "command", "true")); + assertCompleted(ran, 0); + } + } + + List preflights = requestIndexes(requests, RunningCommandsTest::isCohortListing); + List discoveries = requestIndexes(requests, RunningCommandsTest::isSocketDiscovery); + List sends = requestIndexes(requests, request -> hasCommand(request, "send-keys")); + assertEquals(2, preflights.size()); + assertEquals(1, discoveries.size()); + assertEquals(1, sends.size()); + assertTrue(preflights.get(0) < discoveries.getFirst()); + assertTrue(discoveries.getFirst() < preflights.get(1)); + assertEquals(preflights.get(1) + 1, sends.getFirst(), "a tmux query followed the final preflight"); + assertEquals(1, directCommandCount(requests, "wait-for")); + } + @Test void aPaneNotRunningAPosixShellIsRefused(Server server) { server.cmd("new-window", "-d", "-n", "not-a-shell", "cat"); @@ -548,4 +663,148 @@ private static Optional nonce(CommandRequest request) { .map(MatchResult::group) .findFirst(); } + + private static String capture(Server server, String paneId) { + return String.join("\n", server.cmd("capture-pane", "-p", "-t", paneId).stdout()); + } + + private static void assertRefusedBeforeRunWork( + Server server, Pane source, String marker, String... expectedMessageParts) { + CopyOnWriteArrayList requests = new CopyOnWriteArrayList<>(); + try (ProcessTransport processes = new ProcessTransport()) { + TmuxTransport recording = borrowing(request -> { + requests.add(request); + return processes.execute(request); + }); + try (Server measured = Server.using(server.config(), recording)) { + IllegalStateException refused = assertThrows( + IllegalStateException.class, + () -> RunningCommands.run(TestCalls.on( + measured, "pane_id", source.id().value(), "command", "echo " + marker))); + String message = String.valueOf(refused.getMessage()); + assertTrue(message.contains("run_shell_command"), message); + for (String part : expectedMessageParts) { + assertTrue(message.contains(part), message); + } + } + } + assertNoRunWork(requests); + } + + private static void assertNoRunWork(List requests) { + assertEquals(0, commandCount(requests, "capture-pane")); + assertEquals(0, commandCount(requests, "send-keys")); + assertEquals(0, commandCount(requests, "wait-for")); + } + + private static int commandCount(List requests, String name) { + return Math.toIntExact(requests.stream().filter(request -> hasCommand(request, name)).count()); + } + + private static int directCommandCount(List requests, String name) { + return Math.toIntExact(requests.stream() + .filter(request -> request.commands().stream() + .anyMatch(command -> command.getFirst().equals(name))) + .count()); + } + + private static List requestIndexes( + List requests, java.util.function.Predicate predicate) { + return java.util.stream.IntStream.range(0, requests.size()) + .filter(index -> predicate.test(requests.get(index))) + .boxed() + .toList(); + } + + private static boolean hasCommand(CommandRequest request, String name) { + return request.commands().stream().anyMatch(command -> command.getFirst().equals(name) + || command.stream().anyMatch(argument -> argument.contains("'" + name + "'"))); + } + + private static boolean isCohortListing(CommandRequest request) { + return request.commands().stream().anyMatch(RunningCommandsTest::isCohortListing); + } + + private static boolean isCohortListing(List command) { + if (!command.getFirst().equals("list-panes") || !command.contains("-t") || !command.contains("-F")) { + return false; + } + String format = command.get(command.indexOf("-F") + 1); + return List.of( + "pane_id", + "pane_synchronized", + "pane_in_mode", + "pane_dead", + "pane_current_command") + .stream() + .allMatch(format::contains); + } + + private static boolean isSocketDiscovery(CommandRequest request) { + return request.commands().stream().anyMatch(command -> command.equals( + List.of("display-message", "-p", "#{socket_path}"))); + } + + private static void prepare(RunTransition transition, Pane source) { + switch (transition) { + case MODE -> {} + case DEAD, NON_SHELL -> source.options().set("remain-on-exit", "on"); + case PLURAL -> { + source.split(); + source.window().setSynchronizePanes(true); + source.options().set("synchronize-panes", "off"); + } + case DISAPPEAR -> source.split(); + } + } + + private static void apply(RunTransition transition, Server server, Pane source) { + switch (transition) { + case MODE -> source.copyMode(); + case DEAD -> { + source.sendLine("exit"); + awaitUnchecked(() -> "1".equals(source.expand("#{pane_dead}"))); + } + case PLURAL -> source.options().set("synchronize-panes", "on"); + case NON_SHELL -> { + source.sendLine("exec cat"); + awaitUnchecked(() -> "cat".equals(source.expand("#{pane_current_command}"))); + } + case DISAPPEAR -> server.cmd("kill-pane", "-t", source.id().value()); + } + } + + private static void restore(RunTransition transition, Server server, Pane source) { + switch (transition) { + case MODE -> server.cmd("send-keys", "-t", source.id().value(), "-X", "cancel"); + case DEAD, NON_SHELL -> { + server.cmd("respawn-pane", "-k", "-t", source.id().value()); + source.options().unset("remain-on-exit"); + } + case PLURAL -> { + source.window().setSynchronizePanes(false); + source.options().unset("synchronize-panes"); + } + case DISAPPEAR -> {} + } + } + + private enum RunTransition { + MODE, + DEAD, + PLURAL, + NON_SHELL, + DISAPPEAR + } + + private static void awaitUnchecked(BooleanSupplier condition) { + try { + if (!await(condition)) { + throw new IllegalStateException("timed out arranging run transition"); + } + } catch (InterruptedException failure) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("interrupted while arranging run transition", failure); + } + } } diff --git a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/TypingTest.java b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/TypingTest.java index 607aa12..3716fec 100644 --- a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/TypingTest.java +++ b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/TypingTest.java @@ -17,11 +17,13 @@ import io.github.libtmux.transport.ProcessTransport; import io.github.libtmux.transport.TmuxTransport; import java.util.List; +import java.util.Map; import java.util.Set; import java.util.concurrent.CountDownLatch; import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicReference; +import java.util.function.BooleanSupplier; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -56,6 +58,146 @@ void synchronizedInputDisclosesEveryResolvedPane(Server server) { assertEquals(Set.of(source.id().value(), other.id().value()), Set.copyOf(sent.resolvedPaneIds())); } + @Test + void synchronizedKeysReachOnlyTheEffectiveOnCohort(Server server) throws Exception { + var source = server.panes().getFirst(); + var effective = source.split(SplitSpec.builder().build()); + var disabled = source.split(SplitSpec.builder().build()); + source.window().setSynchronizePanes(true); + disabled.options().set("synchronize-panes", "off"); + String marker = "effective-cohort-marker"; + + Typing.Sent sent = sendKeys(server, source.id().value(), marker); + + assertEquals(sorted(source.id().value(), effective.id().value()), sent.resolvedPaneIds()); + assertTrue(await(() -> captureOf(server, source.id().value()).contains(marker))); + assertTrue(await(() -> captureOf(server, effective.id().value()).contains(marker))); + assertFalse(captureOf(server, disabled.id().value()).contains(marker)); + } + + @Test + void sourceOffIgnoresATruePeer(Server server) throws Exception { + var source = server.panes().getFirst(); + var peer = source.split(SplitSpec.builder().build()); + source.window().setSynchronizePanes(true); + source.options().set("synchronize-panes", "off"); + String marker = "source-off-marker"; + + Typing.Sent sent = sendKeys(server, source.id().value(), marker); + + assertEquals(List.of(source.id().value()), sent.resolvedPaneIds()); + assertTrue(await(() -> captureOf(server, source.id().value()).contains(marker))); + assertFalse(captureOf(server, peer.id().value()).contains(marker)); + } + + @Test + void modalRecipientRefusesBeforeDelivery(Server server) { + var source = server.panes().getFirst(); + var modal = source.split(SplitSpec.builder().build()); + source.window().setSynchronizePanes(true); + modal.copyMode(); + assertTrue(modal.mode().isPresent(), "the refusal fixture did not enter a mode"); + String marker = "modal-refusal-marker"; + + assertKeyRefused(server, source.id().value(), modal.id().value(), marker); + } + + @Test + void modalPaneOutsideTheEffectiveCohortDoesNotBlockKeys(Server server) throws Exception { + var source = server.panes().getFirst(); + var recipient = source.split(SplitSpec.builder().build()); + var modal = source.split(SplitSpec.builder().build()); + source.window().setSynchronizePanes(true); + modal.options().set("synchronize-panes", "off"); + modal.copyMode(); + String marker = "outside-modal-marker"; + + Typing.Sent sent = sendKeys(server, source.id().value(), marker); + + assertEquals(sorted(source.id().value(), recipient.id().value()), sent.resolvedPaneIds()); + assertTrue(await(() -> captureOf(server, source.id().value()).contains(marker))); + assertTrue(await(() -> captureOf(server, recipient.id().value()).contains(marker))); + assertFalse(captureOf(server, modal.id().value()).contains(marker)); + } + + @Test + void deadEffectiveRecipientRefusesBeforeDelivery(Server server) throws Exception { + var source = server.panes().getFirst(); + var dead = source.split(SplitSpec.builder().build()); + dead.options().set("remain-on-exit", "on"); + dead.sendLine("exit"); + assertTrue(await(() -> "1".equals(dead.expand("#{pane_dead}"))), "the pane did not become dead"); + source.window().setSynchronizePanes(true); + String marker = "dead-refusal-marker"; + + assertKeyRefused(server, source.id().value(), dead.id().value(), marker); + } + + @Test + void batchResolvesAndGuardsEveryOperationFresh(Server server) throws Exception { + var first = server.panes().getFirst(); + var firstPeer = first.split(SplitSpec.builder().build()); + first.window().setSynchronizePanes(true); + var second = server.sessions().getFirst().newWindow("batch-modal").panes().getFirst(); + var modal = second.split(SplitSpec.builder().build()); + second.window().setSynchronizePanes(true); + modal.copyMode(); + + Map batch = map(Operations.sendKeysBatch(TestCalls.on( + server, + "operations", + List.of( + send(first.id().value(), "batch-first-marker"), + send(second.id().value(), "batch-modal-marker"), + send("%999999", "batch-missing-marker")), + "onError", + "continue"))); + List> rows = rows(batch); + + assertEquals(3, batch.get("completed")); + assertEquals(true, rows.get(0).get("success")); + assertEquals(sorted(first.id().value(), firstPeer.id().value()), rows.get(0).get("resolved_pane_ids")); + assertEquals(false, rows.get(1).get("success")); + assertEquals(sorted(second.id().value(), modal.id().value()), rows.get(1).get("resolved_pane_ids")); + assertEquals(false, rows.get(2).get("success")); + assertEquals(List.of(), rows.get(2).get("resolved_pane_ids")); + assertTrue(await(() -> captureOf(server, first.id().value()).contains("batch-first-marker"))); + assertTrue(await(() -> captureOf(server, firstPeer.id().value()).contains("batch-first-marker"))); + assertFalse(captureOf(server, second.id().value()).contains("batch-modal-marker")); + assertFalse(captureOf(server, modal.id().value()).contains("batch-modal-marker")); + + try (ProcessTransport processes = new ProcessTransport()) { + TmuxTransport failing = new TmuxTransport() { + @Override + public CommandResult execute(CommandRequest request) { + boolean sending = request.commands().stream().anyMatch(command -> command.getFirst() + .equals("send-keys") + || command.stream().anyMatch(argument -> argument.contains("'send-keys'"))); + if (sending) { + throw new IllegalStateException("dispatch refused by fixture"); + } + return processes.execute(request); + } + + @Override + public void close() {} + }; + try (Server measured = Server.using(server.config(), failing)) { + List> failed = rows(map(Operations.sendKeysBatch(TestCalls.on( + measured, + "operations", + List.of(send(first.id().value(), "dispatch-failure-marker")), + "onError", + "continue")))); + assertEquals(false, failed.getFirst().get("success")); + assertTrue(String.valueOf(failed.getFirst().get("error")).contains("dispatch refused by fixture")); + assertEquals( + sorted(first.id().value(), firstPeer.id().value()), + failed.getFirst().get("resolved_pane_ids")); + } + } + } + @Test void sendingNoKeysAtAllSaysWhatWasWanted(Server server) { String pane = server.panes().get(0).id().value(); @@ -81,6 +223,42 @@ void pastedTextArrivesWithoutClaimingAUsersBuffer(Server server) { assertNoOwnedBuffers(server); } + @Test + void pasteChecksOnlyItsTargetAndDoesNotFanOut(Server server) throws Exception { + assumeTrue(server.version().atLeast(SAFE_PASTE_CLEANUP)); + var source = server.panes().getFirst(); + var modal = source.split(SplitSpec.builder().build()); + source.window().setSynchronizePanes(true); + modal.copyMode(); + String marker = "target-only-paste-marker"; + + Typing.Pasted pasted = Typing.pasteText(TestCalls.on( + server, "pane_id", source.id().value(), "text", marker)); + + assertEquals(source.id().value(), pasted.paneId()); + assertTrue(await(() -> captureOf(server, source.id().value()).contains(marker))); + assertFalse(captureOf(server, modal.id().value()).contains(marker)); + assertNoOwnedBuffers(server); + } + + @Test + void pasteRefusesAModalTargetBeforeCreatingABuffer(Server server) { + var pane = server.panes().getFirst(); + pane.copyMode(); + String marker = "modal-paste-marker"; + + IllegalStateException refused = assertThrows( + IllegalStateException.class, + () -> Typing.pasteText(TestCalls.on( + server, "pane_id", pane.id().value(), "text", marker))); + + String message = String.valueOf(refused.getMessage()); + assertTrue(message.contains("paste_text"), message); + assertTrue(message.contains(pane.id().value()), message); + assertFalse(captureOf(server, pane.id().value()).contains(marker)); + assertNoOwnedBuffers(server); + } + @Test void pasteRefusesUnsafeCleanupBeforeCreatingABuffer(Server server) { assumeFalse(server.version().atLeast(SAFE_PASTE_CLEANUP)); @@ -173,6 +351,39 @@ private static String captureOf(Server server, String pane) { return String.join("\n", server.cmd("capture-pane", "-p", "-t", pane).stdout()); } + private static List sorted(String... paneIds) { + return java.util.stream.Stream.of(paneIds).sorted().toList(); + } + + private static Typing.Sent sendKeys(Server server, String paneId, String marker) { + return Typing.sendKeys( + TestCalls.on(server, "pane_id", paneId, "keys", List.of(marker), "literal", true)); + } + + private static void assertKeyRefused(Server server, String source, String blocked, String marker) { + IllegalStateException refused = + assertThrows(IllegalStateException.class, () -> sendKeys(server, source, marker)); + String message = String.valueOf(refused.getMessage()); + assertTrue(message.contains("send_keys"), message); + assertTrue(message.contains(blocked), message); + assertFalse(captureOf(server, source).contains(marker)); + assertFalse(captureOf(server, blocked).contains(marker)); + } + + private static Map send(String paneId, String marker) { + return Map.of("pane_id", paneId, "keys", List.of(marker), "literal", true); + } + + @SuppressWarnings("unchecked") + private static Map map(Object value) { + return (Map) value; + } + + @SuppressWarnings("unchecked") + private static List> rows(Map batch) { + return (List>) java.util.Objects.requireNonNull(batch.get("results")); + } + private static void assertNoOwnedBuffers(Server server) { assertTrue(server.buffers().list().stream() .noneMatch(buffer -> buffer.name().startsWith("libtmux-paste-"))); @@ -188,4 +399,15 @@ private static void await(CountDownLatch latch) { throw new IllegalStateException("interrupted while arranging concurrent pastes", e); } } + + private static boolean await(BooleanSupplier condition) throws InterruptedException { + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(30); + while (System.nanoTime() < deadline) { + if (condition.getAsBoolean()) { + return true; + } + Thread.sleep(20); + } + return condition.getAsBoolean(); + } } From 005cfe826e60907065ae8599aef7c1fffb3e732d Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 5 Sep 2026 13:35:36 -0500 Subject: [PATCH 12/65] Mcp(docs[input]): Define guarded input contract why: Tool callers need the same modal, synchronized, and observational boundaries in schemas, tool descriptions, and user guidance. what: - Require resolved pane ids on every batch result row - Explain effective cohorts, target-only paste, and two run preflights - Preserve README structure, examples, links, and generated inventory --- docs/guide/mcp.md | 15 +++++++++++++ libtmux-mcp/README.md | 7 ++++++ .../java/io/github/libtmux/mcp/Catalog.java | 22 +++++++++++++------ .../libtmux/mcp/CapabilityRegistryTest.java | 19 +++++++++++++++- .../java/io/github/libtmux/mcp/MainTest.java | 1 + 5 files changed, 56 insertions(+), 8 deletions(-) diff --git a/docs/guide/mcp.md b/docs/guide/mcp.md index 69d9dad..72fee52 100644 --- a/docs/guide/mcp.md +++ b/docs/guide/mcp.md @@ -117,6 +117,21 @@ a supported hostile-shell case. The marker `display-message` calls still use the trusted server's normal command path, including configured command aliases and `after-display-message` hooks. +### Pane modes and synchronized input + +Key input follows tmux's effective `synchronize-panes` values: a source whose +effective value is off receives input alone; a source whose value is on sends to +all panes whose effective value is on. The window option supplies the inherited +default, and a pane-level override can change an individual pane's value. One +modal or dead member refuses the whole configured key cohort before dispatch, +while paste-buffer input checks and targets only its requested pane. + +Framed commands refuse a synchronized cohort because their output, completion, +and status describe one pane. They require one normal live shell at the initial +preflight and again immediately before input. Each preflight is an observation, +not an atomic reservation: membership can change before dispatch, and reported +pane ids prove neither actual recipients nor delivery. + ## A cursor, so watching is not re-reading `capture_since` takes an opaque cursor and returns the lines added since it, diff --git a/libtmux-mcp/README.md b/libtmux-mcp/README.md index e36096d..82c1bb0 100644 --- a/libtmux-mcp/README.md +++ b/libtmux-mcp/README.md @@ -191,6 +191,13 @@ text. Set `history: true` on `capture_pane` or `snapshot_pane` for bounded scrollback, use `search_panes` to locate displayed text, and continue from a cursor with `capture_since` instead of entering or cancelling a person's mode. +Key sends resolve the target's current effective synchronized cohort and refuse +the whole send when one configured recipient is modal or dead. Paste remains +target-only, while framed shell runs require one effective recipient at both +preflights. This observation is not atomic: membership can change before +dispatch, and `resolved_pane_ids` reports configured membership rather than +confirmed recipients or delivery. + Batch rows retain the nested MCP envelope rather than flattening its text or structured content. The complete JSON-RPC response, including line framing, is capped at 1,000,000 bytes. A row that would cross that boundary remains in diff --git a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Catalog.java b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Catalog.java index 1bc4535..fb2a0a9 100644 --- a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Catalog.java +++ b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Catalog.java @@ -761,8 +761,11 @@ private static void execute(List tools) { tools.add(tool( "run_shell_command", "Run a shell command", - "Runs one authored command in a trusted pane shell and waits for framed completion; marker " - + "display-message commands honor the selected trusted server's command aliases and hooks.", + "Runs one authored command in a trusted pane shell and waits for singular framed output and " + + "completion. It refuses an effective cohort larger than one at either of two preflights. " + + "Pre-existing exact-client-path, trap, eval, or exit functions are outside the supported " + + "boundary; marker display-message commands honor the trusted server's command aliases " + + "and hooks.", EXECUTE, PANE_COMMAND, effects(OBSERVE, CHANGE), @@ -786,7 +789,8 @@ private static void execute(List tools) { tools.add(tool( "send_keys", "Send keys", - "Sends input to one pane without waiting for output.", + "Sends input to the target's configured effective synchronized cohort without waiting for output. " + + "Reports configured pane ids observed before dispatch, not delivery receipts.", EXECUTE, PANE_INPUT, effects(OBSERVE, CHANGE), @@ -803,7 +807,9 @@ private static void execute(List tools) { tools.add(tool( "send_keys_batch", "Send keys in a batch", - "Sends up to sixty-four ordered pane-input operations.", + "Sends up to sixty-four ordered pane-input operations, resolving and guarding the configured " + + "effective cohort separately for each ordered operation. A later policy or dispatch " + + "failure retains observed membership.", EXECUTE, PANE_INPUT, effects(OBSERVE, CHANGE), @@ -821,7 +827,8 @@ private static void execute(List tools) { tools.add(tool( "paste_text", "Paste text", - "Pastes one literal text block into a pane through an ephemeral buffer.", + "Pastes one literal text block into one target pane through an ephemeral buffer; paste-buffer " + + "input does not fan out to synchronized peers.", EXECUTE, PANE_INPUT, effects(OBSERVE, CHANGE), @@ -841,7 +848,8 @@ private static void execute(List tools) { tools.add(amplifying(tool( "set_synchronize_panes", "Set synchronized panes", - "When enabled, subsequent input is copied to every pane in the window.", + "When enabled, sets the inherited window default; pane-level overrides determine each pane's " + + "effective synchronized value.", EXECUTE, NONE, effects(CHANGE), @@ -1177,7 +1185,7 @@ private static OutputSchema sendBatchOutput() { field("resolved_pane_ids", ARRAY), field("success", BOOLEAN), field("error", STRING)) - .withOptionalFields("resolved_pane_ids", "error") + .withOptionalFields("error") .withPropertySchema("resolved_pane_ids", arrayOf(Map.of("type", "string"))); return shape(field("results", ARRAY), field("completed", INTEGER)) .withPropertySchema("results", arrayOf(row.wireSchema())); diff --git a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/CapabilityRegistryTest.java b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/CapabilityRegistryTest.java index f588332..ebb3112 100644 --- a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/CapabilityRegistryTest.java +++ b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/CapabilityRegistryTest.java @@ -262,7 +262,14 @@ void everyManifestRowDrivesConservativeRegistrationMetadataAndSinkValidation() { assertEquals(Set.of(ToolSpec.InputSink.NESTED_TOOL), batch.inputSinks().get("operations")); assertTrue(batch.description().contains("no separate approval")); assertTrue(batch.description().contains("1,000,000 bytes")); - assertTrue(byName("set_synchronize_panes").description().contains("subsequent input is copied to every pane")); + assertTrue(byName("send_keys").description().contains("configured effective synchronized cohort")); + assertTrue(byName("send_keys").description().contains("not delivery receipts")); + assertTrue(byName("send_keys_batch").description().contains("separately for each ordered operation")); + assertTrue(byName("send_keys_batch").description().contains("retains observed membership")); + assertTrue(byName("set_synchronize_panes").description().contains("inherited window default")); + assertTrue(byName("set_synchronize_panes").description().contains("pane-level overrides")); + assertTrue(byName("paste_text").description().contains("does not fan out")); + assertTrue(byName("run_shell_command").description().contains("two preflights")); assertTrue(byName("run_shell_command").description().contains("trusted pane shell")); assertTrue(byName("run_shell_command").description().contains("command aliases and hooks")); for (String removed : List.of( @@ -601,6 +608,16 @@ void outputSchemasRequireKnownFieldsAndTypeNestedArrays() { Map sends = object(sendProperties.get("results"), "send results"); Map sendRow = object(sends.get("items"), "send result item"); assertEquals("object", sendRow.get("type")); + Map sendRowProperties = object(sendRow.get("properties"), "send result properties"); + assertTrue(strings(sendRow.get("required"), "send result required").contains("resolved_pane_ids")); + assertEquals( + Map.of("type", "string"), + object(sendRowProperties.get("resolved_pane_ids"), "resolved panes").get("items")); + + assertTrue(strings(byName("send_keys").outputSchema().get("required"), "send required") + .contains("resolved_pane_ids")); + assertFalse(object(byName("run_shell_command").outputSchema().get("properties"), "run properties") + .containsKey("resolved_pane_ids")); Object nil = com.fasterxml.jackson.databind.node.NullNode.getInstance(); Map malformedRow = Map.of( diff --git a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/MainTest.java b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/MainTest.java index 9e2f77e..ce02035 100644 --- a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/MainTest.java +++ b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/MainTest.java @@ -157,6 +157,7 @@ void onlyANewDefaultMinimalSocketEnablesTeardownByDefault() { assertEquals("existing", existing.serverState()); assertEquals(45, ToolSurface.resolve(Map.of(), created).tools().size()); + assertEquals(41, ToolSurface.resolve(Map.of(), existing).tools().size()); assertEquals(false, ToolSurface.resolve(Map.of(), existing).tools().containsKey("kill_session")); assertEquals( List.of("kill_pane", "kill_window", "kill_session"), From 77fd33470b78045d4986a4ea156f82506a9933c1 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 5 Sep 2026 13:37:07 -0500 Subject: [PATCH 13/65] Docs(docs[changelog]): Record MCP input fixes why: MCP callers need the modal input and completion framing boundaries in the Unreleased ledger. what: - Record effective-cohort refusal and target-only paste - Record isolated framing, exact routing, and trusted-shell limits --- CHANGELOG.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5aad711..3067e9e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -86,6 +86,21 @@ production. ### Fixed +- **MCP pane input now refuses effective recipients in a human-owned mode.** + `send_keys` and each `send_keys_batch` operation resolve pane-level + `synchronize-panes` overrides before dispatch; `paste_text` remains + target-only, dead configured recipients fail closed, and + `run_shell_command` checks a singular cohort before setup and again before + input because its completion, output, and status are singular. +- **`run_shell_command` no longer relies on mutable pane-shell framing state.** + Completion markers and signalling run in an isolated outer subshell through + an absolute selected tmux executable and the server's resolved `-S` socket, + so ordinary output-command aliases/functions, pane `PATH`/socket variables, + inherited `errexit`, and a readonly nonce name cannot lose completion or + close the pane; the frame also leaves no status variable behind. This assumes + the parent shell has not replaced the exact client word or + `trap`/`eval`/`exit` with functions, and marker commands honor trusted server + hooks. - **Destructive MCP tools fail closed when caller-pane identity cannot be proven.** Uncertain socket, server, or pane identity now requires explicit self-confirmation instead of bypassing the guard. (#6) From 6645673dd5270f3a60c9e97980b289da28ab3e65 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 5 Sep 2026 13:44:42 -0500 Subject: [PATCH 14/65] Build(fix[test]): Shorten tmux quarantine why: Deep module build paths can exhaust AF_UNIX before tmux appends its uid directory and named socket. what: - Derive a short quarantine from worktree and task identity - Recreate each task namespace before launching its test process - Cover the path contract and named sockets with real tmux --- .../kotlin/libtmux.java-library.gradle.kts | 19 +++++++++++++++++-- docs/guide/testing.md | 10 +++++++--- .../it/NamedSocketIntegrationTest.java | 5 +---- .../java/io/github/libtmux/ServerTest.java | 10 +++++++++- 4 files changed, 34 insertions(+), 10 deletions(-) diff --git a/build-logic/src/main/kotlin/libtmux.java-library.gradle.kts b/build-logic/src/main/kotlin/libtmux.java-library.gradle.kts index 54e572b..b0b554a 100644 --- a/build-logic/src/main/kotlin/libtmux.java-library.gradle.kts +++ b/build-logic/src/main/kotlin/libtmux.java-library.gradle.kts @@ -1,4 +1,7 @@ // Shared Java conventions. A module script then declares only what makes it different. +import java.nio.charset.StandardCharsets +import java.security.MessageDigest +import java.util.HexFormat import net.ltgt.gradle.errorprone.errorprone plugins { @@ -94,7 +97,14 @@ tasks.withType().configureEach { // a real server and can kill it. Two environment values decide where a bare client lands: tmux // resolves its default socket under TMUX_TMPDIR when it execs, and $TMUX takes precedence over // that for a client started inside a pane — which the Gradle daemon may well have been. - val tmuxTmpDir = layout.buildDirectory.dir("tmux-tmpdir").get().asFile + // + // The worktree-and-task digest separates concurrent namespaces while the 39-byte path leaves + // AF_UNIX room. Recreate the owned namespace so stale sockets cannot be reused. + val quarantineIdentity = rootProject.rootDir.canonicalPath + "\u0000" + path + val quarantineDigest = MessageDigest.getInstance("SHA-256") + .digest(quarantineIdentity.toByteArray(StandardCharsets.UTF_8)) + val quarantineName = HexFormat.of().formatHex(quarantineDigest, 0, 8) + val tmuxTmpDir = File("/tmp/libtmux-java-test", quarantineName) environment("TMUX_TMPDIR", tmuxTmpDir.absolutePath) environment.remove("TMUX") environment.remove("TMUX_PANE") @@ -110,10 +120,15 @@ tasks.withType().configureEach { val socketRoot = providers.gradleProperty("libtmuxSocketRoot").getOrElse("/tmp/libtmux-java-test") systemProperty("java.io.tmpdir", socketRoot) doFirst { + require(tmuxTmpDir.deleteRecursively()) { + "could not clear this test task's tmux quarantine" + } + require(tmuxTmpDir.mkdirs()) { + "could not create this test task's tmux quarantine" + } require(socketRoot.length <= 40) { "libtmuxSocketRoot is $socketRoot, too long to leave room for a socket under it" } - tmuxTmpDir.mkdirs() File(socketRoot).mkdirs() } diff --git a/docs/guide/testing.md b/docs/guide/testing.md index 801780c..2e91a71 100644 --- a/docs/guide/testing.md +++ b/docs/guide/testing.md @@ -42,9 +42,13 @@ left behind — a test cannot watch its own teardown. ## Isolation from your own tmux -Every test task runs with a build-local `TMUX_TMPDIR` and with `TMUX` and -`TMUX_PANE` removed. A command that omits its `-S` therefore cannot reach the -tmux you are working in. +Every test task runs with a short `TMUX_TMPDIR` under +`/tmp/libtmux-java-test/`, and with `TMUX` and `TMUX_PANE` removed. Its +16-character hexadecimal namespace hashes the canonical worktree and task path, +which separates concurrent modules and linked worktrees without spending the +socket path's limited bytes. The task recreates that namespace before use, so +stale sockets cannot be reused. A command that omits its `-S` therefore cannot +reach the tmux you are working in. This is enforced by the build rather than by every test remembering, because the code under test is exactly what is allowed to be wrong. The suite asserts the diff --git a/integration-tests/src/test/java/io/github/libtmux/it/NamedSocketIntegrationTest.java b/integration-tests/src/test/java/io/github/libtmux/it/NamedSocketIntegrationTest.java index 1e46360..bef048e 100644 --- a/integration-tests/src/test/java/io/github/libtmux/it/NamedSocketIntegrationTest.java +++ b/integration-tests/src/test/java/io/github/libtmux/it/NamedSocketIntegrationTest.java @@ -22,10 +22,7 @@ final class NamedSocketIntegrationTest { private static final String TMUX = System.getProperty("libtmux.tmux", "tmux"); - /** - * Short because {@code TMUX_TMPDIR} already spends about eighty of the ~104 bytes a unix socket - * path may hold. The pid keeps concurrent runs — Gradle's workers, the matrix's lanes — apart. - */ + /** The pid keeps concurrent Gradle workers and matrix lanes apart inside their quarantine. */ private static final String NAMESPACE = "ltj-" + ProcessHandle.current().pid(); @Test diff --git a/libtmux/src/test/java/io/github/libtmux/ServerTest.java b/libtmux/src/test/java/io/github/libtmux/ServerTest.java index 05e87aa..0ac843b 100644 --- a/libtmux/src/test/java/io/github/libtmux/ServerTest.java +++ b/libtmux/src/test/java/io/github/libtmux/ServerTest.java @@ -53,7 +53,15 @@ void nothingHereCanReachATmuxTheBuildDoesNotOwn() { String quarantine = System.getenv("TMUX_TMPDIR"); assertNotNull(quarantine, "without this a bare client lands on the developer's default socket"); - assertTrue(quarantine.contains("build"), "the quarantine must sit inside the build tree: " + quarantine); + Path quarantinePath = Path.of(quarantine); + assertEquals( + Path.of("/tmp/libtmux-java-test"), + quarantinePath.getParent(), + "the quarantine must stay inside this port's test root"); + assertTrue( + quarantinePath.getFileName().toString().matches("[0-9a-f]{16}"), + "the quarantine must identify this build without exposing its path: " + quarantine); + assertTrue(quarantine.length() <= 40, "named sockets need a short quarantine: " + quarantine); } // -------------------------------------------------------------------------------- dispatch From c376196afdfea2779eea8ecfcb0b2b593060cb0a Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 5 Sep 2026 13:46:18 -0500 Subject: [PATCH 15/65] Build(fix[test]): Preserve stale quarantines why: Concurrent Gradle invocations can share a stable task digest, and deleting an old namespace can unlink a live tmux socket. what: - Include the execution owner in each quarantine identity - Refuse prior files and sockets before pruning empty directories - Document concurrent and stale-state handling --- .../kotlin/libtmux.java-library.gradle.kts | 41 +++++++++++++------ docs/guide/testing.md | 10 ++--- 2 files changed, 33 insertions(+), 18 deletions(-) diff --git a/build-logic/src/main/kotlin/libtmux.java-library.gradle.kts b/build-logic/src/main/kotlin/libtmux.java-library.gradle.kts index b0b554a..c782250 100644 --- a/build-logic/src/main/kotlin/libtmux.java-library.gradle.kts +++ b/build-logic/src/main/kotlin/libtmux.java-library.gradle.kts @@ -1,5 +1,8 @@ // Shared Java conventions. A module script then declares only what makes it different. import java.nio.charset.StandardCharsets +import java.nio.file.Files +import java.nio.file.LinkOption +import java.nio.file.Path import java.security.MessageDigest import java.util.HexFormat import net.ltgt.gradle.errorprone.errorprone @@ -98,14 +101,6 @@ tasks.withType().configureEach { // resolves its default socket under TMUX_TMPDIR when it execs, and $TMUX takes precedence over // that for a client started inside a pane — which the Gradle daemon may well have been. // - // The worktree-and-task digest separates concurrent namespaces while the 39-byte path leaves - // AF_UNIX room. Recreate the owned namespace so stale sockets cannot be reused. - val quarantineIdentity = rootProject.rootDir.canonicalPath + "\u0000" + path - val quarantineDigest = MessageDigest.getInstance("SHA-256") - .digest(quarantineIdentity.toByteArray(StandardCharsets.UTF_8)) - val quarantineName = HexFormat.of().formatHex(quarantineDigest, 0, 8) - val tmuxTmpDir = File("/tmp/libtmux-java-test", quarantineName) - environment("TMUX_TMPDIR", tmuxTmpDir.absolutePath) environment.remove("TMUX") environment.remove("TMUX_PANE") @@ -120,12 +115,32 @@ tasks.withType().configureEach { val socketRoot = providers.gradleProperty("libtmuxSocketRoot").getOrElse("/tmp/libtmux-java-test") systemProperty("java.io.tmpdir", socketRoot) doFirst { - require(tmuxTmpDir.deleteRecursively()) { - "could not clear this test task's tmux quarantine" - } - require(tmuxTmpDir.mkdirs()) { - "could not create this test task's tmux quarantine" + // Owner identity separates concurrent invocations; the 39-byte path leaves AF_UNIX room. + val quarantineIdentity = listOf( + rootProject.rootDir.canonicalPath, + path, + ProcessHandle.current().pid().toString(), + ).joinToString("\u0000") + val quarantineDigest = MessageDigest.getInstance("SHA-256") + .digest(quarantineIdentity.toByteArray(StandardCharsets.UTF_8)) + val quarantineName = HexFormat.of().formatHex(quarantineDigest, 0, 8) + val tmuxTmpDir = Path.of("/tmp/libtmux-java-test", quarantineName) + + if (Files.exists(tmuxTmpDir, LinkOption.NOFOLLOW_LINKS)) { + require(Files.isDirectory(tmuxTmpDir, LinkOption.NOFOLLOW_LINKS)) { + "tmux quarantine is not a directory: $tmuxTmpDir" + } + val entries = Files.walk(tmuxTmpDir).use { paths -> paths.toList() } + val stale = entries.firstOrNull { + it != tmuxTmpDir && !Files.isDirectory(it, LinkOption.NOFOLLOW_LINKS) + } + require(stale == null) { + "tmux quarantine contains a stale entry: $stale" + } + entries.asReversed().filter { it != tmuxTmpDir }.forEach(Files::delete) } + Files.createDirectories(tmuxTmpDir) + environment("TMUX_TMPDIR", tmuxTmpDir.toString()) require(socketRoot.length <= 40) { "libtmuxSocketRoot is $socketRoot, too long to leave room for a socket under it" } diff --git a/docs/guide/testing.md b/docs/guide/testing.md index 2e91a71..b835637 100644 --- a/docs/guide/testing.md +++ b/docs/guide/testing.md @@ -44,11 +44,11 @@ left behind — a test cannot watch its own teardown. Every test task runs with a short `TMUX_TMPDIR` under `/tmp/libtmux-java-test/`, and with `TMUX` and `TMUX_PANE` removed. Its -16-character hexadecimal namespace hashes the canonical worktree and task path, -which separates concurrent modules and linked worktrees without spending the -socket path's limited bytes. The task recreates that namespace before use, so -stale sockets cannot be reused. A command that omits its `-S` therefore cannot -reach the tmux you are working in. +16-character hexadecimal namespace hashes the canonical worktree, task path, +and Gradle daemon process. That separates concurrent invocations without +spending the socket path's limited bytes. Before use, the task prunes empty +directory scaffolding but refuses to remove a stale file or socket. A command +that omits its `-S` therefore cannot reach the tmux you are working in. This is enforced by the build rather than by every test remembering, because the code under test is exactly what is allowed to be wrong. The suite asserts the From 477a509431782a6e459cb30d307f89f79e61081c Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 5 Sep 2026 13:49:48 -0500 Subject: [PATCH 16/65] Build(fix[test]): Reclaim named sockets tmux leaves named socket inodes after the server exits. The next run therefore trips the quarantine fail-closed stale-entry guard. Capture the reported process, path, and inode before teardown. Delete only that socket after the process dies, and prune only empty fixture directories. --- docs/guide/testing.md | 2 + .../it/NamedSocketIntegrationTest.java | 94 ++++++++++++++++++- 2 files changed, 91 insertions(+), 5 deletions(-) diff --git a/docs/guide/testing.md b/docs/guide/testing.md index b835637..4cdff4f 100644 --- a/docs/guide/testing.md +++ b/docs/guide/testing.md @@ -49,6 +49,8 @@ and Gradle daemon process. That separates concurrent invocations without spending the socket path's limited bytes. Before use, the task prunes empty directory scaffolding but refuses to remove a stale file or socket. A command that omits its `-S` therefore cannot reach the tmux you are working in. +Explicitly named-socket tests capture the server process and reported inode, +then reclaim only that inode after the process has exited. This is enforced by the build rather than by every test remembering, because the code under test is exactly what is allowed to be wrong. The suite asserts the diff --git a/integration-tests/src/test/java/io/github/libtmux/it/NamedSocketIntegrationTest.java b/integration-tests/src/test/java/io/github/libtmux/it/NamedSocketIntegrationTest.java index bef048e..9447516 100644 --- a/integration-tests/src/test/java/io/github/libtmux/it/NamedSocketIntegrationTest.java +++ b/integration-tests/src/test/java/io/github/libtmux/it/NamedSocketIntegrationTest.java @@ -8,9 +8,13 @@ import io.github.libtmux.ServerConfig; import io.github.libtmux.ServerEndpoint; import java.io.IOException; +import java.nio.file.DirectoryNotEmptyException; import java.nio.file.Files; +import java.nio.file.LinkOption; import java.nio.file.Path; +import java.nio.file.attribute.BasicFileAttributes; import java.util.List; +import java.util.Objects; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; @@ -21,6 +25,8 @@ final class NamedSocketIntegrationTest { private static final String TMUX = System.getProperty("libtmux.tmux", "tmux"); + private static final int UNIX_FILE_TYPE = 0170000; + private static final int UNIX_SOCKET = 0140000; /** The pid keeps concurrent Gradle workers and matrix lanes apart inside their quarantine. */ private static final String NAMESPACE = "ltj-" + ProcessHandle.current().pid(); @@ -44,15 +50,17 @@ void aNamedServerLandsInThisPortsOwnDirectory(@TempDir Path directory) throws Ex socket.toString().length() <= 104, "the socket path is at the limit a unix socket can carry: " + socket); } finally { - server.killServer(); + reclaimNamedServer(server, name); } } } @Test void twoNamesAreTwoServers(@TempDir Path directory) throws Exception { - try (Server first = openNamed(NAMESPACE + "-b", directory); - Server second = openNamed(NAMESPACE + "-c", directory)) { + String firstName = NAMESPACE + "-b"; + String secondName = NAMESPACE + "-c"; + try (Server first = openNamed(firstName, directory); + Server second = openNamed(secondName, directory)) { try { first.newSession("in-first"); second.newSession("in-second"); @@ -63,12 +71,88 @@ void twoNamesAreTwoServers(@TempDir Path directory) throws Exception { assertTrue(!second.hasSession("in-first"), "the second server can see the first's session"); assertNotEquals(reportedSocket(first), reportedSocket(second), "both names resolved to one socket"); } finally { - first.killServer(); - second.killServer(); + try { + reclaimNamedServer(first, firstName); + } finally { + reclaimNamedServer(second, secondName); + } } } } + private static void reclaimNamedServer(Server server, String name) throws IOException { + ProcessHandle process; + Path quarantine; + Path socket; + BasicFileAttributes owned; + try { + List identity = server.cmd("display-message", "-p", "#{pid}\t#{socket_path}") + .stdout(); + if (identity.size() != 1) { + throw new AssertionError("tmux reported identity rows " + identity); + } + + String[] fields = identity.get(0).split("\t", -1); + assertEquals(2, fields.length, "tmux reported a malformed identity row " + identity.get(0)); + process = ProcessHandle.of(Long.parseLong(fields[0])) + .orElseThrow(() -> new AssertionError("the reported tmux process is already gone")); + + Path configuredQuarantine = tmuxTmpDir().toAbsolutePath().normalize(); + quarantine = configuredQuarantine.toRealPath(); + assertEquals( + configuredQuarantine, quarantine, "refusing to reclaim a socket through a linked quarantine path"); + Path reported = Path.of(fields[1]).toAbsolutePath().normalize(); + assertTrue(!Files.isSymbolicLink(reported), "refusing to reclaim a socket through a symbolic link"); + socket = reported.toRealPath(); + assertEquals(reported, socket, "refusing to reclaim a socket through a linked directory"); + assertTrue( + !socket.equals(quarantine) && socket.startsWith(quarantine), + "refusing to reclaim a socket outside this port's quarantine"); + assertEquals(name, socket.getFileName().toString(), "tmux reported another server's socket"); + owned = Files.readAttributes(socket, BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS); + assertTrue(isUnixSocket(socket), "tmux did not report a unix-domain socket inode"); + assertTrue(owned.fileKey() != null, "the filesystem cannot identify the socket inode"); + } finally { + server.killServer(); + } + + assertTrue(Await.until(() -> !process.isAlive()), "the named tmux server did not exit"); + if (Files.exists(socket, LinkOption.NOFOLLOW_LINKS)) { + BasicFileAttributes stale = + Files.readAttributes(socket, BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS); + assertTrue(isUnixSocket(socket), "refusing to remove a non-socket replacement"); + assertEquals(owned.fileKey(), stale.fileKey(), "refusing to remove a replacement socket inode"); + Files.delete(socket); + } + assertTrue(Files.notExists(socket, LinkOption.NOFOLLOW_LINKS), "the named tmux socket was not reclaimed"); + pruneEmptyParents(Objects.requireNonNull(socket.getParent()), quarantine); + } + + private static boolean isUnixSocket(Path path) throws IOException { + int mode = (Integer) Files.getAttribute(path, "unix:mode", LinkOption.NOFOLLOW_LINKS); + return (mode & UNIX_FILE_TYPE) == UNIX_SOCKET; + } + + private static void pruneEmptyParents(Path directory, Path quarantine) throws IOException { + Path candidate = directory; + while (candidate != null && !candidate.equals(quarantine)) { + assertTrue(candidate.startsWith(quarantine), "refusing to prune outside the tmux quarantine"); + if (Files.notExists(candidate, LinkOption.NOFOLLOW_LINKS)) { + candidate = candidate.getParent(); + continue; + } + assertTrue( + Files.isDirectory(candidate, LinkOption.NOFOLLOW_LINKS), + "refusing to prune a non-directory from the tmux quarantine"); + try { + Files.delete(candidate); + } catch (DirectoryNotEmptyException e) { + return; + } + candidate = candidate.getParent(); + } + } + private static Server openNamed(String name, Path directory) throws IOException { return Server.open(ServerConfig.builder() .binary(TMUX) From ff64d54ae8ab07ffdbee8e022b6c8ed388611370 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 5 Sep 2026 13:54:19 -0500 Subject: [PATCH 17/65] Style(chore[format]): Apply Java formatter The forced branch check found formatting drift in the MCP input changes. Run the repository formatter on the affected MCP production and test files. Every file keeps the same non-whitespace byte stream. --- .../io/github/libtmux/mcp/Operations.java | 9 +- .../github/libtmux/mcp/PaneInputCohort.java | 19 ++--- .../github/libtmux/mcp/RunningCommands.java | 6 +- .../java/io/github/libtmux/mcp/Typing.java | 3 +- .../libtmux/mcp/CapabilityRegistryTest.java | 3 +- .../libtmux/mcp/PaneInputCohortTest.java | 84 ++++++++++--------- .../libtmux/mcp/RunningCommandsTest.java | 51 +++++------ .../io/github/libtmux/mcp/TypingTest.java | 25 +++--- 8 files changed, 88 insertions(+), 112 deletions(-) diff --git a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Operations.java b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Operations.java index 9609b9c..c97f94c 100644 --- a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Operations.java +++ b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Operations.java @@ -362,14 +362,7 @@ static Object sendKeysBatch(Call call) { boolean literal = booleanValue(operation.get("literal"), false, "literal"); Typing.sendKeys(pane, keys, literal, cohort); results.add(values( - "index", - index, - "pane_id", - paneId, - "resolved_pane_ids", - resolvedPaneIds, - "success", - true)); + "index", index, "pane_id", paneId, "resolved_pane_ids", resolvedPaneIds, "success", true)); } catch (RuntimeException failure) { results.add(values( "index", diff --git a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/PaneInputCohort.java b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/PaneInputCohort.java index e3fbc1b..42c1f58 100644 --- a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/PaneInputCohort.java +++ b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/PaneInputCohort.java @@ -14,12 +14,8 @@ final class PaneInputCohort { private static final long MAX_TMUX_PANE_ID = 4_294_967_295L; - private static final RowFormat PANES = RowFormat.of( - "pane_id", - "pane_synchronized", - "pane_in_mode", - "pane_dead", - "pane_current_command"); + private static final RowFormat PANES = + RowFormat.of("pane_id", "pane_synchronized", "pane_in_mode", "pane_dead", "pane_current_command"); private static final String TERMINATOR = PANES.template().substring(PANES.template().lastIndexOf('}') + 1); @@ -29,12 +25,7 @@ private PaneInputCohort() {} static Resolution resolve(Pane source) { return parse( source.id().value(), - source.server().cmd(List.of( - "list-panes", - "-t", - source.id().value(), - "-F", - PANES.template()))); + source.server().cmd(List.of("list-panes", "-t", source.id().value(), "-F", PANES.template()))); } static Resolution parse(String sourcePaneId, CommandResult answer) { @@ -155,8 +146,8 @@ void requirePasteTarget(String operation) { String requireSingularCommandPane(String operation) { keyRecipients.forEach(member -> requireWritable(operation, member)); if (keyRecipients.size() != 1) { - throw new IllegalStateException(operation + " requires exactly one effective pane; observed " - + configuredKeyRecipientIds()); + throw new IllegalStateException( + operation + " requires exactly one effective pane; observed " + configuredKeyRecipientIds()); } return source.currentCommand(); } diff --git a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/RunningCommands.java b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/RunningCommands.java index 8f6e0fc..80e7fd9 100644 --- a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/RunningCommands.java +++ b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/RunningCommands.java @@ -75,8 +75,7 @@ static Ran run(Call call) { String command = call.string("command"); Duration timeout = Waits.requested(call); boolean suppressHistory = call.flag("suppress_history", true); - String currentCommand = - PaneInputCohort.resolve(pane).requireSingularCommandPane("run_shell_command"); + String currentCommand = PaneInputCohort.resolve(pane).requireSingularCommandPane("run_shell_command"); requirePosixShell(currentCommand); PaneCommandFrame commandFrame = PaneCommandFrame.resolve(call); @@ -88,8 +87,7 @@ static Ran run(Call call) { Cursor before = Screen.from(pane).cursor(); String typed = payload(commandFrame, command, startMark, endMark, channel, suppressHistory); Pane freshPane = Targets.pane(server, pane.id().value()); - String freshCommand = - PaneInputCohort.resolve(freshPane).requireSingularCommandPane("run_shell_command"); + String freshCommand = PaneInputCohort.resolve(freshPane).requireSingularCommandPane("run_shell_command"); requirePosixShell(freshCommand); freshPane.sendLine(typed); diff --git a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Typing.java b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Typing.java index c83c3c7..d7b6900 100644 --- a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Typing.java +++ b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Typing.java @@ -51,8 +51,7 @@ static Sent sendKeys(Pane pane, List keys, boolean literal) { return sendKeys(pane, keys, literal, cohort); } - static Sent sendKeys( - Pane pane, List keys, boolean literal, PaneInputCohort.Resolution cohort) { + static Sent sendKeys(Pane pane, List keys, boolean literal, PaneInputCohort.Resolution cohort) { List resolved = cohort.requireKeyRecipients("send_keys"); pane.sendKeys(keys, literal); return new Sent( diff --git a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/CapabilityRegistryTest.java b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/CapabilityRegistryTest.java index ebb3112..60b14e6 100644 --- a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/CapabilityRegistryTest.java +++ b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/CapabilityRegistryTest.java @@ -612,7 +612,8 @@ void outputSchemasRequireKnownFieldsAndTypeNestedArrays() { assertTrue(strings(sendRow.get("required"), "send result required").contains("resolved_pane_ids")); assertEquals( Map.of("type", "string"), - object(sendRowProperties.get("resolved_pane_ids"), "resolved panes").get("items")); + object(sendRowProperties.get("resolved_pane_ids"), "resolved panes") + .get("items")); assertTrue(strings(byName("send_keys").outputSchema().get("required"), "send required") .contains("resolved_pane_ids")); diff --git a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/PaneInputCohortTest.java b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/PaneInputCohortTest.java index c387f74..1f45784 100644 --- a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/PaneInputCohortTest.java +++ b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/PaneInputCohortTest.java @@ -46,45 +46,50 @@ void malformedAuthoritativeRowsFailClosed(String label, List stdout) { } @ParameterizedTest - @ValueSource(strings = { - "junk", "$1", " %1", "\n%1", "%00", "%01", "%4294967296", - "%9999999999999999999999999999999999999999" - }) + @ValueSource( + strings = { + "junk", + "$1", + " %1", + "\n%1", + "%00", + "%01", + "%4294967296", + "%9999999999999999999999999999999999999999" + }) void noncanonicalPaneIdsFailClosed(String paneId) { assertThrows( TmuxFormatException.class, - () -> PaneInputCohort.parse("%0", answer(row("%0", "1", "0", "0", "sh"), - row(paneId, "1", "0", "0", "sh")))); + () -> PaneInputCohort.parse( + "%0", answer(row("%0", "1", "0", "0", "sh"), row(paneId, "1", "0", "0", "sh")))); } @Test void strayPhysicalLineCannotBecomePaneId() { assertThrows( TmuxFormatException.class, - () -> PaneInputCohort.parse("%0", answer( - row("%0", "1", "0", "0", "sh"), - "junk", - row("%1", "1", "0", "0", "sh")))); + () -> PaneInputCohort.parse( + "%0", answer(row("%0", "1", "0", "0", "sh"), "junk", row("%1", "1", "0", "0", "sh")))); } @Test void maximumPaneIdPeerIsAccepted() { - var resolved = PaneInputCohort.parse("%0", answer( - row("%0", "1", "0", "0", "sh"), - row("%4294967295", "1", "0", "0", "sh"))); + var resolved = PaneInputCohort.parse( + "%0", answer(row("%0", "1", "0", "0", "sh"), row("%4294967295", "1", "0", "0", "sh"))); assertEquals(List.of("%0", "%4294967295"), resolved.configuredKeyRecipientIds()); } @Test void sourceFlagDeterminesTheEffectiveCohort() { - var sourceOff = PaneInputCohort.parse("%10", answer( - row("%1", "1", "0", "0", "sh"), - row("%10", "0", "0", "0", "sh"))); - var sourceOn = PaneInputCohort.parse("%10", answer( - row("%10", "1", "0", "0", "sh"), - row("%1", "0", "0", "0", "sh"), - row("%0", "1", "0", "0", "sh"))); + var sourceOff = + PaneInputCohort.parse("%10", answer(row("%1", "1", "0", "0", "sh"), row("%10", "0", "0", "0", "sh"))); + var sourceOn = PaneInputCohort.parse( + "%10", + answer( + row("%10", "1", "0", "0", "sh"), + row("%1", "0", "0", "0", "sh"), + row("%0", "1", "0", "0", "sh"))); assertEquals(List.of("%10"), sourceOff.configuredKeyRecipientIds()); assertEquals(List.of("%0", "%10"), sourceOn.configuredKeyRecipientIds()); @@ -103,12 +108,10 @@ void noncanonicalOrPositiveModesAreModal(String mode) { @Test void deadConfiguredMembersFailButDeadNonmembersDoNot() { var deadSource = PaneInputCohort.parse("%0", answer(row("%0", "0", "0", "1", "sh"))); - var deadPeer = PaneInputCohort.parse("%0", answer( - row("%0", "1", "0", "0", "sh"), - row("%1", "1", "0", "1", "sh"))); - var outside = PaneInputCohort.parse("%0", answer( - row("%0", "0", "0", "0", "sh"), - row("%1", "1", "0", "1", "sh"))); + var deadPeer = + PaneInputCohort.parse("%0", answer(row("%0", "1", "0", "0", "sh"), row("%1", "1", "0", "1", "sh"))); + var outside = + PaneInputCohort.parse("%0", answer(row("%0", "0", "0", "0", "sh"), row("%1", "1", "0", "1", "sh"))); assertThrows(IllegalStateException.class, () -> deadSource.requireKeyRecipients("send_keys")); assertThrows(IllegalStateException.class, () -> deadPeer.requireKeyRecipients("send_keys")); @@ -117,18 +120,14 @@ void deadConfiguredMembersFailButDeadNonmembersDoNot() { @Test void pasteChecksOnlyTheSourceWhileCommandsRequireOneRecipient() { - var sourceOnly = PaneInputCohort.parse("%0", answer( - row("%0", "0", "0", "0", "/bin/sh"), - row("%1", "1", "2", "1", "cat"))); - var plural = PaneInputCohort.parse("%0", answer( - row("%0", "1", "0", "0", "/bin/sh"), - row("%1", "1", "0", "0", "sh"))); + var sourceOnly = PaneInputCohort.parse( + "%0", answer(row("%0", "0", "0", "0", "/bin/sh"), row("%1", "1", "2", "1", "cat"))); + var plural = PaneInputCohort.parse( + "%0", answer(row("%0", "1", "0", "0", "/bin/sh"), row("%1", "1", "0", "0", "sh"))); sourceOnly.requirePasteTarget("paste_text"); assertEquals("/bin/sh", sourceOnly.requireSingularCommandPane("run_shell_command")); - assertThrows( - IllegalStateException.class, - () -> plural.requireSingularCommandPane("run_shell_command")); + assertThrows(IllegalStateException.class, () -> plural.requireSingularCommandPane("run_shell_command")); } @Test @@ -155,14 +154,16 @@ public void close() {} } } - List> commands = requests.stream().flatMap(request -> request.commands().stream()).toList(); + List> commands = requests.stream() + .flatMap(request -> request.commands().stream()) + .toList(); assertEquals(1, commands.size()); List listing = commands.getFirst(); assertEquals(List.of("list-panes", "-t"), listing.subList(0, 2)); assertTrue(listing.contains("-F")); String format = listing.get(listing.indexOf("-F") + 1); - for (String field : List.of( - "pane_id", "pane_synchronized", "pane_in_mode", "pane_dead", "pane_current_command")) { + for (String field : + List.of("pane_id", "pane_synchronized", "pane_in_mode", "pane_dead", "pane_current_command")) { assertEquals(1, occurrences(format, "#{" + field + "}")); } } @@ -172,9 +173,10 @@ private static Stream authorityFailures() { Arguments.of("command failed", "%0", new CommandResult(1, List.of(), List.of("gone"))), Arguments.of("no rows", "%0", answer()), Arguments.of("source absent", "%0", answer(row("%1", "0", "0", "0", "sh"))), - Arguments.of("source duplicated", "%0", answer( - row("%0", "0", "0", "0", "sh"), - row("%0", "0", "0", "0", "sh")))); + Arguments.of( + "source duplicated", + "%0", + answer(row("%0", "0", "0", "0", "sh"), row("%0", "0", "0", "0", "sh")))); } private static Stream malformedRows() { diff --git a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/RunningCommandsTest.java b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/RunningCommandsTest.java index 78db320..34af05c 100644 --- a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/RunningCommandsTest.java +++ b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/RunningCommandsTest.java @@ -190,12 +190,8 @@ void aSynchronizedCommandIsRefusedBeforeTyping(Server server) { IllegalStateException refused = assertThrows( IllegalStateException.class, - () -> RunningCommands.run(TestCalls.on( - server, - "pane_id", - source.id().value(), - "command", - "printf '" + marker + "\\n'"))); + () -> RunningCommands.run( + TestCalls.on(server, "pane_id", source.id().value(), "command", "printf '" + marker + "\\n'"))); String message = String.valueOf(refused.getMessage()); assertTrue(message.contains("run_shell_command"), message); @@ -211,7 +207,8 @@ void aModalEffectiveCommandRecipientIsRefusedBeforeBaselineOrTyping(Server serve source.window().setSynchronizePanes(true); modal.copyMode(); - assertRefusedBeforeRunWork(server, source, "modal-must-not-run", modal.id().value()); + assertRefusedBeforeRunWork( + server, source, "modal-must-not-run", modal.id().value()); } @Test @@ -222,7 +219,8 @@ void aDeadCommandPaneIsRefusedBeforeBaselineOrTyping(Server server) throws Excep source.sendLine("exit"); assertTrue(await(() -> "1".equals(source.expand("#{pane_dead}"))), "the pane did not become dead"); - assertRefusedBeforeRunWork(server, source, "dead-must-not-run", "dead", source.id().value()); + assertRefusedBeforeRunWork( + server, source, "dead-must-not-run", "dead", source.id().value()); } @ParameterizedTest(name = "{0}") @@ -246,11 +244,7 @@ void stateTransitionAfterSetupRefusesRun(RunTransition transition, Server server assertThrows( RuntimeException.class, () -> RunningCommands.run(TestCalls.on( - measured, - "pane_id", - source.id().value(), - "command", - "echo transition-must-not-run"))); + measured, "pane_id", source.id().value(), "command", "echo transition-must-not-run"))); } finally { restore(transition, server, source); } @@ -259,7 +253,8 @@ void stateTransitionAfterSetupRefusesRun(RunTransition transition, Server server assertEquals(2, listings.get(), "the run must attempt both authoritative preflights"); assertEquals(0, commandCount(requests, "send-keys")); assertEquals(0, commandCount(requests, "wait-for")); - assertTrue(server.buffers().list().stream().noneMatch(buffer -> buffer.name().startsWith("libtmux-run-"))); + assertTrue(server.buffers().list().stream() + .noneMatch(buffer -> buffer.name().startsWith("libtmux-run-"))); assertTrue(server.panes().stream() .flatMap(pane -> pane.options().all().keySet().stream()) .noneMatch(name -> name.startsWith("@st_"))); @@ -276,8 +271,8 @@ void successfulRunUsesExactlyTwoPreflights(Server server) { return processes.execute(request); }); try (Server measured = Server.using(server.config(), recording)) { - RunningCommands.Ran ran = RunningCommands.run( - TestCalls.on(measured, "pane_id", pane, "command", "true")); + RunningCommands.Ran ran = + RunningCommands.run(TestCalls.on(measured, "pane_id", pane, "command", "true")); assertCompleted(ran, 0); } } @@ -679,8 +674,8 @@ private static void assertRefusedBeforeRunWork( try (Server measured = Server.using(server.config(), recording)) { IllegalStateException refused = assertThrows( IllegalStateException.class, - () -> RunningCommands.run(TestCalls.on( - measured, "pane_id", source.id().value(), "command", "echo " + marker))); + () -> RunningCommands.run( + TestCalls.on(measured, "pane_id", source.id().value(), "command", "echo " + marker))); String message = String.valueOf(refused.getMessage()); assertTrue(message.contains("run_shell_command"), message); for (String part : expectedMessageParts) { @@ -698,7 +693,8 @@ private static void assertNoRunWork(List requests) { } private static int commandCount(List requests, String name) { - return Math.toIntExact(requests.stream().filter(request -> hasCommand(request, name)).count()); + return Math.toIntExact( + requests.stream().filter(request -> hasCommand(request, name)).count()); } private static int directCommandCount(List requests, String name) { @@ -717,8 +713,9 @@ private static List requestIndexes( } private static boolean hasCommand(CommandRequest request, String name) { - return request.commands().stream().anyMatch(command -> command.getFirst().equals(name) - || command.stream().anyMatch(argument -> argument.contains("'" + name + "'"))); + return request.commands().stream() + .anyMatch(command -> command.getFirst().equals(name) + || command.stream().anyMatch(argument -> argument.contains("'" + name + "'"))); } private static boolean isCohortListing(CommandRequest request) { @@ -730,19 +727,13 @@ private static boolean isCohortListing(List command) { return false; } String format = command.get(command.indexOf("-F") + 1); - return List.of( - "pane_id", - "pane_synchronized", - "pane_in_mode", - "pane_dead", - "pane_current_command") - .stream() + return List.of("pane_id", "pane_synchronized", "pane_in_mode", "pane_dead", "pane_current_command").stream() .allMatch(format::contains); } private static boolean isSocketDiscovery(CommandRequest request) { - return request.commands().stream().anyMatch(command -> command.equals( - List.of("display-message", "-p", "#{socket_path}"))); + return request.commands().stream() + .anyMatch(command -> command.equals(List.of("display-message", "-p", "#{socket_path}"))); } private static void prepare(RunTransition transition, Pane source) { diff --git a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/TypingTest.java b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/TypingTest.java index 3716fec..559fd79 100644 --- a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/TypingTest.java +++ b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/TypingTest.java @@ -138,7 +138,8 @@ void batchResolvesAndGuardsEveryOperationFresh(Server server) throws Exception { var first = server.panes().getFirst(); var firstPeer = first.split(SplitSpec.builder().build()); first.window().setSynchronizePanes(true); - var second = server.sessions().getFirst().newWindow("batch-modal").panes().getFirst(); + var second = + server.sessions().getFirst().newWindow("batch-modal").panes().getFirst(); var modal = second.split(SplitSpec.builder().build()); second.window().setSynchronizePanes(true); modal.copyMode(); @@ -156,9 +157,11 @@ void batchResolvesAndGuardsEveryOperationFresh(Server server) throws Exception { assertEquals(3, batch.get("completed")); assertEquals(true, rows.get(0).get("success")); - assertEquals(sorted(first.id().value(), firstPeer.id().value()), rows.get(0).get("resolved_pane_ids")); + assertEquals( + sorted(first.id().value(), firstPeer.id().value()), rows.get(0).get("resolved_pane_ids")); assertEquals(false, rows.get(1).get("success")); - assertEquals(sorted(second.id().value(), modal.id().value()), rows.get(1).get("resolved_pane_ids")); + assertEquals( + sorted(second.id().value(), modal.id().value()), rows.get(1).get("resolved_pane_ids")); assertEquals(false, rows.get(2).get("success")); assertEquals(List.of(), rows.get(2).get("resolved_pane_ids")); assertTrue(await(() -> captureOf(server, first.id().value()).contains("batch-first-marker"))); @@ -170,9 +173,9 @@ void batchResolvesAndGuardsEveryOperationFresh(Server server) throws Exception { TmuxTransport failing = new TmuxTransport() { @Override public CommandResult execute(CommandRequest request) { - boolean sending = request.commands().stream().anyMatch(command -> command.getFirst() - .equals("send-keys") - || command.stream().anyMatch(argument -> argument.contains("'send-keys'"))); + boolean sending = request.commands().stream() + .anyMatch(command -> command.getFirst().equals("send-keys") + || command.stream().anyMatch(argument -> argument.contains("'send-keys'"))); if (sending) { throw new IllegalStateException("dispatch refused by fixture"); } @@ -232,8 +235,8 @@ void pasteChecksOnlyItsTargetAndDoesNotFanOut(Server server) throws Exception { modal.copyMode(); String marker = "target-only-paste-marker"; - Typing.Pasted pasted = Typing.pasteText(TestCalls.on( - server, "pane_id", source.id().value(), "text", marker)); + Typing.Pasted pasted = + Typing.pasteText(TestCalls.on(server, "pane_id", source.id().value(), "text", marker)); assertEquals(source.id().value(), pasted.paneId()); assertTrue(await(() -> captureOf(server, source.id().value()).contains(marker))); @@ -249,8 +252,7 @@ void pasteRefusesAModalTargetBeforeCreatingABuffer(Server server) { IllegalStateException refused = assertThrows( IllegalStateException.class, - () -> Typing.pasteText(TestCalls.on( - server, "pane_id", pane.id().value(), "text", marker))); + () -> Typing.pasteText(TestCalls.on(server, "pane_id", pane.id().value(), "text", marker))); String message = String.valueOf(refused.getMessage()); assertTrue(message.contains("paste_text"), message); @@ -356,8 +358,7 @@ private static List sorted(String... paneIds) { } private static Typing.Sent sendKeys(Server server, String paneId, String marker) { - return Typing.sendKeys( - TestCalls.on(server, "pane_id", paneId, "keys", List.of(marker), "literal", true)); + return Typing.sendKeys(TestCalls.on(server, "pane_id", paneId, "keys", List.of(marker), "literal", true)); } private static void assertKeyRefused(Server server, String source, String blocked, String marker) { From a62c9934d18f39d3640b2003b15846a1998a61e7 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 5 Sep 2026 14:44:59 -0500 Subject: [PATCH 18/65] Junit5(fix[named]): Reclaim named sockets why: Named tmux teardown can leave a dead Unix socket that poisons the next Gradle invocation. what: - Authenticate the reported PID, path, and inode before teardown - Fail closed on replaced entries and prune only empty directories - Reuse the shared cleanup in launcher and integration tests --- .../it/NamedSocketIntegrationTest.java | 102 +--------- .../libtmux/junit5/NamedServerFixture.java | 190 ++++++++++++++++++ .../junit5/NamedServerFixtureTest.java | 150 ++++++++++++++ .../github/libtmux/mcp/McpLauncherTest.java | 9 +- 4 files changed, 353 insertions(+), 98 deletions(-) create mode 100644 libtmux-junit5/src/main/java/io/github/libtmux/junit5/NamedServerFixture.java create mode 100644 libtmux-junit5/src/test/java/io/github/libtmux/junit5/NamedServerFixtureTest.java diff --git a/integration-tests/src/test/java/io/github/libtmux/it/NamedSocketIntegrationTest.java b/integration-tests/src/test/java/io/github/libtmux/it/NamedSocketIntegrationTest.java index 9447516..8212355 100644 --- a/integration-tests/src/test/java/io/github/libtmux/it/NamedSocketIntegrationTest.java +++ b/integration-tests/src/test/java/io/github/libtmux/it/NamedSocketIntegrationTest.java @@ -7,14 +7,11 @@ import io.github.libtmux.Server; import io.github.libtmux.ServerConfig; import io.github.libtmux.ServerEndpoint; +import io.github.libtmux.junit5.NamedServerFixture; import java.io.IOException; -import java.nio.file.DirectoryNotEmptyException; import java.nio.file.Files; -import java.nio.file.LinkOption; import java.nio.file.Path; -import java.nio.file.attribute.BasicFileAttributes; import java.util.List; -import java.util.Objects; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; @@ -25,8 +22,6 @@ final class NamedSocketIntegrationTest { private static final String TMUX = System.getProperty("libtmux.tmux", "tmux"); - private static final int UNIX_FILE_TYPE = 0170000; - private static final int UNIX_SOCKET = 0140000; /** The pid keeps concurrent Gradle workers and matrix lanes apart inside their quarantine. */ private static final String NAMESPACE = "ltj-" + ProcessHandle.current().pid(); @@ -36,11 +31,12 @@ void aNamedServerLandsInThisPortsOwnDirectory(@TempDir Path directory) throws Ex String name = NAMESPACE + "-a"; try (Server server = openNamed(name, directory)) { - try { - server.newSession("named"); + server.newSession("named"); + try (NamedServerFixture owned = NamedServerFixture.own(server, name, tmuxTmpDir())) { Path socket = Path.of(reportedSocket(server)); + assertEquals(socket, owned.socket()); assertEquals(name, socket.getFileName().toString(), "tmux resolved a different name"); assertTrue( socket.startsWith(tmuxTmpDir()), @@ -49,8 +45,6 @@ void aNamedServerLandsInThisPortsOwnDirectory(@TempDir Path directory) throws Ex assertTrue( socket.toString().length() <= 104, "the socket path is at the limit a unix socket can carry: " + socket); - } finally { - reclaimNamedServer(server, name); } } } @@ -61,98 +55,20 @@ void twoNamesAreTwoServers(@TempDir Path directory) throws Exception { String secondName = NAMESPACE + "-c"; try (Server first = openNamed(firstName, directory); Server second = openNamed(secondName, directory)) { - try { - first.newSession("in-first"); - second.newSession("in-second"); + first.newSession("in-first"); + second.newSession("in-second"); + try (NamedServerFixture firstOwned = NamedServerFixture.own(first, firstName, tmuxTmpDir()); + NamedServerFixture secondOwned = NamedServerFixture.own(second, secondName, tmuxTmpDir())) { assertTrue(first.hasSession("in-first")); assertTrue(second.hasSession("in-second")); assertTrue(!first.hasSession("in-second"), "the first server can see the second's session"); assertTrue(!second.hasSession("in-first"), "the second server can see the first's session"); - assertNotEquals(reportedSocket(first), reportedSocket(second), "both names resolved to one socket"); - } finally { - try { - reclaimNamedServer(first, firstName); - } finally { - reclaimNamedServer(second, secondName); - } + assertNotEquals(firstOwned.socket(), secondOwned.socket(), "both names resolved to one socket"); } } } - private static void reclaimNamedServer(Server server, String name) throws IOException { - ProcessHandle process; - Path quarantine; - Path socket; - BasicFileAttributes owned; - try { - List identity = server.cmd("display-message", "-p", "#{pid}\t#{socket_path}") - .stdout(); - if (identity.size() != 1) { - throw new AssertionError("tmux reported identity rows " + identity); - } - - String[] fields = identity.get(0).split("\t", -1); - assertEquals(2, fields.length, "tmux reported a malformed identity row " + identity.get(0)); - process = ProcessHandle.of(Long.parseLong(fields[0])) - .orElseThrow(() -> new AssertionError("the reported tmux process is already gone")); - - Path configuredQuarantine = tmuxTmpDir().toAbsolutePath().normalize(); - quarantine = configuredQuarantine.toRealPath(); - assertEquals( - configuredQuarantine, quarantine, "refusing to reclaim a socket through a linked quarantine path"); - Path reported = Path.of(fields[1]).toAbsolutePath().normalize(); - assertTrue(!Files.isSymbolicLink(reported), "refusing to reclaim a socket through a symbolic link"); - socket = reported.toRealPath(); - assertEquals(reported, socket, "refusing to reclaim a socket through a linked directory"); - assertTrue( - !socket.equals(quarantine) && socket.startsWith(quarantine), - "refusing to reclaim a socket outside this port's quarantine"); - assertEquals(name, socket.getFileName().toString(), "tmux reported another server's socket"); - owned = Files.readAttributes(socket, BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS); - assertTrue(isUnixSocket(socket), "tmux did not report a unix-domain socket inode"); - assertTrue(owned.fileKey() != null, "the filesystem cannot identify the socket inode"); - } finally { - server.killServer(); - } - - assertTrue(Await.until(() -> !process.isAlive()), "the named tmux server did not exit"); - if (Files.exists(socket, LinkOption.NOFOLLOW_LINKS)) { - BasicFileAttributes stale = - Files.readAttributes(socket, BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS); - assertTrue(isUnixSocket(socket), "refusing to remove a non-socket replacement"); - assertEquals(owned.fileKey(), stale.fileKey(), "refusing to remove a replacement socket inode"); - Files.delete(socket); - } - assertTrue(Files.notExists(socket, LinkOption.NOFOLLOW_LINKS), "the named tmux socket was not reclaimed"); - pruneEmptyParents(Objects.requireNonNull(socket.getParent()), quarantine); - } - - private static boolean isUnixSocket(Path path) throws IOException { - int mode = (Integer) Files.getAttribute(path, "unix:mode", LinkOption.NOFOLLOW_LINKS); - return (mode & UNIX_FILE_TYPE) == UNIX_SOCKET; - } - - private static void pruneEmptyParents(Path directory, Path quarantine) throws IOException { - Path candidate = directory; - while (candidate != null && !candidate.equals(quarantine)) { - assertTrue(candidate.startsWith(quarantine), "refusing to prune outside the tmux quarantine"); - if (Files.notExists(candidate, LinkOption.NOFOLLOW_LINKS)) { - candidate = candidate.getParent(); - continue; - } - assertTrue( - Files.isDirectory(candidate, LinkOption.NOFOLLOW_LINKS), - "refusing to prune a non-directory from the tmux quarantine"); - try { - Files.delete(candidate); - } catch (DirectoryNotEmptyException e) { - return; - } - candidate = candidate.getParent(); - } - } - private static Server openNamed(String name, Path directory) throws IOException { return Server.open(ServerConfig.builder() .binary(TMUX) diff --git a/libtmux-junit5/src/main/java/io/github/libtmux/junit5/NamedServerFixture.java b/libtmux-junit5/src/main/java/io/github/libtmux/junit5/NamedServerFixture.java new file mode 100644 index 0000000..a51149b --- /dev/null +++ b/libtmux-junit5/src/main/java/io/github/libtmux/junit5/NamedServerFixture.java @@ -0,0 +1,190 @@ +package io.github.libtmux.junit5; + +import io.github.libtmux.Server; +import java.io.IOException; +import java.nio.file.DirectoryNotEmptyException; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.nio.file.attribute.BasicFileAttributes; +import java.time.Duration; +import java.util.List; +import java.util.Objects; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +/** Owns the safe teardown of a tmux server addressed by socket name. */ +public final class NamedServerFixture implements AutoCloseable { + + private static final int UNIX_FILE_TYPE = 0170000; + private static final int UNIX_SOCKET = 0140000; + private static final Duration EXIT_TIMEOUT = Duration.ofSeconds(10); + + private final Server server; + private final ProcessHandle process; + private final Path quarantine; + private final Path socket; + private final Object fileKey; + private boolean closed; + + private NamedServerFixture(Server server, ProcessHandle process, Path quarantine, Path socket, Object fileKey) { + this.server = server; + this.process = process; + this.quarantine = quarantine; + this.socket = socket; + this.fileKey = fileKey; + } + + /** + * Authenticates a live named server and takes responsibility for ending it. + * + * @param server the server this test started + * @param expectedName the exact socket name the test supplied to tmux + * @param quarantine the {@code TMUX_TMPDIR} assigned to this test task + * @return cleanup bound to the reported process, path, and socket inode + * @throws IOException if the endpoint cannot be inspected + */ + public static NamedServerFixture own(Server server, String expectedName, Path quarantine) throws IOException { + Objects.requireNonNull(server, "server"); + Objects.requireNonNull(expectedName, "expectedName"); + Objects.requireNonNull(quarantine, "quarantine"); + try { + return authenticate(server, expectedName, quarantine); + } catch (IOException | RuntimeException | AssertionError failure) { + try { + server.killServer(); + } catch (RuntimeException killFailure) { + failure.addSuppressed(killFailure); + } + throw failure; + } + } + + private static NamedServerFixture authenticate(Server server, String expectedName, Path configuredQuarantine) + throws IOException { + List identity = + server.cmd("display-message", "-p", "#{pid}\t#{socket_path}").stdout(); + require(identity.size() == 1, "tmux reported identity rows " + identity); + + String row = identity.getFirst(); + String[] fields = row.split("\t", -1); + require(fields.length == 2, "tmux reported a malformed identity row " + row); + ProcessHandle process = ProcessHandle.of(Long.parseLong(fields[0])) + .orElseThrow(() -> new AssertionError("the reported tmux process is already gone")); + + Path normalizedQuarantine = configuredQuarantine.toAbsolutePath().normalize(); + Path quarantine = normalizedQuarantine.toRealPath(); + require( + normalizedQuarantine.equals(quarantine), + "refusing to reclaim a socket through a linked quarantine path"); + Path reported = Path.of(fields[1]).toAbsolutePath().normalize(); + require(!Files.isSymbolicLink(reported), "refusing to reclaim a socket through a symbolic link"); + Path socket = reported.toRealPath(); + require(reported.equals(socket), "refusing to reclaim a socket through a linked directory"); + require( + !socket.equals(quarantine) && socket.startsWith(quarantine), + "refusing to reclaim a socket outside this port's quarantine"); + require( + expectedName.equals(socket.getFileName().toString()), + "tmux reported another server's socket " + socket); + BasicFileAttributes owned = Files.readAttributes(socket, BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS); + require(isUnixSocket(socket), "tmux did not report a unix-domain socket inode"); + require(owned.fileKey() != null, "the filesystem cannot identify the socket inode"); + return new NamedServerFixture(server, process, quarantine, socket, owned.fileKey()); + } + + /** Returns the exact socket path authenticated when ownership was taken. */ + public Path socket() { + return socket; + } + + @Override + public synchronized void close() throws IOException { + if (closed) { + return; + } + + RuntimeException killFailure = null; + try { + server.killServer(); + } catch (RuntimeException failure) { + killFailure = failure; + } + if (!awaitExit(process)) { + AssertionError failure = + new AssertionError("the named tmux server did not exit; leaving " + socket + " in place"); + if (killFailure != null) { + failure.addSuppressed(killFailure); + } + throw failure; + } + + reclaimSocket(); + pruneEmptyParents(Objects.requireNonNull(socket.getParent()), quarantine); + closed = true; + } + + private void reclaimSocket() throws IOException { + Path currentQuarantine = quarantine.toRealPath(); + require( + currentQuarantine.equals(quarantine), + "refusing to reclaim a socket through a replaced quarantine path"); + if (Files.exists(socket, LinkOption.NOFOLLOW_LINKS)) { + require(!Files.isSymbolicLink(socket), "refusing to remove a symbolic-link replacement"); + require(socket.equals(socket.toRealPath()), "refusing to remove a socket through a linked directory"); + require( + !socket.equals(quarantine) && socket.startsWith(quarantine), + "refusing to remove a socket outside this port's quarantine"); + BasicFileAttributes stale = + Files.readAttributes(socket, BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS); + require(isUnixSocket(socket), "refusing to remove a non-socket replacement"); + require(fileKey.equals(stale.fileKey()), "refusing to remove a replacement socket inode"); + Files.delete(socket); + } + require(Files.notExists(socket, LinkOption.NOFOLLOW_LINKS), "the named tmux socket was not reclaimed"); + } + + private static boolean awaitExit(ProcessHandle process) { + try { + process.onExit().get(EXIT_TIMEOUT.toMillis(), TimeUnit.MILLISECONDS); + return !process.isAlive(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return false; + } catch (ExecutionException | TimeoutException e) { + return !process.isAlive(); + } + } + + private static boolean isUnixSocket(Path path) throws IOException { + int mode = (Integer) Files.getAttribute(path, "unix:mode", LinkOption.NOFOLLOW_LINKS); + return (mode & UNIX_FILE_TYPE) == UNIX_SOCKET; + } + + private static void pruneEmptyParents(Path directory, Path quarantine) throws IOException { + Path candidate = directory; + while (candidate != null && !candidate.equals(quarantine)) { + require(candidate.startsWith(quarantine), "refusing to prune outside the tmux quarantine"); + if (Files.notExists(candidate, LinkOption.NOFOLLOW_LINKS)) { + candidate = candidate.getParent(); + continue; + } + require( + Files.isDirectory(candidate, LinkOption.NOFOLLOW_LINKS), + "refusing to prune a non-directory from the tmux quarantine"); + try { + Files.delete(candidate); + } catch (DirectoryNotEmptyException e) { + return; + } + candidate = candidate.getParent(); + } + } + + private static void require(boolean condition, String message) { + if (!condition) { + throw new AssertionError(message); + } + } +} diff --git a/libtmux-junit5/src/test/java/io/github/libtmux/junit5/NamedServerFixtureTest.java b/libtmux-junit5/src/test/java/io/github/libtmux/junit5/NamedServerFixtureTest.java new file mode 100644 index 0000000..ee11ee0 --- /dev/null +++ b/libtmux-junit5/src/test/java/io/github/libtmux/junit5/NamedServerFixtureTest.java @@ -0,0 +1,150 @@ +package io.github.libtmux.junit5; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import io.github.libtmux.Server; +import io.github.libtmux.ServerConfig; +import io.github.libtmux.ServerEndpoint; +import io.github.libtmux.transport.CommandRequest; +import io.github.libtmux.transport.CommandResult; +import io.github.libtmux.transport.TmuxTransport; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +final class NamedServerFixtureTest { + + private static final String TMUX = System.getProperty("libtmux.tmux", "tmux"); + + @Test + void theSameNamedEndpointCanBeOwnedTwice(@TempDir Path directory) throws Exception { + String name = "ltj-owned-" + ProcessHandle.current().pid(); + Path previous = null; + + for (int run = 0; run < 2; run++) { + Path socket; + try (Server server = openNamed(name, directory)) { + server.newSession("owned-" + run); + socket = reportedSocket(server); + try (NamedServerFixture fixture = NamedServerFixture.own(server, name, quarantine())) { + assertEquals(socket, fixture.socket()); + assertTrue(server.hasSession("owned-" + run)); + } + } + assertFalse(Files.exists(socket), "the owned named socket survived teardown"); + if (previous != null) { + assertEquals(previous, socket, "the second run did not reuse the same namespace"); + } + previous = socket; + } + } + + @Test + void aReplacementSentinelIsNeverRemoved(@TempDir Path directory) throws Exception { + String name = "ltj-sentinel-" + ProcessHandle.current().pid(); + Path socket = null; + try (Server server = openNamed(name, directory)) { + server.newSession("sentinel"); + socket = reportedSocket(server); + ProcessHandle process = reportedProcess(server); + NamedServerFixture fixture = NamedServerFixture.own(server, name, quarantine()); + + server.killServer(); + awaitExit(process); + Files.delete(socket); + Files.writeString(socket, "keep"); + + Path planted = socket; + AssertionError refused = assertThrows(AssertionError.class, fixture::close); + String message = String.valueOf(refused.getMessage()); + assertTrue(message.contains("non-socket replacement"), message); + assertEquals("keep", Files.readString(planted)); + } finally { + if (socket != null) { + Files.deleteIfExists(socket); + } + } + } + + @Test + void failedAuthenticationKillsOnlyThroughTheServerHandle(@TempDir Path directory) throws Exception { + Path sentinel = directory.resolve("not-a-socket"); + Files.writeString(sentinel, "keep"); + List requests = new ArrayList<>(); + TmuxTransport transport = new TmuxTransport() { + @Override + public CommandResult execute(CommandRequest request) { + requests.add(request); + if (request.commands().getFirst().getFirst().equals("display-message")) { + return new CommandResult(0, List.of("malformed"), List.of()); + } + return new CommandResult(0, List.of(), List.of()); + } + + @Override + public void close() {} + }; + ServerConfig config = ServerConfig.builder() + .binary(TMUX) + .endpoint(ServerEndpoint.socketPath(sentinel)) + .build(); + + try (Server server = Server.using(config, transport)) { + AssertionError refused = + assertThrows(AssertionError.class, () -> NamedServerFixture.own(server, "expected", directory)); + + assertTrue(String.valueOf(refused.getMessage()).contains("malformed identity row")); + } + assertEquals("keep", Files.readString(sentinel)); + assertEquals(2, requests.size()); + assertEquals( + List.of("display-message", "-p", "#{pid}\t#{socket_path}"), + requests.get(0).commands().getFirst()); + assertEquals(List.of("kill-server"), requests.get(1).commands().getFirst()); + assertTrue(requests.stream().allMatch(request -> request.endpoint().contains(sentinel.toString()))); + } + + private static Server openNamed(String name, Path directory) throws IOException { + Path config = directory.resolve(name + ".conf"); + Files.writeString(config, ""); + return Server.open(ServerConfig.builder() + .binary(TMUX) + .endpoint(ServerEndpoint.namedSocket(name)) + .configFile(config) + .build()); + } + + private static Path reportedSocket(Server server) { + List rows = + server.cmd("display-message", "-p", "#{socket_path}").stdout(); + assertEquals(1, rows.size(), "tmux did not report one socket path"); + return Path.of(rows.getFirst()); + } + + private static ProcessHandle reportedProcess(Server server) { + List rows = server.cmd("display-message", "-p", "#{pid}").stdout(); + assertEquals(1, rows.size(), "tmux did not report one process id"); + long pid = Long.parseLong(rows.getFirst()); + return ProcessHandle.of(pid).orElseThrow(() -> new AssertionError("the named tmux process was not found")); + } + + private static Path quarantine() { + String configured = System.getenv("TMUX_TMPDIR"); + assertTrue(configured != null && !configured.isEmpty(), "the build did not quarantine TMUX_TMPDIR"); + return Path.of(configured); + } + + private static void awaitExit(ProcessHandle process) throws Exception { + process.onExit().get(Duration.ofSeconds(10).toMillis(), TimeUnit.MILLISECONDS); + assertFalse(process.isAlive(), "the named tmux process did not exit"); + } +} diff --git a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/McpLauncherTest.java b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/McpLauncherTest.java index 87df573..c528653 100644 --- a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/McpLauncherTest.java +++ b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/McpLauncherTest.java @@ -9,6 +9,7 @@ import io.github.libtmux.Server; import io.github.libtmux.ServerConfig; import io.github.libtmux.ServerEndpoint; +import io.github.libtmux.junit5.NamedServerFixture; import io.github.libtmux.junit5.TmuxExtension; import io.github.libtmux.junit5.TmuxSocketPath; import io.modelcontextprotocol.client.McpClient; @@ -326,8 +327,9 @@ void aServerAddressedByNameIsFoundToo(@TempDir Path directory) throws Exception String name = "ltj-mcp-" + ProcessHandle.current().pid(); try (Server named = openNamed(name, directory)) { - try { - named.newSession("by-name"); + named.newSession("by-name"); + try (NamedServerFixture owned = NamedServerFixture.own(named, name, Path.of(tmuxTmpDir()))) { + assertEquals(name, owned.socket().getFileName().toString()); try (McpSyncClient client = launch("--socket-name", name, Map.of("TMUX_TMPDIR", tmuxTmpDir()))) { client.initialize(); @@ -337,9 +339,6 @@ void aServerAddressedByNameIsFoundToo(@TempDir Path directory) throws Exception assertTrue(listed.contains("by-name"), "the launcher did not find the named server: " + listed); } - } finally { - // -L leaves no -S for the fixture's sweep to match, so nothing else ends this server. - named.killServer(); } } } From 8e7b9cc89fac6ddf31947b52c3acfb864050e511 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 5 Sep 2026 14:54:57 -0500 Subject: [PATCH 19/65] Pane(fix[input]): Bound send-keys options why: Caller text beginning with a tmux flag is parsed as control input instead of keys. what: - End send-keys option parsing before caller-supplied keys - Cover the core API and MCP single and batch routes --- .../it/PaneOperationsIntegrationTest.java | 23 ++++++++++++++ .../io/github/libtmux/mcp/TypingTest.java | 31 +++++++++++++++++++ .../src/main/java/io/github/libtmux/Pane.java | 2 +- 3 files changed, 55 insertions(+), 1 deletion(-) diff --git a/integration-tests/src/test/java/io/github/libtmux/it/PaneOperationsIntegrationTest.java b/integration-tests/src/test/java/io/github/libtmux/it/PaneOperationsIntegrationTest.java index f9afcf2..0f4e4d1 100644 --- a/integration-tests/src/test/java/io/github/libtmux/it/PaneOperationsIntegrationTest.java +++ b/integration-tests/src/test/java/io/github/libtmux/it/PaneOperationsIntegrationTest.java @@ -121,4 +121,27 @@ void clearingHistoryLeavesThePaneItself(Server server) { assertEquals(pane.id(), pane.refresh().id(), "the pane survives having its scrollback dropped"); } + + @Test + void leadingOptionNamesAreLiteralCallerInput(Server server) throws Exception { + Window window = server.sessions().getFirst().windows().getFirst(); + + for (String input : List.of("-X", "-R", "-N")) { + Pane pane = window.split(); + + pane.sendKeys(List.of(input), true); + + assertTrue(awaitText(pane, input), "tmux parsed caller input as an option: " + input); + } + } + + private static boolean awaitText(Pane pane, String text) throws InterruptedException { + for (int attempt = 0; attempt < 100; attempt++) { + if (String.join("\n", pane.capture()).contains(text)) { + return true; + } + Thread.sleep(20); + } + return String.join("\n", pane.capture()).contains(text); + } } diff --git a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/TypingTest.java b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/TypingTest.java index 559fd79..f777d33 100644 --- a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/TypingTest.java +++ b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/TypingTest.java @@ -46,6 +46,37 @@ void keysAreSentByNameSoAnInterruptInterrupts(Server server) { assertTrue(String.valueOf(sent.note()).contains("not waited for"), String.valueOf(sent.note())); } + @Test + void leadingOptionNamesReachTheSingleSendRoute(Server server) throws Exception { + String pane = server.panes().getFirst().id().value(); + List input = List.of("-X", "-R", "-N"); + + Typing.Sent sent = Typing.sendKeys(TestCalls.on(server, "pane_id", pane, "keys", input, "literal", true)); + + assertEquals(3, sent.keys()); + assertTrue(await(() -> captureOf(server, pane).contains("-X-R-N"))); + } + + @Test + void leadingOptionNamesReachEveryBatchRoute(Server server) throws Exception { + var first = server.panes().getFirst(); + var second = first.split(SplitSpec.builder().build()); + var third = first.split(SplitSpec.builder().build()); + List> operations = List.of( + send(first.id().value(), "-X"), + send(second.id().value(), "-R"), + send(third.id().value(), "-N")); + + List> rows = rows( + map(Operations.sendKeysBatch(TestCalls.on(server, "operations", operations, "onError", "continue")))); + + assertEquals(3, rows.size()); + assertTrue(rows.stream().allMatch(row -> Boolean.TRUE.equals(row.get("success"))), rows.toString()); + assertTrue(await(() -> captureOf(server, first.id().value()).contains("-X"))); + assertTrue(await(() -> captureOf(server, second.id().value()).contains("-R"))); + assertTrue(await(() -> captureOf(server, third.id().value()).contains("-N"))); + } + @Test void synchronizedInputDisclosesEveryResolvedPane(Server server) { var source = server.panes().getFirst(); diff --git a/libtmux/src/main/java/io/github/libtmux/Pane.java b/libtmux/src/main/java/io/github/libtmux/Pane.java index b3304f6..cb7419c 100644 --- a/libtmux/src/main/java/io/github/libtmux/Pane.java +++ b/libtmux/src/main/java/io/github/libtmux/Pane.java @@ -323,7 +323,7 @@ public void sendKeys(List keys, boolean literal) { if (literal) { argv.add("-l"); } - argv.addAll(List.of("-t", state.id().value())); + argv.addAll(List.of("-t", state.id().value(), "--")); argv.addAll(keys); server.run(snapshot, argv); } From 16bc3a5781369a793e6b6bd949b24146e8065cbe Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 5 Sep 2026 14:59:35 -0500 Subject: [PATCH 20/65] Pane(fix[breakOut]): Preserve hash names why: break-pane does not expand tmux formats, so escaping a hash changes the caller's requested name. what: - Pass the requested name raw to break-pane - Keep format escaping on the tmux 3.7 rename fallback - Cover current, oldest, and fallback tmux lanes --- .../io/github/libtmux/it/PaneOperationsIntegrationTest.java | 5 +++-- libtmux/src/main/java/io/github/libtmux/Pane.java | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/integration-tests/src/test/java/io/github/libtmux/it/PaneOperationsIntegrationTest.java b/integration-tests/src/test/java/io/github/libtmux/it/PaneOperationsIntegrationTest.java index 0f4e4d1..0b9d482 100644 --- a/integration-tests/src/test/java/io/github/libtmux/it/PaneOperationsIntegrationTest.java +++ b/integration-tests/src/test/java/io/github/libtmux/it/PaneOperationsIntegrationTest.java @@ -45,10 +45,11 @@ void breakingAPaneOutLeavesTheServerStanding(Server server) { @Test void breakingOutWithAChosenNameUsesIt(Server server) { Pane split = server.sessions().get(0).windows().get(0).split(); + String requested = "chosen-#S"; - Window broken = split.breakOut("chosen"); + Window broken = split.breakOut(requested); - assertEquals("chosen", broken.name()); + assertEquals(requested, broken.name()); assertTrue(server.isAlive()); } diff --git a/libtmux/src/main/java/io/github/libtmux/Pane.java b/libtmux/src/main/java/io/github/libtmux/Pane.java index cb7419c..a4162d2 100644 --- a/libtmux/src/main/java/io/github/libtmux/Pane.java +++ b/libtmux/src/main/java/io/github/libtmux/Pane.java @@ -447,7 +447,7 @@ public Window breakOut(String windowName) { * @param supplied the name to hand tmux, which is never absent because 3.7 crashes without one */ private Window breakNamed(Optional wanted, String supplied) { - List argv = new ArrayList<>(List.of("break-pane", "-d", "-n", TmuxFormats.literal(supplied))); + List argv = new ArrayList<>(List.of("break-pane", "-d", "-n", supplied)); argv.addAll(List.of("-s", state.id().value(), "-P", "-F", BROKEN_OUT.template())); List fields = BROKEN_OUT.split(server.run(snapshot, argv).stdout().get(0)); From fd3f40102a9562ec017cc8b3ad24dcfb6ec97675 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 5 Sep 2026 15:02:01 -0500 Subject: [PATCH 21/65] Mcp(fix[capability]): Declare output risks why: Rich tmux results can return secrets and untrusted instructions, but the registry advertised only one risk dimension for many tools. what: - Define exact risks for all forty-five public tools - Mark content-bearing results sensitive and untrusted - Keep ID, status, boolean, and numeric results structural --- .../java/io/github/libtmux/mcp/Catalog.java | 61 ++++++++++------ .../libtmux/mcp/CapabilityRegistryTest.java | 71 +++++++++++++++++++ 2 files changed, 109 insertions(+), 23 deletions(-) diff --git a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Catalog.java b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Catalog.java index fb2a0a9..d0c62f2 100644 --- a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Catalog.java +++ b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Catalog.java @@ -210,7 +210,7 @@ private static void inspect(List tools) { .wireSchema())), Listings::panes)); - tools.add(inspectMetadata( + tools.add(inspectRichMetadata( "get_server_info", "Get server info", "Reports whether the pinned server exists and its version.", @@ -222,7 +222,7 @@ private static void inspect(List tools) { field("version", STRING), field("sessions", INTEGER)), Operations::serverInfo)); - tools.add(inspectMetadata( + tools.add(inspectRichMetadata( "get_session_info", "Get session info", "Returns metadata for one session.", @@ -230,7 +230,7 @@ private static void inspect(List tools) { sinks(input("session_id", TMUX_LOOKUP)), SESSION_OUTPUT, Operations::sessionInfo)); - tools.add(inspectMetadata( + tools.add(inspectRichMetadata( "get_window_info", "Get window info", "Returns metadata for one window.", @@ -238,7 +238,7 @@ private static void inspect(List tools) { sinks(input("window_id", TMUX_LOOKUP)), WINDOW_OUTPUT, Operations::windowInfo)); - tools.add(inspectMetadata( + tools.add(inspectRichMetadata( "get_pane_info", "Get pane info", "Returns metadata for one pane.", @@ -337,7 +337,7 @@ private static void inspect(List tools) { input("max_lines", ToolSpec.InputSink.NONE)), record(Reading.Found.class, "note"), Reading::search)); - tools.add(inspectMetadata( + tools.add(inspectRichMetadata( "find_pane_by_position", "Find pane by position", "Finds a pane at one of a window's four corners.", @@ -425,7 +425,7 @@ private static void inspect(List tools) { effects(OBSERVE), outputs(TMUX_METADATA, CONFIGURED_COMMAND), true, - false, + true, option, sinks( input("name", TMUX_LOOKUP), @@ -443,7 +443,7 @@ private static void inspect(List tools) { effects(OBSERVE), outputs(PROCESS_ENVIRONMENT), true, - false, + true, List.of(optional("session", "A session name; omit for the global environment.")), sinks(input("session", TMUX_LOOKUP)), record(Settings.Environment.class), @@ -462,7 +462,7 @@ private static void inspect(List tools) { effects(OBSERVE), outputs(CONFIGURED_COMMAND), true, - false, + true, hooks, sinks(input("scope", TMUX_LOOKUP), input("target", TMUX_LOOKUP), input("name", TMUX_LOOKUP)), shape(field("scope", STRING), field("target", STRING), field("count", INTEGER), field("hooks", OBJECT)), @@ -514,6 +514,7 @@ private static void manage(List tools) { required("session_id", "The session ID, such as $1."), required("new_name", "The literal new session name.")), sinks(input("session_id", TMUX_LOOKUP), input("new_name", TMUX_FORMAT)), + true, SESSION_OUTPUT, Operations::renameSession), "new_name")); @@ -526,6 +527,7 @@ private static void manage(List tools) { required("window_id", "The window ID, such as @1."), required("new_name", "The literal new window name.")), sinks(input("window_id", TMUX_LOOKUP), input("new_name", TMUX_FORMAT)), + true, WINDOW_OUTPUT, Operations::renameWindow), "new_name")); @@ -535,6 +537,7 @@ private static void manage(List tools) { "Makes one window active.", List.of(required("window_id", "The window ID, such as @1.")), sinks(input("window_id", TMUX_LOOKUP)), + true, WINDOW_OUTPUT, Operations::selectWindow)); tools.add(manageTool( @@ -543,6 +546,7 @@ private static void manage(List tools) { "Makes one pane active.", List.of(paneId()), sinks(input("pane_id", TMUX_LOOKUP)), + true, PANE_OUTPUT, Operations::selectPane)); tools.add(manageTool( @@ -553,6 +557,7 @@ private static void manage(List tools) { required("window_id", "The window ID, such as @1."), required("layout", "A built-in layout name.")), sinks(input("window_id", TMUX_LOOKUP), input("layout", TMUX_STATE)), + false, record(Shaping.Changed.class, "note"), Shaping::selectLayout)); tools.add(manageTool( @@ -564,6 +569,7 @@ private static void manage(List tools) { number("width", "Width in terminal cells; omit to retain it.", 0), number("height", "Height in terminal cells; omit to retain it.", 0)), sinks(input("window_id", TMUX_LOOKUP), input("width", TMUX_STATE), input("height", TMUX_STATE)), + true, WINDOW_OUTPUT, Operations::resizeWindow)); tools.add(manageTool( @@ -575,6 +581,7 @@ private static void manage(List tools) { number("width", "Width in terminal cells; omit to retain it.", 0), number("height", "Height in terminal cells; omit to retain it.", 0)), sinks(input("pane_id", TMUX_LOOKUP), input("width", TMUX_STATE), input("height", TMUX_STATE)), + false, record(Shaping.Changed.class, "note"), Shaping::resizePane)); tools.add(manageTool( @@ -586,6 +593,7 @@ private static void manage(List tools) { required("session_id", "The destination session ID, such as $1."), number("index", "A destination window index; omit for tmux's choice.", -1)), sinks(input("window_id", TMUX_LOOKUP), input("session_id", TMUX_LOOKUP), input("index", TMUX_STATE)), + false, shape(field("window_id", STRING), field("session_id", STRING), field("index", INTEGER)), Operations::moveWindow)); tools.add(manageTool( @@ -594,6 +602,7 @@ private static void manage(List tools) { "Swaps the positions of two panes.", List.of(paneId(), required("other_pane_id", "The other pane ID, such as %2.")), sinks(input("pane_id", TMUX_LOOKUP), input("other_pane_id", TMUX_LOOKUP)), + false, shape(field("pane_id", STRING), field("other_pane_id", STRING)), Operations::swapPane)); tools.add(literalized( @@ -603,6 +612,7 @@ private static void manage(List tools) { "Replaces a pane's literal title.", List.of(paneId(), required("title", "The literal title.")), sinks(input("pane_id", TMUX_LOOKUP), input("title", TMUX_FORMAT)), + true, PANE_OUTPUT, Operations::setPaneTitle), "title")); @@ -618,7 +628,7 @@ private static void manage(List tools) { NONE, effects(CHANGE), outputs(TMUX_METADATA), - false, + true, true, channelWait, sinks( @@ -633,6 +643,7 @@ private static void manage(List tools) { "Signals one server-wide tmux channel.", List.of(required("channel", "The channel name.")), sinks(input("channel", TMUX_STATE)), + true, record(Channels.Signalled.class), Channels::signal)); tools.add(changeOnlyTool( @@ -641,6 +652,7 @@ private static void manage(List tools) { "Enables or disables tmux mouse handling.", List.of(flag("enabled", "Whether mouse handling is enabled.", false)), sinks(input("enabled", TMUX_STATE)), + false, shape(field("enabled", BOOLEAN)), Operations::setMouseEnabled)); tools.add(changeOnlyTool( @@ -651,6 +663,7 @@ private static void manage(List tools) { required("session_id", "The session ID, such as $1."), requiredNumber("lines", "The nonnegative retained line count.")), sinks(input("session_id", TMUX_LOOKUP), input("lines", TMUX_STATE)), + false, shape(field("session_id", STRING), field("lines", INTEGER)), Operations::setHistoryLimit)); } @@ -665,7 +678,7 @@ private static void execute(List tools) { CONFIGURED_PROCESS, effects(OBSERVE, CHANGE), outputs(TMUX_METADATA), - false, + true, true, List.of( optional("session_name", "A literal session name."), @@ -693,7 +706,7 @@ private static void execute(List tools) { CONFIGURED_PROCESS, effects(OBSERVE, CHANGE), outputs(TMUX_METADATA), - false, + true, true, List.of( required("session_id", "The session ID, such as $1."), @@ -720,7 +733,7 @@ private static void execute(List tools) { CONFIGURED_PROCESS, effects(OBSERVE, CHANGE), outputs(TMUX_METADATA), - false, + true, true, List.of( paneId(), @@ -745,7 +758,7 @@ private static void execute(List tools) { effects(OBSERVE, CHANGE, DELETE), outputs(TMUX_METADATA), false, - true, + false, List.of(paneId(), optional("start_directory", "An absolute literal start directory.")), sinks(input("pane_id", TMUX_LOOKUP), input("start_directory", TMUX_FORMAT)), shape(field("pane_id", STRING), field("restarted", BOOLEAN)), @@ -796,7 +809,7 @@ private static void execute(List tools) { effects(OBSERVE, CHANGE), outputs(TMUX_METADATA), false, - true, + false, keys, sinks( input("pane_id", TMUX_LOOKUP), @@ -814,7 +827,7 @@ private static void execute(List tools) { PANE_INPUT, effects(OBSERVE, CHANGE), outputs(TMUX_METADATA), - false, + true, true, List.of( boundedObjects("operations", "Objects with pane_id, keys and optional literal fields.", 64), @@ -834,7 +847,7 @@ private static void execute(List tools) { effects(OBSERVE, CHANGE), outputs(TMUX_METADATA), false, - true, + false, List.of( paneId(), required("text", "The literal text to paste."), @@ -855,7 +868,7 @@ private static void execute(List tools) { effects(CHANGE), outputs(TMUX_METADATA), false, - true, + false, List.of( required("window_id", "The window ID, such as @1."), flag("enabled", "Whether pane input is synchronized.", false)), @@ -905,7 +918,7 @@ private static List killArguments(String name, String description) { flag("confirm_self", "Permit ending the pane this MCP process runs in.", false)); } - private static ToolSpec inspectMetadata( + private static ToolSpec inspectRichMetadata( String name, String title, String details, @@ -922,7 +935,7 @@ private static ToolSpec inspectMetadata( effects(OBSERVE), outputs(TMUX_METADATA), true, - false, + true, arguments, sinks, output, @@ -935,6 +948,7 @@ private static ToolSpec manageTool( String details, List arguments, Map> sinks, + boolean richOutput, OutputSchema output, java.util.function.Function answer) { return tool( @@ -945,8 +959,8 @@ private static ToolSpec manageTool( NONE, effects(OBSERVE, CHANGE), outputs(TMUX_METADATA), - false, - true, + richOutput, + richOutput, arguments, sinks, output, @@ -959,6 +973,7 @@ private static ToolSpec changeOnlyTool( String details, List arguments, Map> sinks, + boolean richOutput, OutputSchema output, java.util.function.Function answer) { return tool( @@ -969,8 +984,8 @@ private static ToolSpec changeOnlyTool( NONE, effects(CHANGE), outputs(TMUX_METADATA), - false, - true, + richOutput, + richOutput, arguments, sinks, output, diff --git a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/CapabilityRegistryTest.java b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/CapabilityRegistryTest.java index 60b14e6..803f3d6 100644 --- a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/CapabilityRegistryTest.java +++ b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/CapabilityRegistryTest.java @@ -126,6 +126,56 @@ final class CapabilityRegistryTest { "teardown", Set.of("clear_pane_scrollback", "kill_pane", "kill_window", "kill_session")); + private static final OutputRisks RICH_OUTPUT = new OutputRisks(true, true); + private static final OutputRisks STRUCTURAL_OUTPUT = new OutputRisks(false, false); + + private static final Map EXPECTED_OUTPUT_RISKS = Map.ofEntries( + Map.entry("list_sessions", RICH_OUTPUT), + Map.entry("list_windows", RICH_OUTPUT), + Map.entry("list_panes", RICH_OUTPUT), + Map.entry("get_server_info", RICH_OUTPUT), + Map.entry("get_session_info", RICH_OUTPUT), + Map.entry("get_window_info", RICH_OUTPUT), + Map.entry("get_pane_info", RICH_OUTPUT), + Map.entry("capture_pane", RICH_OUTPUT), + Map.entry("capture_since", RICH_OUTPUT), + Map.entry("snapshot_pane", RICH_OUTPUT), + Map.entry("search_panes", RICH_OUTPUT), + Map.entry("find_pane_by_position", RICH_OUTPUT), + Map.entry("wait_for_text", RICH_OUTPUT), + Map.entry("get_tmux_variables", RICH_OUTPUT), + Map.entry("show_option", RICH_OUTPUT), + Map.entry("show_environment", RICH_OUTPUT), + Map.entry("show_hooks", RICH_OUTPUT), + Map.entry("call_read_tools_batch", RICH_OUTPUT), + Map.entry("rename_session", RICH_OUTPUT), + Map.entry("rename_window", RICH_OUTPUT), + Map.entry("select_window", RICH_OUTPUT), + Map.entry("select_pane", RICH_OUTPUT), + Map.entry("select_layout", STRUCTURAL_OUTPUT), + Map.entry("resize_window", RICH_OUTPUT), + Map.entry("resize_pane", STRUCTURAL_OUTPUT), + Map.entry("move_window", STRUCTURAL_OUTPUT), + Map.entry("swap_pane", STRUCTURAL_OUTPUT), + Map.entry("set_pane_title", RICH_OUTPUT), + Map.entry("wait_for_channel", RICH_OUTPUT), + Map.entry("signal_channel", RICH_OUTPUT), + Map.entry("set_mouse_enabled", STRUCTURAL_OUTPUT), + Map.entry("set_history_limit", STRUCTURAL_OUTPUT), + Map.entry("create_session", RICH_OUTPUT), + Map.entry("create_window", RICH_OUTPUT), + Map.entry("split_window", RICH_OUTPUT), + Map.entry("respawn_pane", STRUCTURAL_OUTPUT), + Map.entry("run_shell_command", RICH_OUTPUT), + Map.entry("send_keys", STRUCTURAL_OUTPUT), + Map.entry("send_keys_batch", RICH_OUTPUT), + Map.entry("paste_text", STRUCTURAL_OUTPUT), + Map.entry("set_synchronize_panes", STRUCTURAL_OUTPUT), + Map.entry("clear_pane_scrollback", STRUCTURAL_OUTPUT), + Map.entry("kill_pane", STRUCTURAL_OUTPUT), + Map.entry("kill_window", STRUCTURAL_OUTPUT), + Map.entry("kill_session", STRUCTURAL_OUTPUT)); + @Test void everyManifestRowDrivesConservativeRegistrationMetadataAndSinkValidation() { assertEquals(CATALOG_ORDER, Catalog.tools().stream().map(ToolSpec::name).toList()); @@ -287,6 +337,25 @@ void everyManifestRowDrivesConservativeRegistrationMetadataAndSinkValidation() { () -> Catalog.validate(List.of(byName("get_server_info"), byName("get_server_info")))); } + @Test + void everyToolDeclaresItsExactAdrOutputRisks() { + assertEquals(Set.copyOf(CATALOG_ORDER), EXPECTED_OUTPUT_RISKS.keySet()); + Map actual = Catalog.tools().stream() + .collect(java.util.stream.Collectors.toMap( + ToolSpec::name, + tool -> new OutputRisks(tool.mayExposeSecrets(), tool.mayReturnUntrustedContent()))); + assertEquals(EXPECTED_OUTPUT_RISKS, actual); + + for (ToolSpec tool : Catalog.tools()) { + OutputRisks expected = Objects.requireNonNull(EXPECTED_OUTPUT_RISKS.get(tool.name()), tool.name()); + @SuppressWarnings("unchecked") + Map metadata = (Map) Objects.requireNonNull( + tool.describe().meta().get("com.git-pull.libtmux-mcp/capability"), "capability metadata"); + assertEquals(expected.mayExposeSecrets(), metadata.get("mayExposeSecrets"), tool.name()); + assertEquals(expected.mayReturnUntrustedContent(), metadata.get("mayReturnUntrustedContent"), tool.name()); + } + } + @Test void exactEffectRowsAndExclusionPrunedBatchUnionsStayAligned() { Map> expected = Map.ofEntries( @@ -960,6 +1029,8 @@ private static List wireNames(Set effects) { return effects.stream().map(ToolSpec.TmuxEffect::wireName).toList(); } + private record OutputRisks(boolean mayExposeSecrets, boolean mayReturnUntrustedContent) {} + @SuppressWarnings("unchecked") private static Map object(@Nullable Object value, String name) { if (!(value instanceof Map)) { From 7d27c4c53c972255d560f9da5474cb30ad324e77 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 5 Sep 2026 15:03:20 -0500 Subject: [PATCH 22/65] Docs(style[mcp]): Wrap guide prose why: Several changed guide sentences exceeded the repository prose width convention. what: - Reflow only the affected MCP guide sentences --- docs/guide/mcp.md | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/docs/guide/mcp.md b/docs/guide/mcp.md index 72fee52..eaa459d 100644 --- a/docs/guide/mcp.md +++ b/docs/guide/mcp.md @@ -76,10 +76,10 @@ work bounded. ## Telling output apart from the plumbing -`run_shell_command` has to know when a command finished and what it exited with. An -outer subshell therefore arms an exit trap before starting the command. The trap sends -the numeric status marker and signals a private tmux channel; the wait is tmux's own -`wait-for`. +`run_shell_command` has to know when a command finished and what it exited with. +An outer subshell therefore arms an exit trap before starting the command. The +trap sends the numeric status marker and signals a private tmux channel; the +wait is tmux's own `wait-for`. The catch is that a shell echoes everything typed at it, so that plumbing lands on screen amongst the output. Matching it by its shape does not work: in a narrow @@ -104,18 +104,18 @@ Two consequences worth knowing, both pinned by tests: - The command runs in a **subshell**, so a `cd` or an `export` in it does not outlive the call — and neither does an `exit`, which is what keeps `exit 3` from closing the pane. -- `run_shell_command` returns on the completion signal, which happens *before* the shell - redraws its prompt. A following `capture_since` legitimately reports that - prompt as new output. +- `run_shell_command` returns on the completion signal, which happens *before* + the shell redraws its prompt. A following `capture_since` legitimately reports + that prompt as new output. The command's inner subshell inherits the pane's ordinary environment, options, traps, and functions. The outer frame uses one absolute client and the server's resolved `-S` socket, so output-command aliases and functions, a `tmux` basename -function, pane `PATH`, and pane socket variables do not own completion. Pre-existing -functions named `trap`, `eval`, `exit`, or exactly like that resolved client are not -a supported hostile-shell case. The marker `display-message` calls still use the -trusted server's normal command path, including configured command aliases and -`after-display-message` hooks. +function, pane `PATH`, and pane socket variables do not own completion. +Pre-existing functions named `trap`, `eval`, `exit`, or exactly like that +resolved client are not a supported hostile-shell case. The marker +`display-message` calls still use the trusted server's normal command path, +including configured command aliases and `after-display-message` hooks. ### Pane modes and synchronized input From 37282b921f8ee82e38f63de4559d0826a322fde3 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 5 Sep 2026 15:04:20 -0500 Subject: [PATCH 23/65] Docs(docs[changelog]): Record review fixes why: Public fixture, Pane, and capability behavior changed after review. what: - Record named-server ownership and literal input fixes - Disclose the corrected MCP output-risk contract --- CHANGELOG.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3067e9e..a7fe098 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,9 @@ production. ### Added +- **`NamedServerFixture` safely owns explicitly named test servers.** It binds + teardown to the reported process, socket path, and inode, then fails closed if + any of that identity changes before cleanup. - **`Pane.findWindow` searches by name, title, or content, with case-insensitive and regular-expression matching.** Build a `FindSpec` or configure one inline. (#6) @@ -86,6 +89,16 @@ production. ### Fixed +- **`Pane.sendKeys` preserves option-shaped input.** It ends tmux option parsing + before caller keys, so values such as `-X`, `-R`, and `-N` reach pane programs + through the core API and MCP single or batch routes. +- **`Pane.breakOut` preserves literal `#` in requested window names.** The + `break-pane -n` path receives the raw name; only the tmux 3.7 rename fallback + applies tmux format literalization. +- **MCP capability rows disclose both output risk dimensions.** Pane text, + environment and configured-command values, and names, titles, paths, or + current commands now advertise both secret and untrusted-content risk; + strictly structural results remain false for both. - **MCP pane input now refuses effective recipients in a human-owned mode.** `send_keys` and each `send_keys_batch` operation resolve pane-level `synchronize-panes` overrides before dispatch; `paste_text` remains From 679a76cc9d74cee1936898ab90f54122ab8f0846 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 5 Sep 2026 15:15:10 -0500 Subject: [PATCH 24/65] Transport(test[lifecycle]): Arm TERM fixture why: - Scheduling could expire the deadline before Bash installed its trap. what: - Wait until the trap and initial child are live before cleanup. - Delay setup past the deadline so barrier removal reliably fails. --- .../transport/ProcessTransportTest.java | 32 ++++++++++++++++--- 1 file changed, 28 insertions(+), 4 deletions(-) diff --git a/libtmux/src/test/java/io/github/libtmux/transport/ProcessTransportTest.java b/libtmux/src/test/java/io/github/libtmux/transport/ProcessTransportTest.java index ade032f..09213b6 100644 --- a/libtmux/src/test/java/io/github/libtmux/transport/ProcessTransportTest.java +++ b/libtmux/src/test/java/io/github/libtmux/transport/ProcessTransportTest.java @@ -296,15 +296,39 @@ void outputOverflowReclaimsDescendantsBeforeTheRootCanDisappear(@TempDir Path di @Test void cleanupDoesNotAdoptADescendantSpawnedAfterItsOwnershipSnapshot(@TempDir Path directory) throws Exception { Path descendantPid = directory.resolve("detached.pid"); - String script = "trap '(trap \"\" HUP TERM; echo \"$BASHPID\" > \"$1.tmp\"; " + Path ready = directory.resolve("ready"); + String script = "sleep 1; trap '(trap \"\" HUP TERM; echo \"$BASHPID\" > \"$1.tmp\"; " + "mv \"$1.tmp\" \"$1\"; exec sleep 30) /dev/null 2>&1 & " + "while :; do :; done' TERM; " - + "while :; do sleep 30; done"; + + "ready=0; while :; do sleep 30 & child=$!; " + + "if [ \"$ready\" = 0 ]; then echo ready > \"$2.tmp\"; mv \"$2.tmp\" \"$2\"; ready=1; fi; " + + "wait \"$child\"; done"; + ProcessTransport.ProcessStarter starter = command -> { + Process started = new ProcessBuilder(command).start(); + boolean armed = false; + try { + armed = awaitFile(ready); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IOException("interrupted while arming the cleanup fixture", e); + } finally { + if (!armed) { + started.descendants().forEach(ProcessHandle::destroyForcibly); + started.destroyForcibly(); + } + } + if (!armed) { + throw new IOException("cleanup fixture did not arm"); + } + return started; + }; CommandRequest request = CommandRequest.of( - List.of("/bin/bash"), List.of("-c", script, "probe", descendantPid.toString()), Duration.ofMillis(250)); + List.of("/bin/bash"), + List.of("-c", script, "probe", descendantPid.toString(), ready.toString()), + Duration.ofMillis(250)); long descendant = -1; - try (ProcessTransport transport = new ProcessTransport()) { + try (ProcessTransport transport = new ProcessTransport(1, 1_024, starter, System::nanoTime)) { assertThrows(TmuxTransportException.class, () -> transport.execute(request)); assertTrue(awaitFile(descendantPid), "the cleanup-time descendant never started"); descendant = Long.parseLong(Files.readString(descendantPid).trim()); From 00122b97141115f7d8f3af98e9fcf1f58564e836 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 5 Sep 2026 15:31:04 -0500 Subject: [PATCH 25/65] Junit5(fix[fixture]): Guard server teardown why: A stale fixture could send kill-server to a replacement endpoint. what: - Reauthenticate a live endpoint before ending its server. - Refuse endpoint commands when ownership cannot be proven. - Cover bounded cleanup without harming a replacement server. --- .../libtmux/junit5/NamedServerFixture.java | 33 +++++++++-------- .../junit5/NamedServerFixtureTest.java | 35 +++++++++++++++++-- 2 files changed, 51 insertions(+), 17 deletions(-) diff --git a/libtmux-junit5/src/main/java/io/github/libtmux/junit5/NamedServerFixture.java b/libtmux-junit5/src/main/java/io/github/libtmux/junit5/NamedServerFixture.java index a51149b..bf63959 100644 --- a/libtmux-junit5/src/main/java/io/github/libtmux/junit5/NamedServerFixture.java +++ b/libtmux-junit5/src/main/java/io/github/libtmux/junit5/NamedServerFixture.java @@ -49,16 +49,7 @@ public static NamedServerFixture own(Server server, String expectedName, Path qu Objects.requireNonNull(server, "server"); Objects.requireNonNull(expectedName, "expectedName"); Objects.requireNonNull(quarantine, "quarantine"); - try { - return authenticate(server, expectedName, quarantine); - } catch (IOException | RuntimeException | AssertionError failure) { - try { - server.killServer(); - } catch (RuntimeException killFailure) { - failure.addSuppressed(killFailure); - } - throw failure; - } + return authenticate(server, expectedName, quarantine); } private static NamedServerFixture authenticate(Server server, String expectedName, Path configuredQuarantine) @@ -106,10 +97,15 @@ public synchronized void close() throws IOException { } RuntimeException killFailure = null; - try { - server.killServer(); - } catch (RuntimeException failure) { - killFailure = failure; + if (process.isAlive()) { + authenticateCurrentOwnership(); + try { + server.killServer(); + } catch (RuntimeException failure) { + killFailure = failure; + } + } else if (server.isAlive()) { + authenticateCurrentOwnership(); } if (!awaitExit(process)) { AssertionError failure = @@ -125,6 +121,15 @@ public synchronized void close() throws IOException { closed = true; } + private void authenticateCurrentOwnership() throws IOException { + NamedServerFixture current = authenticate(server, socket.getFileName().toString(), quarantine); + require( + current.process.pid() == process.pid(), + "refusing to terminate a replacement tmux process at " + socket); + require(current.socket.equals(socket), "refusing to terminate a replacement tmux endpoint"); + require(current.fileKey.equals(fileKey), "refusing to terminate a replacement socket inode"); + } + private void reclaimSocket() throws IOException { Path currentQuarantine = quarantine.toRealPath(); require( diff --git a/libtmux-junit5/src/test/java/io/github/libtmux/junit5/NamedServerFixtureTest.java b/libtmux-junit5/src/test/java/io/github/libtmux/junit5/NamedServerFixtureTest.java index ee11ee0..eda95b7 100644 --- a/libtmux-junit5/src/test/java/io/github/libtmux/junit5/NamedServerFixtureTest.java +++ b/libtmux-junit5/src/test/java/io/github/libtmux/junit5/NamedServerFixtureTest.java @@ -3,6 +3,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTimeoutPreemptively; import static org.junit.jupiter.api.Assertions.assertTrue; import io.github.libtmux.Server; @@ -76,7 +77,36 @@ void aReplacementSentinelIsNeverRemoved(@TempDir Path directory) throws Exceptio } @Test - void failedAuthenticationKillsOnlyThroughTheServerHandle(@TempDir Path directory) throws Exception { + void aLiveReplacementServerSurvivesStaleFixtureCleanup(@TempDir Path directory) throws Exception { + String name = "ltj-replacement-" + ProcessHandle.current().pid(); + Path socket; + NamedServerFixture originalFixture; + try (Server original = openNamed(name, directory)) { + original.newSession("original"); + socket = reportedSocket(original); + ProcessHandle originalProcess = reportedProcess(original); + originalFixture = NamedServerFixture.own(original, name, quarantine()); + + original.killServer(); + awaitExit(originalProcess); + Files.delete(socket); + + try (Server replacement = openNamed(name, directory)) { + replacement.newSession("replacement"); + try (NamedServerFixture replacementFixture = NamedServerFixture.own(replacement, name, quarantine())) { + assertEquals(socket, replacementFixture.socket()); + AssertionError refused = assertTimeoutPreemptively( + Duration.ofSeconds(2), () -> assertThrows(AssertionError.class, originalFixture::close)); + + assertTrue(String.valueOf(refused.getMessage()).contains("replacement")); + assertTrue(replacement.hasSession("replacement")); + } + } + } + } + + @Test + void failedAuthenticationNeverSendsAnEndpointCommand(@TempDir Path directory) throws Exception { Path sentinel = directory.resolve("not-a-socket"); Files.writeString(sentinel, "keep"); List requests = new ArrayList<>(); @@ -105,11 +135,10 @@ public void close() {} assertTrue(String.valueOf(refused.getMessage()).contains("malformed identity row")); } assertEquals("keep", Files.readString(sentinel)); - assertEquals(2, requests.size()); + assertEquals(1, requests.size()); assertEquals( List.of("display-message", "-p", "#{pid}\t#{socket_path}"), requests.get(0).commands().getFirst()); - assertEquals(List.of("kill-server"), requests.get(1).commands().getFirst()); assertTrue(requests.stream().allMatch(request -> request.endpoint().contains(sentinel.toString()))); } From 404d4435dce39c5d30d0e1bfdbbdfee240101115 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 5 Sep 2026 15:35:04 -0500 Subject: [PATCH 26/65] Junit5(feat[fixture]): Own exact socket paths why: Explicit MCP endpoints need PID and inode safe cleanup. what: - Add an exact-path ownership overload with containment checks. - Reuse the fixture for launch ownership and assert no residue. - Exercise two consecutive explicit-path lifecycles. --- .../libtmux/junit5/NamedServerFixture.java | 45 ++++++++++++++++--- .../junit5/NamedServerFixtureTest.java | 27 +++++++++++ .../java/io/github/libtmux/mcp/MainTest.java | 16 +++---- 3 files changed, 73 insertions(+), 15 deletions(-) diff --git a/libtmux-junit5/src/main/java/io/github/libtmux/junit5/NamedServerFixture.java b/libtmux-junit5/src/main/java/io/github/libtmux/junit5/NamedServerFixture.java index bf63959..9cae352 100644 --- a/libtmux-junit5/src/main/java/io/github/libtmux/junit5/NamedServerFixture.java +++ b/libtmux-junit5/src/main/java/io/github/libtmux/junit5/NamedServerFixture.java @@ -49,11 +49,36 @@ public static NamedServerFixture own(Server server, String expectedName, Path qu Objects.requireNonNull(server, "server"); Objects.requireNonNull(expectedName, "expectedName"); Objects.requireNonNull(quarantine, "quarantine"); - return authenticate(server, expectedName, quarantine); + return authenticate( + server, + candidate -> require( + expectedName.equals(candidate.getFileName().toString()), + "tmux reported another server's socket " + candidate), + quarantine); } - private static NamedServerFixture authenticate(Server server, String expectedName, Path configuredQuarantine) - throws IOException { + /** + * Authenticates a live server at an exact socket path and takes responsibility for ending it. + * + * @param server the server this test started + * @param expectedSocket the exact socket path the test supplied to tmux + * @param quarantine the owned root that must contain the socket + * @return cleanup bound to the reported process, path, and socket inode + * @throws IOException if the endpoint cannot be inspected + */ + public static NamedServerFixture own(Server server, Path expectedSocket, Path quarantine) throws IOException { + Objects.requireNonNull(server, "server"); + Objects.requireNonNull(expectedSocket, "expectedSocket"); + Objects.requireNonNull(quarantine, "quarantine"); + Path expected = expectedSocket.toAbsolutePath().normalize(); + return authenticate( + server, + candidate -> require(expected.equals(candidate), "tmux reported another server's socket " + candidate), + quarantine); + } + + private static NamedServerFixture authenticate( + Server server, SocketExpectation expectation, Path configuredQuarantine) throws IOException { List identity = server.cmd("display-message", "-p", "#{pid}\t#{socket_path}").stdout(); require(identity.size() == 1, "tmux reported identity rows " + identity); @@ -76,9 +101,7 @@ private static NamedServerFixture authenticate(Server server, String expectedNam require( !socket.equals(quarantine) && socket.startsWith(quarantine), "refusing to reclaim a socket outside this port's quarantine"); - require( - expectedName.equals(socket.getFileName().toString()), - "tmux reported another server's socket " + socket); + expectation.verify(socket); BasicFileAttributes owned = Files.readAttributes(socket, BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS); require(isUnixSocket(socket), "tmux did not report a unix-domain socket inode"); require(owned.fileKey() != null, "the filesystem cannot identify the socket inode"); @@ -122,7 +145,10 @@ public synchronized void close() throws IOException { } private void authenticateCurrentOwnership() throws IOException { - NamedServerFixture current = authenticate(server, socket.getFileName().toString(), quarantine); + NamedServerFixture current = authenticate( + server, + candidate -> require(socket.equals(candidate), "tmux reported another server's socket " + candidate), + quarantine); require( current.process.pid() == process.pid(), "refusing to terminate a replacement tmux process at " + socket); @@ -192,4 +218,9 @@ private static void require(boolean condition, String message) { throw new AssertionError(message); } } + + @FunctionalInterface + private interface SocketExpectation { + void verify(Path socket); + } } diff --git a/libtmux-junit5/src/test/java/io/github/libtmux/junit5/NamedServerFixtureTest.java b/libtmux-junit5/src/test/java/io/github/libtmux/junit5/NamedServerFixtureTest.java index eda95b7..636f1d3 100644 --- a/libtmux-junit5/src/test/java/io/github/libtmux/junit5/NamedServerFixtureTest.java +++ b/libtmux-junit5/src/test/java/io/github/libtmux/junit5/NamedServerFixtureTest.java @@ -49,6 +49,23 @@ void theSameNamedEndpointCanBeOwnedTwice(@TempDir Path directory) throws Excepti } } + @Test + void theSameExplicitEndpointCanBeOwnedTwice(@TempDir Path directory) throws Exception { + Path socket = + quarantine().resolve("ltj-explicit-" + ProcessHandle.current().pid()); + + for (int run = 0; run < 2; run++) { + try (Server server = openPath(socket, directory)) { + server.newSession("explicit-" + run); + try (NamedServerFixture fixture = NamedServerFixture.own(server, socket, quarantine())) { + assertEquals(socket, fixture.socket()); + assertTrue(server.hasSession("explicit-" + run)); + } + } + assertFalse(Files.exists(socket), "the explicit socket survived teardown"); + } + } + @Test void aReplacementSentinelIsNeverRemoved(@TempDir Path directory) throws Exception { String name = "ltj-sentinel-" + ProcessHandle.current().pid(); @@ -152,6 +169,16 @@ private static Server openNamed(String name, Path directory) throws IOException .build()); } + private static Server openPath(Path socket, Path directory) throws IOException { + Path config = directory.resolve(socket.getFileName() + ".conf"); + Files.writeString(config, ""); + return Server.open(ServerConfig.builder() + .binary(TMUX) + .endpoint(ServerEndpoint.socketPath(socket)) + .configFile(config) + .build()); + } + private static Path reportedSocket(Server server) { List rows = server.cmd("display-message", "-p", "#{socket_path}").stdout(); diff --git a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/MainTest.java b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/MainTest.java index ce02035..7dd9cb4 100644 --- a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/MainTest.java +++ b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/MainTest.java @@ -1,12 +1,14 @@ package io.github.libtmux.mcp; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import io.github.libtmux.Server; import io.github.libtmux.ServerConfig; import io.github.libtmux.ServerEndpoint; +import io.github.libtmux.junit5.NamedServerFixture; import io.github.libtmux.transport.CommandRequest; import io.github.libtmux.transport.CommandResult; import io.github.libtmux.transport.TmuxTransport; @@ -186,11 +188,12 @@ void onlyTheLaunchWhosePrivateConfigCreatedTheDaemonOwnsIt() throws IOException LaunchConfiguration second = dedicatedOn(LaunchConfiguration.resolve(List.of(), Map.of()), socket); try (Server firstServer = Server.open(first.config())) { - try { - SocketProfile created = first.profile(firstServer); - assertEquals("created", created.serverState()); - assertTrue(created.defaultTeardown()); + SocketProfile created = first.profile(firstServer); + assertEquals("created", created.serverState()); + assertTrue(created.defaultTeardown()); + try (NamedServerFixture fixture = NamedServerFixture.own(firstServer, socket, root)) { + assertEquals(socket, fixture.socket()); try (Server secondServer = Server.open(second.config())) { SocketProfile existing = second.profile(secondServer); assertEquals("existing", existing.serverState()); @@ -201,12 +204,9 @@ void onlyTheLaunchWhosePrivateConfigCreatedTheDaemonOwnsIt() throws IOException .noneMatch(line -> line.contains(Objects.requireNonNull(first.ownerNonce())) || line.contains(Objects.requireNonNull(second.ownerNonce())))); } - } finally { - if (firstServer.isAlive()) { - firstServer.killServer(); - } } } + assertFalse(Files.exists(socket), "the launcher ownership socket survived teardown"); } @Test From 41abac3d2790f1f99e44458cfd625694f62851b2 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 5 Sep 2026 15:36:18 -0500 Subject: [PATCH 27/65] Docs(style[mcp]): Wrap guide prose why: Three branch-authored guide lines exceeded 80 columns. what: - Reflow only whitespace while preserving every word and link. --- docs/guide/mcp.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/docs/guide/mcp.md b/docs/guide/mcp.md index eaa459d..cd840b5 100644 --- a/docs/guide/mcp.md +++ b/docs/guide/mcp.md @@ -27,8 +27,8 @@ loop as a polling cycle, where it costs a call per look and has no ceiling at al Four waits, cheapest first. -**You wrote the command: `run_shell_command`.** It sends the command, waits for it, and -returns the output with an exit status in one call. +**You wrote the command: `run_shell_command`.** It sends the command, waits for +it, and returns the output with an exit status in one call. **You wrote it but want it composed yourself: `wait_for_channel`.** Append `; tmux wait-for -S mychannel` to whatever you send, then block on the channel. @@ -213,8 +213,8 @@ teardown unless it is requested explicitly. The selection filters the catalog; it does not confine effects. `execute` includes authored shell commands, key input, and pasted text, so it can run -programs or delete data in a pane. Use a separate OS account, socket permissions, -or a container when effects must be contained. +programs or delete data in a pane. Use a separate OS account, socket +permissions, or a container when effects must be contained. A hidden tool is never listed and is not callable. Every visible tool also publishes process reach, tmux effects, output classes, one @@ -264,6 +264,7 @@ recovery: `no pane %9 on this server; call list_panes for the 3 that exist`. ## Further reading - [`libtmux-mcp` README](../../libtmux-mcp/README.md) — running it, and the tool list -- [Filtering](filtering.md) — the expression model Java applications can use outside MCP +- [Filtering](filtering.md) — the expression model Java applications can use + outside MCP - [Watching output as it happens](streaming.md) — the control client directly - [Control-mode subscriptions](../spikes/23-control-subscriptions.md) — what was measured From fdebb44982835f1af69584a2b56729bac675b6e9 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 5 Sep 2026 15:56:29 -0500 Subject: [PATCH 28/65] Junit5(fix[fixture]): Fence server teardown why: A replacement could bind between identity and kill requests. what: - Fence the captured PID and kill in one tmux invocation. - Reject the stale branch without touching its server. - Keep replacement cleanup on the test thread and cover the race. --- .../libtmux/junit5/NamedServerFixture.java | 31 ++++----- .../junit5/NamedServerFixtureTest.java | 66 +++++++++++++++++-- 2 files changed, 74 insertions(+), 23 deletions(-) diff --git a/libtmux-junit5/src/main/java/io/github/libtmux/junit5/NamedServerFixture.java b/libtmux-junit5/src/main/java/io/github/libtmux/junit5/NamedServerFixture.java index 9cae352..76af628 100644 --- a/libtmux-junit5/src/main/java/io/github/libtmux/junit5/NamedServerFixture.java +++ b/libtmux-junit5/src/main/java/io/github/libtmux/junit5/NamedServerFixture.java @@ -1,6 +1,7 @@ package io.github.libtmux.junit5; import io.github.libtmux.Server; +import io.github.libtmux.transport.CommandResult; import java.io.IOException; import java.nio.file.DirectoryNotEmptyException; import java.nio.file.Files; @@ -120,15 +121,10 @@ public synchronized void close() throws IOException { } RuntimeException killFailure = null; - if (process.isAlive()) { - authenticateCurrentOwnership(); - try { - server.killServer(); - } catch (RuntimeException failure) { - killFailure = failure; - } - } else if (server.isAlive()) { - authenticateCurrentOwnership(); + try { + killOwnedServer(); + } catch (RuntimeException failure) { + killFailure = failure; } if (!awaitExit(process)) { AssertionError failure = @@ -144,16 +140,13 @@ public synchronized void close() throws IOException { closed = true; } - private void authenticateCurrentOwnership() throws IOException { - NamedServerFixture current = authenticate( - server, - candidate -> require(socket.equals(candidate), "tmux reported another server's socket " + candidate), - quarantine); - require( - current.process.pid() == process.pid(), - "refusing to terminate a replacement tmux process at " + socket); - require(current.socket.equals(socket), "refusing to terminate a replacement tmux endpoint"); - require(current.fileKey.equals(fileKey), "refusing to terminate a replacement socket inode"); + private void killOwnedServer() { + long pid = process.pid(); + String stale = "libtmux-junit5-stale-owner-" + pid; + CommandResult result = server.cmd("if-shell", "-F", "#{==:#{pid}," + pid + "}", "kill-server", stale); + if (!result.succeeded() && result.stderr().stream().anyMatch(line -> line.contains(stale))) { + throw new AssertionError("refusing to terminate a replacement tmux process at " + socket); + } } private void reclaimSocket() throws IOException { diff --git a/libtmux-junit5/src/test/java/io/github/libtmux/junit5/NamedServerFixtureTest.java b/libtmux-junit5/src/test/java/io/github/libtmux/junit5/NamedServerFixtureTest.java index 636f1d3..ffb5d48 100644 --- a/libtmux-junit5/src/test/java/io/github/libtmux/junit5/NamedServerFixtureTest.java +++ b/libtmux-junit5/src/test/java/io/github/libtmux/junit5/NamedServerFixtureTest.java @@ -3,7 +3,6 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTimeoutPreemptively; import static org.junit.jupiter.api.Assertions.assertTrue; import io.github.libtmux.Server; @@ -110,14 +109,73 @@ void aLiveReplacementServerSurvivesStaleFixtureCleanup(@TempDir Path directory) try (Server replacement = openNamed(name, directory)) { replacement.newSession("replacement"); - try (NamedServerFixture replacementFixture = NamedServerFixture.own(replacement, name, quarantine())) { + ProcessHandle replacementProcess = reportedProcess(replacement); + NamedServerFixture replacementFixture = NamedServerFixture.own(replacement, name, quarantine()); + try { assertEquals(socket, replacementFixture.socket()); - AssertionError refused = assertTimeoutPreemptively( - Duration.ofSeconds(2), () -> assertThrows(AssertionError.class, originalFixture::close)); + AssertionError refused = assertThrows(AssertionError.class, originalFixture::close); assertTrue(String.valueOf(refused.getMessage()).contains("replacement")); assertTrue(replacement.hasSession("replacement")); + } finally { + replacementFixture.close(); } + assertFalse(replacementProcess.isAlive(), "replacement cleanup did not end its process"); + assertFalse(Files.exists(socket), "replacement cleanup left its socket"); + } + } + } + + @Test + void replacementBetweenIdentityReadAndKillSurvives(@TempDir Path directory) throws Exception { + String suffix = Long.toString(ProcessHandle.current().pid()); + String originalName = "ltj-race-original-" + suffix; + String replacementName = "ltj-race-replacement-" + suffix; + + try (Server original = openNamed(originalName, directory); + Server replacement = openNamed(replacementName, directory)) { + original.newSession("race-original"); + replacement.newSession("race-replacement"); + NamedServerFixture originalCleanup = NamedServerFixture.own(original, originalName, quarantine()); + try { + NamedServerFixture replacementCleanup = + NamedServerFixture.own(replacement, replacementName, quarantine()); + try { + List requests = new ArrayList<>(); + int[] identityReads = {0}; + TmuxTransport switching = new TmuxTransport() { + @Override + public CommandResult execute(CommandRequest request) { + requests.add(request); + List command = request.commands().getFirst(); + Server destination = command.getFirst().equals("display-message") && identityReads[0]++ < 2 + ? original + : replacement; + return destination.cmd(command); + } + + @Override + public void close() {} + }; + + try (Server routed = Server.using(original.config(), switching)) { + NamedServerFixture stale = NamedServerFixture.own(routed, originalName, quarantine()); + AssertionError refused = assertThrows(AssertionError.class, stale::close); + + assertTrue(String.valueOf(refused.getMessage()).contains("replacement")); + assertTrue(replacement.hasSession("race-replacement")); + assertEquals( + List.of("display-message", "if-shell"), + requests.stream() + .map(request -> + request.commands().getFirst().getFirst()) + .toList()); + } + } finally { + replacementCleanup.close(); + } + } finally { + originalCleanup.close(); } } } From 4a59399f00e75d26da76c665a797558271723a66 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 5 Sep 2026 16:23:09 -0500 Subject: [PATCH 29/65] Scripts(feat[mcp-swap]): Add remaining clients why: OpenCode and Pi were absent, and a late preflight failure could leave earlier client configs partially swapped. what: - add format-preserving OpenCode and Pi adapters plus the antigravity-to-agy alias - render and validate every selected config before the first write - test isolated per-client and combined swap/revert behavior and document the eight-client surface --- scripts/README.md | 11 +- scripts/mcp_swap.py | 505 +++++++++++++++++++++++++++++++++++---- scripts/test_mcp_swap.py | 295 +++++++++++++++++++++++ 3 files changed, 762 insertions(+), 49 deletions(-) create mode 100644 scripts/test_mcp_swap.py diff --git a/scripts/README.md b/scripts/README.md index 63c34f1..5efa3a4 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -66,6 +66,12 @@ you want. The retired `--safety` and `--watch` swapper arguments are rejected; the [module README](../libtmux-mcp/README.md#run-it) maps their replacements. +The default covers Claude, Codex, Cursor, Gemini, Grok, `agy`, OpenCode, and +Pi. Repeat `--cli` to limit a command; `antigravity` is accepted as an alias +for the canonical `agy` name. OpenCode uses its global `opencode.jsonc` file. +Pi uses the `pi-mcp-adapter` config because Pi has no built-in MCP client; +`detect` and `doctor` report when that adapter is absent. + Put them back: ```console @@ -74,8 +80,9 @@ $ uv run scripts/mcp_swap.py revert It rewrites **global** configs only, touches only the one server entry named by `--name` (default `tmux`), and keeps everything else in the file — including -comments in TOML. The backup is taken once, so swapping something already swapped -still reverts to the config that was there before any of it started. +comments and trailing commas in JSONC, and comments in TOML. The backup is taken +once, so swapping something already swapped still reverts to the config that was +there before any of it started. To try it without changing anything at all, most CLIs take a config per invocation instead — `claude --mcp-config --strict-mcp-config`, or diff --git a/scripts/mcp_swap.py b/scripts/mcp_swap.py index ea3053f..ddd9858 100755 --- a/scripts/mcp_swap.py +++ b/scripts/mcp_swap.py @@ -56,7 +56,7 @@ own machine, not to a repository. - **One server name.** Only the entry named by ``--name`` (default ``tmux``) is touched. Everything else in the file is preserved, - including comments in TOML. + including comments in TOML and JSONC. - **A backup per file, once.** Written beside the original as ``.mcp-swap-backup``. ``revert`` moves it back. """ @@ -65,10 +65,13 @@ import argparse import json +import os import pathlib import shutil +import stat import subprocess import sys +import tempfile import typing as t import tomlkit @@ -76,7 +79,9 @@ REPO = pathlib.Path(__file__).resolve().parent.parent #: What the launcher is called once ``installDist`` has written it. -DIST_LAUNCHER = REPO / "libtmux-mcp" / "build" / "install" / "libtmux-mcp" / "bin" / "libtmux-mcp" +DIST_LAUNCHER = ( + REPO / "libtmux-mcp" / "build" / "install" / "libtmux-mcp" / "bin" / "libtmux-mcp" +) BACKUP_SUFFIX = ".mcp-swap-backup" @@ -88,7 +93,7 @@ class Layer(t.NamedTuple): path: pathlib.Path #: Where the servers live: a path of keys from the document root. at: tuple[str, ...] - #: ``json`` or ``toml``. TOML is edited through tomlkit so comments survive. + #: ``json``, ``jsonc``, or ``toml``. format: str def exists(self) -> bool: @@ -97,25 +102,355 @@ def exists(self) -> bool: LAYERS = ( Layer("claude", pathlib.Path.home() / ".claude.json", ("mcpServers",), "json"), - Layer("codex", pathlib.Path.home() / ".codex" / "config.toml", ("mcp_servers",), "toml"), - Layer("cursor", pathlib.Path.home() / ".cursor" / "mcp.json", ("mcpServers",), "json"), - Layer("gemini", pathlib.Path.home() / ".gemini" / "settings.json", ("mcpServers",), "json"), - Layer("agy", pathlib.Path.home() / ".gemini" / "config" / "mcp_config.json", ("mcpServers",), "json"), - Layer("grok", pathlib.Path.home() / ".grok" / "config.toml", ("mcp_servers",), "toml"), + Layer( + "codex", + pathlib.Path.home() / ".codex" / "config.toml", + ("mcp_servers",), + "toml", + ), + Layer( + "cursor", pathlib.Path.home() / ".cursor" / "mcp.json", ("mcpServers",), "json" + ), + Layer( + "gemini", + pathlib.Path.home() / ".gemini" / "settings.json", + ("mcpServers",), + "json", + ), + Layer( + "grok", pathlib.Path.home() / ".grok" / "config.toml", ("mcp_servers",), "toml" + ), + Layer( + "agy", + pathlib.Path.home() / ".gemini" / "config" / "mcp_config.json", + ("mcpServers",), + "json", + ), + Layer( + "opencode", + pathlib.Path( + os.environ.get("XDG_CONFIG_HOME") or pathlib.Path.home() / ".config" + ) + / "opencode" + / "opencode.jsonc", + ("mcp",), + "jsonc", + ), + Layer( + "pi", + pathlib.Path.home() / ".pi" / "agent" / "mcp.json", + ("mcpServers",), + "jsonc", + ), +) + +CLI_ALIASES = {"antigravity": "agy"} +CLI_COLUMN = max(len(layer.cli) for layer in LAYERS) + 1 + +# Pi itself has no MCP client. This extension reads the config above. +PI_ADAPTER_DIR = ( + pathlib.Path.home() / ".pi" / "agent" / "npm" / "node_modules" / "pi-mcp-adapter" ) +PI_ADAPTER_HINT = "needs the pi-mcp-adapter package; pi has no built-in MCP client" + + +# ------------------------------------------------------------------ JSONC + + +_JSON_WS = " \t\n\r" + + +def _jsonc_blank_comments(text: str) -> str: + """Blank comments without moving offsets used by the edit scanner.""" + out = list(text) + i = 0 + in_string = False + while i < len(text): + char = text[i] + if in_string: + if char == "\\": + i += 2 + continue + if char == '"': + in_string = False + i += 1 + elif char == '"': + in_string = True + i += 1 + elif char == "/" and i + 1 < len(text) and text[i + 1] == "/": + while i < len(text) and text[i] != "\n": + out[i] = " " + i += 1 + elif char == "/" and i + 1 < len(text) and text[i + 1] == "*": + end = text.find("*/", i + 2) + end = len(text) if end == -1 else end + 2 + for index in range(i, end): + if out[index] != "\n": + out[index] = " " + i = end + else: + i += 1 + return "".join(out) + + +def _jsonc_blank_trailing_commas(text: str) -> str: + """Blank trailing commas so the standard JSON decoder can parse JSONC.""" + out = list(text) + i = 0 + in_string = False + last_comma = -1 + while i < len(text): + char = text[i] + if in_string: + if char == "\\": + i += 2 + continue + if char == '"': + in_string = False + i += 1 + continue + if char == '"': + in_string = True + last_comma = -1 + elif char == ",": + last_comma = i + elif char in "}]": + if last_comma != -1: + out[last_comma] = " " + last_comma = -1 + elif char not in _JSON_WS: + last_comma = -1 + i += 1 + return "".join(out) + + +def _jsonc_loads(text: str) -> t.Any: + if not text.strip(): + return {} + return json.loads(_jsonc_blank_trailing_commas(_jsonc_blank_comments(text))) + + +class _JsoncMember(t.NamedTuple): + key: str + start: int + end: int + value_start: int + value_end: int + + +class _JsoncScanner: + """Locate value spans in comment-blanked JSON text.""" + + def __init__(self, text: str) -> None: + self.text = text + self.pos = 0 + + def skip_ws(self) -> None: + while self.pos < len(self.text) and self.text[self.pos] in _JSON_WS: + self.pos += 1 + + def read_string(self) -> str: + start = self.pos + self.pos += 1 + while self.pos < len(self.text): + char = self.text[self.pos] + if char == "\\": + self.pos += 2 + continue + self.pos += 1 + if char == '"': + break + return self.text[start : self.pos] + + def read_value(self) -> tuple[int, int]: + self.skip_ws() + start = self.pos + char = self.text[self.pos] + if char == '"': + self.read_string() + elif char in "{[": + self._read_container() + else: + while ( + self.pos < len(self.text) + and self.text[self.pos] not in ",}]" + and self.text[self.pos] not in _JSON_WS + ): + self.pos += 1 + return start, self.pos + + def _read_container(self) -> None: + self.pos += 1 + depth = 1 + while self.pos < len(self.text) and depth: + char = self.text[self.pos] + if char == '"': + self.read_string() + continue + if char in "{[": + depth += 1 + elif char in "}]": + depth -= 1 + self.pos += 1 + + def read_members(self, start: int) -> list[_JsoncMember]: + self.pos = start + 1 + found: list[_JsoncMember] = [] + while True: + self.skip_ws() + if self.pos >= len(self.text) or self.text[self.pos] == "}": + return found + if self.text[self.pos] == ",": + self.pos += 1 + continue + member_start = self.pos + raw_key = self.read_string() + self.skip_ws() + self.pos += 1 + value_start, value_end = self.read_value() + found.append( + _JsoncMember( + json.loads(raw_key), + member_start, + value_end, + value_start, + value_end, + ) + ) + + +def _jsonc_object_span(text: str, path: tuple[str, ...]) -> tuple[int, int] | None: + scanner = _JsoncScanner(text) + scanner.skip_ws() + if scanner.pos >= len(text) or text[scanner.pos] != "{": + return None + cursor = scanner.pos + for key in path: + match = next( + ( + member + for member in _JsoncScanner(text).read_members(cursor) + if member.key == key + ), + None, + ) + if match is None or text[match.value_start] != "{": + return None + cursor = match.value_start + tail = _JsoncScanner(text) + tail.pos = cursor + return tail.read_value() + + +def _jsonc_render(value: t.Any, depth: int) -> str: + rendered = json.dumps(value, indent=2, ensure_ascii=False) + return rendered.replace("\n", "\n" + " " * depth) + + +def _jsonc_next_edit( + text: str, data: t.Mapping[str, t.Any], path: tuple[str, ...] +) -> tuple[int, int, str] | None: + blanked = _jsonc_blank_comments(text) + span = _jsonc_object_span(blanked, path) + if span is None: + return None + object_start, object_end = span + members = _JsoncScanner(blanked).read_members(object_start) + by_key = {member.key: member for member in members} + depth = len(path) + 1 + pad = " " * depth + + for key, value in data.items(): + member = by_key.get(key) + if member is None: + body = _jsonc_render(value, depth) + name = json.dumps(key, ensure_ascii=False) + if members: + tail = members[-1].end + return tail, tail, f",\n{pad}{name}: {body}" + if blanked[object_start + 1 : object_end - 1].strip(): + return None + interior = text[object_start + 1 : object_end - 1] + anchor = object_start + 1 + len(interior.rstrip()) + closing = " " * (depth - 1) + return anchor, object_end - 1, f"\n{pad}{name}: {body}\n{closing}" + current = json.loads( + _jsonc_blank_trailing_commas(blanked[member.value_start : member.value_end]) + ) + if isinstance(value, dict) and isinstance(current, dict): + nested = _jsonc_next_edit(text, value, (*path, key)) + if nested is not None: + return nested + elif current != value: + return member.value_start, member.value_end, _jsonc_render(value, depth) + + for index, member in enumerate(members): + if member.key in data: + continue + if index: + return members[index - 1].end, member.end, "" + trailing = blanked[member.end : object_end] + drop_to = member.end + if trailing.lstrip(_JSON_WS).startswith(","): + drop_to += trailing.index(",") + 1 + return object_start + 1, drop_to, "" + return None + + +def _jsonc_merge(text: str, data: t.Mapping[str, t.Any]) -> str: + """Reconcile data through text splices, preserving untouched JSONC bytes.""" + if not text.strip(): + return json.dumps(dict(data), indent=2, ensure_ascii=False) + "\n" + for _ in range(10_000): + edit = _jsonc_next_edit(text, data, ()) + if edit is None: + return text + start, end, replacement = edit + text = text[:start] + replacement + text[end:] + raise RuntimeError("JSONC merge did not converge") # ------------------------------------------------------------------ reading and writing +def parse(layer: Layer, raw: bytes) -> t.Any: + text = raw.decode("utf-8") + if layer.format == "toml": + return tomlkit.parse(text) + if layer.format == "jsonc": + return _jsonc_loads(text) + return json.loads(text) + + def load(layer: Layer) -> t.Any: - text = layer.path.read_text(encoding="utf-8") - return tomlkit.parse(text) if layer.format == "toml" else json.loads(text) + return parse(layer, layer.path.read_bytes()) -def save(layer: Layer, document: t.Any) -> None: - text = tomlkit.dumps(document) if layer.format == "toml" else json.dumps(document, indent=2) + "\n" - layer.path.write_text(text, encoding="utf-8") +def render(layer: Layer, document: t.Any, original: bytes) -> bytes: + if layer.format == "toml": + return tomlkit.dumps(document).encode("utf-8") + if layer.format == "jsonc": + return _jsonc_merge(original.decode("utf-8"), document).encode("utf-8") + return (json.dumps(document, indent=2, ensure_ascii=False) + "\n").encode("utf-8") + + +def save(layer: Layer, data: bytes) -> None: + """Replace config bytes atomically while retaining mode and symlinks.""" + target = layer.path.resolve() if layer.path.is_symlink() else layer.path + mode = stat.S_IMODE(target.stat().st_mode) if target.exists() else None + descriptor, temporary_name = tempfile.mkstemp( + prefix=target.name + ".", dir=str(target.parent) + ) + temporary = pathlib.Path(temporary_name) + try: + with os.fdopen(descriptor, "wb") as stream: + if mode is not None: + os.fchmod(stream.fileno(), mode) + stream.write(data) + temporary.replace(target) + except Exception: + temporary.unlink(missing_ok=True) + raise def servers(layer: Layer, document: t.Any, *, create: bool = False) -> t.Any: @@ -134,6 +469,19 @@ def backup_of(layer: Layer) -> pathlib.Path: return layer.path.with_name(layer.path.name + BACKUP_SUFFIX) +def entry_for(layer: Layer, command: str, arguments: list[str]) -> dict[str, t.Any]: + if layer.cli == "opencode": + return {"type": "local", "command": [command, *arguments]} + return {"command": command, "args": arguments} + + +class Prepared(t.NamedTuple): + layer: Layer + original: bytes + output: bytes + entry: dict[str, t.Any] + + # ------------------------------------------------------------------ what to point at @@ -144,12 +492,15 @@ def launcher(args: argparse.Namespace) -> tuple[str, list[str]]: raise SystemExit("--source path needs --bin") command, prefix = args.bin, [] elif args.source == "gradle": - command, prefix = str(REPO / "gradlew"), [ - "--quiet", - "--console=plain", - ":libtmux-mcp:run", - "--args", - ] + command, prefix = ( + str(REPO / "gradlew"), + [ + "--quiet", + "--console=plain", + ":libtmux-mcp:run", + "--args", + ], + ) else: command, prefix = str(DIST_LAUNCHER), [] @@ -173,7 +524,12 @@ def build(args: argparse.Namespace) -> None: return print("building :libtmux-mcp:installDist ...", file=sys.stderr) subprocess.run( - [str(REPO / "gradlew"), "--quiet", "--console=plain", ":libtmux-mcp:installDist"], + [ + str(REPO / "gradlew"), + "--quiet", + "--console=plain", + ":libtmux-mcp:installDist", + ], cwd=REPO, check=True, ) @@ -188,7 +544,10 @@ def cmd_detect(args: argparse.Namespace) -> int: for layer in LAYERS: state = "present" if layer.exists() else "missing" swapped = " (swapped)" if backup_of(layer).is_file() else "" - print(f"{layer.cli:<8} {state:<8} {layer.path}{swapped}") + caveat = "" + if layer.cli == "pi" and not PI_ADAPTER_DIR.is_dir(): + caveat = f" -- {PI_ADAPTER_HINT}" + print(f"{layer.cli:<{CLI_COLUMN}} {state:<8} {layer.path}{swapped}{caveat}") return 0 @@ -202,36 +561,60 @@ def cmd_status(args: argparse.Namespace) -> int: print(f"{layer.cli:<8} unreadable: {error}") continue if entry is None: - print(f"{layer.cli:<8} no '{args.name}' server") + print(f"{layer.cli:<{CLI_COLUMN}} no '{args.name}' server") continue command = entry.get("command", "?") - rest = " ".join(str(word) for word in entry.get("args", [])) - print(f"{layer.cli:<8} {command} {rest}".rstrip()) + if layer.cli == "opencode" and isinstance(command, list): + command, *arguments = command + else: + arguments = entry.get("args", []) + rest = " ".join(str(word) for word in arguments) + print(f"{layer.cli:<{CLI_COLUMN}} {command} {rest}".rstrip()) return 0 def cmd_use(args: argparse.Namespace) -> int: - build(args) command, arguments = launcher(args) - print(f"pointing '{args.name}' at: {command} {' '.join(arguments)}".rstrip(), file=sys.stderr) - + prepared: list[Prepared] = [] for layer in chosen(args): if not layer.exists(): - print(f"{layer.cli:<8} skipped, no config") + print(f"{layer.cli:<{CLI_COLUMN}} skipped, no config") continue - document = load(layer) - entry = {"command": command, "args": arguments} + try: + original = layer.path.read_bytes() + document = parse(layer, original) + entry = entry_for(layer, command, arguments) + into = servers(layer, document, create=True) + into[args.name] = entry + output = render(layer, document, original) + except Exception as error: + raise SystemExit(f"{layer.cli} config is unreadable: {error}") from error + prepared.append(Prepared(layer, original, output, entry)) + + build(args) + print( + f"pointing '{args.name}' at: {command} {' '.join(arguments)}".rstrip(), + file=sys.stderr, + ) + if not args.dry_run: + for item in prepared: + if item.layer.path.read_bytes() != item.original: + raise SystemExit( + f"{item.layer.cli} config changed during preflight; nothing written" + ) + for item in prepared: + layer = item.layer if args.dry_run: - print(f"{layer.cli:<8} would set {args.name} = {json.dumps(entry)}") + print( + f"{layer.cli:<{CLI_COLUMN}} would set {args.name} = {json.dumps(item.entry)}" + ) continue # Taken once. Swapping something already swapped must still revert to # the config that was there before any of this started. if not backup_of(layer).is_file(): shutil.copy2(layer.path, backup_of(layer)) - into = servers(layer, document, create=True) - into[args.name] = entry - save(layer, document) - print(f"{layer.cli:<8} set {args.name}") + save(layer, item.output) + print(f"{layer.cli:<{CLI_COLUMN}} set {args.name}") return 0 @@ -239,13 +622,13 @@ def cmd_revert(args: argparse.Namespace) -> int: for layer in chosen(args): backup = backup_of(layer) if not backup.is_file(): - print(f"{layer.cli:<8} nothing to revert") + print(f"{layer.cli:<{CLI_COLUMN}} nothing to revert") continue if args.dry_run: - print(f"{layer.cli:<8} would restore {backup}") + print(f"{layer.cli:<{CLI_COLUMN}} would restore {backup}") continue shutil.move(str(backup), str(layer.path)) - print(f"{layer.cli:<8} restored") + print(f"{layer.cli:<{CLI_COLUMN}} restored") return 0 @@ -255,7 +638,9 @@ def cmd_doctor(args: argparse.Namespace) -> int: print("no gradlew: is this the repository root?") ok = False if args.source == "dist" and not DIST_LAUNCHER.is_file(): - print(f"no launcher at {DIST_LAUNCHER}; run './gradlew :libtmux-mcp:installDist'") + print( + f"no launcher at {DIST_LAUNCHER}; run './gradlew :libtmux-mcp:installDist'" + ) ok = False for layer in LAYERS: if not layer.exists(): @@ -263,8 +648,14 @@ def cmd_doctor(args: argparse.Namespace) -> int: try: load(layer) except Exception as error: # noqa: BLE001 - a broken config is what this reports - print(f"{layer.cli:<8} will not parse: {error}") + print(f"{layer.cli:<{CLI_COLUMN}} will not parse: {error}") ok = False + if ( + next(layer for layer in LAYERS if layer.cli == "pi").exists() + and not PI_ADAPTER_DIR.is_dir() + ): + print(f"pi{'':<{CLI_COLUMN - 2}} {PI_ADAPTER_HINT}") + ok = False print("ready" if ok else "not ready") return 0 if ok else 1 @@ -272,7 +663,7 @@ def cmd_doctor(args: argparse.Namespace) -> int: def chosen(args: argparse.Namespace) -> tuple[Layer, ...]: if not args.cli: return LAYERS - wanted = set(args.cli) + wanted = {CLI_ALIASES.get(cli, cli) for cli in args.cli} return tuple(layer for layer in LAYERS if layer.cli in wanted) @@ -280,13 +671,28 @@ def chosen(args: argparse.Namespace) -> tuple[Layer, ...]: def build_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser(prog="mcp_swap", description=__doc__.splitlines()[0]) + parser = argparse.ArgumentParser( + prog="mcp_swap", description=__doc__.splitlines()[0] + ) commands = parser.add_subparsers(dest="command", required=True) def shared(sub: argparse.ArgumentParser) -> None: - sub.add_argument("--name", default="tmux", help="the MCP server name to write (default: tmux)") - sub.add_argument("--cli", action="append", choices=[layer.cli for layer in LAYERS]) - sub.add_argument("--dry-run", action="store_true", help="say what would change, change nothing") + sub.add_argument( + "--name", + default="tmux", + help="the MCP server name to write (default: tmux)", + ) + sub.add_argument( + "--cli", + action="append", + choices=[*(layer.cli for layer in LAYERS), *CLI_ALIASES], + help="limit the swap; antigravity is an alias for agy", + ) + sub.add_argument( + "--dry-run", + action="store_true", + help="say what would change, change nothing", + ) detect = commands.add_parser("detect", help="which agent CLIs have a config here") detect.set_defaults(run=cmd_detect) @@ -315,8 +721,13 @@ def shared(sub: argparse.ArgumentParser) -> None: return parser -def main() -> int: - args = build_parser().parse_args() +def main(argv: list[str] | None = None) -> int: + parser = build_parser() + arguments = sys.argv[1:] if argv is None else argv + if not arguments: + parser.print_help() + return 0 + args = parser.parse_args(arguments) return int(args.run(args)) diff --git a/scripts/test_mcp_swap.py b/scripts/test_mcp_swap.py new file mode 100644 index 0000000..adef387 --- /dev/null +++ b/scripts/test_mcp_swap.py @@ -0,0 +1,295 @@ +from __future__ import annotations + +import importlib.util +import json +import pathlib +import re +import stat +import sys +import types + +import pytest +import tomllib + +SCRIPT = pathlib.Path(__file__).with_name("mcp_swap.py") +CANONICAL_CLIS = ( + "claude", + "codex", + "cursor", + "gemini", + "grok", + "agy", + "opencode", + "pi", +) +LAUNCHER = "/opt/libtmux-java/bin/libtmux-mcp" + + +@pytest.fixture +def swapper( + monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path +) -> types.ModuleType: + home = tmp_path / "home" + home.mkdir() + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("XDG_CONFIG_HOME", str(home / ".config")) + name = f"mcp_swap_test_{tmp_path.name}" + spec = importlib.util.spec_from_file_location(name, SCRIPT) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.modules[name] = module + spec.loader.exec_module(module) + return module + + +def _jsonc_loads(text: str) -> object: + without_comments = re.sub(r"(?m)^[ \t]*//[^\n]*(?:\n|$)", "", text) + without_comments = re.sub(r"/\*.*?\*/", "", without_comments, flags=re.DOTALL) + without_trailing_commas = re.sub(r",(?=\s*[}\]])", "", without_comments) + return json.loads(without_trailing_commas) + + +def _seed_configs(swapper: types.ModuleType) -> dict[str, bytes]: + originals: dict[str, bytes] = {} + for layer in swapper.LAYERS: + layer.path.parent.mkdir(parents=True, exist_ok=True) + if layer.format == "toml": + raw = b'title = "keep"\n' + elif layer.cli == "opencode": + raw = ( + b"{\n" + b" // root comment stays\n" + b' "$schema": "https://opencode.ai/config.json",\n' + b' "unrelated": {"keep": true},\n' + b"}\n" + ) + elif layer.cli == "pi": + raw = ( + b"{\n" + b" // pi-mcp-adapter accepts JSONC\n" + b' "unrelated": {"keep": true},\n' + b' "mcpServers": {},\n' + b"}\n" + ) + else: + raw = b'{\n "unrelated": {"keep": true}\n}\n' + layer.path.write_bytes(raw) + layer.path.chmod(0o640) + originals[layer.cli] = raw + return originals + + +def _document(layer: object) -> dict[str, object]: + text = layer.path.read_text(encoding="utf-8") + if layer.format == "toml": + return tomllib.loads(text) + if layer.format == "jsonc": + parsed = _jsonc_loads(text) + assert isinstance(parsed, dict) + return parsed + parsed = json.loads(text) + assert isinstance(parsed, dict) + return parsed + + +def _assert_swapped(layer: object) -> None: + document = _document(layer) + entry = document[layer.at[0]]["tmux"] + if layer.cli == "opencode": + assert entry == { + "type": "local", + "command": [LAUNCHER, "--socket", "/tmp/libtmux-java-dev/test/s"], + } + else: + assert entry == { + "command": LAUNCHER, + "args": ["--socket", "/tmp/libtmux-java-dev/test/s"], + } + + +def _use_args(*extra: str) -> list[str]: + return [ + "use", + "--source", + "path", + "--bin", + LAUNCHER, + "--socket", + "/tmp/libtmux-java-dev/test/s", + *extra, + ] + + +@pytest.mark.parametrize("cli", CANONICAL_CLIS) +def test_each_client_swaps_and_restores_in_isolation( + swapper: types.ModuleType, cli: str +) -> None: + """Selecting one client must not touch any other client's config.""" + originals = _seed_configs(swapper) + + assert swapper.main(_use_args("--cli", cli)) == 0 + selected = next(layer for layer in swapper.LAYERS if layer.cli == cli) + _assert_swapped(selected) + assert stat.S_IMODE(selected.path.stat().st_mode) == 0o640 + assert swapper.backup_of(selected).read_bytes() == originals[cli] + for layer in swapper.LAYERS: + if layer.cli != cli: + assert layer.path.read_bytes() == originals[layer.cli] + assert not swapper.backup_of(layer).exists() + + assert swapper.main(["revert", "--cli", cli]) == 0 + assert selected.path.read_bytes() == originals[cli] + assert stat.S_IMODE(selected.path.stat().st_mode) == 0o640 + assert not swapper.backup_of(selected).exists() + + +def test_all_eight_clients_commit_only_after_full_preflight( + swapper: types.ModuleType, +) -> None: + """The default selection swaps and byte-restores all eight clients.""" + originals = _seed_configs(swapper) + + assert tuple(layer.cli for layer in swapper.LAYERS) == CANONICAL_CLIS + assert swapper.main(_use_args()) == 0 + for layer in swapper.LAYERS: + _assert_swapped(layer) + assert stat.S_IMODE(layer.path.stat().st_mode) == 0o640 + assert swapper.backup_of(layer).read_bytes() == originals[layer.cli] + + assert swapper.main(["revert"]) == 0 + for layer in swapper.LAYERS: + assert layer.path.read_bytes() == originals[layer.cli] + assert stat.S_IMODE(layer.path.stat().st_mode) == 0o640 + assert not swapper.backup_of(layer).exists() + + +def test_failed_late_config_preflight_writes_nothing( + swapper: types.ModuleType, +) -> None: + """A malformed final config must not leave earlier clients half-swapped.""" + originals = _seed_configs(swapper) + pi = next(layer for layer in swapper.LAYERS if layer.cli == "pi") + originals["pi"] = b"{ malformed\n" + pi.path.write_bytes(originals["pi"]) + + with pytest.raises(SystemExit, match="pi.*unreadable"): + swapper.main(_use_args()) + + for layer in swapper.LAYERS: + assert layer.path.read_bytes() == originals[layer.cli] + assert not swapper.backup_of(layer).exists() + + +def test_config_changed_after_render_preflight_writes_nothing( + swapper: types.ModuleType, monkeypatch: pytest.MonkeyPatch +) -> None: + """A concurrent late-config edit must stop before earlier writes begin.""" + originals = _seed_configs(swapper) + pi = next(layer for layer in swapper.LAYERS if layer.cli == "pi") + external = b'{"external": true}\n' + + def change_late_config(_args: object) -> None: + pi.path.write_bytes(external) + + monkeypatch.setattr(swapper, "build", change_late_config) + with pytest.raises(SystemExit, match="pi.*changed during preflight"): + swapper.main(_use_args()) + + for layer in swapper.LAYERS: + expected = external if layer.cli == "pi" else originals[layer.cli] + assert layer.path.read_bytes() == expected + assert not swapper.backup_of(layer).exists() + + +def test_opencode_missing_root_preserves_jsonc_bytes_and_mode( + swapper: types.ModuleType, +) -> None: + """Adding mcp leaves comments, trailing comma, and unrelated bytes alone.""" + _seed_configs(swapper) + layer = next(layer for layer in swapper.LAYERS if layer.cli == "opencode") + before = layer.path.read_text(encoding="utf-8") + + assert swapper.main(_use_args("--cli", "opencode")) == 0 + + after = layer.path.read_text(encoding="utf-8") + assert after.startswith(before[: before.index(",\n}")]) + assert after.endswith(",\n}\n") + assert "// root comment stays" in after + assert _document(layer)["unrelated"] == {"keep": True} + assert stat.S_IMODE(layer.path.stat().st_mode) == 0o640 + _assert_swapped(layer) + + +def test_opencode_replaces_entry_without_dropping_its_comment( + swapper: types.ModuleType, +) -> None: + """A rationale inside the replaced server entry survives the swap.""" + _seed_configs(swapper) + layer = next(layer for layer in swapper.LAYERS if layer.cli == "opencode") + layer.path.write_text( + "{\n" + ' "mcp": {\n' + ' "tmux": {\n' + ' "type": "local",\n' + " // pinned locally; keep this rationale\n" + ' "command": ["old", "server"],\n' + " },\n" + ' "other": {"type": "local", "command": ["echo", "keep"]},\n' + " },\n" + "}\n", + encoding="utf-8", + ) + + assert swapper.main(_use_args("--cli", "opencode")) == 0 + + after = layer.path.read_text(encoding="utf-8") + assert "// pinned locally; keep this rationale" in after + document = _document(layer) + assert document["mcp"]["other"]["command"] == ["echo", "keep"] + _assert_swapped(layer) + + +def test_pi_detect_explains_adapter_availability( + swapper: types.ModuleType, + tmp_path: pathlib.Path, + capsys: pytest.CaptureFixture[str], +) -> None: + """Pi is not reported usable when only its adapter config exists.""" + _seed_configs(swapper) + swapper.PI_ADAPTER_DIR = tmp_path / "missing-adapter" + + assert swapper.main(["detect"]) == 0 + assert swapper.PI_ADAPTER_HINT in capsys.readouterr().out + + swapper.PI_ADAPTER_DIR.mkdir() + assert swapper.main(["detect"]) == 0 + assert swapper.PI_ADAPTER_HINT not in capsys.readouterr().out + + +def test_antigravity_is_an_alias_for_canonical_agy( + swapper: types.ModuleType, +) -> None: + """The legacy name selects one agy layer and never appears as a ninth.""" + originals = _seed_configs(swapper) + + assert swapper.main(_use_args("--cli", "antigravity")) == 0 + + assert tuple(layer.cli for layer in swapper.LAYERS) == CANONICAL_CLIS + agy = next(layer for layer in swapper.LAYERS if layer.cli == "agy") + _assert_swapped(agy) + for layer in swapper.LAYERS: + if layer.cli != "agy": + assert layer.path.read_bytes() == originals[layer.cli] + + +def test_no_arguments_and_explicit_help_exit_zero( + swapper: types.ModuleType, capsys: pytest.CaptureFixture[str] +) -> None: + """Help is a successful query, including the convenient no-arg form.""" + assert swapper.main([]) == 0 + assert "usage: mcp_swap" in capsys.readouterr().out + + with pytest.raises(SystemExit) as stopped: + swapper.main(["--help"]) + assert stopped.value.code == 0 + assert "usage: mcp_swap" in capsys.readouterr().out From bd88007098662047f9663f2c8e71fe9c201a482c Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 5 Sep 2026 16:24:10 -0500 Subject: [PATCH 30/65] Docs(docs[changelog]): Cover MCP swap clients why: Document the expanded development swapper surface and its safety boundary. what: - record the eight clients, JSONC fidelity, Pi prerequisite, alias, and all-config preflight --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a7fe098..cc079e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,10 @@ production. ### Added +- **`mcp_swap.py` configures all eight supported agent clients.** OpenCode edits + preserve JSONC comments and trailing commas, Pi reports its adapter + prerequisite, and `antigravity` selects canonical `agy`; multi-client swaps + validate every config before writing. - **`NamedServerFixture` safely owns explicitly named test servers.** It binds teardown to the reported process, socket path, and inode, then fails closed if any of that identity changes before cleanup. From b87d4b552923b304f49daee96d999077ce1be0f9 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 5 Sep 2026 18:01:50 -0500 Subject: [PATCH 31/65] Mcp(fix[input]): Guard caller and attended panes why: Pane input could reach the caller or a pane visible to a terminal client. what: - Read pane and client state in one preflight snapshot - Guard every configured input member and both run preflights - Cover caller, attention, malformed metadata, and transitions --- docs/guide/mcp.md | 10 ++- libtmux-mcp/README.md | 11 +-- .../java/io/github/libtmux/mcp/Catalog.java | 8 +- .../io/github/libtmux/mcp/Instructions.java | 3 +- .../io/github/libtmux/mcp/Operations.java | 2 +- .../github/libtmux/mcp/PaneInputCohort.java | 89 ++++++++++++++++--- .../github/libtmux/mcp/RunningCommands.java | 6 +- .../java/io/github/libtmux/mcp/Typing.java | 4 +- .../libtmux/mcp/PaneInputCohortTest.java | 86 +++++++++++++++++- .../libtmux/mcp/RunningCommandsTest.java | 77 ++++++++++++++++ .../io/github/libtmux/mcp/TypingTest.java | 49 ++++++++++ 11 files changed, 313 insertions(+), 32 deletions(-) diff --git a/docs/guide/mcp.md b/docs/guide/mcp.md index cd840b5..165a16c 100644 --- a/docs/guide/mcp.md +++ b/docs/guide/mcp.md @@ -123,8 +123,10 @@ Key input follows tmux's effective `synchronize-panes` values: a source whose effective value is off receives input alone; a source whose value is on sends to all panes whose effective value is on. The window option supplies the inherited default, and a pane-level override can change an individual pane's value. One -modal or dead member refuses the whole configured key cohort before dispatch, -while paste-buffer input checks and targets only its requested pane. +modal, dead, caller, or attended member refuses the whole configured key cohort +before dispatch, while paste-buffer input checks and targets only its requested +pane. Each preflight reads the pane cohort and attached-client attention in one +observational snapshot; control-mode clients are not people watching a terminal. Framed commands refuse a synchronized cohort because their output, completion, and status describe one pane. They require one normal live shell at the initial @@ -231,8 +233,8 @@ the model's ability to act at all. tmux says which one in `TMUX_PANE`, but a pane id is only unique within a single server — so the socket is checked too, by resolving both paths, before that pane -is believed to be the caller's own. Unprovable means not the caller's: a wrong -"yes" disarms a guard, while a wrong "no" merely declines to help. +is believed to be the caller's own. Pane input fails closed when that relationship +is malformed or unprovable; read metadata does not claim an uncertain match. `list_panes` marks it as the caller. `kill_pane`, `kill_window`, and `kill_session` refuse it and its containers unless `confirm_self` is passed. diff --git a/libtmux-mcp/README.md b/libtmux-mcp/README.md index 82c1bb0..dc1bcab 100644 --- a/libtmux-mcp/README.md +++ b/libtmux-mcp/README.md @@ -192,11 +192,12 @@ scrollback, use `search_panes` to locate displayed text, and continue from a cursor with `capture_since` instead of entering or cancelling a person's mode. Key sends resolve the target's current effective synchronized cohort and refuse -the whole send when one configured recipient is modal or dead. Paste remains -target-only, while framed shell runs require one effective recipient at both -preflights. This observation is not atomic: membership can change before -dispatch, and `resolved_pane_ids` reports configured membership rather than -confirmed recipients or delivery. +the whole send when one configured recipient is modal, dead, the caller pane, +or displayed by a terminal client. Paste applies the same guard to its target +only, while framed shell runs require one guarded effective recipient at both +preflights. Each preflight reads pane and client state in one snapshot; it is +still an observation rather than a delivery receipt. `resolved_pane_ids` +reports configured membership rather than confirmed recipients or delivery. Batch rows retain the nested MCP envelope rather than flattening its text or structured content. The complete JSON-RPC response, including line framing, is diff --git a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Catalog.java b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Catalog.java index d0c62f2..e6dbc5f 100644 --- a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Catalog.java +++ b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Catalog.java @@ -775,7 +775,8 @@ private static void execute(List tools) { "run_shell_command", "Run a shell command", "Runs one authored command in a trusted pane shell and waits for singular framed output and " - + "completion. It refuses an effective cohort larger than one at either of two preflights. " + + "completion. Its two preflights refuse caller or attended panes and an effective cohort " + + "larger than one. " + "Pre-existing exact-client-path, trap, eval, or exit functions are outside the supported " + "boundary; marker display-message commands honor the trusted server's command aliases " + "and hooks.", @@ -803,7 +804,8 @@ private static void execute(List tools) { "send_keys", "Send keys", "Sends input to the target's configured effective synchronized cohort without waiting for output. " - + "Reports configured pane ids observed before dispatch, not delivery receipts.", + + "Every configured member must be live, nonmodal, and neither caller nor attended. Reports " + + "configured pane ids observed before dispatch, not delivery receipts.", EXECUTE, PANE_INPUT, effects(OBSERVE, CHANGE), @@ -841,7 +843,7 @@ private static void execute(List tools) { "paste_text", "Paste text", "Pastes one literal text block into one target pane through an ephemeral buffer; paste-buffer " - + "input does not fan out to synchronized peers.", + + "input does not fan out to synchronized peers. The target cannot be caller or attended.", EXECUTE, PANE_INPUT, effects(OBSERVE, CHANGE), diff --git a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Instructions.java b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Instructions.java index 71b0c24..39461c0 100644 --- a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Instructions.java +++ b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Instructions.java @@ -30,7 +30,8 @@ Do NOT use them for browser tabs, editor splits (VS Code, Neovim), desktop windo START HERE get_server_info identifies the pinned server. list_panes returns stable pane IDs and \ marks this process's pane when it runs inside the selected server. Direct teardown \ - tools guard that pane. This process cannot address objects outside its selected socket. + tools guard that pane. Pane input also refuses it and panes a terminal client is \ + currently displaying. This process cannot address objects outside its selected socket. WAIT, DO NOT POLL A command you wrote: run_shell_command. It sends, waits, and returns output with an exit status \ diff --git a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Operations.java b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Operations.java index c97f94c..70c788f 100644 --- a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Operations.java +++ b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Operations.java @@ -356,7 +356,7 @@ static Object sendKeysBatch(Call call) { List resolvedPaneIds = List.of(); try { Pane pane = Targets.pane(call.server(), paneId); - PaneInputCohort.Resolution cohort = PaneInputCohort.resolve(pane); + PaneInputCohort.Resolution cohort = PaneInputCohort.resolve(pane, call.caller()); resolvedPaneIds = cohort.configuredKeyRecipientIds(); List keys = strings(operation.get("keys"), "keys"); boolean literal = booleanValue(operation.get("literal"), false, "literal"); diff --git a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/PaneInputCohort.java b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/PaneInputCohort.java index 42c1f58..286f195 100644 --- a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/PaneInputCohort.java +++ b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/PaneInputCohort.java @@ -2,12 +2,17 @@ import io.github.libtmux.LibTmuxException; import io.github.libtmux.Pane; +import io.github.libtmux.PaneId; +import io.github.libtmux.batch.BatchResult; +import io.github.libtmux.batch.OperationResult; import io.github.libtmux.format.RowFormat; import io.github.libtmux.format.TmuxFormatException; import io.github.libtmux.transport.CommandResult; import java.util.LinkedHashMap; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; +import java.util.Set; /** One authoritative view of the panes tmux may receive input through. */ final class PaneInputCohort { @@ -17,25 +22,42 @@ final class PaneInputCohort { private static final RowFormat PANES = RowFormat.of("pane_id", "pane_synchronized", "pane_in_mode", "pane_dead", "pane_current_command"); - private static final String TERMINATOR = - PANES.template().substring(PANES.template().lastIndexOf('}') + 1); + private static final RowFormat CLIENTS = RowFormat.of("client_control_mode", "pane_id", "window_zoomed_flag"); private PaneInputCohort() {} static Resolution resolve(Pane source) { + return resolve(source, Caller.nowhere()); + } + + static Resolution resolve(Pane source, Caller caller) { + BatchResult snapshot = source.server() + .batch() + .add(List.of("list-panes", "-t", source.id().value(), "-F", PANES.template())) + .add(List.of("list-clients", "-F", CLIENTS.template())) + .run(); + if (snapshot.operations().size() != 2) { + throw new LibTmuxException("tmux returned an incomplete pane input snapshot"); + } return parse( source.id().value(), - source.server().cmd(List.of("list-panes", "-t", source.id().value(), "-F", PANES.template()))); + result(snapshot.operations().get(0)), + result(snapshot.operations().get(1)), + caller); } static Resolution parse(String sourcePaneId, CommandResult answer) { + return parse(sourcePaneId, answer, new CommandResult(0, List.of(), List.of()), Caller.nowhere()); + } + + static Resolution parse(String sourcePaneId, CommandResult answer, CommandResult clientAnswer, Caller caller) { if (!answer.succeeded()) { throw new LibTmuxException("tmux could not resolve pane input state"); } if (answer.stdout().isEmpty()) { throw new LibTmuxException("tmux returned no pane input state for " + sourcePaneId); } - int terminators = validateFraming(answer.stdout()); + int terminators = validateFraming(PANES, answer.stdout()); List rows = PANES.rows(answer.stdout()); if (rows.size() != terminators) { throw new TmuxFormatException("tmux returned an incomplete pane input listing"); @@ -58,18 +80,53 @@ static Resolution parse(String sourcePaneId, CommandResult answer) { .sorted(java.util.Comparator.comparing(Member::paneId)) .toList() : List.of(source); - return new Resolution(source, recipients); + Set attended = attended(clientAnswer, members); + return new Resolution(source, recipients, caller, attended); + } + + private static CommandResult result(OperationResult operation) { + return new CommandResult(operation.succeeded() ? 0 : 1, operation.stdout(), operation.stderr()); } - private static int validateFraming(List lines) { + private static Set attended(CommandResult answer, Map members) { + if (!answer.succeeded()) { + throw new LibTmuxException("tmux could not resolve client attention state"); + } + if (answer.stdout().isEmpty()) { + return Set.of(); + } + int terminators = validateFraming(CLIENTS, answer.stdout()); + List rows = CLIENTS.rows(answer.stdout()); + if (rows.size() != terminators) { + throw new TmuxFormatException("tmux returned an incomplete client attention listing"); + } + Set attended = new LinkedHashSet<>(); + for (RowFormat.Row row : rows) { + boolean controlMode = row.flag("client_control_mode"); + String activePane = paneId(row.text("pane_id")); + boolean zoomed = row.flag("window_zoomed_flag"); + if (controlMode || !members.containsKey(activePane)) { + continue; + } + if (zoomed) { + attended.add(activePane); + } else { + attended.addAll(members.keySet()); + } + } + return Set.copyOf(attended); + } + + private static int validateFraming(RowFormat format, List lines) { + String terminator = format.template().substring(format.template().lastIndexOf('}') + 1); int closed = 0; for (String line : lines) { - int marker = line.indexOf(TERMINATOR); + int marker = line.indexOf(terminator); if (marker < 0) { continue; } - if (marker + TERMINATOR.length() != line.length() - || line.indexOf(TERMINATOR, marker + TERMINATOR.length()) >= 0) { + if (marker + terminator.length() != line.length() + || line.indexOf(terminator, marker + terminator.length()) >= 0) { throw new TmuxFormatException("tmux returned a malformed pane input row terminator"); } closed++; @@ -124,10 +181,11 @@ boolean writable() { } } - record Resolution(Member source, List keyRecipients) { + record Resolution(Member source, List keyRecipients, Caller caller, Set attendedPaneIds) { Resolution { keyRecipients = List.copyOf(keyRecipients); + attendedPaneIds = Set.copyOf(attendedPaneIds); } List configuredKeyRecipientIds() { @@ -152,7 +210,16 @@ String requireSingularCommandPane(String operation) { return source.currentCommand(); } - private static void requireWritable(String operation, Member member) { + private void requireWritable(String operation, Member member) { + if (caller.uncertain()) { + throw new IllegalStateException(operation + " refuses input while caller identity is unavailable"); + } + if (caller.isSelf(new PaneId(member.paneId()))) { + throw new IllegalStateException(operation + " refuses caller pane " + member.paneId()); + } + if (attendedPaneIds.contains(member.paneId())) { + throw new IllegalStateException(operation + " refuses attended pane " + member.paneId()); + } if (member.dead()) { throw new IllegalStateException(operation + " refuses dead pane " + member.paneId()); } diff --git a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/RunningCommands.java b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/RunningCommands.java index 80e7fd9..a679958 100644 --- a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/RunningCommands.java +++ b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/RunningCommands.java @@ -75,7 +75,8 @@ static Ran run(Call call) { String command = call.string("command"); Duration timeout = Waits.requested(call); boolean suppressHistory = call.flag("suppress_history", true); - String currentCommand = PaneInputCohort.resolve(pane).requireSingularCommandPane("run_shell_command"); + String currentCommand = + PaneInputCohort.resolve(pane, call.caller()).requireSingularCommandPane("run_shell_command"); requirePosixShell(currentCommand); PaneCommandFrame commandFrame = PaneCommandFrame.resolve(call); @@ -87,7 +88,8 @@ static Ran run(Call call) { Cursor before = Screen.from(pane).cursor(); String typed = payload(commandFrame, command, startMark, endMark, channel, suppressHistory); Pane freshPane = Targets.pane(server, pane.id().value()); - String freshCommand = PaneInputCohort.resolve(freshPane).requireSingularCommandPane("run_shell_command"); + String freshCommand = + PaneInputCohort.resolve(freshPane, call.caller()).requireSingularCommandPane("run_shell_command"); requirePosixShell(freshCommand); freshPane.sendLine(typed); diff --git a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Typing.java b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Typing.java index d7b6900..11a2756 100644 --- a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Typing.java +++ b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Typing.java @@ -43,7 +43,7 @@ static Sent sendKeys(Call call) { "'keys' is empty; give the key names to send, such as [\"C-c\"] or [\"q\"]"); } boolean literal = call.flag("literal", false); - return sendKeys(pane, keys, literal); + return sendKeys(pane, keys, literal, PaneInputCohort.resolve(pane, call.caller())); } static Sent sendKeys(Pane pane, List keys, boolean literal) { @@ -76,7 +76,7 @@ static Pasted pasteText(Call call) { Pane pane = Targets.pane(call.server(), call.string("pane_id")); String text = call.string("text"); boolean enter = call.flag("enter", false); - PaneInputCohort.resolve(pane).requirePasteTarget("paste_text"); + PaneInputCohort.resolve(pane, call.caller()).requirePasteTarget("paste_text"); // tmux turns the line feeds in a buffer into carriage returns as it pastes, so a trailing // newline is what submits the text — there is no flag that means "and Enter". pane.paste(enter ? text + "\n" : text); diff --git a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/PaneInputCohortTest.java b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/PaneInputCohortTest.java index 1f45784..efe6a3d 100644 --- a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/PaneInputCohortTest.java +++ b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/PaneInputCohortTest.java @@ -14,6 +14,7 @@ import io.github.libtmux.transport.ProcessTransport; import io.github.libtmux.transport.TmuxTransport; import java.util.List; +import java.util.Map; import java.util.concurrent.CopyOnWriteArrayList; import java.util.stream.Stream; import org.junit.jupiter.api.Test; @@ -131,7 +132,62 @@ void pasteChecksOnlyTheSourceWhileCommandsRequireOneRecipient() { } @Test - void resolutionUsesOneTargetedFiveFieldListing(Server server) { + void callerProtectionCoversEveryConfiguredMember(Server server) { + var panes = server.panes(); + var source = panes.getFirst(); + var peer = source.split(); + Caller caller = TestCalls.asCaller(server, peer.id().value()).caller(); + var resolved = PaneInputCohort.parse( + source.id().value(), + answer( + row(source.id().value(), "1", "0", "0", "sh"), + row(peer.id().value(), "1", "0", "0", "sh")), + answer(), + caller); + + IllegalStateException refused = + assertThrows(IllegalStateException.class, () -> resolved.requireKeyRecipients("send_keys")); + + assertTrue(String.valueOf(refused.getMessage()).contains(peer.id().value()), refused.getMessage()); + } + + @Test + void attendedProtectionCoversEveryConfiguredMember() { + var resolved = PaneInputCohort.parse( + "%0", + answer(row("%0", "1", "0", "0", "sh"), row("%1", "1", "0", "0", "sh")), + answer(clientRow("0", "%1", "1")), + Caller.nowhere()); + + IllegalStateException refused = + assertThrows(IllegalStateException.class, () -> resolved.requireKeyRecipients("send_keys")); + + assertTrue(String.valueOf(refused.getMessage()).contains("%1"), refused.getMessage()); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("malformedClientRows") + void malformedClientAttentionFailsClosed(String label, CommandResult clients) { + assertThrows( + LibTmuxException.class, + () -> PaneInputCohort.parse("%0", answer(row("%0", "0", "0", "0", "sh")), clients, Caller.nowhere()), + label); + } + + @Test + void uncertainCallerIdentityFailsClosed(Server server) { + Caller uncertain = TestCalls.withEnvironment(server, Map.of("TMUX", "malformed", "TMUX_PANE", "%0")) + .caller(); + var resolved = PaneInputCohort.parse("%0", answer(row("%0", "0", "0", "0", "sh")), answer(), uncertain); + + IllegalStateException refused = + assertThrows(IllegalStateException.class, () -> resolved.requirePasteTarget("paste_text")); + + assertTrue(String.valueOf(refused.getMessage()).contains("caller"), refused.getMessage()); + } + + @Test + void resolutionUsesOnePaneAndClientSnapshot(Server server) { CopyOnWriteArrayList requests = new CopyOnWriteArrayList<>(); try (ProcessTransport processes = new ProcessTransport()) { TmuxTransport recording = new TmuxTransport() { @@ -148,16 +204,18 @@ public void close() {} var pane = measured.panes().getFirst(); requests.clear(); - var resolved = PaneInputCohort.resolve(pane); + var resolved = PaneInputCohort.resolve(pane, Caller.nowhere()); assertEquals(List.of(pane.id().value()), resolved.configuredKeyRecipientIds()); } } + assertEquals(1, requests.size()); List> commands = requests.stream() .flatMap(request -> request.commands().stream()) + .filter(command -> command.getFirst().startsWith("list-")) .toList(); - assertEquals(1, commands.size()); + assertEquals(2, commands.size()); List listing = commands.getFirst(); assertEquals(List.of("list-panes", "-t"), listing.subList(0, 2)); assertTrue(listing.contains("-F")); @@ -166,6 +224,12 @@ public void close() {} List.of("pane_id", "pane_synchronized", "pane_in_mode", "pane_dead", "pane_current_command")) { assertEquals(1, occurrences(format, "#{" + field + "}")); } + List clients = commands.get(1); + assertEquals("list-clients", clients.getFirst()); + String clientFormat = clients.get(clients.indexOf("-F") + 1); + for (String field : List.of("client_control_mode", "pane_id", "window_zoomed_flag")) { + assertEquals(1, occurrences(clientFormat, "#{" + field + "}")); + } } private static Stream authorityFailures() { @@ -197,6 +261,18 @@ private static Stream malformedRows() { Arguments.of("word dead", List.of(row("%0", "0", "0", "on", "sh")))); } + private static Stream malformedClientRows() { + return Stream.of( + Arguments.of("listing failed", new CommandResult(1, List.of(), List.of("gone"))), + Arguments.of("empty control flag", answer(clientRow("", "%0", "0"))), + Arguments.of("word control flag", answer(clientRow("on", "%0", "0"))), + Arguments.of("missing active pane", answer(clientRow("0", "", "0"))), + Arguments.of("invalid active pane", answer(clientRow("0", "0", "0"))), + Arguments.of("empty zoom flag", answer(clientRow("0", "%0", ""))), + Arguments.of("word zoom flag", answer(clientRow("0", "%0", "on"))), + Arguments.of("unterminated row", answer(fields("0", "%0", "0")))); + } + private static CommandResult answer(String... rows) { return new CommandResult(0, List.of(rows), List.of()); } @@ -205,6 +281,10 @@ private static String row(String... fields) { return fields(fields) + TERMINATOR; } + private static String clientRow(String control, String activePane, String zoomed) { + return row(control, activePane, zoomed); + } + private static String fields(String... fields) { return String.join(SEPARATOR, fields); } diff --git a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/RunningCommandsTest.java b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/RunningCommandsTest.java index 34af05c..4d96d99 100644 --- a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/RunningCommandsTest.java +++ b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/RunningCommandsTest.java @@ -25,7 +25,9 @@ import java.util.concurrent.CountDownLatch; import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; import java.util.function.BooleanSupplier; import java.util.regex.MatchResult; import java.util.regex.Pattern; @@ -84,6 +86,57 @@ void aCommandThatSucceedsComesBackWithItsOutputAndStatus(Server server) { assertTrue(ran.framed(), "the plumbing was cut out exactly"); } + @Test + void callerPaneRefusesBeforeRunSetup(Server server) { + String pane = server.panes().getFirst().id().value(); + String marker = "caller-run-marker"; + + IllegalStateException refused = assertThrows( + IllegalStateException.class, + () -> RunningCommands.run( + TestCalls.asCaller(server, pane, "pane_id", pane, "command", "echo " + marker))); + + assertTrue(String.valueOf(refused.getMessage()).contains(pane), refused.getMessage()); + assertFalse(capture(server, pane).contains(marker)); + } + + @Test + void newlyAttendedPaneRefusesAtFinalPreflight(Server server) throws Exception { + Pane pane = server.panes().getFirst(); + String marker = "attended-transition-marker"; + AtomicBoolean attached = new AtomicBoolean(); + AtomicReference client = new AtomicReference<>(); + CopyOnWriteArrayList requests = new CopyOnWriteArrayList<>(); + try (ProcessTransport processes = new ProcessTransport()) { + TmuxTransport transitioning = borrowing(request -> { + CommandResult result = processes.execute(request); + requests.add(request); + if (hasCommand(request, "capture-pane") && attached.compareAndSet(false, true)) { + client.set(attachClient(server, pane)); + } + return result; + }); + try (Server measured = Server.using(server.config(), transitioning)) { + IllegalStateException refused = assertThrows( + IllegalStateException.class, + () -> RunningCommands.run( + TestCalls.on(measured, "pane_id", pane.id().value(), "command", "echo " + marker))); + assertTrue( + String.valueOf(refused.getMessage()).contains(pane.id().value()), refused.getMessage()); + } + } finally { + server.clients().forEach(io.github.libtmux.Client::detach); + Process process = client.get(); + if (process != null && !process.waitFor(5, TimeUnit.SECONDS)) { + process.destroyForcibly(); + } + } + + assertEquals(0, commandCount(requests, "send-keys")); + assertEquals(0, commandCount(requests, "wait-for")); + assertFalse(capture(server, pane.id().value()).contains(marker)); + } + @ParameterizedTest @ValueSource(strings = {"echo(){ :; }; alias printf=:", "alias echo=:; printf(){ :; }"}) void preexistingOutputShadowsCannotHideCompletion(String shadow, Server server, @TempDir Path temporary) @@ -593,6 +646,30 @@ private static void await(CountDownLatch latch) { } } + private static Process attachClient(Server server, Pane pane) { + String socket = + server.cmd("display-message", "-p", "#{socket_path}").stdout().getFirst(); + String command = Shell.quote(server.config().binary()) + " -S " + Shell.quote(socket) + " attach-session -t " + + Shell.quote(pane.window().session().id().value()); + try { + ProcessBuilder builder = new ProcessBuilder("script", "-q", "-c", command, "/dev/null") + .redirectOutput(ProcessBuilder.Redirect.DISCARD) + .redirectError(ProcessBuilder.Redirect.DISCARD); + builder.environment().put("TERM", "xterm"); + Process process = builder.start(); + if (!await(() -> !server.clients().isEmpty())) { + process.destroyForcibly(); + throw new IllegalStateException("the attended client did not attach"); + } + return process; + } catch (InterruptedException failure) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("could not attach the attended test client", failure); + } catch (java.io.IOException failure) { + throw new IllegalStateException("could not attach the attended test client", failure); + } + } + /** * Generous, because these cases wait on a pane's shell and a matrix lane shares its machine * with every other lane. The budget is only ever spent when something is already wrong. diff --git a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/TypingTest.java b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/TypingTest.java index f777d33..1ca3bc4 100644 --- a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/TypingTest.java +++ b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/TypingTest.java @@ -89,6 +89,55 @@ void synchronizedInputDisclosesEveryResolvedPane(Server server) { assertEquals(Set.of(source.id().value(), other.id().value()), Set.copyOf(sent.resolvedPaneIds())); } + @Test + void callerPaneRefusesDirectKeys(Server server) { + String pane = server.panes().getFirst().id().value(); + String marker = "caller-direct-marker"; + + IllegalStateException refused = assertThrows( + IllegalStateException.class, + () -> Typing.sendKeys( + TestCalls.asCaller(server, pane, "pane_id", pane, "keys", List.of(marker), "literal", true))); + + assertTrue(String.valueOf(refused.getMessage()).contains(pane), refused.getMessage()); + assertFalse(captureOf(server, pane).contains(marker)); + } + + @Test + void callerPaneRefusesPaste(Server server) { + assumeTrue(server.version().atLeast(SAFE_PASTE_CLEANUP)); + String pane = server.panes().getFirst().id().value(); + String marker = "caller-paste-marker"; + + IllegalStateException refused = assertThrows( + IllegalStateException.class, + () -> Typing.pasteText(TestCalls.asCaller(server, pane, "pane_id", pane, "text", marker))); + + assertTrue(String.valueOf(refused.getMessage()).contains(pane), refused.getMessage()); + assertFalse(captureOf(server, pane).contains(marker)); + assertNoOwnedBuffers(server); + } + + @Test + void batchProtectsACallerPeerInTheConfiguredCohort(Server server) { + var source = server.panes().getFirst(); + var peer = source.split(SplitSpec.builder().build()); + source.window().setSynchronizePanes(true); + String marker = "caller-batch-peer-marker"; + + Map batch = map(Operations.sendKeysBatch(TestCalls.asCaller( + server, + peer.id().value(), + "operations", + List.of(send(source.id().value(), marker))))); + Map row = rows(batch).getFirst(); + + assertEquals(false, row.get("success")); + assertTrue(String.valueOf(row.get("error")).contains(peer.id().value()), row.toString()); + assertFalse(captureOf(server, source.id().value()).contains(marker)); + assertFalse(captureOf(server, peer.id().value()).contains(marker)); + } + @Test void synchronizedKeysReachOnlyTheEffectiveOnCohort(Server server) throws Exception { var source = server.panes().getFirst(); From cbce0e53c65a7eac96ed326eb175f6283234e939 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 5 Sep 2026 18:02:53 -0500 Subject: [PATCH 32/65] Mcp(test[paste]): Exercise disconnect hook why: The disconnect cleanup test watched a command the paste route does not use. what: - Observe load-buffer and assert the disconnect hook fired --- .../src/test/java/io/github/libtmux/mcp/TypingTest.java | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/TypingTest.java b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/TypingTest.java index 1ca3bc4..89eacb8 100644 --- a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/TypingTest.java +++ b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/TypingTest.java @@ -22,6 +22,7 @@ import java.util.concurrent.CountDownLatch; import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicReference; import java.util.function.BooleanSupplier; import org.junit.jupiter.api.Test; @@ -362,12 +363,14 @@ void aDisconnectDuringAPasteLeavesNothingOnTheServer(Server server) { assumeTrue(server.version().atLeast(SAFE_PASTE_CLEANUP)); String pane = server.panes().get(0).id().value(); try (ProcessTransport processes = new ProcessTransport()) { + AtomicBoolean disconnected = new AtomicBoolean(); AtomicReference pasting = new AtomicReference<>(); TmuxTransport disconnecting = new TmuxTransport() { @Override public CommandResult execute(CommandRequest request) { CommandResult result = processes.execute(request); - if (String.join(" ", request.commands().get(0)).contains("set-buffer")) { + if (String.join(" ", request.commands().get(0)).contains("load-buffer")) { + disconnected.set(true); pasting.get().close(); } return result; @@ -383,6 +386,7 @@ public void close() {} } catch (RuntimeException expected) { // The disconnect is what this arranges; surviving it is not what is being asserted. } + assertTrue(disconnected.get(), "the disconnect hook did not observe buffer setup"); } assertNoOwnedBuffers(server); From 3ae1c2d9a2390d6d7beff442b16b6c28d150a6d3 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 5 Sep 2026 18:15:23 -0500 Subject: [PATCH 33/65] Mcp(fix[route]): Reject ASCII controls why: Framed tmux routes accepted C0 and DEL bytes from startup or server metadata. what: - Guard startup selectors before opening a transport - Guard resolved executable and socket paths before framing - Retain apostrophe and non-control route support --- docs/guide/mcp.md | 2 + libtmux-mcp/README.md | 2 + .../libtmux/mcp/LaunchConfiguration.java | 12 ++-- .../github/libtmux/mcp/PaneCommandFrame.java | 11 +++- .../io/github/libtmux/mcp/RouteValue.java | 20 +++++++ .../java/io/github/libtmux/mcp/MainTest.java | 59 +++++++++++++++++++ .../libtmux/mcp/PaneCommandFrameTest.java | 54 +++++++++++++++++ 7 files changed, 152 insertions(+), 8 deletions(-) create mode 100644 libtmux-mcp/src/main/java/io/github/libtmux/mcp/RouteValue.java diff --git a/docs/guide/mcp.md b/docs/guide/mcp.md index 165a16c..1f0e512 100644 --- a/docs/guide/mcp.md +++ b/docs/guide/mcp.md @@ -116,6 +116,8 @@ Pre-existing functions named `trap`, `eval`, `exit`, or exactly like that resolved client are not a supported hostile-shell case. The marker `display-message` calls still use the trusted server's normal command path, including configured command aliases and `after-display-message` hooks. +Executable and socket routes refuse ASCII control characters and DEL before +those values can enter the frame. ### Pane modes and synchronized input diff --git a/libtmux-mcp/README.md b/libtmux-mcp/README.md index dc1bcab..464a9fa 100644 --- a/libtmux-mcp/README.md +++ b/libtmux-mcp/README.md @@ -264,6 +264,8 @@ socket. Ordinary output aliases and functions are tolerated; pre-existing functions named `trap`, `eval`, `exit`, or exactly like that resolved client are outside this boundary. Marker `display-message` calls honor the selected trusted server's command aliases and hooks. +ASCII control characters and DEL are refused in executable and socket routes +before those values can enter framing. **You did not write it.** Always pass `stop`: diff --git a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/LaunchConfiguration.java b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/LaunchConfiguration.java index 8437f5e..1edb5fd 100644 --- a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/LaunchConfiguration.java +++ b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/LaunchConfiguration.java @@ -40,12 +40,12 @@ static LaunchConfiguration resolve(List args, Map enviro String flag = args.get(index); switch (flag) { case "--socket" -> { - flaggedPath = absolute(value(args, ++index, flag), flag); + flaggedPath = absolute(RouteValue.requireSafe(value(args, ++index, flag), flag), flag); } case "--socket-name" -> { - flaggedName = nonempty(value(args, ++index, flag), flag); + flaggedName = nonempty(RouteValue.requireSafe(value(args, ++index, flag), flag), flag); } - case "--tmux" -> binary = nonempty(value(args, ++index, flag), flag); + case "--tmux" -> binary = nonempty(RouteValue.requireSafe(value(args, ++index, flag), flag), flag); case "--safety" -> throw new IllegalArgumentException( "--safety was retired; select unordered toolsets with " + ToolSurface.TOOLSETS_ENV); @@ -121,6 +121,7 @@ private SocketProfile dedicatedProfile(Server server) { private SocketProfile socketProfile( String serverState, String configurationProvenance, boolean defaultTeardown, String resolvedSocketPath) { + RouteValue.requireSafe(resolvedSocketPath, "resolved tmux socket path"); return new SocketProfile( selector, selectionProvenance, @@ -142,10 +143,11 @@ private static SocketChoice socket(Map environment) { ServerEndpoint.namedSocket(DEFAULT_SOCKET), "name:" + DEFAULT_SOCKET, "default-dedicated", true); } if (configuredPath != null) { - Path path = absolute(configuredPath, SOCKET_PATH_ENV); + Path path = absolute(RouteValue.requireSafe(configuredPath, SOCKET_PATH_ENV), SOCKET_PATH_ENV); return new SocketChoice(ServerEndpoint.socketPath(path), "path:" + path, "operator-current", false); } - String name = nonempty(Objects.requireNonNull(configuredName, SOCKET_ENV), SOCKET_ENV); + String name = nonempty( + RouteValue.requireSafe(Objects.requireNonNull(configuredName, SOCKET_ENV), SOCKET_ENV), SOCKET_ENV); return new SocketChoice(ServerEndpoint.namedSocket(name), "name:" + name, "operator-current", false); } diff --git a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/PaneCommandFrame.java b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/PaneCommandFrame.java index 7c602d1..cde701c 100644 --- a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/PaneCommandFrame.java +++ b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/PaneCommandFrame.java @@ -39,6 +39,7 @@ static PaneCommandFrame resolve( } static String resolveExecutable(String configured, Map environment, Path workingDirectory) { + RouteValue.requireSafe(configured, "tmux executable"); if (configured.indexOf(File.separatorChar) >= 0) { Path selected = Path.of(configured); return requireExecutable(selected.isAbsolute() ? selected : workingDirectory.resolve(selected), configured); @@ -71,15 +72,18 @@ private static String requireExecutable(Path selected, String configured) { throw new IllegalArgumentException( "tmux executable '" + configured + "' did not resolve to an absolute executable file"); } - return resolved.toString(); + return RouteValue.requireSafe(resolved.toString(), "resolved tmux executable"); } catch (IOException failure) { throw new IllegalArgumentException("tmux executable '" + configured + "' could not be resolved", failure); } } static String resolveSocket(Optional supplied, Supplier socketQuery) { - if (supplied.isPresent() && !supplied.orElseThrow().isBlank()) { - return requireAbsoluteSocket(supplied.orElseThrow()); + if (supplied.isPresent()) { + String retained = RouteValue.requireSafe(supplied.orElseThrow(), "retained tmux socket path"); + if (!retained.isBlank()) { + return requireAbsoluteSocket(retained); + } } CommandResult result = socketQuery.get(); if (!result.succeeded() || result.stdout().size() != 1) { @@ -89,6 +93,7 @@ static String resolveSocket(Optional supplied, Supplier s } private static String requireAbsoluteSocket(String socket) { + RouteValue.requireSafe(socket, "resolved tmux socket path"); if (socket.isBlank() || !Path.of(socket).isAbsolute()) { throw new IllegalArgumentException("tmux socket path must be nonblank and absolute"); } diff --git a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/RouteValue.java b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/RouteValue.java new file mode 100644 index 0000000..32d882f --- /dev/null +++ b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/RouteValue.java @@ -0,0 +1,20 @@ +package io.github.libtmux.mcp; + +import java.util.Objects; + +/** Text that may identify the tmux executable or socket but must never frame a command. */ +final class RouteValue { + + private RouteValue() {} + + static String requireSafe(String value, String field) { + Objects.requireNonNull(value, field); + for (int index = 0; index < value.length(); index++) { + char character = value.charAt(index); + if (character <= 0x1f || character == 0x7f) { + throw new IllegalArgumentException(field + " contains an ASCII control character"); + } + } + return value; + } +} diff --git a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/MainTest.java b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/MainTest.java index 7dd9cb4..b4b12b4 100644 --- a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/MainTest.java +++ b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/MainTest.java @@ -19,6 +19,7 @@ import java.util.Map; import java.util.Objects; import java.util.UUID; +import java.util.stream.IntStream; import org.junit.jupiter.api.Test; /** @@ -264,6 +265,64 @@ void malformedSocketAndConfigurationSelectorsFailClosed() { () -> LaunchConfiguration.resolve(List.of(), Map.of(LaunchConfiguration.CONFIG_ENV, ""))); } + @Test + void startupRejectsControlCharactersInClientRoutes() { + for (int value : IntStream.concat(IntStream.rangeClosed(0, 0x1f), IntStream.of(0x7f)) + .toArray()) { + String control = Character.toString(value); + String label = String.format("U+%04X", value); + assertThrows( + IllegalArgumentException.class, + () -> LaunchConfiguration.resolve(List.of("--tmux", "tmux" + control), Map.of()), + label + " executable"); + assertThrows( + IllegalArgumentException.class, + () -> LaunchConfiguration.resolve(List.of("--socket-name", "socket" + control), Map.of()), + label + " socket name"); + assertThrows( + IllegalArgumentException.class, + () -> LaunchConfiguration.resolve(List.of("--socket", "/tmp/socket" + control), Map.of()), + label + " socket path"); + assertThrows( + IllegalArgumentException.class, + () -> LaunchConfiguration.resolve( + List.of(), Map.of(LaunchConfiguration.SOCKET_ENV, "socket" + control)), + label + " environment socket name"); + assertThrows( + IllegalArgumentException.class, + () -> LaunchConfiguration.resolve( + List.of(), Map.of(LaunchConfiguration.SOCKET_PATH_ENV, "/tmp/socket" + control)), + label + " environment socket path"); + } + } + + @Test + void startupKeepsApostrophesInClientRoutes() { + LaunchConfiguration launch = + LaunchConfiguration.resolve(List.of("--tmux", "/tmp/tmux's", "--socket-name", "socket's"), Map.of()); + + assertEquals("/tmp/tmux's", launch.config().binary()); + assertEquals(ServerEndpoint.namedSocket("socket's"), launch.config().endpoint()); + } + + @Test + void startupRejectsControlCharactersReportedInAResolvedSocket() { + LaunchConfiguration launch = LaunchConfiguration.resolve(List.of("--socket", "/tmp/configured.sock"), Map.of()); + TmuxTransport reporting = new TmuxTransport() { + @Override + public CommandResult execute(CommandRequest request) { + return new CommandResult(0, List.of("/tmp/reported\nsocket"), List.of()); + } + + @Override + public void close() {} + }; + + try (Server server = Server.using(launch.config(), reporting)) { + assertThrows(IllegalArgumentException.class, () -> launch.profile(server)); + } + } + private static SocketProfile profile(LaunchConfiguration launch, String marker) { TmuxTransport transport = new TmuxTransport() { @Override diff --git a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/PaneCommandFrameTest.java b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/PaneCommandFrameTest.java index 5a0ba62..7b116d3 100644 --- a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/PaneCommandFrameTest.java +++ b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/PaneCommandFrameTest.java @@ -20,6 +20,7 @@ import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicReference; import java.util.function.BooleanSupplier; +import java.util.stream.IntStream; import java.util.stream.Stream; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; @@ -117,6 +118,54 @@ void malformedSocketRoutesFailClosed(String label, Optional supplied, Co IllegalArgumentException.class, () -> PaneCommandFrame.resolveSocket(supplied, () -> result), label); } + @ParameterizedTest(name = "ASCII route control {0}") + @MethodSource("routeControls") + void asciiControlsCannotEnterSocketRoutes(String label, String control) { + AtomicInteger retainedQueries = new AtomicInteger(); + assertThrows( + IllegalArgumentException.class, + () -> PaneCommandFrame.resolveSocket(Optional.of("/tmp/retained" + control + ".sock"), () -> { + retainedQueries.incrementAndGet(); + return result("/tmp/unused.sock"); + }), + label); + assertEquals(0, retainedQueries.get(), label); + assertThrows( + IllegalArgumentException.class, + () -> PaneCommandFrame.resolveSocket( + Optional.empty(), () -> result("/tmp/discovered" + control + ".sock")), + label); + } + + @Test + void unsafeExecutableRefusesBeforeSocketDiscovery(@TempDir Path temporary) throws Exception { + for (String control : List.of("\u0001", "\n", "\u001f", "\u007f")) { + Path unsafe = executable(temporary.resolve("tmux" + control + "client")); + AtomicInteger queries = new AtomicInteger(); + ServerConfig config = config(unsafe, ServerEndpoint.defaultSocket()); + + assertThrows( + IllegalArgumentException.class, + () -> PaneCommandFrame.resolve(config, Optional.empty(), Map.of(), temporary, () -> { + queries.incrementAndGet(); + return result("/tmp/socket"); + })); + assertEquals(0, queries.get(), "unsafe executable reached socket discovery"); + } + } + + @Test + void apostrophesRemainValidRouteCharacters(@TempDir Path temporary) throws Exception { + Path executable = executable(temporary.resolve("tmux's")); + + assertEquals( + executable.toRealPath().toString(), + PaneCommandFrame.resolveExecutable(executable.toString(), Map.of(), temporary)); + assertEquals("/tmp/socket's", PaneCommandFrame.resolveSocket(Optional.of("/tmp/socket's"), () -> { + throw new AssertionError("a retained socket must avoid discovery"); + })); + } + @ParameterizedTest @MethodSource("routeKinds") void framedCommandsStayOnTheResolvedEndpoint(String kind, @TempDir Path temporary) throws Exception { @@ -217,6 +266,11 @@ private static Stream malformedSockets() { new CommandResult(0, List.of("/tmp/unused.sock"), List.of()))); } + private static Stream routeControls() { + return IntStream.concat(IntStream.rangeClosed(0, 0x1f), IntStream.of(0x7f)) + .mapToObj(value -> Arguments.of(String.format("U+%04X", value), Character.toString(value))); + } + private static Path executable(Path path) throws IOException { Files.writeString(path, "#!/bin/sh\nexit 0\n"); assertTrue(path.toFile().setExecutable(true), "could not make test executable"); From 5c27a71969eccc93b0a1287ce0313fb1876105e4 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 5 Sep 2026 18:29:04 -0500 Subject: [PATCH 34/65] Mcp(fix[swap]): Make swaps transactional why: A late config or backup failure could leave earlier clients changed, and revert replaced config symlinks. what: - Preflight and stage every selected config and backup - Reverse proven writes and preserve recovery copies on failure - Write through config symlinks and keep dry-run observational --- scripts/README.md | 4 +- scripts/mcp_swap.py | 792 +++++++++++++++++++++++++++++++++++---- scripts/test_mcp_swap.py | 415 ++++++++++++++++++++ 3 files changed, 1147 insertions(+), 64 deletions(-) diff --git a/scripts/README.md b/scripts/README.md index 5efa3a4..623201f 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -82,7 +82,9 @@ It rewrites **global** configs only, touches only the one server entry named by `--name` (default `tmux`), and keeps everything else in the file — including comments and trailing commas in JSONC, and comments in TOML. The backup is taken once, so swapping something already swapped still reverts to the config that was -there before any of it started. +there before any of it started. All selected files commit as one transaction; +failed commits reverse in order and retain recovery copies if exact rollback is +not possible. `--dry-run` parses the complete plan without building or writing. To try it without changing anything at all, most CLIs take a config per invocation instead — `claude --mcp-config --strict-mcp-config`, or diff --git a/scripts/mcp_swap.py b/scripts/mcp_swap.py index ddd9858..6ecabb8 100755 --- a/scripts/mcp_swap.py +++ b/scripts/mcp_swap.py @@ -49,7 +49,7 @@ Scope ----- -Deliberately narrow, and best-effort: +Deliberately narrow, and transactional: - **Global configs only.** Project-local ``.mcp.json`` and ``.cursor/mcp.json`` are left alone; a swap is a thing you do to your @@ -59,6 +59,9 @@ including comments in TOML and JSONC. - **A backup per file, once.** Written beside the original as ``.mcp-swap-backup``. ``revert`` moves it back. +- **One all-client transaction.** Every selected config and backup destination + is checked and staged before replacement. A failure rolls back in reverse; + recovery copies remain when an exact rollback cannot be proven. """ from __future__ import annotations @@ -67,7 +70,6 @@ import json import os import pathlib -import shutil import stat import subprocess import sys @@ -434,25 +436,6 @@ def render(layer: Layer, document: t.Any, original: bytes) -> bytes: return (json.dumps(document, indent=2, ensure_ascii=False) + "\n").encode("utf-8") -def save(layer: Layer, data: bytes) -> None: - """Replace config bytes atomically while retaining mode and symlinks.""" - target = layer.path.resolve() if layer.path.is_symlink() else layer.path - mode = stat.S_IMODE(target.stat().st_mode) if target.exists() else None - descriptor, temporary_name = tempfile.mkstemp( - prefix=target.name + ".", dir=str(target.parent) - ) - temporary = pathlib.Path(temporary_name) - try: - with os.fdopen(descriptor, "wb") as stream: - if mode is not None: - os.fchmod(stream.fileno(), mode) - stream.write(data) - temporary.replace(target) - except Exception: - temporary.unlink(missing_ok=True) - raise - - def servers(layer: Layer, document: t.Any, *, create: bool = False) -> t.Any: """The mapping of server name to launch spec, or None when there is none.""" node = document @@ -475,13 +458,713 @@ def entry_for(layer: Layer, command: str, arguments: list[str]) -> dict[str, t.A return {"command": command, "args": arguments} -class Prepared(t.NamedTuple): +class FileState(t.NamedTuple): + device: int + inode: int + mode: int + size: int + modified_ns: int + data: bytes + + +class DirectoryState(t.NamedTuple): + logical: pathlib.Path + physical: pathlib.Path + symlink: bool + link_text: str | None + link_device: int + link_inode: int + link_mode: int + device: int + inode: int + mode: int + + +class ConfigState(t.NamedTuple): layer: Layer - original: bytes + parent: DirectoryState + symlink: bool + link_text: str | None + link_device: int + link_inode: int + link_mode: int + target: pathlib.Path + file: FileState + + +class BackupState(t.NamedTuple): + path: pathlib.Path + parent: DirectoryState + physical: pathlib.Path + file: FileState | None + + +class PreparedUse(t.NamedTuple): + config: ConfigState + backup: BackupState output: bytes entry: dict[str, t.Any] +class PreparedRevert(t.NamedTuple): + config: ConfigState + backup: BackupState + + +class StagedUse(t.NamedTuple): + plan: PreparedUse + output: pathlib.Path + recovery: pathlib.Path + backup: pathlib.Path | None + + +class StagedRevert(t.NamedTuple): + plan: PreparedRevert + restored: pathlib.Path + recovery: pathlib.Path + backup_recovery: pathlib.Path + + +class ConfigWrite(t.NamedTuple): + config: ConfigState + committed: FileState + recovery: pathlib.Path + + +class BackupWrite(t.NamedTuple): + backup: BackupState + committed: FileState + cli: str + + +class BackupRemoval(t.NamedTuple): + backup: BackupState + recovery: pathlib.Path + cli: str + + +def _file_state(path: pathlib.Path) -> FileState: + before = path.stat() + if not stat.S_ISREG(before.st_mode): + raise ValueError(f"{path} is not a regular file") + data = path.read_bytes() + after = path.stat() + before_key = ( + before.st_dev, + before.st_ino, + before.st_mode, + before.st_size, + before.st_mtime_ns, + ) + after_key = ( + after.st_dev, + after.st_ino, + after.st_mode, + after.st_size, + after.st_mtime_ns, + ) + if before_key != after_key: + raise RuntimeError(f"{path} changed while it was read") + return FileState( + after.st_dev, + after.st_ino, + stat.S_IMODE(after.st_mode), + after.st_size, + after.st_mtime_ns, + data, + ) + + +def _directory_state(path: pathlib.Path) -> DirectoryState: + logical = path.lstat() + symlink = stat.S_ISLNK(logical.st_mode) + if not symlink and not stat.S_ISDIR(logical.st_mode): + raise ValueError(f"{path} is not a directory or directory symlink") + physical = path.resolve(strict=True) + details = physical.stat() + if not stat.S_ISDIR(details.st_mode): + raise ValueError(f"{path} is not a directory") + return DirectoryState( + path, + physical, + symlink, + os.readlink(path) if symlink else None, + logical.st_dev, + logical.st_ino, + logical.st_mode, + details.st_dev, + details.st_ino, + stat.S_IMODE(details.st_mode), + ) + + +def _config_state(layer: Layer) -> ConfigState: + parent = _directory_state(layer.path.parent) + details = layer.path.lstat() + symlink = stat.S_ISLNK(details.st_mode) + if not symlink and not stat.S_ISREG(details.st_mode): + raise ValueError(f"{layer.path} is not a regular file or symlink") + link_text = os.readlink(layer.path) if symlink else None + target = layer.path.resolve(strict=True) + file = _file_state(target) + if not symlink and (details.st_dev, details.st_ino) != ( + file.device, + file.inode, + ): + raise RuntimeError(f"{layer.path} changed while it was resolved") + return ConfigState( + layer, + parent, + symlink, + link_text, + details.st_dev, + details.st_ino, + details.st_mode, + target, + file, + ) + + +def _backup_state(layer: Layer, *, required: bool = False) -> BackupState: + path = backup_of(layer) + parent = _directory_state(path.parent) + physical = parent.physical / path.name + if not os.path.lexists(path): + if required: + raise FileNotFoundError(path) + return BackupState(path, parent, physical, None) + details = path.lstat() + if stat.S_ISLNK(details.st_mode) or not stat.S_ISREG(details.st_mode): + raise ValueError(f"{path} is not a regular file") + if path.resolve(strict=True) != physical: + raise RuntimeError(f"{path} did not resolve in its preflight directory") + file = _file_state(physical) + if (details.st_dev, details.st_ino) != (file.device, file.inode): + raise RuntimeError(f"{path} changed while it was resolved") + return BackupState(path, parent, physical, file) + + +def _verify_directory(expected: DirectoryState) -> None: + current = _directory_state(expected.logical) + if current != expected: + raise RuntimeError(f"{expected.logical} changed") + + +def _verify_config(config: ConfigState, expected: FileState) -> None: + _verify_directory(config.parent) + details = config.layer.path.lstat() + if config.symlink: + if ( + not stat.S_ISLNK(details.st_mode) + or os.readlink(config.layer.path) != config.link_text + or (details.st_dev, details.st_ino, details.st_mode) + != (config.link_device, config.link_inode, config.link_mode) + ): + raise RuntimeError(f"{config.layer.path} symlink changed") + elif not stat.S_ISREG(details.st_mode): + raise RuntimeError(f"{config.layer.path} topology changed") + if config.layer.path.resolve(strict=True) != config.target: + raise RuntimeError(f"{config.layer.path} target changed") + current = _file_state(config.target) + if current != expected: + raise RuntimeError(f"{config.layer.path} identity, mode, or bytes changed") + if not config.symlink and (details.st_dev, details.st_ino) != ( + current.device, + current.inode, + ): + raise RuntimeError(f"{config.layer.path} logical identity changed") + + +def _verify_restored_config(config: ConfigState, *, data: bytes, mode: int) -> None: + _verify_directory(config.parent) + details = config.layer.path.lstat() + if config.symlink: + if ( + not stat.S_ISLNK(details.st_mode) + or os.readlink(config.layer.path) != config.link_text + or (details.st_dev, details.st_ino, details.st_mode) + != (config.link_device, config.link_inode, config.link_mode) + ): + raise RuntimeError(f"{config.layer.path} symlink changed") + elif not stat.S_ISREG(details.st_mode): + raise RuntimeError(f"{config.layer.path} topology changed") + if config.layer.path.resolve(strict=True) != config.target: + raise RuntimeError(f"{config.layer.path} target changed") + current = _file_state(config.target) + if current.data != data or current.mode != mode: + raise RuntimeError(f"{config.layer.path} was not restored exactly") + + +def _verify_backup(backup: BackupState, expected: FileState | None) -> None: + _verify_directory(backup.parent) + if expected is None: + if os.path.lexists(backup.path): + raise RuntimeError(f"{backup.path} appeared") + return + if not os.path.lexists(backup.path) or backup.path.is_symlink(): + raise RuntimeError(f"{backup.path} topology changed") + if backup.path.resolve(strict=True) != backup.physical: + raise RuntimeError(f"{backup.path} target changed") + if _file_state(backup.physical) != expected: + raise RuntimeError(f"{backup.path} identity, mode, or bytes changed") + + +def _verify_restored_backup(backup: BackupState) -> None: + expected = t.cast(FileState, backup.file) + _verify_directory(backup.parent) + if not os.path.lexists(backup.path) or backup.path.is_symlink(): + raise RuntimeError(f"{backup.path} topology changed") + if backup.path.resolve(strict=True) != backup.physical: + raise RuntimeError(f"{backup.path} target changed") + current = _file_state(backup.physical) + if current.data != expected.data or current.mode != expected.mode: + raise RuntimeError(f"{backup.path} was not restored exactly") + + +def _reject_duplicate_targets(plans: t.Iterable[t.Any]) -> None: + config_paths: dict[pathlib.Path, str] = {} + config_inodes: dict[tuple[int, int], str] = {} + all_paths: dict[pathlib.Path, str] = {} + all_inodes: dict[tuple[int, int], str] = {} + for plan in plans: + config = plan.config + cli = config.layer.cli + by_path = config_paths.get(config.target) + by_inode = config_inodes.get((config.file.device, config.file.inode)) + if by_path is not None or by_inode is not None: + other = by_path or by_inode + raise SystemExit(f"duplicate physical config target for {other} and {cli}") + config_paths[config.target] = cli + config_inodes[(config.file.device, config.file.inode)] = cli + owner = all_paths.get(config.target) or all_inodes.get( + (config.file.device, config.file.inode) + ) + if owner is not None: + raise SystemExit( + f"duplicate transaction destination for {owner} and {cli} config" + ) + all_paths[config.target] = f"{cli} config" + all_inodes[(config.file.device, config.file.inode)] = f"{cli} config" + + backup = plan.backup + backup_inode = ( + None if backup.file is None else (backup.file.device, backup.file.inode) + ) + owner = all_paths.get(backup.physical) + if owner is None and backup_inode is not None: + owner = all_inodes.get(backup_inode) + if owner is not None: + raise SystemExit( + f"duplicate transaction destination for {owner} and {cli} backup" + ) + all_paths[backup.physical] = f"{cli} backup" + if backup_inode is not None: + all_inodes[backup_inode] = f"{cli} backup" + + +def _stage( + directory: pathlib.Path, + logical_name: str, + role: str, + data: bytes, + mode: int, +) -> pathlib.Path: + descriptor, temporary_name = tempfile.mkstemp( + prefix=f".{logical_name}.mcp-swap-{role}-", dir=str(directory) + ) + temporary = pathlib.Path(temporary_name) + try: + with os.fdopen(descriptor, "wb") as stream: + os.fchmod(stream.fileno(), mode) + stream.write(data) + stream.flush() + os.fsync(stream.fileno()) + return temporary + except Exception: + temporary.unlink(missing_ok=True) + raise + + +def _apply_replace( + staged: pathlib.Path, destination: pathlib.Path +) -> tuple[FileState, Exception | None]: + staged_state = _file_state(staged) + delayed: Exception | None = None + try: + os.replace(staged, destination) + except Exception as error: + try: + moved = _file_state(destination) == staged_state + except (OSError, RuntimeError, ValueError): + moved = False + if not moved: + raise + delayed = error + committed = _file_state(destination) + if committed != staged_state: + raise RuntimeError(f"atomic replacement of {destination} was not exact") + return committed, delayed + + +def _apply_unlink(path: pathlib.Path) -> Exception | None: + delayed: Exception | None = None + try: + os.unlink(path) + except Exception as error: + if os.path.lexists(path): + raise + delayed = error + if os.path.lexists(path): + raise RuntimeError(f"{path} still exists after removal") + return delayed + + +def _cleanup_owned( + owned: set[pathlib.Path], preserve: set[pathlib.Path] | None = None +) -> list[str]: + retained = preserve or set() + errors: list[str] = [] + for path in sorted(owned - retained, key=str): + try: + path.unlink(missing_ok=True) + except OSError as error: + errors.append(f"could not remove task-owned stage {path}: {error}") + return errors + + +def _transaction_failure( + action: str, + error: Exception, + rollback_errors: list[str], + cleanup_errors: list[str], + preserved: set[pathlib.Path], +) -> t.NoReturn: + details = [f"{action} failed: {error}"] + if rollback_errors: + details.append("rollback incomplete: " + "; ".join(rollback_errors)) + if cleanup_errors: + details.append("cleanup incomplete: " + "; ".join(cleanup_errors)) + if preserved: + details.append( + "recovery artifacts: " + + ", ".join(str(path) for path in sorted(preserved, key=str)) + ) + raise SystemExit("; ".join(details)) from error + + +def _plan_use( + args: argparse.Namespace, command: str, arguments: list[str] +) -> list[PreparedUse]: + prepared: list[PreparedUse] = [] + for layer in chosen(args): + if not os.path.lexists(layer.path): + print(f"{layer.cli:<{CLI_COLUMN}} skipped, no config") + continue + try: + config = _config_state(layer) + document = parse(layer, config.file.data) + entry = entry_for(layer, command, arguments) + into = servers(layer, document, create=True) + into[args.name] = entry + output = render(layer, document, config.file.data) + except Exception as error: + raise SystemExit(f"{layer.cli} config is unreadable: {error}") from error + try: + backup = _backup_state(layer) + except Exception as error: + raise SystemExit(f"{layer.cli} backup is unusable: {error}") from error + prepared.append(PreparedUse(config, backup, output, entry)) + _reject_duplicate_targets(prepared) + return prepared + + +def _plan_revert(args: argparse.Namespace) -> list[PreparedRevert]: + prepared: list[PreparedRevert] = [] + for layer in chosen(args): + if not os.path.lexists(backup_of(layer)): + print(f"{layer.cli:<{CLI_COLUMN}} nothing to revert") + continue + try: + backup = _backup_state(layer, required=True) + except Exception as error: + raise SystemExit(f"{layer.cli} backup is unusable: {error}") from error + try: + config = _config_state(layer) + except Exception as error: + raise SystemExit(f"{layer.cli} config is unreadable: {error}") from error + prepared.append(PreparedRevert(config, backup)) + _reject_duplicate_targets(prepared) + return prepared + + +def _changed_config(config: ConfigState, expected: FileState) -> None: + try: + _verify_config(config, expected) + except Exception as error: + raise RuntimeError( + f"{config.layer.cli} config changed during preflight" + ) from error + + +def _changed_backup(backup: BackupState, expected: FileState | None, cli: str) -> None: + try: + _verify_backup(backup, expected) + except Exception as error: + raise RuntimeError(f"{cli} backup changed during preflight") from error + + +def _stage_use(plans: list[PreparedUse], owned: set[pathlib.Path]) -> list[StagedUse]: + staged: list[StagedUse] = [] + try: + for plan in plans: + config = plan.config + output = _stage( + config.target.parent, + config.layer.path.name, + "output", + plan.output, + config.file.mode, + ) + owned.add(output) + recovery = _stage( + config.target.parent, + config.layer.path.name, + "recovery", + config.file.data, + config.file.mode, + ) + owned.add(recovery) + backup = None + if plan.backup.file is None: + backup = _stage( + plan.backup.parent.physical, + plan.backup.path.name, + "new", + config.file.data, + config.file.mode, + ) + owned.add(backup) + staged.append(StagedUse(plan, output, recovery, backup)) + except Exception as error: + cleanup = _cleanup_owned(owned) + detail = f"swap staging failed: {error}" + if cleanup: + detail += "; " + "; ".join(cleanup) + raise SystemExit(detail) from error + return staged + + +def _stage_revert( + plans: list[PreparedRevert], owned: set[pathlib.Path] +) -> list[StagedRevert]: + staged: list[StagedRevert] = [] + try: + for plan in plans: + config = plan.config + backup = t.cast(FileState, plan.backup.file) + restored = _stage( + config.target.parent, + config.layer.path.name, + "restore", + backup.data, + backup.mode, + ) + owned.add(restored) + recovery = _stage( + config.target.parent, + config.layer.path.name, + "recovery", + config.file.data, + config.file.mode, + ) + owned.add(recovery) + backup_recovery = _stage( + plan.backup.parent.physical, + plan.backup.path.name, + "recovery", + backup.data, + backup.mode, + ) + owned.add(backup_recovery) + staged.append(StagedRevert(plan, restored, recovery, backup_recovery)) + except Exception as error: + cleanup = _cleanup_owned(owned) + detail = f"revert staging failed: {error}" + if cleanup: + detail += "; " + "; ".join(cleanup) + raise SystemExit(detail) from error + return staged + + +def _rollback_use( + operations: list[ConfigWrite | BackupWrite], + owned: set[pathlib.Path], +) -> tuple[list[str], set[pathlib.Path]]: + errors: list[str] = [] + preserved: set[pathlib.Path] = set() + failed_configs: set[str] = set() + for operation in reversed(operations): + if isinstance(operation, ConfigWrite): + cli = operation.config.layer.cli + try: + _verify_config(operation.config, operation.committed) + _apply_replace(operation.recovery, operation.config.target) + owned.discard(operation.recovery) + _verify_restored_config( + operation.config, + data=operation.config.file.data, + mode=operation.config.file.mode, + ) + except Exception as error: # noqa: BLE001 - continue reverse rollback + failed_configs.add(cli) + if operation.recovery.exists(): + preserved.add(operation.recovery) + errors.append(f"{cli} config: {error}") + continue + + cli = operation.cli + if cli in failed_configs: + preserved.add(operation.backup.path) + continue + try: + _verify_backup(operation.backup, operation.committed) + _apply_unlink(operation.backup.physical) + except Exception as error: # noqa: BLE001 - continue reverse rollback + preserved.add(operation.backup.path) + errors.append(f"{cli} backup: {error}") + return errors, preserved + + +def _commit_use(staged: list[StagedUse], owned: set[pathlib.Path]) -> None: + operations: list[ConfigWrite | BackupWrite] = [] + committed_backups: dict[str, FileState] = {} + try: + for item in staged: + _changed_config(item.plan.config, item.plan.config.file) + _changed_backup( + item.plan.backup, + item.plan.backup.file, + item.plan.config.layer.cli, + ) + + for item in staged: + if item.backup is None: + continue + plan = item.plan + cli = plan.config.layer.cli + _changed_config(plan.config, plan.config.file) + _changed_backup(plan.backup, None, cli) + committed, delayed = _apply_replace(item.backup, plan.backup.physical) + owned.discard(item.backup) + operations.append(BackupWrite(plan.backup, committed, cli)) + committed_backups[cli] = committed + if delayed is not None: + raise delayed + + for item in staged: + plan = item.plan + cli = plan.config.layer.cli + _changed_config(plan.config, plan.config.file) + expected_backup = committed_backups.get(cli, plan.backup.file) + _changed_backup(plan.backup, expected_backup, cli) + committed, delayed = _apply_replace(item.output, plan.config.target) + owned.discard(item.output) + operations.append(ConfigWrite(plan.config, committed, item.recovery)) + if delayed is not None: + raise delayed + except Exception as error: # noqa: BLE001 - every commit failure rolls back + rollback_errors, preserved = _rollback_use(operations, owned) + cleanup_errors = _cleanup_owned(owned, preserved) + _transaction_failure("swap", error, rollback_errors, cleanup_errors, preserved) + + cleanup_errors = _cleanup_owned(owned) + for error in cleanup_errors: + print(f"warning: {error}", file=sys.stderr) + + +def _rollback_revert( + operations: list[ConfigWrite | BackupRemoval], + owned: set[pathlib.Path], +) -> tuple[list[str], set[pathlib.Path]]: + errors: list[str] = [] + preserved: set[pathlib.Path] = set() + for operation in reversed(operations): + if isinstance(operation, BackupRemoval): + cli = operation.cli + try: + _verify_directory(operation.backup.parent) + if os.path.lexists(operation.backup.path): + raise RuntimeError( + f"{operation.backup.path} appeared before rollback" + ) + _apply_replace(operation.recovery, operation.backup.physical) + owned.discard(operation.recovery) + _verify_restored_backup(operation.backup) + except Exception as error: # noqa: BLE001 - continue reverse rollback + if operation.recovery.exists(): + preserved.add(operation.recovery) + errors.append(f"{cli} backup: {error}") + continue + + cli = operation.config.layer.cli + try: + _verify_config(operation.config, operation.committed) + _apply_replace(operation.recovery, operation.config.target) + owned.discard(operation.recovery) + _verify_restored_config( + operation.config, + data=operation.config.file.data, + mode=operation.config.file.mode, + ) + except Exception as error: # noqa: BLE001 - continue reverse rollback + if operation.recovery.exists(): + preserved.add(operation.recovery) + errors.append(f"{cli} config: {error}") + return errors, preserved + + +def _commit_revert(staged: list[StagedRevert], owned: set[pathlib.Path]) -> None: + operations: list[ConfigWrite | BackupRemoval] = [] + committed_configs: dict[str, FileState] = {} + try: + for item in staged: + plan = item.plan + _changed_config(plan.config, plan.config.file) + _changed_backup(plan.backup, plan.backup.file, plan.config.layer.cli) + + for item in staged: + plan = item.plan + cli = plan.config.layer.cli + _changed_config(plan.config, plan.config.file) + _changed_backup(plan.backup, plan.backup.file, cli) + committed, delayed = _apply_replace(item.restored, plan.config.target) + owned.discard(item.restored) + operations.append(ConfigWrite(plan.config, committed, item.recovery)) + committed_configs[cli] = committed + if delayed is not None: + raise delayed + + for item in staged: + plan = item.plan + cli = plan.config.layer.cli + _verify_config(plan.config, committed_configs[cli]) + _changed_backup(plan.backup, plan.backup.file, cli) + delayed = _apply_unlink(plan.backup.physical) + operations.append(BackupRemoval(plan.backup, item.backup_recovery, cli)) + if delayed is not None: + raise delayed + except Exception as error: # noqa: BLE001 - every commit failure rolls back + rollback_errors, preserved = _rollback_revert(operations, owned) + cleanup_errors = _cleanup_owned(owned, preserved) + _transaction_failure( + "revert", error, rollback_errors, cleanup_errors, preserved + ) + + cleanup_errors = _cleanup_owned(owned) + for error in cleanup_errors: + print(f"warning: {error}", file=sys.stderr) + + # ------------------------------------------------------------------ what to point at @@ -575,59 +1258,42 @@ def cmd_status(args: argparse.Namespace) -> int: def cmd_use(args: argparse.Namespace) -> int: command, arguments = launcher(args) - prepared: list[Prepared] = [] - for layer in chosen(args): - if not layer.exists(): - print(f"{layer.cli:<{CLI_COLUMN}} skipped, no config") - continue - try: - original = layer.path.read_bytes() - document = parse(layer, original) - entry = entry_for(layer, command, arguments) - into = servers(layer, document, create=True) - into[args.name] = entry - output = render(layer, document, original) - except Exception as error: - raise SystemExit(f"{layer.cli} config is unreadable: {error}") from error - prepared.append(Prepared(layer, original, output, entry)) - - build(args) + prepared = _plan_use(args, command, arguments) print( f"pointing '{args.name}' at: {command} {' '.join(arguments)}".rstrip(), file=sys.stderr, ) - if not args.dry_run: + if args.dry_run: for item in prepared: - if item.layer.path.read_bytes() != item.original: - raise SystemExit( - f"{item.layer.cli} config changed during preflight; nothing written" - ) - for item in prepared: - layer = item.layer - if args.dry_run: + layer = item.config.layer print( f"{layer.cli:<{CLI_COLUMN}} would set {args.name} = {json.dumps(item.entry)}" ) - continue - # Taken once. Swapping something already swapped must still revert to - # the config that was there before any of this started. - if not backup_of(layer).is_file(): - shutil.copy2(layer.path, backup_of(layer)) - save(layer, item.output) + return 0 + + build(args) + owned: set[pathlib.Path] = set() + staged = _stage_use(prepared, owned) + _commit_use(staged, owned) + for item in prepared: + layer = item.config.layer print(f"{layer.cli:<{CLI_COLUMN}} set {args.name}") return 0 def cmd_revert(args: argparse.Namespace) -> int: - for layer in chosen(args): - backup = backup_of(layer) - if not backup.is_file(): - print(f"{layer.cli:<{CLI_COLUMN}} nothing to revert") - continue - if args.dry_run: - print(f"{layer.cli:<{CLI_COLUMN}} would restore {backup}") - continue - shutil.move(str(backup), str(layer.path)) + prepared = _plan_revert(args) + if args.dry_run: + for item in prepared: + layer = item.config.layer + print(f"{layer.cli:<{CLI_COLUMN}} would restore {item.backup.path}") + return 0 + + owned: set[pathlib.Path] = set() + staged = _stage_revert(prepared, owned) + _commit_revert(staged, owned) + for item in prepared: + layer = item.config.layer print(f"{layer.cli:<{CLI_COLUMN}} restored") return 0 diff --git a/scripts/test_mcp_swap.py b/scripts/test_mcp_swap.py index adef387..2566135 100644 --- a/scripts/test_mcp_swap.py +++ b/scripts/test_mcp_swap.py @@ -2,6 +2,7 @@ import importlib.util import json +import os import pathlib import re import stat @@ -201,6 +202,352 @@ def change_late_config(_args: object) -> None: assert not swapper.backup_of(layer).exists() +def test_late_backup_destination_failure_writes_nothing( + swapper: types.ModuleType, +) -> None: + """Every backup destination must be feasible before the first write.""" + originals = _seed_configs(swapper) + pi = next(layer for layer in swapper.LAYERS if layer.cli == "pi") + blocked = swapper.backup_of(pi) + blocked.mkdir() + + with pytest.raises(SystemExit, match="pi.*backup"): + swapper.main(_use_args()) + + for layer in swapper.LAYERS: + assert layer.path.read_bytes() == originals[layer.cli] + if layer.cli != pi.cli: + assert not swapper.backup_of(layer).exists() + assert list(blocked.iterdir()) == [] + + +def test_use_commit_failure_rolls_back_every_client( + swapper: types.ModuleType, monkeypatch: pytest.MonkeyPatch +) -> None: + """A later config failure must reverse all earlier config and backup writes.""" + originals = _seed_configs(swapper) + pi = next(layer for layer in swapper.LAYERS if layer.cli == "pi") + destinations = _fail_first_replace_to(monkeypatch, pi.path) + + with pytest.raises((OSError, SystemExit), match="synthetic replace failure"): + swapper.main(_use_args()) + + _assert_original_state(swapper, originals) + configs = [layer.path for layer in swapper.LAYERS] + assert [path for path in destinations if path in configs] == [ + *configs, + *reversed(configs[:-1]), + ] + _assert_no_stages(swapper) + + +def test_failed_repeat_use_preserves_existing_backups( + swapper: types.ModuleType, monkeypatch: pytest.MonkeyPatch +) -> None: + """Rollback may not remove backups owned by an earlier successful swap.""" + _seed_configs(swapper) + assert swapper.main(_use_args()) == 0 + before = {layer.cli: _layer_state(swapper, layer) for layer in swapper.LAYERS} + pi = next(layer for layer in swapper.LAYERS if layer.cli == "pi") + _fail_first_replace_to(monkeypatch, pi.path) + + with pytest.raises((OSError, SystemExit), match="synthetic replace failure"): + swapper.main(_use_args("--bin", "/opt/another/libtmux-mcp")) + + assert { + layer.cli: _layer_state(swapper, layer) for layer in swapper.LAYERS + } == before + _assert_no_stages(swapper) + + +def test_revert_commit_failure_restores_the_swapped_transaction( + swapper: types.ModuleType, monkeypatch: pytest.MonkeyPatch +) -> None: + """A later restore failure must put earlier clients and backups back.""" + _seed_configs(swapper) + assert swapper.main(_use_args()) == 0 + before = {layer.cli: _layer_state(swapper, layer) for layer in swapper.LAYERS} + pi = next(layer for layer in swapper.LAYERS if layer.cli == "pi") + destinations = _fail_first_replace_to(monkeypatch, pi.path) + + with pytest.raises((OSError, SystemExit), match="synthetic replace failure"): + swapper.main(["revert"]) + + assert { + layer.cli: _layer_state(swapper, layer) for layer in swapper.LAYERS + } == before + configs = [layer.path for layer in swapper.LAYERS] + assert [path for path in destinations if path in configs] == [ + *configs, + *reversed(configs[:-1]), + ] + _assert_no_stages(swapper) + + +def test_symlink_config_survives_use_and_revert( + swapper: types.ModuleType, tmp_path: pathlib.Path +) -> None: + """Both directions write through a config symlink without replacing it.""" + originals = _seed_configs(swapper) + layer = next(layer for layer in swapper.LAYERS if layer.cli == "claude") + target = tmp_path / "real" / "claude.json" + target.parent.mkdir() + target.write_bytes(originals[layer.cli]) + target.chmod(0o640) + layer.path.unlink() + link_text = os.path.relpath(target, layer.path.parent) + layer.path.symlink_to(link_text) + + assert swapper.main(_use_args("--cli", layer.cli)) == 0 + assert layer.path.is_symlink() + assert os.readlink(layer.path) == link_text + _assert_swapped(layer) + + assert swapper.main(["revert", "--cli", layer.cli]) == 0 + assert layer.path.is_symlink() + assert os.readlink(layer.path) == link_text + assert target.read_bytes() == originals[layer.cli] + assert stat.S_IMODE(target.stat().st_mode) == 0o640 + assert not swapper.backup_of(layer).exists() + assert not any(".mcp-swap-" in path.name for path in tmp_path.rglob("*")) + + +def test_duplicate_physical_config_targets_are_rejected( + swapper: types.ModuleType, +) -> None: + """Two logical client configs may not race to replace one physical file.""" + originals = _seed_configs(swapper) + claude = next(layer for layer in swapper.LAYERS if layer.cli == "claude") + cursor = next(layer for layer in swapper.LAYERS if layer.cli == "cursor") + cursor.path.unlink() + cursor.path.symlink_to(claude.path) + + with pytest.raises(SystemExit, match="duplicate physical config target"): + swapper.main(_use_args()) + + assert claude.path.read_bytes() == originals[claude.cli] + assert cursor.path.is_symlink() + assert all(not swapper.backup_of(layer).exists() for layer in swapper.LAYERS) + + +def test_symlink_transition_after_planning_writes_nothing( + swapper: types.ModuleType, + monkeypatch: pytest.MonkeyPatch, + tmp_path: pathlib.Path, +) -> None: + """A link retargeted to identical bytes still invalidates the plan.""" + originals = _seed_configs(swapper) + pi = next(layer for layer in swapper.LAYERS if layer.cli == "pi") + first = tmp_path / "first.json" + second = tmp_path / "second.json" + for target in (first, second): + target.write_bytes(originals[pi.cli]) + target.chmod(0o640) + pi.path.unlink() + pi.path.symlink_to(first) + + def retarget(_args: object) -> None: + pi.path.unlink() + pi.path.symlink_to(second) + + monkeypatch.setattr(swapper, "build", retarget) + with pytest.raises(SystemExit, match="pi.*changed during preflight"): + swapper.main(_use_args()) + + assert pi.path.is_symlink() and pi.path.resolve() == second + assert second.read_bytes() == originals[pi.cli] + assert all( + layer.path.read_bytes() == originals[layer.cli] for layer in swapper.LAYERS + ) + assert all(not swapper.backup_of(layer).exists() for layer in swapper.LAYERS) + + +def test_mode_transition_after_planning_writes_nothing( + swapper: types.ModuleType, monkeypatch: pytest.MonkeyPatch +) -> None: + """Mode is part of the preflight identity even when bytes do not change.""" + originals = _seed_configs(swapper) + pi = next(layer for layer in swapper.LAYERS if layer.cli == "pi") + + def change_mode(_args: object) -> None: + pi.path.chmod(0o600) + + monkeypatch.setattr(swapper, "build", change_mode) + with pytest.raises(SystemExit, match="pi.*changed during preflight"): + swapper.main(_use_args()) + + assert pi.path.read_bytes() == originals[pi.cli] + assert stat.S_IMODE(pi.path.stat().st_mode) == 0o600 + assert all( + layer.path.read_bytes() == originals[layer.cli] for layer in swapper.LAYERS + ) + assert all(not swapper.backup_of(layer).exists() for layer in swapper.LAYERS) + + +def test_backup_appearance_after_planning_writes_nothing( + swapper: types.ModuleType, monkeypatch: pytest.MonkeyPatch +) -> None: + """A concurrent backup must be preserved and invalidate the whole plan.""" + originals = _seed_configs(swapper) + pi = next(layer for layer in swapper.LAYERS if layer.cli == "pi") + external = b"external backup\n" + + def create_backup(_args: object) -> None: + swapper.backup_of(pi).write_bytes(external) + + monkeypatch.setattr(swapper, "build", create_backup) + with pytest.raises(SystemExit, match="pi.*backup changed during preflight"): + swapper.main(_use_args()) + + _assert_original_state(swapper, originals, backups={pi.cli: external}) + + +def test_existing_backup_mode_change_invalidates_the_plan( + swapper: types.ModuleType, monkeypatch: pytest.MonkeyPatch +) -> None: + """A retained backup's identity and mode are frozen with the configs.""" + _seed_configs(swapper) + assert swapper.main(_use_args()) == 0 + before = {layer.cli: _layer_state(swapper, layer) for layer in swapper.LAYERS} + pi = next(layer for layer in swapper.LAYERS if layer.cli == "pi") + pi_backup = swapper.backup_of(pi) + + def change_backup_mode(_args: object) -> None: + pi_backup.chmod(0o600) + + monkeypatch.setattr(swapper, "build", change_backup_mode) + with pytest.raises(SystemExit, match="pi.*backup changed during preflight"): + swapper.main(_use_args("--bin", "/opt/another/libtmux-mcp")) + + for layer in swapper.LAYERS: + current = _layer_state(swapper, layer) + if layer.cli == pi.cli: + assert current[:-1] == before[layer.cli][:-1] + assert current[-1] == 0o600 + else: + assert current == before[layer.cli] + _assert_no_stages(swapper) + + +def test_dry_run_plans_without_building_or_writing( + swapper: types.ModuleType, monkeypatch: pytest.MonkeyPatch +) -> None: + """Dry-run parses every config and backup destination but changes no files.""" + originals = _seed_configs(swapper) + + def forbidden_build(_args: object) -> None: + raise AssertionError("dry-run built the distribution") + + monkeypatch.setattr(swapper, "build", forbidden_build) + assert swapper.main(_use_args("--dry-run")) == 0 + _assert_original_state(swapper, originals) + _assert_no_stages(swapper) + + +def test_staging_failure_cleans_up_before_any_destination_write( + swapper: types.ModuleType, monkeypatch: pytest.MonkeyPatch +) -> None: + """A late stage failure leaves configs, backups, and earlier stages absent.""" + originals = _seed_configs(swapper) + pi = next(layer for layer in swapper.LAYERS if layer.cli == "pi") + real_stage = swapper._stage + + def fail_pi_stage( + directory: pathlib.Path, + logical_name: str, + role: str, + data: bytes, + mode: int, + ) -> pathlib.Path: + if logical_name == pi.path.name and role == "output": + raise OSError("synthetic stage failure") + return real_stage(directory, logical_name, role, data, mode) + + monkeypatch.setattr(swapper, "_stage", fail_pi_stage) + with pytest.raises(SystemExit, match="synthetic stage failure"): + swapper.main(_use_args()) + + _assert_original_state(swapper, originals) + _assert_no_stages(swapper) + + +def test_failed_rollback_preserves_backup_and_recovery_stage( + swapper: types.ModuleType, monkeypatch: pytest.MonkeyPatch +) -> None: + """Unknown rollback state must retain both recoverable copies.""" + originals = _seed_configs(swapper) + claude = next(layer for layer in swapper.LAYERS if layer.cli == "claude") + cursor = next(layer for layer in swapper.LAYERS if layer.cli == "cursor") + real_replace = os.replace + claude_writes = 0 + + def fail_commit_and_rollback(src: object, dst: object) -> None: + nonlocal claude_writes + destination = pathlib.Path(dst) + if destination == cursor.path: + raise OSError("synthetic replace failure") + if destination == claude.path: + claude_writes += 1 + if claude_writes == 2: + raise OSError("synthetic rollback failure") + real_replace(src, dst) + + monkeypatch.setattr(os, "replace", fail_commit_and_rollback) + with pytest.raises(SystemExit, match="synthetic rollback failure"): + swapper.main(_use_args()) + + assert swapper.backup_of(claude).read_bytes() == originals[claude.cli] + recovery = list(claude.path.parent.glob(f".{claude.path.name}.mcp-swap-recovery-*")) + assert len(recovery) == 1 + assert recovery[0].read_bytes() == originals[claude.cli] + assert stat.S_IMODE(recovery[0].stat().st_mode) == 0o640 + + +def test_failed_backup_rollback_preserves_its_recovery_copy( + swapper: types.ModuleType, monkeypatch: pytest.MonkeyPatch +) -> None: + """A removed backup is retained as a stage if recreation cannot be proven.""" + originals = _seed_configs(swapper) + assert swapper.main(_use_args()) == 0 + swapped = {layer.cli: layer.path.read_bytes() for layer in swapper.LAYERS} + claude = next(layer for layer in swapper.LAYERS if layer.cli == "claude") + codex = next(layer for layer in swapper.LAYERS if layer.cli == "codex") + claude_backup = swapper.backup_of(claude) + codex_backup = swapper.backup_of(codex) + real_replace = os.replace + real_unlink = os.unlink + rollback_started = False + + def fail_backup_restore(src: object, dst: object) -> None: + if rollback_started and pathlib.Path(dst) == claude_backup: + raise OSError("synthetic backup rollback failure") + real_replace(src, dst) + + def fail_later_removal(path: object, *args: object, **kwargs: object) -> None: + nonlocal rollback_started + if pathlib.Path(path) == codex_backup: + rollback_started = True + raise OSError("synthetic backup removal failure") + real_unlink(path, *args, **kwargs) + + monkeypatch.setattr(os, "replace", fail_backup_restore) + monkeypatch.setattr(os, "unlink", fail_later_removal) + with pytest.raises(SystemExit, match="synthetic backup rollback failure"): + swapper.main(["revert"]) + + for layer in swapper.LAYERS: + assert layer.path.read_bytes() == swapped[layer.cli] + assert not claude_backup.exists() + recovery = list( + claude_backup.parent.glob(f".{claude_backup.name}.mcp-swap-recovery-*") + ) + assert len(recovery) == 1 + assert recovery[0].read_bytes() == originals[claude.cli] + assert stat.S_IMODE(recovery[0].stat().st_mode) == 0o640 + for layer in swapper.LAYERS[1:]: + assert swapper.backup_of(layer).read_bytes() == originals[layer.cli] + + def test_opencode_missing_root_preserves_jsonc_bytes_and_mode( swapper: types.ModuleType, ) -> None: @@ -293,3 +640,71 @@ def test_no_arguments_and_explicit_help_exit_zero( swapper.main(["--help"]) assert stopped.value.code == 0 assert "usage: mcp_swap" in capsys.readouterr().out + + +def _assert_original_state( + swapper: types.ModuleType, + originals: dict[str, bytes], + *, + backups: dict[str, bytes] | None = None, +) -> None: + expected_backups = backups or {} + for layer in swapper.LAYERS: + assert layer.path.read_bytes() == originals[layer.cli] + assert stat.S_IMODE(layer.path.stat().st_mode) == 0o640 + backup = swapper.backup_of(layer) + if layer.cli in expected_backups: + assert backup.read_bytes() == expected_backups[layer.cli] + else: + assert not backup.exists() + + +def _layer_state(swapper: types.ModuleType, layer: object) -> tuple[object, ...]: + link = os.readlink(layer.path) if layer.path.is_symlink() else None + backup = swapper.backup_of(layer) + return ( + layer.path.is_symlink(), + link, + layer.path.read_bytes(), + stat.S_IMODE(layer.path.stat().st_mode), + backup.is_symlink(), + backup.read_bytes() if backup.is_file() else None, + stat.S_IMODE(backup.stat().st_mode) if backup.is_file() else None, + ) + + +def _assert_no_stages(swapper: types.ModuleType) -> None: + home = next(layer for layer in swapper.LAYERS if layer.cli == "claude").path.parent + roles = re.compile(r"\.mcp-swap-(?:new|output|recovery|restore)-") + assert [path for path in home.rglob("*") if roles.search(path.name)] == [] + + +def _fail_first_replace_to( + monkeypatch: pytest.MonkeyPatch, destination: pathlib.Path +) -> list[pathlib.Path]: + real_replace = os.replace + real_rename = os.rename + failed = False + destinations: list[pathlib.Path] = [] + + def replace(src: object, dst: object, *args: object, **kwargs: object) -> None: + nonlocal failed + target = pathlib.Path(dst) + destinations.append(target) + if target == destination and not failed: + failed = True + raise OSError("synthetic replace failure") + real_replace(src, dst, *args, **kwargs) + + def rename(src: object, dst: object, *args: object, **kwargs: object) -> None: + nonlocal failed + target = pathlib.Path(dst) + destinations.append(target) + if target == destination and not failed: + failed = True + raise OSError("synthetic replace failure") + real_rename(src, dst, *args, **kwargs) + + monkeypatch.setattr(os, "replace", replace) + monkeypatch.setattr(os, "rename", rename) + return destinations From bf5f61a5e976a812c7ee2225517b6e50965ed279 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 5 Sep 2026 18:30:14 -0500 Subject: [PATCH 35/65] Docs(docs[changelog]): Record swap transactions why: The client-swap entry stopped at parse preflight and did not describe atomic rollback, symlink preservation, or dry-run behavior. what: - Document all-selected transactional use and revert - Record symlink, rollback, and observational dry-run guarantees --- CHANGELOG.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cc079e4..714fbf7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,8 +16,9 @@ production. - **`mcp_swap.py` configures all eight supported agent clients.** OpenCode edits preserve JSONC comments and trailing commas, Pi reports its adapter - prerequisite, and `antigravity` selects canonical `agy`; multi-client swaps - validate every config before writing. + prerequisite, and `antigravity` selects canonical `agy`. Multi-client use and + revert preflight and stage one transaction, preserve config symlinks, reverse + proven writes on failure, and keep `--dry-run` fully observational. - **`NamedServerFixture` safely owns explicitly named test servers.** It binds teardown to the reported process, socket path, and inode, then fails closed if any of that identity changes before cleanup. From 55adc31ab4f449b5a80c79f29e7178e7516d52a1 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 5 Sep 2026 18:54:38 -0500 Subject: [PATCH 36/65] Scripts(fix[mcp-swap]): Own recovery state why: A backup alone cannot prove that revert still owns the current config. Human edits and path replacements could be overwritten. what: Persist a bounded state record for the exact config, backup, topology, and server route. Validate it before repeat use or revert, and preserve physical identities through transaction rollback. --- scripts/mcp_swap.py | 582 ++++++++++++++++++++++++++++++++++----- scripts/test_mcp_swap.py | 324 +++++++++++++++++++++- 2 files changed, 824 insertions(+), 82 deletions(-) diff --git a/scripts/mcp_swap.py b/scripts/mcp_swap.py index 6ecabb8..e839bf3 100755 --- a/scripts/mcp_swap.py +++ b/scripts/mcp_swap.py @@ -67,6 +67,7 @@ from __future__ import annotations import argparse +import hashlib import json import os import pathlib @@ -86,6 +87,9 @@ ) BACKUP_SUFFIX = ".mcp-swap-backup" +STATE_SUFFIX = ".state" +STATE_VERSION = 1 +STATE_MAX_BYTES = 16 * 1024 class Layer(t.NamedTuple): @@ -452,6 +456,11 @@ def backup_of(layer: Layer) -> pathlib.Path: return layer.path.with_name(layer.path.name + BACKUP_SUFFIX) +def state_of(layer: Layer) -> pathlib.Path: + backup = backup_of(layer) + return backup.with_name(backup.name + STATE_SUFFIX) + + def entry_for(layer: Layer, command: str, arguments: list[str]) -> dict[str, t.Any]: if layer.cli == "opencode": return {"type": "local", "command": [command, *arguments]} @@ -499,16 +508,29 @@ class BackupState(t.NamedTuple): file: FileState | None +class StateFile(t.NamedTuple): + path: pathlib.Path + parent: DirectoryState + physical: pathlib.Path + file: FileState | None + record: dict[str, t.Any] | None + + class PreparedUse(t.NamedTuple): config: ConfigState backup: BackupState + state: StateFile output: bytes entry: dict[str, t.Any] + server_name: str + command: str + arguments: tuple[str, ...] class PreparedRevert(t.NamedTuple): config: ConfigState backup: BackupState + state: StateFile class StagedUse(t.NamedTuple): @@ -516,6 +538,8 @@ class StagedUse(t.NamedTuple): output: pathlib.Path recovery: pathlib.Path backup: pathlib.Path | None + state: pathlib.Path + state_recovery: pathlib.Path | None class StagedRevert(t.NamedTuple): @@ -523,6 +547,7 @@ class StagedRevert(t.NamedTuple): restored: pathlib.Path recovery: pathlib.Path backup_recovery: pathlib.Path + state_recovery: pathlib.Path class ConfigWrite(t.NamedTuple): @@ -531,18 +556,37 @@ class ConfigWrite(t.NamedTuple): recovery: pathlib.Path +class ConfigRemoval(t.NamedTuple): + config: ConfigState + recovery: pathlib.Path + + class BackupWrite(t.NamedTuple): backup: BackupState committed: FileState cli: str +class StateWrite(t.NamedTuple): + state: StateFile + backup: BackupState + committed: FileState + recovery: pathlib.Path | None + cli: str + + class BackupRemoval(t.NamedTuple): backup: BackupState recovery: pathlib.Path cli: str +class StateRemoval(t.NamedTuple): + state: StateFile + recovery: pathlib.Path + cli: str + + def _file_state(path: pathlib.Path) -> FileState: before = path.stat() if not stat.S_ISREG(before.st_mode): @@ -644,6 +688,173 @@ def _backup_state(layer: Layer, *, required: bool = False) -> BackupState: return BackupState(path, parent, physical, file) +def _file_document(file: FileState) -> dict[str, t.Any]: + return { + "device": file.device, + "inode": file.inode, + "mode": file.mode, + "sha256": hashlib.sha256(file.data).hexdigest(), + "size": file.size, + } + + +def _directory_document(directory: DirectoryState) -> dict[str, t.Any]: + return { + "device": directory.device, + "inode": directory.inode, + "link_device": directory.link_device if directory.symlink else None, + "link_inode": directory.link_inode if directory.symlink else None, + "link_mode": directory.link_mode if directory.symlink else None, + "link_text": directory.link_text, + "logical": str(directory.logical), + "mode": directory.mode, + "physical": str(directory.physical), + "symlink": directory.symlink, + } + + +def _config_document(config: ConfigState, file: FileState) -> dict[str, t.Any]: + return { + "file": _file_document(file), + "link_device": config.link_device if config.symlink else None, + "link_inode": config.link_inode if config.symlink else None, + "link_mode": config.link_mode if config.symlink else None, + "link_text": config.link_text, + "logical": str(config.layer.path), + "parent": _directory_document(config.parent), + "symlink": config.symlink, + "target": str(config.target), + } + + +def _record_bytes(record: dict[str, t.Any]) -> bytes: + data = (json.dumps(record, sort_keys=True, separators=(",", ":")) + "\n").encode() + if len(data) > STATE_MAX_BYTES: + raise ValueError(f"recovery state exceeds {STATE_MAX_BYTES} bytes") + return data + + +def _object(value: t.Any, keys: set[str], label: str) -> dict[str, t.Any]: + if not isinstance(value, dict) or set(value) != keys: + raise ValueError(f"{label} has unknown or missing fields") + return value + + +def _decode_record(layer: Layer, data: bytes) -> dict[str, t.Any]: + if len(data) > STATE_MAX_BYTES: + raise ValueError(f"recovery state exceeds {STATE_MAX_BYTES} bytes") + try: + root = json.loads(data) + except (UnicodeDecodeError, json.JSONDecodeError) as error: + raise ValueError("recovery state is malformed") from error + document = _object( + root, {"backup", "cli", "config", "server", "version"}, "recovery state" + ) + if type(document["version"]) is not int or document["version"] != STATE_VERSION: + raise ValueError("recovery state version is unsupported") + if type(document["cli"]) is not str or document["cli"] != layer.cli: + raise ValueError("recovery state names another client") + server = _object( + document["server"], {"arguments", "command", "name"}, "recovery server" + ) + arguments = server["arguments"] + if ( + not isinstance(arguments, list) + or len(arguments) > 128 + or any(not isinstance(argument, str) for argument in arguments) + or type(server["command"]) is not str + or type(server["name"]) is not str + ): + raise ValueError("recovery server route is invalid") + _object(document["backup"], {"file", "parent", "path", "target"}, "recovery backup") + if not isinstance(document["config"], dict): + raise TypeError("recovery config is invalid") + return document + + +def _same_typed(left: t.Any, right: t.Any) -> bool: + if type(left) is not type(right): + return False + if isinstance(left, dict): + return set(left) == set(right) and all( + _same_typed(left[key], right[key]) for key in left + ) + if isinstance(left, list): + return len(left) == len(right) and all( + _same_typed(one, two) for one, two in zip(left, right, strict=True) + ) + return bool(left == right) + + +def _state_file(layer: Layer, *, required: bool = False) -> StateFile: + path = state_of(layer) + parent = _directory_state(path.parent) + physical = parent.physical / path.name + if not os.path.lexists(path): + if required: + raise FileNotFoundError(path) + return StateFile(path, parent, physical, None, None) + details = path.lstat() + if stat.S_ISLNK(details.st_mode) or not stat.S_ISREG(details.st_mode): + raise ValueError(f"{path} is not a regular file") + if details.st_size > STATE_MAX_BYTES: + raise ValueError(f"{path} exceeds {STATE_MAX_BYTES} bytes") + if path.resolve(strict=True) != physical: + raise RuntimeError(f"{path} did not resolve in its preflight directory") + file = _file_state(physical) + if (details.st_dev, details.st_ino) != (file.device, file.inode): + raise RuntimeError(f"{path} changed while it was resolved") + if file.mode != 0o600: + raise ValueError(f"{path} mode is not 0600") + return StateFile(path, parent, physical, file, _decode_record(layer, file.data)) + + +def _record_for( + plan: PreparedUse, target_file: FileState, backup_file: FileState +) -> dict[str, t.Any]: + return { + "backup": { + "file": _file_document(backup_file), + "parent": _directory_document(plan.backup.parent), + "path": str(plan.backup.path), + "target": str(plan.backup.physical), + }, + "cli": plan.config.layer.cli, + "config": _config_document(plan.config, target_file), + "server": { + "arguments": list(plan.arguments), + "command": plan.command, + "name": plan.server_name, + }, + "version": STATE_VERSION, + } + + +def _verify_owned_recovery( + config: ConfigState, backup: BackupState, state: StateFile +) -> None: + record = state.record + backup_file = backup.file + if record is None or backup_file is None: + raise RuntimeError("recovery backup and state are incomplete") + if not _same_typed(record["config"], _config_document(config, config.file)): + raise RuntimeError("config no longer matches the recovery state") + route = record["server"] + document = parse(config.layer, config.file.data) + actual = (servers(config.layer, document) or {}).get(route["name"]) + expected = entry_for(config.layer, route["command"], route["arguments"]) + if actual != expected: + raise RuntimeError("server route no longer matches the recovery state") + expected_backup = { + "file": _file_document(backup_file), + "parent": _directory_document(backup.parent), + "path": str(backup.path), + "target": str(backup.physical), + } + if not _same_typed(record["backup"], expected_backup): + raise RuntimeError("backup no longer matches the recovery state") + + def _verify_directory(expected: DirectoryState) -> None: current = _directory_state(expected.logical) if current != expected: @@ -675,10 +886,10 @@ def _verify_config(config: ConfigState, expected: FileState) -> None: raise RuntimeError(f"{config.layer.path} logical identity changed") -def _verify_restored_config(config: ConfigState, *, data: bytes, mode: int) -> None: +def _verify_missing_config(config: ConfigState) -> None: _verify_directory(config.parent) - details = config.layer.path.lstat() if config.symlink: + details = config.layer.path.lstat() if ( not stat.S_ISLNK(details.st_mode) or os.readlink(config.layer.path) != config.link_text @@ -686,39 +897,31 @@ def _verify_restored_config(config: ConfigState, *, data: bytes, mode: int) -> N != (config.link_device, config.link_inode, config.link_mode) ): raise RuntimeError(f"{config.layer.path} symlink changed") - elif not stat.S_ISREG(details.st_mode): - raise RuntimeError(f"{config.layer.path} topology changed") - if config.layer.path.resolve(strict=True) != config.target: - raise RuntimeError(f"{config.layer.path} target changed") - current = _file_state(config.target) - if current.data != data or current.mode != mode: - raise RuntimeError(f"{config.layer.path} was not restored exactly") + elif os.path.lexists(config.layer.path): + raise RuntimeError(f"{config.layer.path} appeared") + if os.path.lexists(config.target): + raise RuntimeError(f"{config.target} appeared") -def _verify_backup(backup: BackupState, expected: FileState | None) -> None: - _verify_directory(backup.parent) +def _verify_artifact( + artifact: BackupState | StateFile, expected: FileState | None +) -> None: + _verify_directory(artifact.parent) if expected is None: - if os.path.lexists(backup.path): - raise RuntimeError(f"{backup.path} appeared") + if os.path.lexists(artifact.path): + raise RuntimeError(f"{artifact.path} appeared") return - if not os.path.lexists(backup.path) or backup.path.is_symlink(): - raise RuntimeError(f"{backup.path} topology changed") - if backup.path.resolve(strict=True) != backup.physical: - raise RuntimeError(f"{backup.path} target changed") - if _file_state(backup.physical) != expected: - raise RuntimeError(f"{backup.path} identity, mode, or bytes changed") - - -def _verify_restored_backup(backup: BackupState) -> None: - expected = t.cast(FileState, backup.file) - _verify_directory(backup.parent) - if not os.path.lexists(backup.path) or backup.path.is_symlink(): - raise RuntimeError(f"{backup.path} topology changed") - if backup.path.resolve(strict=True) != backup.physical: - raise RuntimeError(f"{backup.path} target changed") - current = _file_state(backup.physical) - if current.data != expected.data or current.mode != expected.mode: - raise RuntimeError(f"{backup.path} was not restored exactly") + if not os.path.lexists(artifact.path) or artifact.path.is_symlink(): + raise RuntimeError(f"{artifact.path} topology changed") + if artifact.path.resolve(strict=True) != artifact.physical: + raise RuntimeError(f"{artifact.path} target changed") + if _file_state(artifact.physical) != expected: + raise RuntimeError(f"{artifact.path} identity, mode, or bytes changed") + + +def _verify_restored_artifact(artifact: BackupState | StateFile) -> None: + expected = t.cast(FileState, artifact.file) + _verify_artifact(artifact, expected) def _reject_duplicate_targets(plans: t.Iterable[t.Any]) -> None: @@ -761,6 +964,21 @@ def _reject_duplicate_targets(plans: t.Iterable[t.Any]) -> None: if backup_inode is not None: all_inodes[backup_inode] = f"{cli} backup" + state = plan.state + state_inode = ( + None if state.file is None else (state.file.device, state.file.inode) + ) + owner = all_paths.get(state.physical) + if owner is None and state_inode is not None: + owner = all_inodes.get(state_inode) + if owner is not None: + raise SystemExit( + f"duplicate transaction destination for {owner} and {cli} state" + ) + all_paths[state.physical] = f"{cli} state" + if state_inode is not None: + all_inodes[state_inode] = f"{cli} state" + def _stage( directory: pathlib.Path, @@ -862,6 +1080,23 @@ def _plan_use( continue try: config = _config_state(layer) + except Exception as error: + raise SystemExit(f"{layer.cli} config is unreadable: {error}") from error + try: + backup = _backup_state(layer) + except Exception as error: + raise SystemExit(f"{layer.cli} backup is unusable: {error}") from error + try: + state = _state_file(layer) + if (backup.file is None) != (state.file is None): + raise RuntimeError("backup and state must exist together") + if state.file is not None: + _verify_owned_recovery(config, backup, state) + except Exception as error: + raise SystemExit( + f"{layer.cli} recovery state is unusable: {error}" + ) from error + try: document = parse(layer, config.file.data) entry = entry_for(layer, command, arguments) into = servers(layer, document, create=True) @@ -869,11 +1104,40 @@ def _plan_use( output = render(layer, document, config.file.data) except Exception as error: raise SystemExit(f"{layer.cli} config is unreadable: {error}") from error + plan = PreparedUse( + config, + backup, + state, + output, + entry, + args.name, + command, + tuple(arguments), + ) + largest_identity = (1 << 64) - 1 + preview_target = FileState( + largest_identity, + largest_identity, + config.file.mode, + len(output), + 0, + output, + ) + preview_backup = backup.file or FileState( + largest_identity, + largest_identity, + config.file.mode, + config.file.size, + 0, + config.file.data, + ) try: - backup = _backup_state(layer) + _record_bytes(_record_for(plan, preview_target, preview_backup)) except Exception as error: - raise SystemExit(f"{layer.cli} backup is unusable: {error}") from error - prepared.append(PreparedUse(config, backup, output, entry)) + raise SystemExit( + f"{layer.cli} recovery state is unusable: {error}" + ) from error + prepared.append(plan) _reject_duplicate_targets(prepared) return prepared @@ -881,18 +1145,40 @@ def _plan_use( def _plan_revert(args: argparse.Namespace) -> list[PreparedRevert]: prepared: list[PreparedRevert] = [] for layer in chosen(args): - if not os.path.lexists(backup_of(layer)): + backup_exists = os.path.lexists(backup_of(layer)) + state_exists = os.path.lexists(state_of(layer)) + if not backup_exists and not state_exists: print(f"{layer.cli:<{CLI_COLUMN}} nothing to revert") continue + if backup_exists != state_exists: + raise SystemExit(f"{layer.cli} recovery backup and state are incomplete") try: backup = _backup_state(layer, required=True) except Exception as error: raise SystemExit(f"{layer.cli} backup is unusable: {error}") from error + try: + state = _state_file(layer, required=True) + except Exception as error: + raise SystemExit( + f"{layer.cli} recovery state is unusable: {error}" + ) from error try: config = _config_state(layer) except Exception as error: raise SystemExit(f"{layer.cli} config is unreadable: {error}") from error - prepared.append(PreparedRevert(config, backup)) + try: + _verify_owned_recovery(config, backup, state) + record = t.cast(dict[str, t.Any], state.record) + server_name = record["server"]["name"] + if server_name != args.name: + raise RuntimeError( + f"state belongs to server {server_name!r}, not {args.name!r}" + ) + except Exception as error: + raise SystemExit( + f"{layer.cli} recovery ownership changed: {error}" + ) from error + prepared.append(PreparedRevert(config, backup, state)) _reject_duplicate_targets(prepared) return prepared @@ -908,11 +1194,18 @@ def _changed_config(config: ConfigState, expected: FileState) -> None: def _changed_backup(backup: BackupState, expected: FileState | None, cli: str) -> None: try: - _verify_backup(backup, expected) + _verify_artifact(backup, expected) except Exception as error: raise RuntimeError(f"{cli} backup changed during preflight") from error +def _changed_state(state: StateFile, expected: FileState | None, cli: str) -> None: + try: + _verify_artifact(state, expected) + except Exception as error: + raise RuntimeError(f"{cli} recovery state changed during preflight") from error + + def _stage_use(plans: list[PreparedUse], owned: set[pathlib.Path]) -> list[StagedUse]: staged: list[StagedUse] = [] try: @@ -944,7 +1237,33 @@ def _stage_use(plans: list[PreparedUse], owned: set[pathlib.Path]) -> list[Stage config.file.mode, ) owned.add(backup) - staged.append(StagedUse(plan, output, recovery, backup)) + target_file = _file_state(output) + backup_file = ( + _file_state(backup) + if backup is not None + else t.cast(FileState, plan.backup.file) + ) + state = _stage( + plan.state.parent.physical, + plan.state.path.name, + "state", + _record_bytes(_record_for(plan, target_file, backup_file)), + 0o600, + ) + owned.add(state) + state_recovery = None + if plan.state.file is not None: + state_recovery = _stage( + plan.state.parent.physical, + plan.state.path.name, + "recovery-state", + plan.state.file.data, + plan.state.file.mode, + ) + owned.add(state_recovery) + staged.append( + StagedUse(plan, output, recovery, backup, state, state_recovery) + ) except Exception as error: cleanup = _cleanup_owned(owned) detail = f"swap staging failed: {error}" @@ -986,7 +1305,24 @@ def _stage_revert( backup.mode, ) owned.add(backup_recovery) - staged.append(StagedRevert(plan, restored, recovery, backup_recovery)) + state = t.cast(FileState, plan.state.file) + state_recovery = _stage( + plan.state.parent.physical, + plan.state.path.name, + "recovery-state", + state.data, + state.mode, + ) + owned.add(state_recovery) + staged.append( + StagedRevert( + plan, + restored, + recovery, + backup_recovery, + state_recovery, + ) + ) except Exception as error: cleanup = _cleanup_owned(owned) detail = f"revert staging failed: {error}" @@ -996,38 +1332,66 @@ def _stage_revert( return staged +def _restore_removed_config( + operation: ConfigWrite | ConfigRemoval, + owned: set[pathlib.Path], +) -> None: + if isinstance(operation, ConfigWrite): + _verify_config(operation.config, operation.committed) + else: + _verify_missing_config(operation.config) + _apply_replace(operation.recovery, operation.config.target) + owned.discard(operation.recovery) + _verify_config(operation.config, operation.config.file) + + def _rollback_use( - operations: list[ConfigWrite | BackupWrite], + operations: list[ConfigWrite | ConfigRemoval | BackupWrite | StateWrite], owned: set[pathlib.Path], ) -> tuple[list[str], set[pathlib.Path]]: errors: list[str] = [] preserved: set[pathlib.Path] = set() - failed_configs: set[str] = set() + blocked: set[str] = set() for operation in reversed(operations): - if isinstance(operation, ConfigWrite): + if isinstance(operation, (ConfigWrite, ConfigRemoval)): cli = operation.config.layer.cli try: - _verify_config(operation.config, operation.committed) - _apply_replace(operation.recovery, operation.config.target) - owned.discard(operation.recovery) - _verify_restored_config( - operation.config, - data=operation.config.file.data, - mode=operation.config.file.mode, - ) + _restore_removed_config(operation, owned) except Exception as error: # noqa: BLE001 - continue reverse rollback - failed_configs.add(cli) + blocked.add(cli) if operation.recovery.exists(): preserved.add(operation.recovery) errors.append(f"{cli} config: {error}") continue + if isinstance(operation, StateWrite): + cli = operation.cli + if cli in blocked: + preserved.update((operation.state.path, operation.backup.path)) + continue + try: + _verify_artifact(operation.state, operation.committed) + if operation.state.file is None: + _apply_unlink(operation.state.physical) + else: + recovery = t.cast(pathlib.Path, operation.recovery) + _apply_replace(recovery, operation.state.physical) + owned.discard(recovery) + _verify_restored_artifact(operation.state) + except Exception as error: # noqa: BLE001 - continue reverse rollback + blocked.add(cli) + preserved.update((operation.state.path, operation.backup.path)) + if operation.recovery is not None and operation.recovery.exists(): + preserved.add(operation.recovery) + errors.append(f"{cli} recovery state: {error}") + continue + cli = operation.cli - if cli in failed_configs: + if cli in blocked: preserved.add(operation.backup.path) continue try: - _verify_backup(operation.backup, operation.committed) + _verify_artifact(operation.backup, operation.committed) _apply_unlink(operation.backup.physical) except Exception as error: # noqa: BLE001 - continue reverse rollback preserved.add(operation.backup.path) @@ -1036,8 +1400,9 @@ def _rollback_use( def _commit_use(staged: list[StagedUse], owned: set[pathlib.Path]) -> None: - operations: list[ConfigWrite | BackupWrite] = [] + operations: list[ConfigWrite | ConfigRemoval | BackupWrite | StateWrite] = [] committed_backups: dict[str, FileState] = {} + committed_states: dict[str, FileState] = {} try: for item in staged: _changed_config(item.plan.config, item.plan.config.file) @@ -1046,6 +1411,11 @@ def _commit_use(staged: list[StagedUse], owned: set[pathlib.Path]) -> None: item.plan.backup.file, item.plan.config.layer.cli, ) + _changed_state( + item.plan.state, + item.plan.state.file, + item.plan.config.layer.cli, + ) for item in staged: if item.backup is None: @@ -1067,9 +1437,46 @@ def _commit_use(staged: list[StagedUse], owned: set[pathlib.Path]) -> None: _changed_config(plan.config, plan.config.file) expected_backup = committed_backups.get(cli, plan.backup.file) _changed_backup(plan.backup, expected_backup, cli) + _changed_state(plan.state, plan.state.file, cli) + committed, delayed = _apply_replace(item.state, plan.state.physical) + owned.discard(item.state) + operations.append( + StateWrite( + plan.state, + plan.backup, + committed, + item.state_recovery, + cli, + ) + ) + committed_states[cli] = committed + if delayed is not None: + raise delayed + + for item in staged: + plan = item.plan + cli = plan.config.layer.cli + _changed_config(plan.config, plan.config.file) + expected_backup = committed_backups.get(cli, plan.backup.file) + _changed_backup(plan.backup, expected_backup, cli) + _changed_state(plan.state, committed_states[cli], cli) + removed, delayed = _apply_replace(plan.config.target, item.recovery) + operations.append(ConfigRemoval(plan.config, item.recovery)) + if removed != plan.config.file: + raise RuntimeError(f"{cli} config recovery identity changed") + if delayed is not None: + raise delayed committed, delayed = _apply_replace(item.output, plan.config.target) owned.discard(item.output) - operations.append(ConfigWrite(plan.config, committed, item.recovery)) + operations[-1] = ConfigWrite(plan.config, committed, item.recovery) + expected_record = _record_for( + plan, committed, t.cast(FileState, expected_backup) + ) + if ( + _decode_record(plan.config.layer, committed_states[cli].data) + != expected_record + ): + raise RuntimeError(f"{cli} recovery state does not own the new config") if delayed is not None: raise delayed except Exception as error: # noqa: BLE001 - every commit failure rolls back @@ -1083,12 +1490,30 @@ def _commit_use(staged: list[StagedUse], owned: set[pathlib.Path]) -> None: def _rollback_revert( - operations: list[ConfigWrite | BackupRemoval], + operations: list[ConfigWrite | ConfigRemoval | BackupRemoval | StateRemoval], owned: set[pathlib.Path], ) -> tuple[list[str], set[pathlib.Path]]: errors: list[str] = [] preserved: set[pathlib.Path] = set() for operation in reversed(operations): + if isinstance(operation, StateRemoval): + cli = operation.cli + try: + _verify_directory(operation.state.parent) + if os.path.lexists(operation.state.path): + raise RuntimeError( + f"{operation.state.path} appeared before rollback" + ) + _apply_replace(operation.recovery, operation.state.physical) + owned.discard(operation.recovery) + _verify_restored_artifact(operation.state) + except Exception as error: # noqa: BLE001 - continue reverse rollback + if operation.recovery.exists(): + preserved.add(operation.recovery) + preserved.add(operation.state.path) + errors.append(f"{cli} recovery state: {error}") + continue + if isinstance(operation, BackupRemoval): cli = operation.cli try: @@ -1099,7 +1524,7 @@ def _rollback_revert( ) _apply_replace(operation.recovery, operation.backup.physical) owned.discard(operation.recovery) - _verify_restored_backup(operation.backup) + _verify_restored_artifact(operation.backup) except Exception as error: # noqa: BLE001 - continue reverse rollback if operation.recovery.exists(): preserved.add(operation.recovery) @@ -1108,14 +1533,7 @@ def _rollback_revert( cli = operation.config.layer.cli try: - _verify_config(operation.config, operation.committed) - _apply_replace(operation.recovery, operation.config.target) - owned.discard(operation.recovery) - _verify_restored_config( - operation.config, - data=operation.config.file.data, - mode=operation.config.file.mode, - ) + _restore_removed_config(operation, owned) except Exception as error: # noqa: BLE001 - continue reverse rollback if operation.recovery.exists(): preserved.add(operation.recovery) @@ -1124,22 +1542,30 @@ def _rollback_revert( def _commit_revert(staged: list[StagedRevert], owned: set[pathlib.Path]) -> None: - operations: list[ConfigWrite | BackupRemoval] = [] + operations: list[ConfigWrite | ConfigRemoval | BackupRemoval | StateRemoval] = [] committed_configs: dict[str, FileState] = {} try: for item in staged: plan = item.plan _changed_config(plan.config, plan.config.file) _changed_backup(plan.backup, plan.backup.file, plan.config.layer.cli) + _changed_state(plan.state, plan.state.file, plan.config.layer.cli) for item in staged: plan = item.plan cli = plan.config.layer.cli _changed_config(plan.config, plan.config.file) _changed_backup(plan.backup, plan.backup.file, cli) + _changed_state(plan.state, plan.state.file, cli) + removed, delayed = _apply_replace(plan.config.target, item.recovery) + operations.append(ConfigRemoval(plan.config, item.recovery)) + if removed != plan.config.file: + raise RuntimeError(f"{cli} config recovery identity changed") + if delayed is not None: + raise delayed committed, delayed = _apply_replace(item.restored, plan.config.target) owned.discard(item.restored) - operations.append(ConfigWrite(plan.config, committed, item.recovery)) + operations[-1] = ConfigWrite(plan.config, committed, item.recovery) committed_configs[cli] = committed if delayed is not None: raise delayed @@ -1149,8 +1575,28 @@ def _commit_revert(staged: list[StagedRevert], owned: set[pathlib.Path]) -> None cli = plan.config.layer.cli _verify_config(plan.config, committed_configs[cli]) _changed_backup(plan.backup, plan.backup.file, cli) - delayed = _apply_unlink(plan.backup.physical) + _changed_state(plan.state, plan.state.file, cli) + removed, delayed = _apply_replace( + plan.backup.physical, item.backup_recovery + ) operations.append(BackupRemoval(plan.backup, item.backup_recovery, cli)) + if removed != plan.backup.file: + raise RuntimeError(f"{cli} backup recovery identity changed") + if delayed is not None: + raise delayed + + for item in staged: + plan = item.plan + cli = plan.config.layer.cli + _verify_config(plan.config, committed_configs[cli]) + _verify_directory(plan.backup.parent) + if os.path.lexists(plan.backup.path): + raise RuntimeError(f"{cli} backup still exists after removal") + _changed_state(plan.state, plan.state.file, cli) + removed, delayed = _apply_replace(plan.state.physical, item.state_recovery) + operations.append(StateRemoval(plan.state, item.state_recovery, cli)) + if removed != plan.state.file: + raise RuntimeError(f"{cli} recovery state identity changed") if delayed is not None: raise delayed except Exception as error: # noqa: BLE001 - every commit failure rolls back diff --git a/scripts/test_mcp_swap.py b/scripts/test_mcp_swap.py index 2566135..a86b526 100644 --- a/scripts/test_mcp_swap.py +++ b/scripts/test_mcp_swap.py @@ -133,6 +133,7 @@ def test_each_client_swaps_and_restores_in_isolation( _assert_swapped(selected) assert stat.S_IMODE(selected.path.stat().st_mode) == 0o640 assert swapper.backup_of(selected).read_bytes() == originals[cli] + assert _state_of(swapper, selected).is_file() for layer in swapper.LAYERS: if layer.cli != cli: assert layer.path.read_bytes() == originals[layer.cli] @@ -142,6 +143,7 @@ def test_each_client_swaps_and_restores_in_isolation( assert selected.path.read_bytes() == originals[cli] assert stat.S_IMODE(selected.path.stat().st_mode) == 0o640 assert not swapper.backup_of(selected).exists() + assert not _state_of(swapper, selected).exists() def test_all_eight_clients_commit_only_after_full_preflight( @@ -156,12 +158,14 @@ def test_all_eight_clients_commit_only_after_full_preflight( _assert_swapped(layer) assert stat.S_IMODE(layer.path.stat().st_mode) == 0o640 assert swapper.backup_of(layer).read_bytes() == originals[layer.cli] + assert _state_of(swapper, layer).is_file() assert swapper.main(["revert"]) == 0 for layer in swapper.LAYERS: assert layer.path.read_bytes() == originals[layer.cli] assert stat.S_IMODE(layer.path.stat().st_mode) == 0o640 assert not swapper.backup_of(layer).exists() + assert not _state_of(swapper, layer).exists() def test_failed_late_config_preflight_writes_nothing( @@ -236,11 +240,38 @@ def test_use_commit_failure_rolls_back_every_client( configs = [layer.path for layer in swapper.LAYERS] assert [path for path in destinations if path in configs] == [ *configs, - *reversed(configs[:-1]), + *reversed(configs), ] _assert_no_stages(swapper) +def test_state_publication_failure_rolls_back_every_client( + swapper: types.ModuleType, monkeypatch: pytest.MonkeyPatch +) -> None: + """A later sidecar failure reverses earlier sidecars and backups.""" + originals = _seed_configs(swapper) + states = [_state_of(swapper, layer) for layer in swapper.LAYERS] + destinations = _fail_first_replace_to(monkeypatch, states[-1]) + real_unlink = os.unlink + removed: list[pathlib.Path] = [] + + def track_state_removal(path: object, *args: object, **kwargs: object) -> None: + target = pathlib.Path(path) + if target in states: + removed.append(target) + real_unlink(path, *args, **kwargs) + + monkeypatch.setattr(os, "unlink", track_state_removal) + + with pytest.raises(SystemExit, match="synthetic replace failure"): + swapper.main(_use_args()) + + _assert_original_state(swapper, originals) + assert [path for path in destinations if path in states] == states + assert removed == list(reversed(states[:-1])) + _assert_no_stages(swapper) + + def test_failed_repeat_use_preserves_existing_backups( swapper: types.ModuleType, monkeypatch: pytest.MonkeyPatch ) -> None: @@ -260,6 +291,43 @@ def test_failed_repeat_use_preserves_existing_backups( _assert_no_stages(swapper) +def test_repeat_use_refuses_an_unowned_config_edit( + swapper: types.ModuleType, +) -> None: + """A retained backup never authorizes overwriting a human edit.""" + _seed_configs(swapper) + layer = next(layer for layer in swapper.LAYERS if layer.cli == "claude") + assert swapper.main(_use_args("--cli", layer.cli)) == 0 + human = layer.path.read_bytes() + b"\n" + layer.path.write_bytes(human) + + with pytest.raises(SystemExit, match="claude"): + swapper.main(_use_args("--cli", layer.cli, "--bin", "/opt/next/mcp")) + + assert layer.path.read_bytes() == human + assert swapper.backup_of(layer).is_file() + assert _state_of(swapper, layer).is_file() + + +def test_repeat_use_updates_owned_state_but_keeps_the_first_backup( + swapper: types.ModuleType, +) -> None: + """An owned repeat swap advances its record without moving its baseline.""" + originals = _seed_configs(swapper) + layer = next(layer for layer in swapper.LAYERS if layer.cli == "claude") + assert swapper.main(_use_args("--cli", layer.cli)) == 0 + backup = swapper.backup_of(layer) + first_state = _state_of(swapper, layer).read_bytes() + + assert swapper.main(_use_args("--cli", layer.cli, "--bin", "/opt/next/mcp")) == 0 + + assert backup.read_bytes() == originals[layer.cli] + assert _state_of(swapper, layer).read_bytes() != first_state + assert _document(layer)["mcpServers"]["tmux"]["command"] == "/opt/next/mcp" + assert swapper.main(["revert", "--cli", layer.cli]) == 0 + assert layer.path.read_bytes() == originals[layer.cli] + + def test_revert_commit_failure_restores_the_swapped_transaction( swapper: types.ModuleType, monkeypatch: pytest.MonkeyPatch ) -> None: @@ -279,11 +347,81 @@ def test_revert_commit_failure_restores_the_swapped_transaction( configs = [layer.path for layer in swapper.LAYERS] assert [path for path in destinations if path in configs] == [ *configs, - *reversed(configs[:-1]), + *reversed(configs), ] _assert_no_stages(swapper) +def test_state_removal_failure_restores_the_swapped_transaction( + swapper: types.ModuleType, monkeypatch: pytest.MonkeyPatch +) -> None: + """Sidecar cleanup participates in reverse all-client rollback.""" + _seed_configs(swapper) + assert swapper.main(_use_args()) == 0 + before = {layer.cli: _owned_layer_state(swapper, layer) for layer in swapper.LAYERS} + pi = next(layer for layer in swapper.LAYERS if layer.cli == "pi") + blocked = _state_of(swapper, pi) + real_replace = swapper._apply_replace + + def fail_state_removal( + source: pathlib.Path, destination: pathlib.Path + ) -> tuple[object, object]: + if pathlib.Path(source) == blocked: + raise OSError("synthetic state removal failure") + return real_replace(source, destination) + + monkeypatch.setattr(swapper, "_apply_replace", fail_state_removal) + with pytest.raises(SystemExit, match="synthetic state removal failure"): + swapper.main(["revert"]) + + assert { + layer.cli: _owned_layer_state(swapper, layer) for layer in swapper.LAYERS + } == before + assert swapper.main(["revert", "--dry-run"]) == 0 + _assert_no_stages(swapper) + + +def test_revert_refuses_a_human_edit_and_retains_recovery( + swapper: types.ModuleType, +) -> None: + """Revert owns only the exact config state written by use.""" + originals = _seed_configs(swapper) + layer = next(layer for layer in swapper.LAYERS if layer.cli == "claude") + assert swapper.main(_use_args("--cli", layer.cli)) == 0 + human = layer.path.read_bytes() + b"\n" + layer.path.write_bytes(human) + + with pytest.raises(SystemExit, match="claude"): + swapper.main(["revert", "--cli", layer.cli]) + + assert layer.path.read_bytes() == human + assert swapper.backup_of(layer).read_bytes() == originals[layer.cli] + assert _state_of(swapper, layer).is_file() + + +def test_revert_refuses_a_same_path_inode_replacement( + swapper: types.ModuleType, +) -> None: + """Identical bytes at a new physical config identity are not owned.""" + _seed_configs(swapper) + layer = next(layer for layer in swapper.LAYERS if layer.cli == "claude") + assert swapper.main(_use_args("--cli", layer.cli)) == 0 + swapped = layer.path.read_bytes() + prior_inode = layer.path.stat().st_ino + replacement = layer.path.with_name("replacement.json") + replacement.write_bytes(swapped) + replacement.chmod(0o640) + os.replace(replacement, layer.path) + assert layer.path.stat().st_ino != prior_inode + + with pytest.raises(SystemExit, match="claude"): + swapper.main(["revert", "--cli", layer.cli]) + + assert layer.path.read_bytes() == swapped + assert swapper.backup_of(layer).is_file() + assert _state_of(swapper, layer).is_file() + + def test_symlink_config_survives_use_and_revert( swapper: types.ModuleType, tmp_path: pathlib.Path ) -> None: @@ -312,6 +450,134 @@ def test_symlink_config_survives_use_and_revert( assert not any(".mcp-swap-" in path.name for path in tmp_path.rglob("*")) +def test_revert_refuses_a_config_symlink_retarget( + swapper: types.ModuleType, tmp_path: pathlib.Path +) -> None: + """A link moved after use cannot redirect restoration into another file.""" + originals = _seed_configs(swapper) + layer = next(layer for layer in swapper.LAYERS if layer.cli == "claude") + first = tmp_path / "first.json" + second = tmp_path / "second.json" + first.write_bytes(originals[layer.cli]) + first.chmod(0o640) + layer.path.unlink() + layer.path.symlink_to(first) + assert swapper.main(_use_args("--cli", layer.cli)) == 0 + swapped = first.read_bytes() + second.write_bytes(swapped) + second.chmod(0o640) + layer.path.unlink() + layer.path.symlink_to(second) + + with pytest.raises(SystemExit, match="claude"): + swapper.main(["revert", "--cli", layer.cli]) + + assert second.read_bytes() == swapped + assert first.read_bytes() == swapped + assert swapper.backup_of(layer).is_file() + assert _state_of(swapper, layer).is_file() + + +@pytest.mark.parametrize("artifact", ["backup", "state"]) +def test_revert_refuses_tampered_recovery_artifacts( + swapper: types.ModuleType, artifact: str +) -> None: + """Neither half of the recovery unit may change before restore.""" + _seed_configs(swapper) + layer = next(layer for layer in swapper.LAYERS if layer.cli == "claude") + assert swapper.main(_use_args("--cli", layer.cli)) == 0 + swapped = layer.path.read_bytes() + selected = ( + swapper.backup_of(layer) if artifact == "backup" else _state_of(swapper, layer) + ) + if artifact == "backup": + tampered = b"tampered recovery artifact\n" + else: + document = json.loads(selected.read_text(encoding="utf-8")) + document["server"]["command"] = "/tampered/mcp" + tampered = ( + json.dumps(document, sort_keys=True, separators=(",", ":")) + "\n" + ).encode() + selected.write_bytes(tampered) + + with pytest.raises(SystemExit, match="claude"): + swapper.main(["revert", "--cli", layer.cli]) + + assert layer.path.read_bytes() == swapped + assert selected.read_bytes() == tampered + assert swapper.backup_of(layer).exists() + assert _state_of(swapper, layer).exists() + + +def test_revert_refuses_a_replaced_backup_inode( + swapper: types.ModuleType, +) -> None: + """Byte-identical backup replacement still loses recovery ownership.""" + _seed_configs(swapper) + layer = next(layer for layer in swapper.LAYERS if layer.cli == "claude") + assert swapper.main(_use_args("--cli", layer.cli)) == 0 + swapped = layer.path.read_bytes() + backup = swapper.backup_of(layer) + replacement = backup.with_name("replacement.backup") + replacement.write_bytes(backup.read_bytes()) + replacement.chmod(stat.S_IMODE(backup.stat().st_mode)) + os.replace(replacement, backup) + + with pytest.raises(SystemExit, match="claude"): + swapper.main(["revert", "--cli", layer.cli]) + + assert layer.path.read_bytes() == swapped + assert backup.is_file() and _state_of(swapper, layer).is_file() + + +@pytest.mark.parametrize("dry_run", [True, False], ids=["dry-run", "commit"]) +def test_all_selected_revert_preflights_every_recovery_record( + swapper: types.ModuleType, dry_run: bool +) -> None: + """A bad final record blocks every selected restore, including dry-run.""" + _seed_configs(swapper) + assert swapper.main(_use_args()) == 0 + pi = next(layer for layer in swapper.LAYERS if layer.cli == "pi") + _state_of(swapper, pi).write_bytes(b"{ malformed\n") + before = {layer.cli: _owned_layer_state(swapper, layer) for layer in swapper.LAYERS} + command = ["revert", *(["--dry-run"] if dry_run else [])] + + with pytest.raises(SystemExit, match="pi"): + swapper.main(command) + + assert { + layer.cli: _owned_layer_state(swapper, layer) for layer in swapper.LAYERS + } == before + _assert_no_stages(swapper) + + +def test_recovery_record_is_bounded_private_and_route_specific( + swapper: types.ModuleType, +) -> None: + """The durable ownership record identifies the exact requested route.""" + _seed_configs(swapper) + layer = next(layer for layer in swapper.LAYERS if layer.cli == "claude") + args = _use_args("--cli", layer.cli, "--name", "private-tmux") + assert swapper.main(args) == 0 + state = _state_of(swapper, layer) + document = json.loads(state.read_text(encoding="utf-8")) + + assert stat.S_ISREG(state.lstat().st_mode) + assert stat.S_IMODE(state.stat().st_mode) == 0o600 + assert state.stat().st_size <= 16 * 1024 + assert document["version"] == 1 + assert document["cli"] == layer.cli + assert document["server"] == { + "name": "private-tmux", + "command": LAUNCHER, + "arguments": ["--socket", "/tmp/libtmux-java-dev/test/s"], + } + + with pytest.raises(SystemExit, match="claude"): + swapper.main(["revert", "--cli", layer.cli]) + assert swapper.main(["revert", "--cli", layer.cli, "--name", "private-tmux"]) == 0 + + def test_duplicate_physical_config_targets_are_rejected( swapper: types.ModuleType, ) -> None: @@ -330,6 +596,26 @@ def test_duplicate_physical_config_targets_are_rejected( assert all(not swapper.backup_of(layer).exists() for layer in swapper.LAYERS) +def test_duplicate_recovery_destinations_are_rejected( + swapper: types.ModuleType, monkeypatch: pytest.MonkeyPatch +) -> None: + """A sidecar cannot share another selected client's physical destination.""" + originals = _seed_configs(swapper) + claude = next(layer for layer in swapper.LAYERS if layer.cli == "claude") + cursor = next(layer for layer in swapper.LAYERS if layer.cli == "cursor") + real_state_of = swapper.state_of + shared = real_state_of(claude) + + def overlapping_state(layer: object) -> pathlib.Path: + return shared if layer.cli == cursor.cli else real_state_of(layer) + + monkeypatch.setattr(swapper, "state_of", overlapping_state) + with pytest.raises(SystemExit, match="duplicate transaction destination"): + swapper.main(_use_args()) + + _assert_original_state(swapper, originals) + + def test_symlink_transition_after_planning_writes_nothing( swapper: types.ModuleType, monkeypatch: pytest.MonkeyPatch, @@ -422,8 +708,9 @@ def change_backup_mode(_args: object) -> None: for layer in swapper.LAYERS: current = _layer_state(swapper, layer) if layer.cli == pi.cli: - assert current[:-1] == before[layer.cli][:-1] - assert current[-1] == 0o600 + expected = list(before[layer.cli]) + expected[6] = 0o600 + assert current == tuple(expected) else: assert current == before[layer.cli] _assert_no_stages(swapper) @@ -515,23 +802,18 @@ def test_failed_backup_rollback_preserves_its_recovery_copy( claude_backup = swapper.backup_of(claude) codex_backup = swapper.backup_of(codex) real_replace = os.replace - real_unlink = os.unlink rollback_started = False def fail_backup_restore(src: object, dst: object) -> None: - if rollback_started and pathlib.Path(dst) == claude_backup: - raise OSError("synthetic backup rollback failure") - real_replace(src, dst) - - def fail_later_removal(path: object, *args: object, **kwargs: object) -> None: nonlocal rollback_started - if pathlib.Path(path) == codex_backup: + if pathlib.Path(src) == codex_backup: rollback_started = True raise OSError("synthetic backup removal failure") - real_unlink(path, *args, **kwargs) + if rollback_started and pathlib.Path(dst) == claude_backup: + raise OSError("synthetic backup rollback failure") + real_replace(src, dst) monkeypatch.setattr(os, "replace", fail_backup_restore) - monkeypatch.setattr(os, "unlink", fail_later_removal) with pytest.raises(SystemExit, match="synthetic backup rollback failure"): swapper.main(["revert"]) @@ -657,11 +939,13 @@ def _assert_original_state( assert backup.read_bytes() == expected_backups[layer.cli] else: assert not backup.exists() + assert not _state_of(swapper, layer).exists() def _layer_state(swapper: types.ModuleType, layer: object) -> tuple[object, ...]: link = os.readlink(layer.path) if layer.path.is_symlink() else None backup = swapper.backup_of(layer) + state = _state_of(swapper, layer) return ( layer.path.is_symlink(), link, @@ -670,12 +954,24 @@ def _layer_state(swapper: types.ModuleType, layer: object) -> tuple[object, ...] backup.is_symlink(), backup.read_bytes() if backup.is_file() else None, stat.S_IMODE(backup.stat().st_mode) if backup.is_file() else None, + state.is_symlink(), + state.read_bytes() if state.is_file() else None, + stat.S_IMODE(state.stat().st_mode) if state.is_file() else None, ) +def _state_of(swapper: types.ModuleType, layer: object) -> pathlib.Path: + backup = swapper.backup_of(layer) + return backup.with_name(backup.name + ".state") + + +def _owned_layer_state(swapper: types.ModuleType, layer: object) -> tuple[object, ...]: + return _layer_state(swapper, layer) + + def _assert_no_stages(swapper: types.ModuleType) -> None: home = next(layer for layer in swapper.LAYERS if layer.cli == "claude").path.parent - roles = re.compile(r"\.mcp-swap-(?:new|output|recovery|restore)-") + roles = re.compile(r"\.mcp-swap-(?:new|output|recovery|restore|state)-") assert [path for path in home.rglob("*") if roles.search(path.name)] == [] From 03f2931dc53cbbf8e2638a58f94dbcf507ba7c72 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 5 Sep 2026 18:56:07 -0500 Subject: [PATCH 37/65] Docs(docs[scripts]): Explain recovery ownership why: Revert now depends on an authenticated backup and state pair, but the usage guide still described a backup-only workflow. what: Document exact ownership checks, fail-closed retention, and state participation in the all-client transaction. --- scripts/README.md | 12 +++++++----- scripts/mcp_swap.py | 17 +++++++++-------- 2 files changed, 16 insertions(+), 13 deletions(-) diff --git a/scripts/README.md b/scripts/README.md index 623201f..aadf4eb 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -80,11 +80,13 @@ $ uv run scripts/mcp_swap.py revert It rewrites **global** configs only, touches only the one server entry named by `--name` (default `tmux`), and keeps everything else in the file — including -comments and trailing commas in JSONC, and comments in TOML. The backup is taken -once, so swapping something already swapped still reverts to the config that was -there before any of it started. All selected files commit as one transaction; -failed commits reverse in order and retain recovery copies if exact rollback is -not possible. `--dry-run` parses the complete plan without building or writing. +comments and trailing commas in JSONC, and comments in TOML. A backup and its +private recovery record are taken together once. Repeat swaps and reverts first +verify the exact config, path topology, backup, record, and server route; a +mismatch leaves the recovery pair intact. All selected files commit as one +transaction; failed commits reverse in order and retain recovery files if exact +rollback is not possible. `--dry-run` validates the complete plan without +building or writing. To try it without changing anything at all, most CLIs take a config per invocation instead — `claude --mcp-config --strict-mcp-config`, or diff --git a/scripts/mcp_swap.py b/scripts/mcp_swap.py index e839bf3..3258066 100755 --- a/scripts/mcp_swap.py +++ b/scripts/mcp_swap.py @@ -7,9 +7,9 @@ Use when you want to try the server you are editing in a real agent rather than in a test. ``use`` rewrites each CLI's global config; ``revert`` -restores from the timestamped backup the swap wrote. Swapping a config -that is already swapped keeps the first backup rather than taking a new -one, so ``revert`` always lands on the pre-swap config. +restores from the backup and recovery record the swap wrote. Swapping a +config that is already swapped keeps the first backup, after verifying +the owned recovery state, so ``revert`` lands on the pre-swap config. Sources ------- @@ -57,11 +57,12 @@ - **One server name.** Only the entry named by ``--name`` (default ``tmux``) is touched. Everything else in the file is preserved, including comments in TOML and JSONC. -- **A backup per file, once.** Written beside the original as - ``.mcp-swap-backup``. ``revert`` moves it back. -- **One all-client transaction.** Every selected config and backup destination - is checked and staged before replacement. A failure rolls back in reverse; - recovery copies remain when an exact rollback cannot be proven. +- **A recovery pair per file, once.** The backup and its private, versioned + ``.state`` record are written beside the original. ``revert`` proceeds only + while the config, topology, backup, record, and server route still match. +- **One all-client transaction.** Every selected config, backup, and state + destination is checked and staged before replacement. A failure rolls back + in reverse; recovery files remain when exact rollback cannot be proven. """ from __future__ import annotations From eb2687a3e21d1578fdd3874c2e09644dea9aa7cc Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 5 Sep 2026 18:57:17 -0500 Subject: [PATCH 38/65] Docs(docs[changelog]): Record swap recovery state why: The Unreleased swap entry covers transactions and symlinks but omits the persistent ownership proof that now guards revert. what: Record versioned recovery state and fail-closed retention on drift. --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 714fbf7..ef48159 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,9 @@ production. prerequisite, and `antigravity` selects canonical `agy`. Multi-client use and revert preflight and stage one transaction, preserve config symlinks, reverse proven writes on failure, and keep `--dry-run` fully observational. + Persistent, versioned recovery records bind each backup to the exact swapped + config, path topology, and server route; drift fails closed without deleting + recovery. - **`NamedServerFixture` safely owns explicitly named test servers.** It binds teardown to the reported process, socket path, and inode, then fails closed if any of that identity changes before cleanup. From c06c89d0e60b628d3aa93546ca4c7b0c27dc6bf5 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 5 Sep 2026 19:00:53 -0500 Subject: [PATCH 39/65] Scripts(fix[mcp-swap]): Preserve state identity why: Repeat use copied prior state instead of moving the owned file, so rollback changed its inode and reported an incomplete recovery. what: Move prior state into its recovery slot before replacement, and prove exact identity restoration across state and config failures. --- scripts/mcp_swap.py | 31 ++++++++++++++++++++++--------- scripts/test_mcp_swap.py | 31 ++++++++++++++++++++++++++----- 2 files changed, 48 insertions(+), 14 deletions(-) diff --git a/scripts/mcp_swap.py b/scripts/mcp_swap.py index 3258066..bc66e0d 100755 --- a/scripts/mcp_swap.py +++ b/scripts/mcp_swap.py @@ -571,7 +571,7 @@ class BackupWrite(t.NamedTuple): class StateWrite(t.NamedTuple): state: StateFile backup: BackupState - committed: FileState + committed: FileState | None recovery: pathlib.Path | None cli: str @@ -1439,17 +1439,30 @@ def _commit_use(staged: list[StagedUse], owned: set[pathlib.Path]) -> None: expected_backup = committed_backups.get(cli, plan.backup.file) _changed_backup(plan.backup, expected_backup, cli) _changed_state(plan.state, plan.state.file, cli) + if plan.state.file is not None: + recovery = t.cast(pathlib.Path, item.state_recovery) + removed, delayed = _apply_replace(plan.state.physical, recovery) + operations.append( + StateWrite(plan.state, plan.backup, None, recovery, cli) + ) + if removed != plan.state.file: + raise RuntimeError(f"{cli} recovery state identity changed") + if delayed is not None: + raise delayed + _verify_artifact(plan.state, None) committed, delayed = _apply_replace(item.state, plan.state.physical) owned.discard(item.state) - operations.append( - StateWrite( - plan.state, - plan.backup, - committed, - item.state_recovery, - cli, - ) + operation = StateWrite( + plan.state, + plan.backup, + committed, + item.state_recovery, + cli, ) + if plan.state.file is None: + operations.append(operation) + else: + operations[-1] = operation committed_states[cli] = committed if delayed is not None: raise delayed diff --git a/scripts/test_mcp_swap.py b/scripts/test_mcp_swap.py index a86b526..90598da 100644 --- a/scripts/test_mcp_swap.py +++ b/scripts/test_mcp_swap.py @@ -272,22 +272,43 @@ def track_state_removal(path: object, *args: object, **kwargs: object) -> None: _assert_no_stages(swapper) -def test_failed_repeat_use_preserves_existing_backups( - swapper: types.ModuleType, monkeypatch: pytest.MonkeyPatch +@pytest.mark.parametrize("failure", ["state", "config"]) +def test_failed_repeat_use_restores_existing_recovery_identity( + swapper: types.ModuleType, + monkeypatch: pytest.MonkeyPatch, + failure: str, ) -> None: - """Rollback may not remove backups owned by an earlier successful swap.""" + """Repeat-use rollback restores the owned recovery pair itself.""" _seed_configs(swapper) assert swapper.main(_use_args()) == 0 before = {layer.cli: _layer_state(swapper, layer) for layer in swapper.LAYERS} + state_inodes = { + layer.cli: ( + _state_of(swapper, layer).stat().st_dev, + _state_of(swapper, layer).stat().st_ino, + ) + for layer in swapper.LAYERS + } pi = next(layer for layer in swapper.LAYERS if layer.cli == "pi") - _fail_first_replace_to(monkeypatch, pi.path) + destination = _state_of(swapper, pi) if failure == "state" else pi.path + _fail_first_replace_to(monkeypatch, destination) - with pytest.raises((OSError, SystemExit), match="synthetic replace failure"): + with pytest.raises( + (OSError, SystemExit), match="synthetic replace failure" + ) as stopped: swapper.main(_use_args("--bin", "/opt/another/libtmux-mcp")) + assert "rollback incomplete" not in str(stopped.value) assert { layer.cli: _layer_state(swapper, layer) for layer in swapper.LAYERS } == before + assert { + layer.cli: ( + _state_of(swapper, layer).stat().st_dev, + _state_of(swapper, layer).stat().st_ino, + ) + for layer in swapper.LAYERS + } == state_inodes _assert_no_stages(swapper) From d1f7c8e5c8bad93fbf6a6ccf8233aae4975a88eb Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 5 Sep 2026 20:01:25 -0500 Subject: [PATCH 40/65] Scripts(fix[mcp-swap]): Retain prior state why: A repeat use can publish a new config immediately before a human replacement makes rollback unprovable. Cleaning the previous state inode then destroys part of the authenticated recovery unit. what: - Preserve prior state recovery when config rollback is blocked. - Cover the post-publication human replacement deterministically. --- scripts/mcp_swap.py | 2 ++ scripts/test_mcp_swap.py | 56 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/scripts/mcp_swap.py b/scripts/mcp_swap.py index bc66e0d..3ea6a99 100755 --- a/scripts/mcp_swap.py +++ b/scripts/mcp_swap.py @@ -1369,6 +1369,8 @@ def _rollback_use( cli = operation.cli if cli in blocked: preserved.update((operation.state.path, operation.backup.path)) + if operation.recovery is not None: + preserved.add(operation.recovery) continue try: _verify_artifact(operation.state, operation.committed) diff --git a/scripts/test_mcp_swap.py b/scripts/test_mcp_swap.py index 90598da..71c941a 100644 --- a/scripts/test_mcp_swap.py +++ b/scripts/test_mcp_swap.py @@ -312,6 +312,62 @@ def test_failed_repeat_use_restores_existing_recovery_identity( _assert_no_stages(swapper) +def test_blocked_repeat_use_retains_prior_state_recovery( + swapper: types.ModuleType, monkeypatch: pytest.MonkeyPatch +) -> None: + """A human replacement must not discard the prior recovery state.""" + _seed_configs(swapper) + layer = next(layer for layer in swapper.LAYERS if layer.cli == "claude") + assert swapper.main(_use_args("--cli", layer.cli)) == 0 + state = _state_of(swapper, layer) + prior_config = layer.path.read_bytes() + prior_state = state.read_bytes() + prior_identity = (state.stat().st_dev, state.stat().st_ino) + original_backup = swapper.backup_of(layer).read_bytes() + human = b'{"human": true}\n' + human_identity: tuple[int, int] | None = None + real_replace = os.replace + + def replace_then_human_edit(src: object, dst: object) -> None: + nonlocal human_identity + source = pathlib.Path(src) + destination = pathlib.Path(dst) + real_replace(source, destination) + if destination == layer.path and "mcp-swap-output" in source.name: + replacement = destination.with_name(f".{destination.name}.human") + replacement.write_bytes(human) + replacement.chmod(0o640) + real_replace(replacement, destination) + human_identity = (destination.stat().st_dev, destination.stat().st_ino) + raise OSError("synthetic post-commit failure") + + monkeypatch.setattr(os, "replace", replace_then_human_edit) + with pytest.raises(SystemExit, match="rollback incomplete"): + swapper.main( + _use_args( + "--cli", + layer.cli, + "--bin", + "/opt/another/libtmux-mcp", + ) + ) + + assert layer.path.read_bytes() == human + assert (layer.path.stat().st_dev, layer.path.stat().st_ino) == human_identity + assert swapper.backup_of(layer).read_bytes() == original_backup + config_recoveries = list( + layer.path.parent.glob(f".{layer.path.name}.mcp-swap-recovery-*") + ) + assert len(config_recoveries) == 1 + assert config_recoveries[0].read_bytes() == prior_config + recoveries = list(state.parent.glob(f".{state.name}.mcp-swap-recovery-state-*")) + assert len(recoveries) == 1 + assert recoveries[0].read_bytes() == prior_state + assert (recoveries[0].stat().st_dev, recoveries[0].stat().st_ino) == ( + prior_identity + ) + + def test_repeat_use_refuses_an_unowned_config_edit( swapper: types.ModuleType, ) -> None: From 49d1c3f2ccb031b7c433ec2d2028bacf928a4aae Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 5 Sep 2026 20:09:42 -0500 Subject: [PATCH 41/65] Mcp(fix[paste]): Recheck before dispatch why: A pane can enter a human-owned mode after paste setup, and empty text was rejected before its safety guard. Either path violated the target-only paste boundary. what: - Stage one private buffer, then revalidate the target before one paste. - Treat empty text without Enter as a guarded, buffer-free no-op. - Clean a staged buffer when the guard or paste dispatch fails. --- .../main/java/io/github/libtmux/mcp/Call.java | 8 + .../java/io/github/libtmux/mcp/Typing.java | 9 +- .../io/github/libtmux/mcp/TypingTest.java | 150 ++++++++++++++---- .../src/main/java/io/github/libtmux/Pane.java | 34 ++++ 4 files changed, 170 insertions(+), 31 deletions(-) diff --git a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Call.java b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Call.java index 8916906..e30ea91 100644 --- a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Call.java +++ b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Call.java @@ -41,6 +41,14 @@ String string(String name) { return maybe(name).orElseThrow(() -> new IllegalArgumentException("missing required argument '" + name + "'")); } + String stringIncludingEmpty(String name) { + Object value = arguments.get(name); + if (value == null) { + throw new IllegalArgumentException("missing required argument '" + name + "'"); + } + return value.toString(); + } + Optional maybe(String name) { Object value = arguments.get(name); if (value == null || (value instanceof String text && text.isEmpty())) { diff --git a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Typing.java b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Typing.java index 11a2756..6966a26 100644 --- a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Typing.java +++ b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Typing.java @@ -74,12 +74,17 @@ static Sent sendKeys(Pane pane, List keys, boolean literal, PaneInputCoh */ static Pasted pasteText(Call call) { Pane pane = Targets.pane(call.server(), call.string("pane_id")); - String text = call.string("text"); + String text = call.stringIncludingEmpty("text"); boolean enter = call.flag("enter", false); PaneInputCohort.resolve(pane, call.caller()).requirePasteTarget("paste_text"); + if (text.isEmpty() && !enter) { + return new Pasted(pane.id().value(), 0, 0, "Empty text without Enter; nothing was sent."); + } // tmux turns the line feeds in a buffer into carriage returns as it pastes, so a trailing // newline is what submits the text — there is no flag that means "and Enter". - pane.paste(enter ? text + "\n" : text); + pane.paste( + enter ? text + "\n" : text, + () -> PaneInputCohort.resolve(pane, call.caller()).requirePasteTarget("paste_text")); return new Pasted( pane.id().value(), text.length(), diff --git a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/TypingTest.java b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/TypingTest.java index 89eacb8..bb1f205 100644 --- a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/TypingTest.java +++ b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/TypingTest.java @@ -16,6 +16,7 @@ import io.github.libtmux.transport.CommandResult; import io.github.libtmux.transport.ProcessTransport; import io.github.libtmux.transport.TmuxTransport; +import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.Set; @@ -23,7 +24,6 @@ import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; -import java.util.concurrent.atomic.AtomicReference; import java.util.function.BooleanSupplier; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -298,11 +298,26 @@ void pastedTextArrivesWithoutClaimingAUsersBuffer(Server server) { assumeTrue(server.version().atLeast(SAFE_PASTE_CLEANUP)); String pane = server.panes().get(0).id().value(); server.buffers().set("libtmux-paste", "user-owned"); + List requests = new ArrayList<>(); - Typing.Pasted pasted = Typing.pasteText(TestCalls.on(server, "pane_id", pane, "text", "Enter [C-c] done")); + Typing.Pasted pasted; + try (ProcessTransport processes = new ProcessTransport()) { + TmuxTransport recording = borrowing(request -> { + requests.add(request); + return processes.execute(request); + }); + try (Server measured = Server.using(server.config(), recording)) { + pasted = Typing.pasteText(TestCalls.on(measured, "pane_id", pane, "text", "Enter [C-c] done")); + } + } assertEquals(16, pasted.characters()); assertTrue(String.valueOf(pasted.note()).contains("pass 'enter'"), String.valueOf(pasted.note())); + assertEquals( + 1, + requests.stream() + .filter(request -> hasCommand(request, "paste-buffer")) + .count()); assertEquals("user-owned", server.buffers().show("libtmux-paste")); assertNoOwnedBuffers(server); } @@ -342,6 +357,73 @@ void pasteRefusesAModalTargetBeforeCreatingABuffer(Server server) { assertNoOwnedBuffers(server); } + @Test + void emptyPasteGuardsBeforeItsBufferFreeNoOp(Server server) { + var pane = server.panes().getFirst(); + List requests = new ArrayList<>(); + try (ProcessTransport processes = new ProcessTransport()) { + TmuxTransport recording = borrowing(request -> { + requests.add(request); + return processes.execute(request); + }); + try (Server measured = Server.using(server.config(), recording)) { + Typing.Pasted empty = Typing.pasteText( + TestCalls.on(measured, "pane_id", pane.id().value(), "text", "")); + assertEquals(0, empty.characters()); + assertTrue(String.valueOf(empty.note()).contains("nothing was sent")); + pane.copyMode(); + try { + assertThrows( + IllegalStateException.class, + () -> Typing.pasteText( + TestCalls.on(measured, "pane_id", pane.id().value(), "text", ""))); + } finally { + server.cmd("send-keys", "-t", pane.id().value(), "-X", "cancel"); + } + } + } + + assertEquals( + 2, requests.stream().filter(TypingTest::isPaneInputSnapshot).count()); + assertFalse(requests.stream().anyMatch(request -> hasCommand(request, "load-buffer"))); + assertFalse(requests.stream().anyMatch(request -> hasCommand(request, "paste-buffer"))); + assertNoOwnedBuffers(server); + } + + @Test + void pasteRechecksTargetAfterStaging(Server server) { + assumeTrue(server.version().atLeast(SAFE_PASTE_CLEANUP)); + var pane = server.panes().getFirst(); + AtomicBoolean staged = new AtomicBoolean(); + AtomicBoolean pasted = new AtomicBoolean(); + try (ProcessTransport processes = new ProcessTransport()) { + TmuxTransport changing = borrowing(request -> { + CommandResult result = processes.execute(request); + if (hasCommand(request, "load-buffer") && staged.compareAndSet(false, true)) { + pane.copyMode(); + } + if (hasCommand(request, "paste-buffer")) { + pasted.set(true); + } + return result; + }); + try (Server measured = Server.using(server.config(), changing)) { + try { + assertThrows( + IllegalStateException.class, + () -> Typing.pasteText( + TestCalls.on(measured, "pane_id", pane.id().value(), "text", "must-not-dispatch"))); + } finally { + server.cmd("send-keys", "-t", pane.id().value(), "-X", "cancel"); + } + } + } + + assertTrue(staged.get(), "the transition seam did not observe staging"); + assertFalse(pasted.get(), "paste dispatched after the target became modal"); + assertNoOwnedBuffers(server); + } + @Test void pasteRefusesUnsafeCleanupBeforeCreatingABuffer(Server server) { assumeFalse(server.version().atLeast(SAFE_PASTE_CLEANUP)); @@ -357,42 +439,28 @@ void pasteRefusesUnsafeCleanupBeforeCreatingABuffer(Server server) { assertNoOwnedBuffers(server); } - /** A client that goes away mid-paste is the case that decides whether the text can outlive it. */ @Test - void aDisconnectDuringAPasteLeavesNothingOnTheServer(Server server) { + void aDispatchFailureDuringPasteLeavesNothingOnTheServer(Server server) { assumeTrue(server.version().atLeast(SAFE_PASTE_CLEANUP)); String pane = server.panes().get(0).id().value(); try (ProcessTransport processes = new ProcessTransport()) { - AtomicBoolean disconnected = new AtomicBoolean(); - AtomicReference pasting = new AtomicReference<>(); - TmuxTransport disconnecting = new TmuxTransport() { - @Override - public CommandResult execute(CommandRequest request) { - CommandResult result = processes.execute(request); - if (String.join(" ", request.commands().get(0)).contains("load-buffer")) { - disconnected.set(true); - pasting.get().close(); - } - return result; + AtomicBoolean failed = new AtomicBoolean(); + TmuxTransport refusing = borrowing(request -> { + if (hasCommand(request, "paste-buffer") && failed.compareAndSet(false, true)) { + throw new IllegalStateException("synthetic paste failure"); } - - @Override - public void close() {} - }; - Server cut = Server.using(server.config(), disconnecting); - pasting.set(cut); - try { - Typing.pasteText(TestCalls.on(cut, "pane_id", pane, "text", "secret-text")); - } catch (RuntimeException expected) { - // The disconnect is what this arranges; surviving it is not what is being asserted. + return processes.execute(request); + }); + try (Server cut = Server.using(server.config(), refusing)) { + assertThrows( + IllegalStateException.class, + () -> Typing.pasteText(TestCalls.on(cut, "pane_id", pane, "text", "secret-text"))); } - assertTrue(disconnected.get(), "the disconnect hook did not observe buffer setup"); + assertTrue(failed.get(), "the failure seam did not observe paste dispatch"); } assertNoOwnedBuffers(server); - assertTrue( - String.join("\n", server.cmd("capture-pane", "-p", "-t", pane).stdout()) - .contains("secret-text")); + assertFalse(captureOf(server, pane).contains("secret-text")); } @Test @@ -474,6 +542,30 @@ private static void assertNoOwnedBuffers(Server server) { .noneMatch(buffer -> buffer.name().startsWith("libtmux-paste-"))); } + private static TmuxTransport borrowing(java.util.function.Function execute) { + return new TmuxTransport() { + @Override + public CommandResult execute(CommandRequest request) { + return execute.apply(request); + } + + @Override + public void close() {} + }; + } + + private static boolean hasCommand(CommandRequest request, String command) { + return request.commands().stream() + .anyMatch(argv -> + argv.getFirst().equals(command) || argv.stream().anyMatch(part -> part.contains(command))); + } + + private static boolean isPaneInputSnapshot(CommandRequest request) { + return request.commands().stream() + .anyMatch(argv -> argv.getFirst().equals("list-panes") + && argv.stream().anyMatch(part -> part.contains("pane_in_mode"))); + } + private static void await(CountDownLatch latch) { try { if (!latch.await(5, TimeUnit.SECONDS)) { diff --git a/libtmux/src/main/java/io/github/libtmux/Pane.java b/libtmux/src/main/java/io/github/libtmux/Pane.java index a4162d2..d65e803 100644 --- a/libtmux/src/main/java/io/github/libtmux/Pane.java +++ b/libtmux/src/main/java/io/github/libtmux/Pane.java @@ -590,6 +590,40 @@ public void paste(String text) { } } + /** + * As {@link #paste(String)}, after a caller rechecks state once its private buffer is ready. + * + *

If the check throws, the text is not pasted and the private buffer is removed. + * + * @param beforePaste runs after staging and immediately before the paste dispatch + */ + public void paste(String text, Runnable beforePaste) { + Objects.requireNonNull(text, "text"); + Objects.requireNonNull(beforePaste, "beforePaste"); + if (text.indexOf('\0') >= 0) { + throw new IllegalArgumentException("pasted text cannot contain NUL"); + } + TmuxVersion running = server.version(snapshot); + if (!running.atLeast(Buffers.EXACT_NAMED_DELETE)) { + throw new UnsupportedTmuxVersion("pasting text", Buffers.EXACT_NAMED_DELETE, running); + } + String buffer = "libtmux-paste-" + UUID.randomUUID(); + try { + server.runTogether(snapshot, text, List.of(List.of("load-buffer", "-b", buffer, "-"))); + beforePaste.run(); + server.run( + snapshot, + List.of("paste-buffer", "-d", "-b", buffer, "-t", state.id().value())); + } catch (RuntimeException failure) { + try { + server.buffers().delete(buffer); + } catch (RuntimeException ignored) { + // Already gone, or the server is; neither changes what the caller is told. + } + throw failure; + } + } + /** Discards this pane's scrollback. */ public void clearHistory() { server.run(snapshot, List.of("clear-history", "-t", state.id().value())); From 2fd3913b2d06fdcf74fe89acb17e2a90ab35c859 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 5 Sep 2026 20:54:52 -0500 Subject: [PATCH 42/65] Scripts(fix[mcp-swap]): Fail on cleanup residue why: A completed swap or revert could report success while private recovery stages remained after cleanup failed. what: - Return a transaction failure when post-commit stage cleanup is incomplete - Cover use and revert cleanup failures and residue-free success --- scripts/mcp_swap.py | 24 +++++++++++++++------ scripts/test_mcp_swap.py | 46 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 7 deletions(-) diff --git a/scripts/mcp_swap.py b/scripts/mcp_swap.py index 3ea6a99..b85a249 100755 --- a/scripts/mcp_swap.py +++ b/scripts/mcp_swap.py @@ -1047,10 +1047,24 @@ def _cleanup_owned( try: path.unlink(missing_ok=True) except OSError as error: - errors.append(f"could not remove task-owned stage {path}: {error}") + if os.path.lexists(path): + errors.append(f"could not remove task-owned stage {path}: {error}") return errors +def _require_cleanup(action: str, owned: set[pathlib.Path]) -> None: + errors = _cleanup_owned(owned) + if not errors: + return + retained = {path for path in owned if os.path.lexists(path)} + detail = f"{action} committed but cleanup incomplete: " + "; ".join(errors) + if retained: + detail += "; recovery artifacts: " + ", ".join( + str(path) for path in sorted(retained, key=str) + ) + raise SystemExit(detail) + + def _transaction_failure( action: str, error: Exception, @@ -1500,9 +1514,7 @@ def _commit_use(staged: list[StagedUse], owned: set[pathlib.Path]) -> None: cleanup_errors = _cleanup_owned(owned, preserved) _transaction_failure("swap", error, rollback_errors, cleanup_errors, preserved) - cleanup_errors = _cleanup_owned(owned) - for error in cleanup_errors: - print(f"warning: {error}", file=sys.stderr) + _require_cleanup("swap", owned) def _rollback_revert( @@ -1622,9 +1634,7 @@ def _commit_revert(staged: list[StagedRevert], owned: set[pathlib.Path]) -> None "revert", error, rollback_errors, cleanup_errors, preserved ) - cleanup_errors = _cleanup_owned(owned) - for error in cleanup_errors: - print(f"warning: {error}", file=sys.stderr) + _require_cleanup("revert", owned) # ------------------------------------------------------------------ what to point at diff --git a/scripts/test_mcp_swap.py b/scripts/test_mcp_swap.py index 71c941a..97397a5 100644 --- a/scripts/test_mcp_swap.py +++ b/scripts/test_mcp_swap.py @@ -835,6 +835,41 @@ def fail_pi_stage( _assert_no_stages(swapper) +def test_use_cleanup_failure_is_not_reported_as_success( + swapper: types.ModuleType, monkeypatch: pytest.MonkeyPatch +) -> None: + """A committed swap with retained private stages must return failure.""" + _seed_configs(swapper) + claude = next(layer for layer in swapper.LAYERS if layer.cli == "claude") + _fail_stage_cleanup(monkeypatch) + + with pytest.raises(SystemExit, match="cleanup incomplete") as stopped: + swapper.main(_use_args("--cli", claude.cli)) + + recovery = list(claude.path.parent.glob(f".{claude.path.name}.mcp-swap-recovery-*")) + assert len(recovery) == 1 + assert str(recovery[0]) in str(stopped.value) + _assert_swapped(claude) + + +def test_revert_cleanup_failure_is_not_reported_as_success( + swapper: types.ModuleType, monkeypatch: pytest.MonkeyPatch +) -> None: + """A committed revert with retained private stages must return failure.""" + originals = _seed_configs(swapper) + claude = next(layer for layer in swapper.LAYERS if layer.cli == "claude") + assert swapper.main(_use_args("--cli", claude.cli)) == 0 + _fail_stage_cleanup(monkeypatch) + + with pytest.raises(SystemExit, match="cleanup incomplete") as stopped: + swapper.main(["revert", "--cli", claude.cli]) + + recoveries = list(claude.path.parent.glob("*.mcp-swap-recovery-*")) + assert len(recoveries) == 3 + assert all(str(path) in str(stopped.value) for path in recoveries) + assert claude.path.read_bytes() == originals[claude.cli] + + def test_failed_rollback_preserves_backup_and_recovery_stage( swapper: types.ModuleType, monkeypatch: pytest.MonkeyPatch ) -> None: @@ -1052,6 +1087,17 @@ def _assert_no_stages(swapper: types.ModuleType) -> None: assert [path for path in home.rglob("*") if roles.search(path.name)] == [] +def _fail_stage_cleanup(monkeypatch: pytest.MonkeyPatch) -> None: + real_unlink = pathlib.Path.unlink + + def refuse(path: pathlib.Path, *args: object, **kwargs: object) -> None: + if ".mcp-swap-" in path.name: + raise OSError("synthetic cleanup failure") + real_unlink(path, *args, **kwargs) + + monkeypatch.setattr(pathlib.Path, "unlink", refuse) + + def _fail_first_replace_to( monkeypatch: pytest.MonkeyPatch, destination: pathlib.Path ) -> list[pathlib.Path]: From 07e1209cf2e4fb1a61b0f7ec841bb6b531d7979f Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 5 Sep 2026 21:23:56 -0500 Subject: [PATCH 43/65] Scripts(fix[mcp-swap]): Bind transaction paths why: Concurrent swaps and late path replacements could bypass preflight, remove an unexpected inode, or mutate through a replaced lock. what: - Hold and authenticate the persistent swap lock for mutations - Reject config, backup, and state aliases to the lock - Preserve unexpected inodes at every use and revert replace boundary --- scripts/mcp_swap.py | 648 ++++++++++++++++++++++++++------ scripts/test_mcp_swap.py | 775 +++++++++++++++++++++++++++++++++++++-- 2 files changed, 1287 insertions(+), 136 deletions(-) diff --git a/scripts/mcp_swap.py b/scripts/mcp_swap.py index b85a249..8079262 100755 --- a/scripts/mcp_swap.py +++ b/scripts/mcp_swap.py @@ -68,6 +68,8 @@ from __future__ import annotations import argparse +import contextlib +import fcntl import hashlib import json import os @@ -93,6 +95,17 @@ STATE_MAX_BYTES = 16 * 1024 +def _xdg_state_home() -> pathlib.Path: + raw = os.environ.get("XDG_STATE_HOME") + if raw and pathlib.Path(raw).is_absolute(): + return pathlib.Path(raw) + return pathlib.Path.home() / ".local" / "state" + + +SWAP_LOCK_DIR = _xdg_state_home() / "libtmux-mcp-dev" / "swap" +SWAP_LOCK_FILE = SWAP_LOCK_DIR / "state.lock" + + class Layer(t.NamedTuple): """One CLI's global config, and how its MCP servers are spelled in it.""" @@ -477,6 +490,9 @@ class FileState(t.NamedTuple): data: bytes +OwnedFiles = dict[pathlib.Path, FileState] + + class DirectoryState(t.NamedTuple): logical: pathlib.Path physical: pathlib.Path @@ -490,6 +506,17 @@ class DirectoryState(t.NamedTuple): mode: int +class LockState(t.NamedTuple): + logical: pathlib.Path + physical: pathlib.Path + parent: DirectoryState | None + device: int | None + inode: int | None + mode: int | None + links: int | None + descriptor: int | None + + class ConfigState(t.NamedTuple): layer: Layer parent: DirectoryState @@ -620,6 +647,29 @@ def _file_state(path: pathlib.Path) -> FileState: ) +def _regular_file_state(path: pathlib.Path) -> FileState: + details = path.lstat() + if stat.S_ISLNK(details.st_mode) or not stat.S_ISREG(details.st_mode): + raise ValueError(f"{path} is not a regular file") + file = _file_state(path) + if (details.st_dev, details.st_ino) != (file.device, file.inode): + raise RuntimeError(f"{path} changed while it was resolved") + return file + + +def _own(owned: OwnedFiles, path: pathlib.Path) -> None: + owned[path] = _regular_file_state(path) + + +def _release(owned: OwnedFiles, path: pathlib.Path) -> None: + owned.pop(path, None) + + +def _release_missing(owned: OwnedFiles, path: pathlib.Path) -> None: + if not os.path.lexists(path): + _release(owned, path) + + def _directory_state(path: pathlib.Path) -> DirectoryState: logical = path.lstat() symlink = stat.S_ISLNK(logical.st_mode) @@ -643,6 +693,118 @@ def _directory_state(path: pathlib.Path) -> DirectoryState: ) +def _inspect_lock(*, descriptor: int | None = None) -> LockState: + parent = None + if os.path.lexists(SWAP_LOCK_DIR): + parent = _directory_state(SWAP_LOCK_DIR) + if parent.symlink: + raise RuntimeError(f"swap lock directory is a symlink: {SWAP_LOCK_DIR}") + physical = parent.physical / SWAP_LOCK_FILE.name + else: + physical = SWAP_LOCK_FILE.resolve(strict=False) + if not os.path.lexists(SWAP_LOCK_FILE): + return LockState( + SWAP_LOCK_FILE, physical, parent, None, None, None, None, descriptor + ) + before = SWAP_LOCK_FILE.lstat() + if stat.S_ISLNK(before.st_mode) or not stat.S_ISREG(before.st_mode): + raise RuntimeError(f"swap lock is not a regular file: {SWAP_LOCK_FILE}") + resolved = SWAP_LOCK_FILE.resolve(strict=True) + after = SWAP_LOCK_FILE.lstat() + before_key = (before.st_dev, before.st_ino, before.st_mode, before.st_nlink) + after_key = (after.st_dev, after.st_ino, after.st_mode, after.st_nlink) + if before_key != after_key or resolved != physical: + raise RuntimeError( + f"swap lock changed while it was inspected: {SWAP_LOCK_FILE}" + ) + return LockState( + SWAP_LOCK_FILE, + physical, + parent, + after.st_dev, + after.st_ino, + stat.S_IMODE(after.st_mode), + after.st_nlink, + descriptor, + ) + + +def _validate_lock(lock: LockState) -> None: + if lock.device is None or lock.inode is None: + if lock.descriptor is not None: + raise RuntimeError(f"swap lock path disappeared: {lock.logical}") + return + if lock.mode != 0o600: + raise RuntimeError(f"swap lock mode is not 0600: {lock.logical}") + if lock.links != 1: + raise RuntimeError(f"swap lock has hard links: {lock.logical}") + if lock.descriptor is None: + return + current = _inspect_lock(descriptor=lock.descriptor) + expected = lock._replace(descriptor=lock.descriptor) + if current != expected: + raise RuntimeError(f"swap lock path changed: {lock.logical}") + opened = os.fstat(lock.descriptor) + if ( + not stat.S_ISREG(opened.st_mode) + or (opened.st_dev, opened.st_ino) != (lock.device, lock.inode) + or stat.S_IMODE(opened.st_mode) != lock.mode + or opened.st_nlink != lock.links + ): + raise RuntimeError(f"swap lock descriptor changed: {lock.logical}") + + +@contextlib.contextmanager +def _state_lock() -> t.Iterator[LockState]: + SWAP_LOCK_DIR.mkdir(parents=True, exist_ok=True) + directory_fd: int | None = None + lock_fd: int | None = None + try: + if not hasattr(os, "O_NOFOLLOW") or not hasattr(os, "O_DIRECTORY"): + raise RuntimeError( + "platform cannot open the swap lock without following links" + ) + directory_flags = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW + directory_flags |= getattr(os, "O_CLOEXEC", 0) + directory_fd = os.open(SWAP_LOCK_DIR, directory_flags) + parent = _directory_state(SWAP_LOCK_DIR) + opened_parent = os.fstat(directory_fd) + if parent.symlink or (opened_parent.st_dev, opened_parent.st_ino) != ( + parent.device, + parent.inode, + ): + raise RuntimeError(f"swap lock directory changed: {SWAP_LOCK_DIR}") + lock_flags = os.O_RDWR | os.O_NOFOLLOW | getattr(os, "O_CLOEXEC", 0) + try: + lock_fd = os.open( + SWAP_LOCK_FILE.name, + lock_flags | os.O_CREAT | os.O_EXCL, + 0o600, + dir_fd=directory_fd, + ) + os.fchmod(lock_fd, 0o600) + except FileExistsError: + lock_fd = os.open(SWAP_LOCK_FILE.name, lock_flags, dir_fd=directory_fd) + fcntl.flock(lock_fd, fcntl.LOCK_EX) + lock = _inspect_lock(descriptor=lock_fd) + _validate_lock(lock) + except Exception as error: + if lock_fd is not None: + os.close(lock_fd) + if directory_fd is not None: + os.close(directory_fd) + raise SystemExit(f"swap lock is unusable: {error}") from error + try: + yield lock + try: + _validate_lock(lock) + except Exception as error: + raise SystemExit(f"swap lock changed before release: {error}") from error + finally: + os.close(t.cast(int, lock_fd)) + os.close(t.cast(int, directory_fd)) + + def _config_state(layer: Layer) -> ConfigState: parent = _directory_state(layer.path.parent) details = layer.path.lstat() @@ -925,11 +1087,48 @@ def _verify_restored_artifact(artifact: BackupState | StateFile) -> None: _verify_artifact(artifact, expected) -def _reject_duplicate_targets(plans: t.Iterable[t.Any]) -> None: +def _reject_duplicate_targets( + plans: t.Iterable[t.Any], lock: LockState | None = None +) -> None: config_paths: dict[pathlib.Path, str] = {} config_inodes: dict[tuple[int, int], str] = {} all_paths: dict[pathlib.Path, str] = {} all_inodes: dict[tuple[int, int], str] = {} + + def claim( + label: str, + logical: pathlib.Path, + physical: pathlib.Path, + inode: tuple[int, int] | None, + ) -> None: + owner = next( + ( + all_paths[path] + for path in dict.fromkeys((logical, physical)) + if path in all_paths and all_paths[path] != label + ), + None, + ) + if owner is None and inode is not None: + owner = all_inodes.get(inode) + if owner == label: + owner = None + if owner is not None: + raise SystemExit( + f"duplicate transaction destination for {owner} and {label}" + ) + all_paths[logical] = label + all_paths[physical] = label + if inode is not None: + all_inodes[inode] = label + + if lock is not None: + lock_inode = ( + None + if lock.device is None or lock.inode is None + else (lock.device, lock.inode) + ) + claim("swap lock", lock.logical, lock.physical, lock_inode) for plan in plans: config = plan.config cli = config.layer.cli @@ -940,45 +1139,33 @@ def _reject_duplicate_targets(plans: t.Iterable[t.Any]) -> None: raise SystemExit(f"duplicate physical config target for {other} and {cli}") config_paths[config.target] = cli config_inodes[(config.file.device, config.file.inode)] = cli - owner = all_paths.get(config.target) or all_inodes.get( - (config.file.device, config.file.inode) + claim( + f"{cli} config", + config.layer.path, + config.target, + (config.file.device, config.file.inode), ) - if owner is not None: - raise SystemExit( - f"duplicate transaction destination for {owner} and {cli} config" - ) - all_paths[config.target] = f"{cli} config" - all_inodes[(config.file.device, config.file.inode)] = f"{cli} config" backup = plan.backup backup_inode = ( None if backup.file is None else (backup.file.device, backup.file.inode) ) - owner = all_paths.get(backup.physical) - if owner is None and backup_inode is not None: - owner = all_inodes.get(backup_inode) - if owner is not None: - raise SystemExit( - f"duplicate transaction destination for {owner} and {cli} backup" - ) - all_paths[backup.physical] = f"{cli} backup" - if backup_inode is not None: - all_inodes[backup_inode] = f"{cli} backup" + claim(f"{cli} backup", backup.path, backup.physical, backup_inode) state = plan.state state_inode = ( None if state.file is None else (state.file.device, state.file.inode) ) - owner = all_paths.get(state.physical) - if owner is None and state_inode is not None: - owner = all_inodes.get(state_inode) - if owner is not None: - raise SystemExit( - f"duplicate transaction destination for {owner} and {cli} state" - ) - all_paths[state.physical] = f"{cli} state" - if state_inode is not None: - all_inodes[state_inode] = f"{cli} state" + claim(f"{cli} state", state.path, state.physical, state_inode) + + +def _check_lock_plan(plans: t.Iterable[t.Any]) -> None: + try: + lock = _inspect_lock() + _reject_duplicate_targets(plans, lock) + _validate_lock(lock) + except Exception as error: + raise SystemExit(f"swap lock is unusable: {error}") from error def _stage( @@ -1005,55 +1192,162 @@ def _stage( def _apply_replace( - staged: pathlib.Path, destination: pathlib.Path + staged: pathlib.Path, + destination: pathlib.Path, + *, + expected: FileState, + destination_expected: FileState, + lock: LockState, ) -> tuple[FileState, Exception | None]: - staged_state = _file_state(staged) + _validate_lock(lock) + if _regular_file_state(staged) != expected: + raise RuntimeError(f"{staged} changed before atomic take-aside") + if _regular_file_state(destination) != destination_expected: + raise RuntimeError(f"{destination} changed before atomic take-aside") + delayed = _apply_unlink( + destination, + expected=destination_expected, + lock=lock, + ) + if delayed is not None: + raise delayed + committed, delayed = _publish_absent( + staged, + destination, + expected=expected, + lock=lock, + ) + try: + removal_error = _apply_unlink(staged, expected=expected, lock=lock) + except Exception as error: # noqa: BLE001 - retain the committed recovery + removal_error = error + if delayed is None: + delayed = removal_error + return committed, delayed + + +def _publish_absent( + staged: pathlib.Path, + destination: pathlib.Path, + *, + expected: FileState, + lock: LockState, +) -> tuple[FileState, Exception | None]: + _validate_lock(lock) + if _regular_file_state(staged) != expected: + raise RuntimeError(f"{staged} changed before atomic publication") + if os.path.lexists(destination): + raise RuntimeError(f"{destination} appeared before atomic publication") + _validate_lock(lock) delayed: Exception | None = None try: - os.replace(staged, destination) + os.link(staged, destination, follow_symlinks=False) except Exception as error: try: - moved = _file_state(destination) == staged_state + committed = _regular_file_state(destination) except (OSError, RuntimeError, ValueError): - moved = False - if not moved: - raise + raise error + if committed != expected: + raise RuntimeError( + f"atomic publication of {destination} was not exact" + ) from error delayed = error - committed = _file_state(destination) - if committed != staged_state: - raise RuntimeError(f"atomic replacement of {destination} was not exact") + else: + committed = _regular_file_state(destination) + if committed != expected: + raise RuntimeError(f"atomic publication of {destination} was not exact") return committed, delayed -def _apply_unlink(path: pathlib.Path) -> Exception | None: +def _remove_exact(path: pathlib.Path, expected: FileState) -> Exception | None: + quarantine_dir = pathlib.Path( + tempfile.mkdtemp(prefix=f".{path.name}.mcp-swap-retained-", dir=path.parent) + ) + quarantine_dir.chmod(0o700) + quarantine = quarantine_dir / "artifact" delayed: Exception | None = None try: - os.unlink(path) - except Exception as error: - if os.path.lexists(path): - raise + path.rename(quarantine) + except Exception as error: # noqa: BLE001 - authenticate a possibly completed move + try: + current = _regular_file_state(quarantine) + except (OSError, RuntimeError, ValueError): + try: + quarantine_dir.rmdir() + except OSError: + pass + raise error delayed = error + else: + current = _regular_file_state(quarantine) + if current != expected: + raise RuntimeError(f"{path} changed; retained at {quarantine_dir}") if os.path.lexists(path): - raise RuntimeError(f"{path} still exists after removal") + delayed = delayed or RuntimeError(f"{path} appeared during removal") + try: + quarantine.unlink() + except Exception as error: + if os.path.lexists(quarantine): + raise RuntimeError( + f"{path} removal failed; retained at {quarantine_dir}: {error}" + ) from error + delayed = delayed or error + if os.path.lexists(quarantine): + raise RuntimeError(f"{quarantine} still exists after removal") + try: + quarantine_dir.rmdir() + except Exception as error: + if quarantine_dir.exists(): + raise + delayed = delayed or error + return delayed + + +def _apply_unlink( + path: pathlib.Path, + *, + expected: FileState, + lock: LockState, +) -> Exception | None: + _validate_lock(lock) + if _regular_file_state(path) != expected: + raise RuntimeError(f"{path} changed before removal") + _validate_lock(lock) + delayed = _remove_exact(path, expected) + try: + _validate_lock(lock) + except Exception as error: # noqa: BLE001 - preserve post-unlink failure + if delayed is None: + delayed = error return delayed def _cleanup_owned( - owned: set[pathlib.Path], preserve: set[pathlib.Path] | None = None + owned: OwnedFiles, + preserve: set[pathlib.Path] | None = None, + *, + lock: LockState, ) -> list[str]: retained = preserve or set() errors: list[str] = [] - for path in sorted(owned - retained, key=str): + for path in sorted( + (candidate for candidate in owned if candidate not in retained), key=str + ): try: - path.unlink(missing_ok=True) - except OSError as error: - if os.path.lexists(path): - errors.append(f"could not remove task-owned stage {path}: {error}") + if not os.path.lexists(path): + continue + _validate_lock(lock) + delayed = _remove_exact(path, owned[path]) + if delayed is not None: + raise delayed + _validate_lock(lock) + except (OSError, RuntimeError, ValueError) as error: + errors.append(f"could not remove task-owned stage {path}: {error}") return errors -def _require_cleanup(action: str, owned: set[pathlib.Path]) -> None: - errors = _cleanup_owned(owned) +def _require_cleanup(action: str, owned: OwnedFiles, lock: LockState) -> None: + errors = _cleanup_owned(owned, lock=lock) if not errors: return retained = {path for path in owned if os.path.lexists(path)} @@ -1221,10 +1515,13 @@ def _changed_state(state: StateFile, expected: FileState | None, cli: str) -> No raise RuntimeError(f"{cli} recovery state changed during preflight") from error -def _stage_use(plans: list[PreparedUse], owned: set[pathlib.Path]) -> list[StagedUse]: +def _stage_use( + plans: list[PreparedUse], owned: OwnedFiles, lock: LockState +) -> list[StagedUse]: staged: list[StagedUse] = [] try: for plan in plans: + _validate_lock(lock) config = plan.config output = _stage( config.target.parent, @@ -1233,7 +1530,7 @@ def _stage_use(plans: list[PreparedUse], owned: set[pathlib.Path]) -> list[Stage plan.output, config.file.mode, ) - owned.add(output) + _own(owned, output) recovery = _stage( config.target.parent, config.layer.path.name, @@ -1241,7 +1538,7 @@ def _stage_use(plans: list[PreparedUse], owned: set[pathlib.Path]) -> list[Stage config.file.data, config.file.mode, ) - owned.add(recovery) + _own(owned, recovery) backup = None if plan.backup.file is None: backup = _stage( @@ -1251,7 +1548,7 @@ def _stage_use(plans: list[PreparedUse], owned: set[pathlib.Path]) -> list[Stage config.file.data, config.file.mode, ) - owned.add(backup) + _own(owned, backup) target_file = _file_state(output) backup_file = ( _file_state(backup) @@ -1265,7 +1562,7 @@ def _stage_use(plans: list[PreparedUse], owned: set[pathlib.Path]) -> list[Stage _record_bytes(_record_for(plan, target_file, backup_file)), 0o600, ) - owned.add(state) + _own(owned, state) state_recovery = None if plan.state.file is not None: state_recovery = _stage( @@ -1275,12 +1572,13 @@ def _stage_use(plans: list[PreparedUse], owned: set[pathlib.Path]) -> list[Stage plan.state.file.data, plan.state.file.mode, ) - owned.add(state_recovery) + _own(owned, state_recovery) staged.append( StagedUse(plan, output, recovery, backup, state, state_recovery) ) + _validate_lock(lock) except Exception as error: - cleanup = _cleanup_owned(owned) + cleanup = _cleanup_owned(owned, lock=lock) detail = f"swap staging failed: {error}" if cleanup: detail += "; " + "; ".join(cleanup) @@ -1289,11 +1587,12 @@ def _stage_use(plans: list[PreparedUse], owned: set[pathlib.Path]) -> list[Stage def _stage_revert( - plans: list[PreparedRevert], owned: set[pathlib.Path] + plans: list[PreparedRevert], owned: OwnedFiles, lock: LockState ) -> list[StagedRevert]: staged: list[StagedRevert] = [] try: for plan in plans: + _validate_lock(lock) config = plan.config backup = t.cast(FileState, plan.backup.file) restored = _stage( @@ -1303,7 +1602,7 @@ def _stage_revert( backup.data, backup.mode, ) - owned.add(restored) + _own(owned, restored) recovery = _stage( config.target.parent, config.layer.path.name, @@ -1311,7 +1610,7 @@ def _stage_revert( config.file.data, config.file.mode, ) - owned.add(recovery) + _own(owned, recovery) backup_recovery = _stage( plan.backup.parent.physical, plan.backup.path.name, @@ -1319,7 +1618,7 @@ def _stage_revert( backup.data, backup.mode, ) - owned.add(backup_recovery) + _own(owned, backup_recovery) state = t.cast(FileState, plan.state.file) state_recovery = _stage( plan.state.parent.physical, @@ -1328,7 +1627,7 @@ def _stage_revert( state.data, state.mode, ) - owned.add(state_recovery) + _own(owned, state_recovery) staged.append( StagedRevert( plan, @@ -1338,8 +1637,9 @@ def _stage_revert( state_recovery, ) ) + _validate_lock(lock) except Exception as error: - cleanup = _cleanup_owned(owned) + cleanup = _cleanup_owned(owned, lock=lock) detail = f"revert staging failed: {error}" if cleanup: detail += "; " + "; ".join(cleanup) @@ -1349,20 +1649,36 @@ def _stage_revert( def _restore_removed_config( operation: ConfigWrite | ConfigRemoval, - owned: set[pathlib.Path], + owned: OwnedFiles, + lock: LockState, ) -> None: if isinstance(operation, ConfigWrite): _verify_config(operation.config, operation.committed) + delayed = _apply_unlink( + operation.config.target, + expected=operation.committed, + lock=lock, + ) + if delayed is not None: + raise delayed else: _verify_missing_config(operation.config) - _apply_replace(operation.recovery, operation.config.target) - owned.discard(operation.recovery) + _, delayed = _publish_absent( + operation.recovery, + operation.config.target, + expected=owned[operation.recovery], + lock=lock, + ) + _release_missing(owned, operation.recovery) + if delayed is not None: + raise delayed _verify_config(operation.config, operation.config.file) def _rollback_use( operations: list[ConfigWrite | ConfigRemoval | BackupWrite | StateWrite], - owned: set[pathlib.Path], + owned: OwnedFiles, + lock: LockState, ) -> tuple[list[str], set[pathlib.Path]]: errors: list[str] = [] preserved: set[pathlib.Path] = set() @@ -1371,7 +1687,7 @@ def _rollback_use( if isinstance(operation, (ConfigWrite, ConfigRemoval)): cli = operation.config.layer.cli try: - _restore_removed_config(operation, owned) + _restore_removed_config(operation, owned, lock) except Exception as error: # noqa: BLE001 - continue reverse rollback blocked.add(cli) if operation.recovery.exists(): @@ -1389,11 +1705,32 @@ def _rollback_use( try: _verify_artifact(operation.state, operation.committed) if operation.state.file is None: - _apply_unlink(operation.state.physical) + delayed = _apply_unlink( + operation.state.physical, + expected=operation.committed, + lock=lock, + ) + if delayed is not None: + raise delayed else: recovery = t.cast(pathlib.Path, operation.recovery) - _apply_replace(recovery, operation.state.physical) - owned.discard(recovery) + if operation.committed is not None: + delayed = _apply_unlink( + operation.state.physical, + expected=operation.committed, + lock=lock, + ) + if delayed is not None: + raise delayed + _, delayed = _publish_absent( + recovery, + operation.state.physical, + expected=owned[recovery], + lock=lock, + ) + _release_missing(owned, recovery) + if delayed is not None: + raise delayed _verify_restored_artifact(operation.state) except Exception as error: # noqa: BLE001 - continue reverse rollback blocked.add(cli) @@ -1409,14 +1746,20 @@ def _rollback_use( continue try: _verify_artifact(operation.backup, operation.committed) - _apply_unlink(operation.backup.physical) + delayed = _apply_unlink( + operation.backup.physical, + expected=operation.committed, + lock=lock, + ) + if delayed is not None: + raise delayed except Exception as error: # noqa: BLE001 - continue reverse rollback preserved.add(operation.backup.path) errors.append(f"{cli} backup: {error}") return errors, preserved -def _commit_use(staged: list[StagedUse], owned: set[pathlib.Path]) -> None: +def _commit_use(staged: list[StagedUse], owned: OwnedFiles, lock: LockState) -> None: operations: list[ConfigWrite | ConfigRemoval | BackupWrite | StateWrite] = [] committed_backups: dict[str, FileState] = {} committed_states: dict[str, FileState] = {} @@ -1441,12 +1784,18 @@ def _commit_use(staged: list[StagedUse], owned: set[pathlib.Path]) -> None: cli = plan.config.layer.cli _changed_config(plan.config, plan.config.file) _changed_backup(plan.backup, None, cli) - committed, delayed = _apply_replace(item.backup, plan.backup.physical) - owned.discard(item.backup) + committed, delayed = _publish_absent( + item.backup, + plan.backup.physical, + expected=owned[item.backup], + lock=lock, + ) + _release_missing(owned, item.backup) operations.append(BackupWrite(plan.backup, committed, cli)) committed_backups[cli] = committed if delayed is not None: raise delayed + _verify_artifact(plan.backup, committed) for item in staged: plan = item.plan @@ -1457,7 +1806,15 @@ def _commit_use(staged: list[StagedUse], owned: set[pathlib.Path]) -> None: _changed_state(plan.state, plan.state.file, cli) if plan.state.file is not None: recovery = t.cast(pathlib.Path, item.state_recovery) - removed, delayed = _apply_replace(plan.state.physical, recovery) + removed, delayed = _apply_replace( + plan.state.physical, + recovery, + expected=plan.state.file, + destination_expected=owned[recovery], + lock=lock, + ) + if removed == plan.state.file: + owned[recovery] = removed operations.append( StateWrite(plan.state, plan.backup, None, recovery, cli) ) @@ -1466,8 +1823,13 @@ def _commit_use(staged: list[StagedUse], owned: set[pathlib.Path]) -> None: if delayed is not None: raise delayed _verify_artifact(plan.state, None) - committed, delayed = _apply_replace(item.state, plan.state.physical) - owned.discard(item.state) + committed, delayed = _publish_absent( + item.state, + plan.state.physical, + expected=owned[item.state], + lock=lock, + ) + _release_missing(owned, item.state) operation = StateWrite( plan.state, plan.backup, @@ -1482,6 +1844,7 @@ def _commit_use(staged: list[StagedUse], owned: set[pathlib.Path]) -> None: committed_states[cli] = committed if delayed is not None: raise delayed + _verify_artifact(plan.state, committed) for item in staged: plan = item.plan @@ -1490,15 +1853,29 @@ def _commit_use(staged: list[StagedUse], owned: set[pathlib.Path]) -> None: expected_backup = committed_backups.get(cli, plan.backup.file) _changed_backup(plan.backup, expected_backup, cli) _changed_state(plan.state, committed_states[cli], cli) - removed, delayed = _apply_replace(plan.config.target, item.recovery) + removed, delayed = _apply_replace( + plan.config.target, + item.recovery, + expected=plan.config.file, + destination_expected=owned[item.recovery], + lock=lock, + ) + if removed == plan.config.file: + owned[item.recovery] = removed operations.append(ConfigRemoval(plan.config, item.recovery)) if removed != plan.config.file: raise RuntimeError(f"{cli} config recovery identity changed") if delayed is not None: raise delayed - committed, delayed = _apply_replace(item.output, plan.config.target) - owned.discard(item.output) + committed, delayed = _publish_absent( + item.output, + plan.config.target, + expected=owned[item.output], + lock=lock, + ) + _release_missing(owned, item.output) operations[-1] = ConfigWrite(plan.config, committed, item.recovery) + _verify_config(plan.config, committed) expected_record = _record_for( plan, committed, t.cast(FileState, expected_backup) ) @@ -1510,16 +1887,17 @@ def _commit_use(staged: list[StagedUse], owned: set[pathlib.Path]) -> None: if delayed is not None: raise delayed except Exception as error: # noqa: BLE001 - every commit failure rolls back - rollback_errors, preserved = _rollback_use(operations, owned) - cleanup_errors = _cleanup_owned(owned, preserved) + rollback_errors, preserved = _rollback_use(operations, owned, lock) + cleanup_errors = _cleanup_owned(owned, preserved, lock=lock) _transaction_failure("swap", error, rollback_errors, cleanup_errors, preserved) - _require_cleanup("swap", owned) + _require_cleanup("swap", owned, lock) def _rollback_revert( operations: list[ConfigWrite | ConfigRemoval | BackupRemoval | StateRemoval], - owned: set[pathlib.Path], + owned: OwnedFiles, + lock: LockState, ) -> tuple[list[str], set[pathlib.Path]]: errors: list[str] = [] preserved: set[pathlib.Path] = set() @@ -1532,8 +1910,15 @@ def _rollback_revert( raise RuntimeError( f"{operation.state.path} appeared before rollback" ) - _apply_replace(operation.recovery, operation.state.physical) - owned.discard(operation.recovery) + _, delayed = _publish_absent( + operation.recovery, + operation.state.physical, + expected=owned[operation.recovery], + lock=lock, + ) + _release_missing(owned, operation.recovery) + if delayed is not None: + raise delayed _verify_restored_artifact(operation.state) except Exception as error: # noqa: BLE001 - continue reverse rollback if operation.recovery.exists(): @@ -1550,8 +1935,15 @@ def _rollback_revert( raise RuntimeError( f"{operation.backup.path} appeared before rollback" ) - _apply_replace(operation.recovery, operation.backup.physical) - owned.discard(operation.recovery) + _, delayed = _publish_absent( + operation.recovery, + operation.backup.physical, + expected=owned[operation.recovery], + lock=lock, + ) + _release_missing(owned, operation.recovery) + if delayed is not None: + raise delayed _verify_restored_artifact(operation.backup) except Exception as error: # noqa: BLE001 - continue reverse rollback if operation.recovery.exists(): @@ -1561,7 +1953,7 @@ def _rollback_revert( cli = operation.config.layer.cli try: - _restore_removed_config(operation, owned) + _restore_removed_config(operation, owned, lock) except Exception as error: # noqa: BLE001 - continue reverse rollback if operation.recovery.exists(): preserved.add(operation.recovery) @@ -1569,7 +1961,9 @@ def _rollback_revert( return errors, preserved -def _commit_revert(staged: list[StagedRevert], owned: set[pathlib.Path]) -> None: +def _commit_revert( + staged: list[StagedRevert], owned: OwnedFiles, lock: LockState +) -> None: operations: list[ConfigWrite | ConfigRemoval | BackupRemoval | StateRemoval] = [] committed_configs: dict[str, FileState] = {} try: @@ -1585,18 +1979,32 @@ def _commit_revert(staged: list[StagedRevert], owned: set[pathlib.Path]) -> None _changed_config(plan.config, plan.config.file) _changed_backup(plan.backup, plan.backup.file, cli) _changed_state(plan.state, plan.state.file, cli) - removed, delayed = _apply_replace(plan.config.target, item.recovery) + removed, delayed = _apply_replace( + plan.config.target, + item.recovery, + expected=plan.config.file, + destination_expected=owned[item.recovery], + lock=lock, + ) + if removed == plan.config.file: + owned[item.recovery] = removed operations.append(ConfigRemoval(plan.config, item.recovery)) if removed != plan.config.file: raise RuntimeError(f"{cli} config recovery identity changed") if delayed is not None: raise delayed - committed, delayed = _apply_replace(item.restored, plan.config.target) - owned.discard(item.restored) + committed, delayed = _publish_absent( + item.restored, + plan.config.target, + expected=owned[item.restored], + lock=lock, + ) + _release_missing(owned, item.restored) operations[-1] = ConfigWrite(plan.config, committed, item.recovery) committed_configs[cli] = committed if delayed is not None: raise delayed + _verify_config(plan.config, committed) for item in staged: plan = item.plan @@ -1605,13 +2013,20 @@ def _commit_revert(staged: list[StagedRevert], owned: set[pathlib.Path]) -> None _changed_backup(plan.backup, plan.backup.file, cli) _changed_state(plan.state, plan.state.file, cli) removed, delayed = _apply_replace( - plan.backup.physical, item.backup_recovery + plan.backup.physical, + item.backup_recovery, + expected=plan.backup.file, + destination_expected=owned[item.backup_recovery], + lock=lock, ) + if removed == plan.backup.file: + owned[item.backup_recovery] = removed operations.append(BackupRemoval(plan.backup, item.backup_recovery, cli)) if removed != plan.backup.file: raise RuntimeError(f"{cli} backup recovery identity changed") if delayed is not None: raise delayed + _verify_artifact(plan.backup, None) for item in staged: plan = item.plan @@ -1621,20 +2036,29 @@ def _commit_revert(staged: list[StagedRevert], owned: set[pathlib.Path]) -> None if os.path.lexists(plan.backup.path): raise RuntimeError(f"{cli} backup still exists after removal") _changed_state(plan.state, plan.state.file, cli) - removed, delayed = _apply_replace(plan.state.physical, item.state_recovery) + removed, delayed = _apply_replace( + plan.state.physical, + item.state_recovery, + expected=plan.state.file, + destination_expected=owned[item.state_recovery], + lock=lock, + ) + if removed == plan.state.file: + owned[item.state_recovery] = removed operations.append(StateRemoval(plan.state, item.state_recovery, cli)) if removed != plan.state.file: raise RuntimeError(f"{cli} recovery state identity changed") if delayed is not None: raise delayed + _verify_artifact(plan.state, None) except Exception as error: # noqa: BLE001 - every commit failure rolls back - rollback_errors, preserved = _rollback_revert(operations, owned) - cleanup_errors = _cleanup_owned(owned, preserved) + rollback_errors, preserved = _rollback_revert(operations, owned, lock) + cleanup_errors = _cleanup_owned(owned, preserved, lock=lock) _transaction_failure( "revert", error, rollback_errors, cleanup_errors, preserved ) - _require_cleanup("revert", owned) + _require_cleanup("revert", owned, lock) # ------------------------------------------------------------------ what to point at @@ -1731,6 +2155,7 @@ def cmd_status(args: argparse.Namespace) -> int: def cmd_use(args: argparse.Namespace) -> int: command, arguments = launcher(args) prepared = _plan_use(args, command, arguments) + _check_lock_plan(prepared) print( f"pointing '{args.name}' at: {command} {' '.join(arguments)}".rstrip(), file=sys.stderr, @@ -1744,9 +2169,12 @@ def cmd_use(args: argparse.Namespace) -> int: return 0 build(args) - owned: set[pathlib.Path] = set() - staged = _stage_use(prepared, owned) - _commit_use(staged, owned) + with _state_lock() as lock: + _reject_duplicate_targets(prepared, lock) + _validate_lock(lock) + owned: OwnedFiles = {} + staged = _stage_use(prepared, owned, lock) + _commit_use(staged, owned, lock) for item in prepared: layer = item.config.layer print(f"{layer.cli:<{CLI_COLUMN}} set {args.name}") @@ -1755,15 +2183,19 @@ def cmd_use(args: argparse.Namespace) -> int: def cmd_revert(args: argparse.Namespace) -> int: prepared = _plan_revert(args) + _check_lock_plan(prepared) if args.dry_run: for item in prepared: layer = item.config.layer print(f"{layer.cli:<{CLI_COLUMN}} would restore {item.backup.path}") return 0 - owned: set[pathlib.Path] = set() - staged = _stage_revert(prepared, owned) - _commit_revert(staged, owned) + with _state_lock() as lock: + _reject_duplicate_targets(prepared, lock) + _validate_lock(lock) + owned: OwnedFiles = {} + staged = _stage_revert(prepared, owned, lock) + _commit_revert(staged, owned, lock) for item in prepared: layer = item.config.layer print(f"{layer.cli:<{CLI_COLUMN}} restored") diff --git a/scripts/test_mcp_swap.py b/scripts/test_mcp_swap.py index 97397a5..557d85f 100644 --- a/scripts/test_mcp_swap.py +++ b/scripts/test_mcp_swap.py @@ -1,5 +1,6 @@ from __future__ import annotations +import fcntl import importlib.util import json import os @@ -34,6 +35,7 @@ def swapper( home.mkdir() monkeypatch.setenv("HOME", str(home)) monkeypatch.setenv("XDG_CONFIG_HOME", str(home / ".config")) + monkeypatch.setenv("XDG_STATE_HOME", str(home / ".local" / "state")) name = f"mcp_swap_test_{tmp_path.name}" spec = importlib.util.spec_from_file_location(name, SCRIPT) assert spec is not None and spec.loader is not None @@ -168,6 +170,666 @@ def test_all_eight_clients_commit_only_after_full_preflight( assert not _state_of(swapper, layer).exists() +@pytest.mark.parametrize("alias_kind", ["symlink", "hardlink"]) +@pytest.mark.parametrize("dry_run", [False, True], ids=["use", "dry-run"]) +def test_config_alias_to_swap_lock_is_rejected( + swapper: types.ModuleType, alias_kind: str, dry_run: bool +) -> None: + """A selected config cannot name the persistent transaction lock.""" + originals = _seed_configs(swapper) + claude = next(layer for layer in swapper.LAYERS if layer.cli == "claude") + swap_lock = _swap_lock(swapper) + swap_lock.parent.mkdir(parents=True) + swap_lock.write_bytes(originals[claude.cli]) + swap_lock.chmod(0o600) + lock_state = _path_identity(swap_lock) + claude.path.unlink() + if alias_kind == "symlink": + claude.path.symlink_to(swap_lock) + else: + os.link(swap_lock, claude.path) + config_state = _path_identity(claude.path) + args = _use_args("--cli", claude.cli) + if dry_run: + args.append("--dry-run") + + with pytest.raises(SystemExit, match="lock"): + swapper.main(args) + + assert _path_identity(swap_lock) == lock_state + assert _path_identity(claude.path) == config_state + assert not swapper.backup_of(claude).exists() + assert not _state_of(swapper, claude).exists() + + +@pytest.mark.parametrize("alias_kind", ["symlink", "hardlink"]) +@pytest.mark.parametrize("artifact", ["backup", "state"]) +@pytest.mark.parametrize( + "operation", ["use", "use-dry-run", "revert", "revert-dry-run"] +) +def test_recovery_alias_to_swap_lock_is_rejected( + swapper: types.ModuleType, + alias_kind: str, + artifact: str, + operation: str, +) -> None: + """Neither owned recovery file can become the transaction lock.""" + _seed_configs(swapper) + claude = next(layer for layer in swapper.LAYERS if layer.cli == "claude") + assert swapper.main(_use_args("--cli", claude.cli)) == 0 + swap_lock = _swap_lock(swapper) + recovery = ( + swapper.backup_of(claude) + if artifact == "backup" + else _state_of(swapper, claude) + ) + before = _owned_layer_state(swapper, claude) + swap_lock.unlink() + if alias_kind == "symlink": + swap_lock.symlink_to(recovery) + else: + os.link(recovery, swap_lock) + lock_state = _path_identity(swap_lock) + command = ( + ["revert", "--cli", claude.cli] + if operation.startswith("revert") + else _use_args("--cli", claude.cli, "--bin", "/opt/next/libtmux-mcp") + ) + if operation.endswith("dry-run"): + command.append("--dry-run") + + with pytest.raises(SystemExit, match="lock"): + swapper.main(command) + + assert _owned_layer_state(swapper, claude) == before + assert _path_identity(swap_lock) == lock_state + + +@pytest.mark.parametrize("artifact", ["backup", "state"]) +def test_prospective_lock_alias_is_rejected_before_build_or_creation( + swapper: types.ModuleType, + monkeypatch: pytest.MonkeyPatch, + artifact: str, +) -> None: + """An absent recovery path cannot become the lock before alias checks.""" + originals = _seed_configs(swapper) + claude = next(layer for layer in swapper.LAYERS if layer.cli == "claude") + swap_lock = _swap_lock(swapper) + swap_lock.parent.mkdir(parents=True) + built = False + + if artifact == "backup": + monkeypatch.setattr(swapper, "backup_of", lambda _layer: swap_lock) + else: + monkeypatch.setattr(swapper, "state_of", lambda _layer: swap_lock) + + def build(_args: object) -> None: + nonlocal built + built = True + + monkeypatch.setattr(swapper, "build", build) + with pytest.raises(SystemExit, match="lock"): + swapper.main(_use_args("--cli", claude.cli)) + + assert not built + assert claude.path.read_bytes() == originals[claude.cli] + assert not os.path.lexists(swap_lock) + assert swap_lock.parent.is_dir() + + +@pytest.mark.parametrize("dry_run", [False, True], ids=["use", "dry-run"]) +@pytest.mark.parametrize("defect", ["mode", "directory", "directory-symlink"]) +def test_unsafe_swap_lock_topology_is_rejected( + swapper: types.ModuleType, dry_run: bool, defect: str +) -> None: + """Lock inspection rejects unsafe type, mode, and directory topology.""" + originals = _seed_configs(swapper) + claude = next(layer for layer in swapper.LAYERS if layer.cli == "claude") + swap_lock = _swap_lock(swapper) + if defect == "directory-symlink": + target = claude.path.parent / "lock-target" + target.mkdir() + swap_lock.parent.parent.mkdir(parents=True) + swap_lock.parent.symlink_to(target, target_is_directory=True) + (target / swap_lock.name).write_bytes(b"") + (target / swap_lock.name).chmod(0o600) + else: + swap_lock.parent.mkdir(parents=True) + if defect == "directory": + swap_lock.mkdir() + else: + swap_lock.write_bytes(b"") + swap_lock.chmod(0o640) + args = _use_args("--cli", claude.cli) + if dry_run: + args.append("--dry-run") + + with pytest.raises(SystemExit, match="lock"): + swapper.main(args) + + assert claude.path.read_bytes() == originals[claude.cli] + assert not swapper.backup_of(claude).exists() + + +@pytest.mark.parametrize("operation", ["use", "revert"]) +def test_transaction_holds_and_revalidates_the_swap_lock( + swapper: types.ModuleType, + monkeypatch: pytest.MonkeyPatch, + operation: str, +) -> None: + """The lock stays exclusive and a same-path replacement stops the run.""" + _seed_configs(swapper) + claude = next(layer for layer in swapper.LAYERS if layer.cli == "claude") + if operation == "revert": + assert swapper.main(_use_args("--cli", claude.cli)) == 0 + swap_lock = _swap_lock(swapper) + real_link = os.link + real_replace = os.replace + replacement_inode: int | None = None + observed_locked = False + + def link( + source: object, + destination: object, + *args: object, + **kwargs: object, + ) -> None: + nonlocal observed_locked, replacement_inode + source_path = pathlib.Path(source) + destination_path = pathlib.Path(destination) + if replacement_inode is None and ".mcp-swap-" in destination_path.name: + competitor = os.open(swap_lock, os.O_RDWR) + try: + with pytest.raises(BlockingIOError): + fcntl.flock(competitor, fcntl.LOCK_EX | fcntl.LOCK_NB) + observed_locked = True + finally: + os.close(competitor) + human_lock = swap_lock.with_name("human-state.lock") + human_lock.write_bytes(b"human lock replacement\n") + human_lock.chmod(0o600) + real_replace(human_lock, swap_lock) + replacement_inode = swap_lock.stat().st_ino + real_link(source_path, destination_path, *args, **kwargs) + + monkeypatch.setattr(os, "link", link) + command = ( + ["revert", "--cli", claude.cli] + if operation == "revert" + else _use_args("--cli", claude.cli) + ) + + with pytest.raises(SystemExit, match="lock"): + swapper.main(command) + + assert observed_locked + assert replacement_inode is not None + assert swap_lock.stat().st_ino == replacement_inode + assert swap_lock.read_bytes() == b"human lock replacement\n" + + +def test_lock_directory_replacement_after_file_open_is_rejected( + swapper: types.ModuleType, monkeypatch: pytest.MonkeyPatch +) -> None: + """An open descriptor cannot authenticate a disappeared lock path.""" + originals = _seed_configs(swapper) + claude = next(layer for layer in swapper.LAYERS if layer.cli == "claude") + swap_lock = _swap_lock(swapper) + displaced = swap_lock.parent.with_name("displaced-swap-lock") + real_flock = fcntl.flock + real_rename = os.rename + injected = False + + def flock(descriptor: int, operation: int) -> None: + nonlocal injected + real_flock(descriptor, operation) + if injected or operation != fcntl.LOCK_EX: + return + real_rename(swap_lock.parent, displaced) + swap_lock.parent.mkdir() + injected = True + + monkeypatch.setattr(fcntl, "flock", flock) + with pytest.raises(SystemExit, match="lock"): + swapper.main(_use_args("--cli", claude.cli)) + + assert injected + assert claude.path.read_bytes() == originals[claude.cli] + assert not swapper.backup_of(claude).exists() + assert (displaced / swap_lock.name).is_file() + assert not os.path.lexists(swap_lock) + + +def test_use_and_revert_keep_one_private_persistent_lock( + swapper: types.ModuleType, +) -> None: + """Successful mutations reuse a private single-link lock inode.""" + _seed_configs(swapper) + claude = next(layer for layer in swapper.LAYERS if layer.cli == "claude") + swap_lock = _swap_lock(swapper) + + assert swapper.main(_use_args("--cli", claude.cli)) == 0 + first = _path_identity(swap_lock) + assert stat.S_IMODE(swap_lock.stat().st_mode) == 0o600 + assert swap_lock.stat().st_nlink == 1 + assert swapper.main(["revert", "--cli", claude.cli]) == 0 + assert _path_identity(swap_lock) == first + _assert_no_stages(swapper) + + +def test_dry_run_does_not_acquire_an_existing_lock( + swapper: types.ModuleType, monkeypatch: pytest.MonkeyPatch +) -> None: + """Dry-run inspects lock identity without creating a lock owner.""" + _seed_configs(swapper) + swap_lock = _swap_lock(swapper) + swap_lock.parent.mkdir(parents=True) + swap_lock.write_bytes(b"") + swap_lock.chmod(0o600) + before = _path_identity(swap_lock) + + def forbidden(*_args: object, **_kwargs: object) -> None: + raise AssertionError("dry-run acquired the swap lock") + + monkeypatch.setattr(fcntl, "flock", forbidden) + assert swapper.main(_use_args("--cli", "claude", "--dry-run")) == 0 + assert _path_identity(swap_lock) == before + + +@pytest.mark.parametrize("timing", ["before-read", "inside-rename"]) +@pytest.mark.parametrize( + ("operation", "boundary"), + [ + ("use", "backup-publish"), + ("use", "state-publish"), + ("use", "config-take-aside"), + ("use", "config-publish"), + ("repeat-use", "state-take-aside"), + ("revert", "config-take-aside"), + ("revert", "config-publish"), + ("revert", "backup-take-aside"), + ("revert", "state-take-aside"), + ], +) +def test_late_transition_source_replacement_survives( + swapper: types.ModuleType, + monkeypatch: pytest.MonkeyPatch, + operation: str, + boundary: str, + timing: str, +) -> None: + """Every commit boundary rejects and retains a late source inode.""" + _seed_configs(swapper) + claude = next(layer for layer in swapper.LAYERS if layer.cli == "claude") + if operation in {"repeat-use", "revert"}: + assert swapper.main(_use_args("--cli", claude.cli)) == 0 + backup = swapper.backup_of(claude) + state = _state_of(swapper, claude) + real_apply = swapper._apply_replace + real_publish = swapper._publish_absent + real_link = os.link + real_replace = os.replace + unexpected_inode: int | None = None + + def selected(source: pathlib.Path, destination: pathlib.Path) -> bool: + if boundary == "backup-publish": + return destination == backup and ".mcp-swap-new-" in source.name + if boundary == "state-publish": + return destination == state and ".mcp-swap-state-" in source.name + if boundary == "config-take-aside": + return source == claude.path and ".mcp-swap-recovery-" in destination.name + if boundary == "config-publish": + role = "restore" if operation == "revert" else "output" + return destination == claude.path and f".mcp-swap-{role}-" in source.name + if boundary == "state-take-aside": + return source == state and ".mcp-swap-recovery-state-" in destination.name + if boundary == "backup-take-aside": + return source == backup and ".mcp-swap-recovery-" in destination.name + raise AssertionError(f"unknown boundary {boundary}") + + def inject(source: pathlib.Path) -> None: + nonlocal unexpected_inode + human = source.with_name(f".{source.name}.human-{boundary}") + human.write_bytes(b"human boundary replacement\n") + human.chmod(0o600) + real_replace(human, source) + unexpected_inode = source.stat().st_ino + + if timing == "before-read": + + def apply( + source: pathlib.Path, + destination: pathlib.Path, + *args: object, + **kwargs: object, + ) -> tuple[object, object]: + source_path = pathlib.Path(source) + destination_path = pathlib.Path(destination) + if unexpected_inode is None and selected(source_path, destination_path): + inject(source_path) + return real_apply(source_path, destination_path, *args, **kwargs) + + monkeypatch.setattr(swapper, "_apply_replace", apply) + + def publish( + source: pathlib.Path, + destination: pathlib.Path, + *args: object, + **kwargs: object, + ) -> tuple[object, object]: + source_path = pathlib.Path(source) + destination_path = pathlib.Path(destination) + if unexpected_inode is None and selected(source_path, destination_path): + inject(source_path) + return real_publish(source_path, destination_path, *args, **kwargs) + + monkeypatch.setattr(swapper, "_publish_absent", publish) + else: + + def replace(source: object, destination: object) -> None: + source_path = pathlib.Path(source) + destination_path = pathlib.Path(destination) + if unexpected_inode is None and selected(source_path, destination_path): + inject(source_path) + real_replace(source_path, destination_path) + + monkeypatch.setattr(os, "replace", replace) + + def link( + source: object, + destination: object, + *args: object, + **kwargs: object, + ) -> None: + source_path = pathlib.Path(source) + destination_path = pathlib.Path(destination) + if unexpected_inode is None and selected(source_path, destination_path): + inject(source_path) + real_link(source_path, destination_path, *args, **kwargs) + + monkeypatch.setattr(os, "link", link) + + command = ( + ["revert", "--cli", claude.cli] + if operation == "revert" + else _use_args( + "--cli", + claude.cli, + *(["--bin", "/opt/next/libtmux-mcp"] if operation == "repeat-use" else []), + ) + ) + with pytest.raises(SystemExit): + swapper.main(command) + + assert unexpected_inode is not None + assert unexpected_inode in { + path.stat().st_ino for path in claude.path.parent.rglob("*") if path.is_file() + } + + +@pytest.mark.parametrize( + ("operation", "boundary"), + [ + ("use", "backup-publish"), + ("use", "state-publish"), + ("use", "config-take-aside"), + ("use", "config-publish"), + ("repeat-use", "state-take-aside"), + ("revert", "config-take-aside"), + ("revert", "config-publish"), + ("revert", "backup-take-aside"), + ("revert", "state-take-aside"), + ], +) +def test_transition_never_overwrites_a_late_destination( + swapper: types.ModuleType, + monkeypatch: pytest.MonkeyPatch, + operation: str, + boundary: str, +) -> None: + """A file arriving at a transition destination survives failure.""" + _seed_configs(swapper) + claude = next(layer for layer in swapper.LAYERS if layer.cli == "claude") + if operation in {"repeat-use", "revert"}: + assert swapper.main(_use_args("--cli", claude.cli)) == 0 + backup = swapper.backup_of(claude) + state = _state_of(swapper, claude) + real_link = os.link + real_replace = os.replace + unexpected_inode: int | None = None + + def selected(source: pathlib.Path, destination: pathlib.Path) -> bool: + if boundary == "backup-publish": + return destination == backup and ".mcp-swap-new-" in source.name + if boundary == "state-publish": + return destination == state and ".mcp-swap-state-" in source.name + if boundary == "config-take-aside": + return source == claude.path and ".mcp-swap-recovery-" in destination.name + if boundary == "state-take-aside": + return source == state and ".mcp-swap-recovery-state-" in destination.name + if boundary == "backup-take-aside": + return source == backup and ".mcp-swap-recovery-" in destination.name + role = "restore" if operation == "revert" else "output" + return destination == claude.path and f".mcp-swap-{role}-" in source.name + + def appear(destination: pathlib.Path) -> None: + nonlocal unexpected_inode + human = destination.with_name(f".{destination.name}.human-{boundary}") + human.write_bytes(b"human destination replacement\n") + human.chmod(0o600) + real_replace(human, destination) + unexpected_inode = destination.stat().st_ino + + def link( + source: object, destination: object, *args: object, **kwargs: object + ) -> None: + source_path = pathlib.Path(source) + destination_path = pathlib.Path(destination) + if unexpected_inode is None and selected(source_path, destination_path): + appear(destination_path) + real_link(source, destination, *args, **kwargs) + + def replace(source: object, destination: object) -> None: + source_path = pathlib.Path(source) + destination_path = pathlib.Path(destination) + if unexpected_inode is None and selected(source_path, destination_path): + appear(destination_path) + real_replace(source_path, destination_path) + + monkeypatch.setattr(os, "link", link) + monkeypatch.setattr(os, "replace", replace) + command = ( + ["revert", "--cli", claude.cli] + if operation == "revert" + else _use_args( + "--cli", + claude.cli, + *(["--bin", "/opt/next/libtmux-mcp"] if operation == "repeat-use" else []), + ) + ) + + with pytest.raises(SystemExit): + swapper.main(command) + + assert unexpected_inode is not None + assert unexpected_inode in { + path.stat().st_ino for path in claude.path.parent.rglob("*") if path.is_file() + } + + +def test_exact_removal_retains_a_late_replacement( + swapper: types.ModuleType, monkeypatch: pytest.MonkeyPatch +) -> None: + """Removing an owned public path never deletes a substituted inode.""" + _seed_configs(swapper) + claude = next(layer for layer in swapper.LAYERS if layer.cli == "claude") + expected = swapper._regular_file_state(claude.path) + real_rename = os.rename + real_replace = os.replace + real_unlink = os.unlink + unexpected_inode: int | None = None + + def inject(path: pathlib.Path) -> None: + nonlocal unexpected_inode + if unexpected_inode is not None or path != claude.path: + return + human = path.with_name("human-removal-replacement.json") + human.write_bytes(b"human removal replacement\n") + human.chmod(0o600) + real_replace(human, path) + unexpected_inode = path.stat().st_ino + + def rename( + source: object, + destination: object, + *args: object, + **kwargs: object, + ) -> None: + inject(pathlib.Path(source)) + real_rename(source, destination, *args, **kwargs) + + def unlink(path: object, *args: object, **kwargs: object) -> None: + inject(pathlib.Path(path)) + real_unlink(path, *args, **kwargs) + + monkeypatch.setattr(os, "rename", rename) + monkeypatch.setattr(os, "unlink", unlink) + with ( + swapper._state_lock() as lock, + pytest.raises(RuntimeError, match="changed|retained"), + ): + swapper._apply_unlink(claude.path, expected=expected, lock=lock) + + assert unexpected_inode is not None + assert unexpected_inode in { + path.stat().st_ino for path in claude.path.parent.rglob("*") if path.is_file() + } + + +@pytest.mark.parametrize( + ("operation", "role"), [("use", "output"), ("revert", "restore")] +) +def test_cleanup_retains_a_late_owned_path_replacement( + swapper: types.ModuleType, + monkeypatch: pytest.MonkeyPatch, + operation: str, + role: str, +) -> None: + """Use and revert cleanup retain a substituted task-owned inode.""" + _seed_configs(swapper) + claude = next(layer for layer in swapper.LAYERS if layer.cli == "claude") + if operation == "revert": + assert swapper.main(_use_args("--cli", claude.cli)) == 0 + real_rename = os.rename + real_replace = os.replace + real_unlink = os.unlink + unexpected_inode: int | None = None + + def inject(path: pathlib.Path) -> None: + nonlocal unexpected_inode + if unexpected_inode is not None or f".mcp-swap-{role}-" not in path.name: + return + human = path.with_name(f".{path.name}.human-cleanup") + human.write_bytes(b"human cleanup replacement\n") + human.chmod(0o600) + real_replace(human, path) + unexpected_inode = path.stat().st_ino + + def rename( + source: object, + destination: object, + *args: object, + **kwargs: object, + ) -> None: + inject(pathlib.Path(source)) + real_rename(source, destination, *args, **kwargs) + + def unlink(path: object, *args: object, **kwargs: object) -> None: + inject(pathlib.Path(path)) + real_unlink(path, *args, **kwargs) + + monkeypatch.setattr(os, "rename", rename) + monkeypatch.setattr(os, "unlink", unlink) + command = ( + ["revert", "--cli", claude.cli] + if operation == "revert" + else _use_args("--cli", claude.cli) + ) + + with pytest.raises(SystemExit, match="cleanup|retained|task-owned"): + swapper.main(command) + + assert unexpected_inode is not None + assert unexpected_inode in { + path.stat().st_ino for path in claude.path.parent.rglob("*") if path.is_file() + } + + +@pytest.mark.parametrize( + ("operation", "role"), [("use", "output"), ("revert", "restore")] +) +def test_config_symlink_retargeted_during_publish_is_preserved( + swapper: types.ModuleType, + monkeypatch: pytest.MonkeyPatch, + tmp_path: pathlib.Path, + operation: str, + role: str, +) -> None: + """A late symlink retarget stops the transaction without touching its target.""" + originals = _seed_configs(swapper) + claude = next(layer for layer in swapper.LAYERS if layer.cli == "claude") + first = tmp_path / "first.json" + second = tmp_path / "human.json" + first.write_bytes(originals[claude.cli]) + first.chmod(0o640) + second.write_bytes(b'{"human": "retargeted"}\n') + second.chmod(0o600) + human_identity = _path_identity(second) + claude.path.unlink() + claude.path.symlink_to(first) + if operation == "revert": + assert swapper.main(_use_args("--cli", claude.cli)) == 0 + real_link = os.link + retargeted = False + + def link( + source: object, + destination: object, + *args: object, + **kwargs: object, + ) -> None: + nonlocal retargeted + source_path = pathlib.Path(source) + destination_path = pathlib.Path(destination) + if ( + not retargeted + and destination_path == first + and f".mcp-swap-{role}-" in source_path.name + ): + claude.path.unlink() + claude.path.symlink_to(second) + retargeted = True + real_link(source, destination, *args, **kwargs) + + monkeypatch.setattr(os, "link", link) + command = ( + ["revert", "--cli", claude.cli] + if operation == "revert" + else _use_args("--cli", claude.cli) + ) + with pytest.raises(SystemExit, match="claude|symlink|target"): + swapper.main(command) + + assert retargeted + assert claude.path.is_symlink() and claude.path.resolve() == second + assert _path_identity(second) == human_identity + assert swapper.backup_of(claude).is_file() + assert _state_of(swapper, claude).is_file() + assert [ + path for path in first.parent.iterdir() if ".mcp-swap-recovery-" in path.name + ] + + def test_failed_late_config_preflight_writes_nothing( swapper: types.ModuleType, ) -> None: @@ -252,16 +914,21 @@ def test_state_publication_failure_rolls_back_every_client( originals = _seed_configs(swapper) states = [_state_of(swapper, layer) for layer in swapper.LAYERS] destinations = _fail_first_replace_to(monkeypatch, states[-1]) - real_unlink = os.unlink + real_rename = os.rename removed: list[pathlib.Path] = [] - def track_state_removal(path: object, *args: object, **kwargs: object) -> None: - target = pathlib.Path(path) + def track_state_removal( + source: object, + destination: object, + *args: object, + **kwargs: object, + ) -> None: + target = pathlib.Path(source) if target in states: removed.append(target) - real_unlink(path, *args, **kwargs) + real_rename(source, destination, *args, **kwargs) - monkeypatch.setattr(os, "unlink", track_state_removal) + monkeypatch.setattr(os, "rename", track_state_removal) with pytest.raises(SystemExit, match="synthetic replace failure"): swapper.main(_use_args()) @@ -326,13 +993,16 @@ def test_blocked_repeat_use_retains_prior_state_recovery( original_backup = swapper.backup_of(layer).read_bytes() human = b'{"human": true}\n' human_identity: tuple[int, int] | None = None + real_link = os.link real_replace = os.replace - def replace_then_human_edit(src: object, dst: object) -> None: + def publish_then_human_edit( + src: object, dst: object, *args: object, **kwargs: object + ) -> None: nonlocal human_identity source = pathlib.Path(src) destination = pathlib.Path(dst) - real_replace(source, destination) + real_link(source, destination, *args, **kwargs) if destination == layer.path and "mcp-swap-output" in source.name: replacement = destination.with_name(f".{destination.name}.human") replacement.write_bytes(human) @@ -341,7 +1011,7 @@ def replace_then_human_edit(src: object, dst: object) -> None: human_identity = (destination.stat().st_dev, destination.stat().st_ino) raise OSError("synthetic post-commit failure") - monkeypatch.setattr(os, "replace", replace_then_human_edit) + monkeypatch.setattr(os, "link", publish_then_human_edit) with pytest.raises(SystemExit, match="rollback incomplete"): swapper.main( _use_args( @@ -441,11 +1111,14 @@ def test_state_removal_failure_restores_the_swapped_transaction( real_replace = swapper._apply_replace def fail_state_removal( - source: pathlib.Path, destination: pathlib.Path + source: pathlib.Path, + destination: pathlib.Path, + *args: object, + **kwargs: object, ) -> tuple[object, object]: if pathlib.Path(source) == blocked: raise OSError("synthetic state removal failure") - return real_replace(source, destination) + return real_replace(source, destination, *args, **kwargs) monkeypatch.setattr(swapper, "_apply_replace", fail_state_removal) with pytest.raises(SystemExit, match="synthetic state removal failure"): @@ -803,9 +1476,13 @@ def forbidden_build(_args: object) -> None: raise AssertionError("dry-run built the distribution") monkeypatch.setattr(swapper, "build", forbidden_build) + swap_lock = _swap_lock(swapper) + assert not os.path.lexists(swap_lock) assert swapper.main(_use_args("--dry-run")) == 0 _assert_original_state(swapper, originals) _assert_no_stages(swapper) + assert not os.path.lexists(swap_lock) + assert not swap_lock.parent.exists() def test_staging_failure_cleans_up_before_any_destination_write( @@ -841,14 +1518,14 @@ def test_use_cleanup_failure_is_not_reported_as_success( """A committed swap with retained private stages must return failure.""" _seed_configs(swapper) claude = next(layer for layer in swapper.LAYERS if layer.cli == "claude") - _fail_stage_cleanup(monkeypatch) + _fail_stage_cleanup(monkeypatch, "output") with pytest.raises(SystemExit, match="cleanup incomplete") as stopped: swapper.main(_use_args("--cli", claude.cli)) - recovery = list(claude.path.parent.glob(f".{claude.path.name}.mcp-swap-recovery-*")) - assert len(recovery) == 1 - assert str(recovery[0]) in str(stopped.value) + retained = list(claude.path.parent.glob(".*mcp-swap-output-*mcp-swap-retained-*")) + assert len(retained) == 1 + assert str(retained[0]) in str(stopped.value) _assert_swapped(claude) @@ -859,14 +1536,14 @@ def test_revert_cleanup_failure_is_not_reported_as_success( originals = _seed_configs(swapper) claude = next(layer for layer in swapper.LAYERS if layer.cli == "claude") assert swapper.main(_use_args("--cli", claude.cli)) == 0 - _fail_stage_cleanup(monkeypatch) + _fail_stage_cleanup(monkeypatch, "restore") with pytest.raises(SystemExit, match="cleanup incomplete") as stopped: swapper.main(["revert", "--cli", claude.cli]) - recoveries = list(claude.path.parent.glob("*.mcp-swap-recovery-*")) - assert len(recoveries) == 3 - assert all(str(path) in str(stopped.value) for path in recoveries) + retained = list(claude.path.parent.glob(".*mcp-swap-restore-*mcp-swap-retained-*")) + assert len(retained) == 1 + assert str(retained[0]) in str(stopped.value) assert claude.path.read_bytes() == originals[claude.cli] @@ -877,10 +1554,12 @@ def test_failed_rollback_preserves_backup_and_recovery_stage( originals = _seed_configs(swapper) claude = next(layer for layer in swapper.LAYERS if layer.cli == "claude") cursor = next(layer for layer in swapper.LAYERS if layer.cli == "cursor") - real_replace = os.replace + real_link = os.link claude_writes = 0 - def fail_commit_and_rollback(src: object, dst: object) -> None: + def fail_commit_and_rollback( + src: object, dst: object, *args: object, **kwargs: object + ) -> None: nonlocal claude_writes destination = pathlib.Path(dst) if destination == cursor.path: @@ -889,9 +1568,9 @@ def fail_commit_and_rollback(src: object, dst: object) -> None: claude_writes += 1 if claude_writes == 2: raise OSError("synthetic rollback failure") - real_replace(src, dst) + real_link(src, dst, *args, **kwargs) - monkeypatch.setattr(os, "replace", fail_commit_and_rollback) + monkeypatch.setattr(os, "link", fail_commit_and_rollback) with pytest.raises(SystemExit, match="synthetic rollback failure"): swapper.main(_use_args()) @@ -913,19 +1592,31 @@ def test_failed_backup_rollback_preserves_its_recovery_copy( codex = next(layer for layer in swapper.LAYERS if layer.cli == "codex") claude_backup = swapper.backup_of(claude) codex_backup = swapper.backup_of(codex) - real_replace = os.replace + real_apply = swapper._apply_replace + real_link = os.link rollback_started = False - def fail_backup_restore(src: object, dst: object) -> None: + def fail_backup_removal( + src: pathlib.Path, + dst: pathlib.Path, + *args: object, + **kwargs: object, + ) -> tuple[object, object]: nonlocal rollback_started if pathlib.Path(src) == codex_backup: rollback_started = True raise OSError("synthetic backup removal failure") + return real_apply(src, dst, *args, **kwargs) + + def fail_backup_restore( + src: object, dst: object, *args: object, **kwargs: object + ) -> None: if rollback_started and pathlib.Path(dst) == claude_backup: raise OSError("synthetic backup rollback failure") - real_replace(src, dst) + real_link(src, dst, *args, **kwargs) - monkeypatch.setattr(os, "replace", fail_backup_restore) + monkeypatch.setattr(swapper, "_apply_replace", fail_backup_removal) + monkeypatch.setattr(os, "link", fail_backup_restore) with pytest.raises(SystemExit, match="synthetic backup rollback failure"): swapper.main(["revert"]) @@ -1077,6 +1768,23 @@ def _state_of(swapper: types.ModuleType, layer: object) -> pathlib.Path: return backup.with_name(backup.name + ".state") +def _swap_lock(swapper: types.ModuleType) -> pathlib.Path: + home = next(layer for layer in swapper.LAYERS if layer.cli == "claude").path.parent + return home / ".local" / "state" / "libtmux-mcp-dev" / "swap" / "state.lock" + + +def _path_identity(path: pathlib.Path) -> tuple[object, ...]: + details = path.lstat() + return ( + stat.S_ISLNK(details.st_mode), + os.readlink(path) if stat.S_ISLNK(details.st_mode) else None, + path.read_bytes(), + stat.S_IMODE(path.stat().st_mode), + path.stat().st_dev, + path.stat().st_ino, + ) + + def _owned_layer_state(swapper: types.ModuleType, layer: object) -> tuple[object, ...]: return _layer_state(swapper, layer) @@ -1087,11 +1795,11 @@ def _assert_no_stages(swapper: types.ModuleType) -> None: assert [path for path in home.rglob("*") if roles.search(path.name)] == [] -def _fail_stage_cleanup(monkeypatch: pytest.MonkeyPatch) -> None: +def _fail_stage_cleanup(monkeypatch: pytest.MonkeyPatch, role: str) -> None: real_unlink = pathlib.Path.unlink def refuse(path: pathlib.Path, *args: object, **kwargs: object) -> None: - if ".mcp-swap-" in path.name: + if f".mcp-swap-{role}-" in path.parent.name: raise OSError("synthetic cleanup failure") real_unlink(path, *args, **kwargs) @@ -1101,6 +1809,7 @@ def refuse(path: pathlib.Path, *args: object, **kwargs: object) -> None: def _fail_first_replace_to( monkeypatch: pytest.MonkeyPatch, destination: pathlib.Path ) -> list[pathlib.Path]: + real_link = os.link real_replace = os.replace real_rename = os.rename failed = False @@ -1124,6 +1833,16 @@ def rename(src: object, dst: object, *args: object, **kwargs: object) -> None: raise OSError("synthetic replace failure") real_rename(src, dst, *args, **kwargs) + def link(src: object, dst: object, *args: object, **kwargs: object) -> None: + nonlocal failed + target = pathlib.Path(dst) + destinations.append(target) + if target == destination and not failed: + failed = True + raise OSError("synthetic replace failure") + real_link(src, dst, *args, **kwargs) + + monkeypatch.setattr(os, "link", link) monkeypatch.setattr(os, "replace", replace) monkeypatch.setattr(os, "rename", rename) return destinations From 29a17f18747c591e5c3ddc335c7d0ceba424af98 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 5 Sep 2026 22:56:33 -0500 Subject: [PATCH 44/65] Mcp(fix[input]): Authenticate pane authority why: Pane input accepted disabled panes and incomplete or inconsistent inherited caller claims. what: - Parse canonical caller identity and compare its physical endpoint first - Snapshot daemon generation and caller topology with pane input state - Refuse disabled, malformed, or inconsistent input authority --- .../java/io/github/libtmux/mcp/Caller.java | 98 +++++++--- .../github/libtmux/mcp/PaneInputCohort.java | 178 ++++++++++++++++-- .../io/github/libtmux/mcp/CallerTest.java | 68 +++++++ .../libtmux/mcp/PaneInputCohortTest.java | 78 +++++++- .../libtmux/mcp/RunningCommandsTest.java | 2 +- .../java/io/github/libtmux/mcp/TestCalls.java | 2 +- .../io/github/libtmux/mcp/TypingTest.java | 100 ++++++++++ 7 files changed, 475 insertions(+), 51 deletions(-) create mode 100644 libtmux-mcp/src/test/java/io/github/libtmux/mcp/CallerTest.java diff --git a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Caller.java b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Caller.java index d21b552..3783cf8 100644 --- a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Caller.java +++ b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Caller.java @@ -2,11 +2,11 @@ import io.github.libtmux.PaneId; import io.github.libtmux.Server; -import io.github.libtmux.TmuxEnvironment; import java.io.IOException; import java.nio.file.Path; import java.util.Map; import java.util.Optional; +import java.util.Set; import org.jspecify.annotations.Nullable; /** @@ -33,11 +33,11 @@ private enum Relation { private static final Caller UNKNOWN = new Caller(Relation.UNKNOWN, null); private final Relation relation; - private final @Nullable PaneId pane; + private final @Nullable Claim claim; - private Caller(Relation relation, @Nullable PaneId pane) { + private Caller(Relation relation, @Nullable Claim claim) { this.relation = relation; - this.pane = pane; + this.claim = claim; } /** Works out which pane, if any, on {@code server} is the one this process runs in. */ @@ -46,31 +46,50 @@ static Caller of(Server server) { } static Caller of(Server server, Map environment) { - String raw = environment.get("TMUX"); - if (raw == null || raw.isEmpty()) { + boolean hasTmux = environment.containsKey("TMUX"); + boolean hasPane = environment.containsKey("TMUX_PANE"); + if (!hasTmux && !hasPane) { return NOWHERE; } - Optional inside = TmuxEnvironment.of(environment); - if (inside.isEmpty()) { + if (!hasTmux || !hasPane) { return UNKNOWN; } - TmuxEnvironment here = inside.get(); - Optional pane = here.pane(); - if (pane.isEmpty()) { + Claim claim = Claim.parse(environment.get("TMUX"), environment.get("TMUX_PANE")); + if (claim == null) { + return UNKNOWN; + } + FileRelation socket = sameFile(claim.socket(), socketOf(server)); + if (socket == FileRelation.DIFFERENT) { + return DIFFERENT; + } + if (socket == FileRelation.UNKNOWN) { return UNKNOWN; } Long serverPid = pidOf(server); - if (serverPid == null) { + if (serverPid == null || serverPid != claim.serverPid()) { return UNKNOWN; } - if (serverPid != here.serverPid()) { - return DIFFERENT; + return new Caller(Relation.SELF, claim); + } + + void requireConsistent(long serverPid, String socket, Map> paneSessions) { + if (relation != Relation.SELF) { + return; + } + Claim selected = java.util.Objects.requireNonNull(claim); + FileRelation socketRelation; + try { + socketRelation = sameFile(selected.socket(), Path.of(socket)); + } catch (RuntimeException failure) { + socketRelation = FileRelation.UNKNOWN; + } + Set sessions = paneSessions.get(selected.pane().value()); + if (serverPid != selected.serverPid() + || socketRelation != FileRelation.SAME + || sessions == null + || !sessions.contains(selected.sessionId())) { + throw new IllegalStateException("pane input refuses inconsistent caller identity"); } - return switch (sameFile(here.socket(), socketOf(server))) { - case SAME -> new Caller(Relation.SELF, pane.get()); - case DIFFERENT -> DIFFERENT; - case UNKNOWN -> UNKNOWN; - }; } private enum FileRelation { @@ -95,12 +114,12 @@ static Caller nowhere() { /** The pane this process runs in, empty when it does not run in one on this server. */ Optional pane() { - return Optional.ofNullable(pane); + return claim == null ? Optional.empty() : Optional.of(claim.pane()); } /** Whether acting on {@code target} would act on the conversation itself. */ boolean isSelf(PaneId target) { - return target.equals(pane); + return claim != null && target.equals(claim.pane()); } /** Whether the process is inside tmux but its relation to this server is unprovable. */ @@ -132,4 +151,41 @@ private static FileRelation sameFile(Path left, @Nullable Path right) { return FileRelation.UNKNOWN; } } + + private record Claim(Path socket, long serverPid, String sessionId, PaneId pane) { + + private static @Nullable Claim parse(@Nullable String tmux, @Nullable String pane) { + if (tmux == null || tmux.isEmpty() || pane == null || pane.isEmpty()) { + return null; + } + int lastComma = tmux.lastIndexOf(','); + int firstOfPair = tmux.lastIndexOf(',', lastComma - 1); + if (lastComma < 0 || firstOfPair < 0) { + return null; + } + String socket = tmux.substring(0, firstOfPair); + String pid = tmux.substring(firstOfPair + 1, lastComma); + String session = tmux.substring(lastComma + 1); + try { + RouteValue.requireSafe(socket, "caller tmux socket path"); + Path socketPath = Path.of(socket); + long parsedPid = Long.parseLong(pid); + long parsedSession = Long.parseLong(session); + long parsedPane = Long.parseLong(pane.substring(1)); + if (!socketPath.isAbsolute() + || parsedPid <= 0 + || !pid.equals(Long.toString(parsedPid)) + || parsedSession < 0 + || !session.equals(Long.toString(parsedSession)) + || parsedPane < 0 + || parsedPane > 4_294_967_295L + || !pane.equals("%" + parsedPane)) { + return null; + } + return new Claim(socketPath, parsedPid, "$" + session, new PaneId(pane)); + } catch (RuntimeException failure) { + return null; + } + } + } } diff --git a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/PaneInputCohort.java b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/PaneInputCohort.java index 286f195..1d77336 100644 --- a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/PaneInputCohort.java +++ b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/PaneInputCohort.java @@ -3,6 +3,7 @@ import io.github.libtmux.LibTmuxException; import io.github.libtmux.Pane; import io.github.libtmux.PaneId; +import io.github.libtmux.ServerIdentity; import io.github.libtmux.batch.BatchResult; import io.github.libtmux.batch.OperationResult; import io.github.libtmux.format.RowFormat; @@ -19,8 +20,18 @@ final class PaneInputCohort { private static final long MAX_TMUX_PANE_ID = 4_294_967_295L; - private static final RowFormat PANES = - RowFormat.of("pane_id", "pane_synchronized", "pane_in_mode", "pane_dead", "pane_current_command"); + private static final RowFormat PANES = RowFormat.of( + "pane_id", + "pane_synchronized", + "pane_in_mode", + "pane_dead", + "pane_current_command", + "pane_input_off", + "session_id", + "window_id", + "pid", + "start_time", + "socket_path"); private static final RowFormat CLIENTS = RowFormat.of("client_control_mode", "pane_id", "window_zoomed_flag"); @@ -31,15 +42,17 @@ static Resolution resolve(Pane source) { } static Resolution resolve(Pane source, Caller caller) { + ServerIdentity identity = source.server().identity(); BatchResult snapshot = source.server() .batch() - .add(List.of("list-panes", "-t", source.id().value(), "-F", PANES.template())) + .add(List.of("list-panes", "-a", "-F", PANES.template())) .add(List.of("list-clients", "-F", CLIENTS.template())) .run(); if (snapshot.operations().size() != 2) { throw new LibTmuxException("tmux returned an incomplete pane input snapshot"); } return parse( + identity.realm(), source.id().value(), result(snapshot.operations().get(0)), result(snapshot.operations().get(1)), @@ -47,10 +60,15 @@ static Resolution resolve(Pane source, Caller caller) { } static Resolution parse(String sourcePaneId, CommandResult answer) { - return parse(sourcePaneId, answer, new CommandResult(0, List.of(), List.of()), Caller.nowhere()); + return parse("test", sourcePaneId, answer, new CommandResult(0, List.of(), List.of()), Caller.nowhere()); } static Resolution parse(String sourcePaneId, CommandResult answer, CommandResult clientAnswer, Caller caller) { + return parse("test", sourcePaneId, answer, clientAnswer, caller); + } + + private static Resolution parse( + String realm, String sourcePaneId, CommandResult answer, CommandResult clientAnswer, Caller caller) { if (!answer.succeeded()) { throw new LibTmuxException("tmux could not resolve pane input state"); } @@ -64,31 +82,55 @@ static Resolution parse(String sourcePaneId, CommandResult answer, CommandResult } Map members = new LinkedHashMap<>(); + Authority authority = null; for (RowFormat.Row row : rows) { + Authority observed = authority(realm, row); + if (authority != null && !authority.equals(observed)) { + throw new TmuxFormatException("tmux returned pane rows from different server generations"); + } + authority = observed; Member member = member(row); - if (members.putIfAbsent(member.paneId(), member) != null) { - throw new LibTmuxException("tmux returned duplicate pane input state"); + Member prior = members.get(member.paneId()); + if (prior == null) { + members.put(member.paneId(), member); + } else if (!prior.samePane(member)) { + throw new LibTmuxException("tmux returned inconsistent duplicate pane input state"); + } else { + members.put(member.paneId(), prior.withSessions(member.sessionIds())); } } Member source = members.get(sourcePaneId); if (source == null) { throw new LibTmuxException("tmux returned no pane input state for " + sourcePaneId); } + Map windowMembers = members.values().stream() + .filter(member -> member.windowId().equals(source.windowId())) + .collect(java.util.stream.Collectors.toMap( + Member::paneId, + java.util.function.Function.identity(), + (left, right) -> left, + LinkedHashMap::new)); List recipients = source.synchronizedPane() - ? members.values().stream() + ? windowMembers.values().stream() .filter(Member::synchronizedPane) .sorted(java.util.Comparator.comparing(Member::paneId)) .toList() : List.of(source); - Set attended = attended(clientAnswer, members); - return new Resolution(source, recipients, caller, attended); + Set attended = attended(clientAnswer, members, source.windowId()); + Authority generation = java.util.Objects.requireNonNull(authority); + caller.requireConsistent( + generation.serverPid(), + generation.socketPath(), + members.values().stream() + .collect(java.util.stream.Collectors.toMap(Member::paneId, Member::sessionIds))); + return new Resolution(generation, source, recipients, caller, attended); } private static CommandResult result(OperationResult operation) { return new CommandResult(operation.succeeded() ? 0 : 1, operation.stdout(), operation.stderr()); } - private static Set attended(CommandResult answer, Map members) { + private static Set attended(CommandResult answer, Map members, String sourceWindowId) { if (!answer.succeeded()) { throw new LibTmuxException("tmux could not resolve client attention state"); } @@ -105,13 +147,23 @@ private static Set attended(CommandResult answer, Map me boolean controlMode = row.flag("client_control_mode"); String activePane = paneId(row.text("pane_id")); boolean zoomed = row.flag("window_zoomed_flag"); - if (controlMode || !members.containsKey(activePane)) { + if (controlMode) { + continue; + } + Member active = members.get(activePane); + if (active == null) { + throw new TmuxFormatException("a terminal client reported an unknown active pane"); + } + if (!active.windowId().equals(sourceWindowId)) { continue; } if (zoomed) { attended.add(activePane); } else { - attended.addAll(members.keySet()); + members.values().stream() + .filter(member -> member.windowId().equals(sourceWindowId)) + .map(Member::paneId) + .forEach(attended::add); } } return Set.copyOf(attended); @@ -150,38 +202,121 @@ private static Member member(RowFormat.Row row) { if (command.isEmpty()) { throw new TmuxFormatException("pane_current_command was empty"); } - return new Member(paneId, synchronizedPane, mode, rawMode.equals("0"), dead, command); + boolean inputDisabled = row.flag("pane_input_off"); + String sessionId = targetId(row.text("session_id"), '$', "session_id"); + String windowId = targetId(row.text("window_id"), '@', "window_id"); + return new Member( + paneId, + synchronizedPane, + mode, + rawMode.equals("0"), + dead, + command, + inputDisabled, + windowId, + Set.of(sessionId)); + } + + private static Authority authority(String realm, RowFormat.Row row) { + long pid = positiveCanonical(row.text("pid"), "pid"); + long started = positiveCanonical(row.text("start_time"), "start_time"); + String socket = row.text("socket_path"); + try { + RouteValue.requireSafe(socket, "tmux socket path"); + if (socket.isBlank() || !java.nio.file.Path.of(socket).isAbsolute()) { + throw new TmuxFormatException("socket_path was not absolute"); + } + } catch (IllegalArgumentException failure) { + throw new TmuxFormatException("socket_path was invalid", failure); + } + return new Authority(realm, socket, pid, started); + } + + private static long positiveCanonical(String value, String field) { + try { + long parsed = Long.parseLong(value); + if (parsed <= 0 || !value.equals(Long.toString(parsed))) { + throw new TmuxFormatException(field + " was not a canonical positive number"); + } + return parsed; + } catch (NumberFormatException failure) { + throw new TmuxFormatException(field + " was not a canonical positive number", failure); + } } private static String paneId(String value) { - if (!value.startsWith("%") || value.length() == 1) { - throw new TmuxFormatException("pane_id was invalid"); + return targetId(value, '%', "pane_id"); + } + + private static String targetId(String value, char sigil, String field) { + if (value.length() == 1 || value.isEmpty() || value.charAt(0) != sigil) { + throw new TmuxFormatException(field + " was invalid"); } try { long id = Long.parseLong(value.substring(1)); - if (id < 0 || id > MAX_TMUX_PANE_ID || !value.equals("%" + id)) { - throw new TmuxFormatException("pane_id was invalid"); + if (id < 0 || id > MAX_TMUX_PANE_ID || !value.equals(sigil + Long.toString(id))) { + throw new TmuxFormatException(field + " was invalid"); } return value; } catch (NumberFormatException failure) { - throw new TmuxFormatException("pane_id was invalid", failure); + throw new TmuxFormatException(field + " was invalid", failure); } } + record Authority(String realm, String socketPath, long serverPid, long startTime) {} + record Member( String paneId, boolean synchronizedPane, long mode, boolean canonicalZeroMode, boolean dead, - String currentCommand) { + String currentCommand, + boolean inputDisabled, + String windowId, + Set sessionIds) { + + Member { + sessionIds = Set.copyOf(sessionIds); + } boolean writable() { return mode == 0 && canonicalZeroMode; } + + boolean samePane(Member other) { + return paneId.equals(other.paneId) + && synchronizedPane == other.synchronizedPane + && mode == other.mode + && canonicalZeroMode == other.canonicalZeroMode + && dead == other.dead + && currentCommand.equals(other.currentCommand) + && inputDisabled == other.inputDisabled + && windowId.equals(other.windowId); + } + + Member withSessions(Set more) { + Set merged = new LinkedHashSet<>(sessionIds); + merged.addAll(more); + return new Member( + paneId, + synchronizedPane, + mode, + canonicalZeroMode, + dead, + currentCommand, + inputDisabled, + windowId, + merged); + } } - record Resolution(Member source, List keyRecipients, Caller caller, Set attendedPaneIds) { + record Resolution( + Authority authority, + Member source, + List keyRecipients, + Caller caller, + Set attendedPaneIds) { Resolution { keyRecipients = List.copyOf(keyRecipients); @@ -223,6 +358,9 @@ private void requireWritable(String operation, Member member) { if (member.dead()) { throw new IllegalStateException(operation + " refuses dead pane " + member.paneId()); } + if (member.inputDisabled()) { + throw new IllegalStateException(operation + " refuses input-disabled pane " + member.paneId()); + } if (!member.writable()) { throw new IllegalStateException( operation + " refuses pane " + member.paneId() + " while it is in a human-owned mode"); diff --git a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/CallerTest.java b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/CallerTest.java new file mode 100644 index 0000000..138c7e3 --- /dev/null +++ b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/CallerTest.java @@ -0,0 +1,68 @@ +package io.github.libtmux.mcp; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import io.github.libtmux.Server; +import io.github.libtmux.junit5.TmuxExtension; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +@ExtendWith(TmuxExtension.class) +final class CallerTest { + + @Test + void onlyTwoAbsentVariablesMeanDetached(Server server) { + assertFalse(Caller.of(server, Map.of()).uncertain()); + assertTrue(Caller.of(server, Map.of("TMUX", "")).uncertain()); + assertTrue(Caller.of(server, Map.of("TMUX_PANE", "%0")).uncertain()); + assertTrue(Caller.of(server, Map.of("TMUX", validTmux(server), "TMUX_PANE", "")) + .uncertain()); + } + + @Test + void selectedSocketWithWrongPidIsUncertain(Server server) { + String socket = server.expand("#{socket_path}"); + long pid = Long.parseLong(server.expand("#{pid}")); + String session = server.sessions().getFirst().id().value().substring(1); + + Caller caller = Caller.of(server, Map.of("TMUX", socket + "," + (pid + 1) + "," + session, "TMUX_PANE", "%0")); + + assertTrue(caller.uncertain()); + } + + @Test + void canonicalForeignSocketIsNotSelected(Server server) { + long pid = Long.parseLong(server.expand("#{pid}")); + String session = server.sessions().getFirst().id().value().substring(1); + + Caller caller = Caller.of(server, Map.of("TMUX", "/dev/null," + pid + "," + session, "TMUX_PANE", "%0")); + + assertFalse(caller.uncertain()); + assertTrue(caller.pane().isEmpty()); + } + + @Test + void noncanonicalSelectedClaimsAreUncertain(Server server) { + String socket = server.expand("#{socket_path}"); + String pid = server.expand("#{pid}"); + String session = server.sessions().getFirst().id().value().substring(1); + + assertTrue(Caller.of(server, Map.of("TMUX", socket + ",0" + pid + "," + session, "TMUX_PANE", "%0")) + .uncertain()); + assertTrue(Caller.of(server, Map.of("TMUX", socket + "," + pid + ",0" + session, "TMUX_PANE", "%0")) + .uncertain()); + assertTrue(Caller.of(server, Map.of("TMUX", socket + "," + pid + ",$" + session, "TMUX_PANE", "%0")) + .uncertain()); + assertTrue(Caller.of(server, Map.of("TMUX", socket + "," + pid + "," + session, "TMUX_PANE", "%00")) + .uncertain()); + assertTrue(Caller.of(server, Map.of("TMUX", "relative," + pid + "," + session, "TMUX_PANE", "%0")) + .uncertain()); + } + + private static String validTmux(Server server) { + return server.expand("#{socket_path}") + "," + server.expand("#{pid}") + "," + + server.sessions().getFirst().id().value().substring(1); + } +} diff --git a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/PaneInputCohortTest.java b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/PaneInputCohortTest.java index efe6a3d..18f3d76 100644 --- a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/PaneInputCohortTest.java +++ b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/PaneInputCohortTest.java @@ -140,8 +140,8 @@ void callerProtectionCoversEveryConfiguredMember(Server server) { var resolved = PaneInputCohort.parse( source.id().value(), answer( - row(source.id().value(), "1", "0", "0", "sh"), - row(peer.id().value(), "1", "0", "0", "sh")), + liveRow(server, source.id().value(), "1", "0", "0", "sh"), + liveRow(server, peer.id().value(), "1", "0", "0", "sh")), answer(), caller); @@ -174,6 +174,15 @@ void malformedClientAttentionFailsClosed(String label, CommandResult clients) { label); } + @Test + void unknownTerminalClientPaneFailsClosed() { + assertThrows( + TmuxFormatException.class, + () -> PaneInputCohort.parse( + "%0", + answer(row("%0", "0", "0", "0", "sh")), answer(clientRow("0", "%9", "0")), Caller.nowhere())); + } + @Test void uncertainCallerIdentityFailsClosed(Server server) { Caller uncertain = TestCalls.withEnvironment(server, Map.of("TMUX", "malformed", "TMUX_PANE", "%0")) @@ -217,11 +226,21 @@ public void close() {} .toList(); assertEquals(2, commands.size()); List listing = commands.getFirst(); - assertEquals(List.of("list-panes", "-t"), listing.subList(0, 2)); + assertEquals(List.of("list-panes", "-a"), listing.subList(0, 2)); assertTrue(listing.contains("-F")); String format = listing.get(listing.indexOf("-F") + 1); - for (String field : - List.of("pane_id", "pane_synchronized", "pane_in_mode", "pane_dead", "pane_current_command")) { + for (String field : List.of( + "pane_id", + "pane_synchronized", + "pane_in_mode", + "pane_dead", + "pane_current_command", + "pane_input_off", + "session_id", + "window_id", + "pid", + "start_time", + "socket_path")) { assertEquals(1, occurrences(format, "#{" + field + "}")); } List clients = commands.get(1); @@ -238,9 +257,11 @@ private static Stream authorityFailures() { Arguments.of("no rows", "%0", answer()), Arguments.of("source absent", "%0", answer(row("%1", "0", "0", "0", "sh"))), Arguments.of( - "source duplicated", + "source duplicated inconsistently", "%0", - answer(row("%0", "0", "0", "0", "sh"), row("%0", "0", "0", "0", "sh")))); + answer( + row("%0", "0", "0", "0", "sh"), + row("%0", "0", "0", "0", "sh", "0", "$0", "@1", "1", "1", "/tmp/test-tmux")))); } private static Stream malformedRows() { @@ -258,7 +279,20 @@ private static Stream malformedRows() { Arguments.of("word mode", List.of(row("%0", "0", "on", "0", "sh"))), Arguments.of("negative mode", List.of(row("%0", "0", "-1", "0", "sh"))), Arguments.of("word synchronized", List.of(row("%0", "on", "0", "0", "sh"))), - Arguments.of("word dead", List.of(row("%0", "0", "0", "on", "sh")))); + Arguments.of("word dead", List.of(row("%0", "0", "0", "on", "sh"))), + Arguments.of("empty input-off", List.of(row("%0", "0", "0", "0", "sh", ""))), + Arguments.of("word input-off", List.of(row("%0", "0", "0", "0", "sh", "on"))), + Arguments.of( + "generation mismatch", + List.of( + row("%0", "0", "0", "0", "sh"), + row("%1", "0", "0", "0", "sh", "0", "$0", "@0", "1", "2", "/tmp/test-tmux"))), + Arguments.of( + "noncanonical pid", + List.of(row("%0", "0", "0", "0", "sh", "0", "$0", "@0", "01", "1", "/tmp/test-tmux"))), + Arguments.of( + "relative socket", + List.of(row("%0", "0", "0", "0", "sh", "0", "$0", "@0", "1", "1", "relative")))); } private static Stream malformedClientRows() { @@ -278,6 +312,14 @@ private static CommandResult answer(String... rows) { } private static String row(String... fields) { + if (fields.length == 5 || fields.length == 6) { + List complete = new java.util.ArrayList<>(List.of(fields)); + if (complete.size() == 5) { + complete.add("0"); + } + complete.addAll(List.of("$0", "@0", "1", "1", "/tmp/test-tmux")); + return fields(complete.toArray(String[]::new)) + TERMINATOR; + } return fields(fields) + TERMINATOR; } @@ -285,6 +327,26 @@ private static String clientRow(String control, String activePane, String zoomed return row(control, activePane, zoomed); } + private static String liveRow( + Server server, String pane, String synchronizedPane, String mode, String dead, String command) { + var handle = server.panes().stream() + .filter(candidate -> candidate.id().value().equals(pane)) + .findFirst() + .orElseThrow(); + return row( + pane, + synchronizedPane, + mode, + dead, + command, + "0", + handle.window().session().id().value(), + handle.window().id().value(), + server.expand("#{pid}"), + server.expand("#{start_time}"), + server.expand("#{socket_path}")); + } + private static String fields(String... fields) { return String.join(SEPARATOR, fields); } diff --git a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/RunningCommandsTest.java b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/RunningCommandsTest.java index 4d96d99..bb7877e 100644 --- a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/RunningCommandsTest.java +++ b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/RunningCommandsTest.java @@ -800,7 +800,7 @@ private static boolean isCohortListing(CommandRequest request) { } private static boolean isCohortListing(List command) { - if (!command.getFirst().equals("list-panes") || !command.contains("-t") || !command.contains("-F")) { + if (!command.getFirst().equals("list-panes") || !command.contains("-a") || !command.contains("-F")) { return false; } String format = command.get(command.indexOf("-F") + 1); diff --git a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/TestCalls.java b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/TestCalls.java index dd057bc..a04541d 100644 --- a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/TestCalls.java +++ b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/TestCalls.java @@ -30,7 +30,7 @@ static Call asCaller(Server server, String paneId, Object... pairs) { Map environment = Map.of( "TMUX", socket(server) + "," + server.expand("#{pid}") + "," - + server.sessions().get(0).id().value(), + + server.sessions().get(0).id().value().substring(1), "TMUX_PANE", paneId); return withEnvironment(server, environment, plain.arguments()); diff --git a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/TypingTest.java b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/TypingTest.java index bb1f205..5f8e1f8 100644 --- a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/TypingTest.java +++ b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/TypingTest.java @@ -119,6 +119,106 @@ void callerPaneRefusesPaste(Server server) { assertNoOwnedBuffers(server); } + @Test + void inconsistentCallerSessionRefusesPaneInput(Server server) { + var callerPane = server.panes().getFirst(); + var otherSession = server.newSession("caller-session-mismatch"); + var target = otherSession.windows().getFirst().panes().getFirst(); + String socket = server.expand("#{socket_path}"); + String sessionNumber = otherSession.id().value().substring(1); + Map environment = Map.of( + "TMUX", + socket + "," + server.expand("#{pid}") + "," + sessionNumber, + "TMUX_PANE", + callerPane.id().value()); + + assertThrows( + IllegalStateException.class, + () -> Typing.sendKeys(TestCalls.withEnvironment( + server, + environment, + "pane_id", + target.id().value(), + "keys", + List.of("caller-session-mismatch-marker"), + "literal", + true))); + } + + @Test + void callerInAnotherSessionDoesNotBlockTheTarget(Server server) throws Exception { + var callerPane = server.panes().getFirst(); + var otherSession = server.newSession("caller-other-session"); + var target = otherSession.windows().getFirst().panes().getFirst(); + String socket = server.expand("#{socket_path}"); + String callerSession = callerPane.window().session().id().value().substring(1); + Map environment = Map.of( + "TMUX", + socket + "," + server.expand("#{pid}") + "," + callerSession, + "TMUX_PANE", + callerPane.id().value()); + String marker = "off-window-caller-marker"; + + Typing.sendKeys(TestCalls.withEnvironment( + server, environment, "pane_id", target.id().value(), "keys", List.of(marker), "literal", true)); + + assertTrue(await(() -> captureOf(server, target.id().value()).contains(marker))); + } + + @Test + void freshPaneSnapshotRevalidatesTheCallerPid(Server server) { + var callerPane = server.panes().getFirst(); + var target = callerPane.split(SplitSpec.builder().build()); + String pid = server.expand("#{pid}"); + String wrongPid = Long.toString(Long.parseLong(pid) + 1); + AtomicBoolean changed = new AtomicBoolean(); + try (ProcessTransport processes = new ProcessTransport()) { + TmuxTransport changing = borrowing(request -> { + CommandResult result = processes.execute(request); + if (isPaneInputSnapshot(request) && changed.compareAndSet(false, true)) { + return new CommandResult( + result.exitCode(), + result.stdout().stream() + .map(line -> line.replace(pid, wrongPid)) + .toList(), + result.stderr()); + } + return result; + }); + try (Server measured = Server.using(server.config(), changing)) { + assertThrows( + IllegalStateException.class, + () -> Typing.sendKeys(TestCalls.asCaller( + measured, + callerPane.id().value(), + "pane_id", + target.id().value(), + "keys", + List.of("fresh-caller-pid-marker"), + "literal", + true))); + } + } + + assertTrue(changed.get(), "the snapshot seam did not change the reported generation"); + assertFalse(captureOf(server, target.id().value()).contains("fresh-caller-pid-marker")); + } + + @Test + void inputDisabledConfiguredPaneRefusesKeys(Server server) { + var source = server.panes().getFirst(); + var disabled = source.split(SplitSpec.builder().build()); + source.window().setSynchronizePanes(true); + server.run(List.of("select-pane", "-t", disabled.id().value(), "-d")); + assertEquals("1", disabled.expand("#{pane_input_off}")); + + try { + assertKeyRefused(server, source.id().value(), disabled.id().value(), "input-disabled-marker"); + } finally { + server.run(List.of("select-pane", "-t", disabled.id().value(), "-e")); + } + } + @Test void batchProtectsACallerPeerInTheConfiguredCohort(Server server) { var source = server.panes().getFirst(); From 98a00cb5190e472b87961e82b95cac673911026a Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 5 Sep 2026 23:03:23 -0500 Subject: [PATCH 45/65] Mcp(fix[teardown]): Authenticate self override why: confirm_self could bypass incomplete or stale inherited caller identity and end an unproven target. what: - Reject uncertain caller claims before considering confirmation - Recheck pane, session, daemon, and socket in one tmux response - Preserve exact-self and foreign-daemon teardown behavior --- .../java/io/github/libtmux/mcp/Caller.java | 37 +++++++++++++ .../java/io/github/libtmux/mcp/Shaping.java | 22 ++++---- .../libtmux/mcp/ToolsAgainstTmuxTest.java | 52 +++++++++++++++++++ 3 files changed, 101 insertions(+), 10 deletions(-) diff --git a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Caller.java b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Caller.java index 3783cf8..b6a39b7 100644 --- a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Caller.java +++ b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Caller.java @@ -2,8 +2,11 @@ import io.github.libtmux.PaneId; import io.github.libtmux.Server; +import io.github.libtmux.format.RowFormat; +import io.github.libtmux.transport.CommandResult; import java.io.IOException; import java.nio.file.Path; +import java.util.List; import java.util.Map; import java.util.Optional; import java.util.Set; @@ -21,6 +24,8 @@ */ final class Caller { + private static final RowFormat CURRENT = RowFormat.of("pane_id", "session_id", "pid", "socket_path"); + private enum Relation { OUTSIDE, DIFFERENT_SERVER, @@ -127,6 +132,38 @@ boolean uncertain() { return relation == Relation.UNKNOWN; } + /** Rechecks an inherited self claim in one tmux response immediately before confirmation. */ + boolean freshlyAuthenticated(Server server) { + if (relation != Relation.SELF || claim == null) { + return false; + } + try { + CommandResult answer = + server.cmd("display-message", "-p", "-t", claim.pane().value(), CURRENT.template()); + if (!answer.succeeded() || answer.stdout().size() != 1) { + return false; + } + String terminator = CURRENT.template().substring(CURRENT.template().lastIndexOf('}') + 1); + if (!answer.stdout().getFirst().endsWith(terminator)) { + return false; + } + List rows = CURRENT.rows(answer.stdout()); + if (rows.size() != 1) { + return false; + } + RowFormat.Row row = rows.getFirst(); + String socket = row.text("socket_path"); + RouteValue.requireSafe(socket, "caller tmux socket path"); + return row.text("pane_id").equals(claim.pane().value()) + && row.text("session_id").equals(claim.sessionId()) + && row.text("pid").equals(Long.toString(claim.serverPid())) + && Path.of(socket).isAbsolute() + && sameFile(claim.socket(), Path.of(socket)) == FileRelation.SAME; + } catch (RuntimeException failure) { + return false; + } + } + /** tmux is asked which socket it is on, rather than the endpoint being reassembled from flags. */ private static @Nullable Path socketOf(Server server) { try { diff --git a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Shaping.java b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Shaping.java index 02a9ccd..e006a7e 100644 --- a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Shaping.java +++ b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Shaping.java @@ -200,19 +200,21 @@ static Ended kill(Call call) { * that happened. */ private static void guard(Call call, List going, boolean confirmed, String kind) { - if (confirmed) { - return; - } - if (call.caller().uncertain()) { + Caller caller = call.caller(); + if (caller.uncertain()) { throw new IllegalStateException( "Refused. This process is inside tmux, but could not prove whether the target " - + "contains its own pane. Pass confirm_self=true only if disconnecting " - + "this conversation is the actual goal."); + + "contains its own pane. Retry from a complete, current caller context; " + + "confirm_self cannot override uncertainty."); } - Optional mine = call.caller().pane(); - if (mine.isEmpty() - || (!"server".equals(kind) - && going.stream().noneMatch(pane -> call.caller().isSelf(pane.id())))) { + Optional mine = caller.pane(); + if (mine.isEmpty() || (!"server".equals(kind) && going.stream().noneMatch(pane -> caller.isSelf(pane.id())))) { + return; + } + if (confirmed) { + if (!caller.freshlyAuthenticated(call.server())) { + throw new IllegalStateException("Refused. confirm_self requires a freshly authenticated caller pane."); + } return; } List others = going.stream() diff --git a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/ToolsAgainstTmuxTest.java b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/ToolsAgainstTmuxTest.java index 59b834d..655b7e6 100644 --- a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/ToolsAgainstTmuxTest.java +++ b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/ToolsAgainstTmuxTest.java @@ -143,6 +143,58 @@ void uncertainCallerIdentityRefusesServerKill(Server server) { assertTrue(server.isAlive(), "uncertainty must not disable the destructive guard"); } + @Test + void confirmationCannotOverrideUncertainCallerIdentity(Server server) { + String pane = server.sessions().get(0).windows().get(0).split().id().value(); + String tmux = server.expand("#{socket_path},#{pid},0"); + List> uncertain = List.of( + Map.of("TMUX", tmux), + Map.of("TMUX_PANE", pane), + Map.of("TMUX", "", "TMUX_PANE", pane), + Map.of("TMUX", tmux, "TMUX_PANE", ""), + Map.of("TMUX", "malformed", "TMUX_PANE", pane)); + + for (Map environment : uncertain) { + assertThrows( + IllegalStateException.class, + () -> Shaping.kill( + TestCalls.withEnvironment(server, environment, "target", pane, "confirm_self", true))); + } + + assertTrue(server.panes().stream() + .anyMatch(candidate -> candidate.id().value().equals(pane))); + } + + @Test + void aCompleteForeignCallerDoesNotNeedSelfConfirmation(Server server) { + String pane = server.sessions().get(0).windows().get(0).split().id().value(); + Map foreign = Map.of("TMUX", "/dev/null,1,0", "TMUX_PANE", "%0"); + + Shaping.kill(TestCalls.withEnvironment(server, foreign, "target", pane, "confirm_self", true)); + + assertTrue(server.panes().stream() + .noneMatch(candidate -> candidate.id().value().equals(pane))); + } + + @Test + void confirmationCannotOverrideAStaleCallerSession(Server server) { + String pane = server.sessions().get(0).windows().get(0).split().id().value(); + Call confirmed = TestCalls.asCaller(server, pane, "target", pane, "confirm_self", true); + String destination = server.newSession("moved-caller") + .windows() + .get(0) + .panes() + .get(0) + .id() + .value(); + server.cmd("move-pane", "-s", pane, "-t", destination); + + assertThrows(IllegalStateException.class, () -> Shaping.kill(confirmed)); + + assertTrue(server.panes().stream() + .anyMatch(candidate -> candidate.id().value().equals(pane))); + } + /** The window holding the caller's pane is as fatal as the pane itself. */ @Test void killingAWindowHoldingTheCallersPaneIsRefusedToo(Server server) { From 4b7262cec9b5a64c74ec79644e3c1c80d31e7b7e Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 6 Sep 2026 00:07:56 -0500 Subject: [PATCH 46/65] Mcp(fix[input]): Authenticate client placement why: Terminal attention trusted an active pane without proving the client's session and window, while control clients parsed irrelevant fields. what: - Decode terminal session, window, pane, and zoom from one snapshot - Exclude control clients before parsing terminal-only context - Reject unknown, malformed, and inconsistent client placements --- .../github/libtmux/mcp/PaneInputCohort.java | 12 ++- .../libtmux/mcp/PaneInputCohortTest.java | 85 ++++++++++++++++--- 2 files changed, 82 insertions(+), 15 deletions(-) diff --git a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/PaneInputCohort.java b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/PaneInputCohort.java index 1d77336..15847f8 100644 --- a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/PaneInputCohort.java +++ b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/PaneInputCohort.java @@ -33,7 +33,8 @@ final class PaneInputCohort { "start_time", "socket_path"); - private static final RowFormat CLIENTS = RowFormat.of("client_control_mode", "pane_id", "window_zoomed_flag"); + private static final RowFormat CLIENTS = + RowFormat.of("client_control_mode", "session_id", "window_id", "pane_id", "window_zoomed_flag"); private PaneInputCohort() {} @@ -145,15 +146,20 @@ private static Set attended(CommandResult answer, Map me Set attended = new LinkedHashSet<>(); for (RowFormat.Row row : rows) { boolean controlMode = row.flag("client_control_mode"); - String activePane = paneId(row.text("pane_id")); - boolean zoomed = row.flag("window_zoomed_flag"); if (controlMode) { continue; } + String clientSession = targetId(row.text("session_id"), '$', "session_id"); + String clientWindow = targetId(row.text("window_id"), '@', "window_id"); + String activePane = paneId(row.text("pane_id")); + boolean zoomed = row.flag("window_zoomed_flag"); Member active = members.get(activePane); if (active == null) { throw new TmuxFormatException("a terminal client reported an unknown active pane"); } + if (!active.windowId().equals(clientWindow) || !active.sessionIds().contains(clientSession)) { + throw new TmuxFormatException("a terminal client reported inconsistent active pane placement"); + } if (!active.windowId().equals(sourceWindowId)) { continue; } diff --git a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/PaneInputCohortTest.java b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/PaneInputCohortTest.java index 18f3d76..2f1ee90 100644 --- a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/PaneInputCohortTest.java +++ b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/PaneInputCohortTest.java @@ -156,7 +156,7 @@ void attendedProtectionCoversEveryConfiguredMember() { var resolved = PaneInputCohort.parse( "%0", answer(row("%0", "1", "0", "0", "sh"), row("%1", "1", "0", "0", "sh")), - answer(clientRow("0", "%1", "1")), + answer(clientRow("0", "$0", "@0", "%1", "1")), Caller.nowhere()); IllegalStateException refused = @@ -180,7 +180,56 @@ void unknownTerminalClientPaneFailsClosed() { TmuxFormatException.class, () -> PaneInputCohort.parse( "%0", - answer(row("%0", "0", "0", "0", "sh")), answer(clientRow("0", "%9", "0")), Caller.nowhere())); + answer(row("%0", "0", "0", "0", "sh")), + answer(clientRow("0", "$0", "@0", "%9", "0")), + Caller.nowhere())); + } + + @Test + void controlClientsAreExcludedBeforeTheirOtherFieldsAreParsed() { + var resolved = PaneInputCohort.parse( + "%0", answer(row("%0", "0", "0", "0", "sh")), answer(clientRow("1", "", "", "", "")), Caller.nowhere()); + + assertEquals(List.of("%0"), resolved.requireKeyRecipients("send_keys")); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("invalidTerminalClientPlacements") + void terminalClientPlacementMustMatchThePaneSnapshot(String label, String client) { + assertThrows( + TmuxFormatException.class, + () -> PaneInputCohort.parse( + "%0", answer(row("%0", "0", "0", "0", "sh")), answer(client), Caller.nowhere()), + label); + } + + @Test + void linkedPanePlacementIsAcceptedForTheClientsSession() { + var resolved = PaneInputCohort.parse( + "%0", + answer( + row("%0", "0", "0", "0", "sh"), + row("%0", "0", "0", "0", "sh", "0", "$1", "@0", "1", "1", "/tmp/test-tmux")), + answer(clientRow("0", "$1", "@0", "%0", "1")), + Caller.nowhere()); + + IllegalStateException refused = + assertThrows(IllegalStateException.class, () -> resolved.requirePasteTarget("paste_text")); + + assertTrue(String.valueOf(refused.getMessage()).contains("attended"), refused.getMessage()); + } + + @Test + void validClientInAnotherWindowDoesNotAttendTheTarget() { + var resolved = PaneInputCohort.parse( + "%0", + answer( + row("%0", "0", "0", "0", "sh"), + row("%1", "0", "0", "0", "sh", "0", "$0", "@1", "1", "1", "/tmp/test-tmux")), + answer(clientRow("0", "$0", "@1", "%1", "0")), + Caller.nowhere()); + + assertEquals(List.of("%0"), resolved.requireKeyRecipients("send_keys")); } @Test @@ -246,7 +295,8 @@ public void close() {} List clients = commands.get(1); assertEquals("list-clients", clients.getFirst()); String clientFormat = clients.get(clients.indexOf("-F") + 1); - for (String field : List.of("client_control_mode", "pane_id", "window_zoomed_flag")) { + for (String field : + List.of("client_control_mode", "session_id", "window_id", "pane_id", "window_zoomed_flag")) { assertEquals(1, occurrences(clientFormat, "#{" + field + "}")); } } @@ -298,13 +348,23 @@ private static Stream malformedRows() { private static Stream malformedClientRows() { return Stream.of( Arguments.of("listing failed", new CommandResult(1, List.of(), List.of("gone"))), - Arguments.of("empty control flag", answer(clientRow("", "%0", "0"))), - Arguments.of("word control flag", answer(clientRow("on", "%0", "0"))), - Arguments.of("missing active pane", answer(clientRow("0", "", "0"))), - Arguments.of("invalid active pane", answer(clientRow("0", "0", "0"))), - Arguments.of("empty zoom flag", answer(clientRow("0", "%0", ""))), - Arguments.of("word zoom flag", answer(clientRow("0", "%0", "on"))), - Arguments.of("unterminated row", answer(fields("0", "%0", "0")))); + Arguments.of("empty control flag", answer(clientRow("", "$0", "@0", "%0", "0"))), + Arguments.of("word control flag", answer(clientRow("on", "$0", "@0", "%0", "0"))), + Arguments.of("missing active pane", answer(clientRow("0", "$0", "@0", "", "0"))), + Arguments.of("invalid active pane", answer(clientRow("0", "$0", "@0", "0", "0"))), + Arguments.of("empty zoom flag", answer(clientRow("0", "$0", "@0", "%0", ""))), + Arguments.of("word zoom flag", answer(clientRow("0", "$0", "@0", "%0", "on"))), + Arguments.of("unterminated row", answer(fields("0", "$0", "@0", "%0", "0")))); + } + + private static Stream invalidTerminalClientPlacements() { + return Stream.of( + Arguments.of("missing session", clientRow("0", "", "@0", "%0", "0")), + Arguments.of("invalid session", clientRow("0", "0", "@0", "%0", "0")), + Arguments.of("missing window", clientRow("0", "$0", "", "%0", "0")), + Arguments.of("invalid window", clientRow("0", "$0", "0", "%0", "0")), + Arguments.of("session mismatch", clientRow("0", "$1", "@0", "%0", "0")), + Arguments.of("window mismatch", clientRow("0", "$0", "@1", "%0", "0"))); } private static CommandResult answer(String... rows) { @@ -323,8 +383,9 @@ private static String row(String... fields) { return fields(fields) + TERMINATOR; } - private static String clientRow(String control, String activePane, String zoomed) { - return row(control, activePane, zoomed); + private static String clientRow( + String control, String sessionId, String windowId, String activePane, String zoomed) { + return fields(control, sessionId, windowId, activePane, zoomed) + TERMINATOR; } private static String liveRow( From 79e9459a4f768a9b81febad8c1e63efda89b2fb5 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 6 Sep 2026 00:12:27 -0500 Subject: [PATCH 47/65] Mcp(fix[input]): Reserve transient dispatch why: Pane input had no process-wide owner across its final safety check, so another writer could overlap delivery or paste staging. what: - Reserve full-generation configured cohorts across each dispatch - Recheck exact pane context immediately before send and paste - Unify physical socket aliases and keep empty paste buffer-free --- .../libtmux/mcp/PaneInputReservations.java | 145 +++++++++++ .../java/io/github/libtmux/mcp/Typing.java | 47 ++-- .../io/github/libtmux/mcp/TypingTest.java | 232 +++++++++++++++++- 3 files changed, 402 insertions(+), 22 deletions(-) create mode 100644 libtmux-mcp/src/main/java/io/github/libtmux/mcp/PaneInputReservations.java diff --git a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/PaneInputReservations.java b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/PaneInputReservations.java new file mode 100644 index 0000000..b538883 --- /dev/null +++ b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/PaneInputReservations.java @@ -0,0 +1,145 @@ +package io.github.libtmux.mcp; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.BasicFileAttributes; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +/** Process-wide ownership of the panes an MCP input operation may reach. */ +final class PaneInputReservations { + + private static final Object MONITOR = new Object(); + private static final Set HELD = new HashSet<>(); + + private PaneInputReservations() {} + + static Lease keys(PaneInputCohort.Resolution initial, String operation) { + initial.requireKeyRecipients(operation); + return acquire(initial, initial.keyRecipients(), operation); + } + + static Lease paste(PaneInputCohort.Resolution initial, String operation) { + initial.requirePasteTarget(operation); + return acquire(initial, List.of(initial.source()), operation); + } + + private static Lease acquire( + PaneInputCohort.Resolution initial, List members, String operation) { + Signature signature = Signature.capture(initial, members, operation); + Set panes = signature.keys(members); + synchronized (MONITOR) { + if (panes.stream().anyMatch(HELD::contains)) { + throw new IllegalStateException(operation + " refuses pane input already owned by another operation"); + } + HELD.addAll(panes); + } + return new Lease(operation, signature, panes); + } + + static final class Lease implements AutoCloseable { + + private final String operation; + private final Signature initial; + private final Set panes; + private boolean closed; + + private Lease(String operation, Signature initial, Set panes) { + this.operation = operation; + this.initial = initial; + this.panes = Set.copyOf(panes); + } + + List requireSameKeys(PaneInputCohort.Resolution fresh) { + List resolved = fresh.requireKeyRecipients(operation); + requireSame(Signature.capture(fresh, fresh.keyRecipients(), operation)); + return resolved; + } + + void requireSamePaste(PaneInputCohort.Resolution fresh) { + fresh.requirePasteTarget(operation); + requireSame(Signature.capture(fresh, List.of(fresh.source()), operation)); + } + + private void requireSame(Signature fresh) { + synchronized (MONITOR) { + if (closed || !HELD.containsAll(panes)) { + throw new IllegalStateException(operation + " lost pane input ownership"); + } + if (!initial.equals(fresh)) { + throw new IllegalStateException(operation + " refuses pane input state changed after setup"); + } + } + } + + @Override + public void close() { + synchronized (MONITOR) { + if (!closed) { + HELD.removeAll(panes); + closed = true; + } + } + } + } + + private record Signature( + Generation generation, + PaneInputCohort.Member source, + List members, + Set attendedPaneIds) { + + private Signature { + members = List.copyOf(members); + attendedPaneIds = Set.copyOf(attendedPaneIds); + } + + static Signature capture( + PaneInputCohort.Resolution resolution, List members, String operation) { + return new Signature( + Generation.capture(resolution.authority(), operation), + resolution.source(), + members, + resolution.attendedPaneIds()); + } + + Set keys(List selected) { + Set keys = new HashSet<>(); + for (PaneInputCohort.Member member : selected) { + keys.add(new PaneKey(generation, member.paneId())); + } + return Set.copyOf(keys); + } + } + + private record PaneKey(Generation generation, String paneId) {} + + private record Generation(Endpoint endpoint, long serverPid, long startTime) { + + static Generation capture(PaneInputCohort.Authority authority, String operation) { + return new Generation( + Endpoint.capture(authority.realm(), authority.socketPath(), operation), + authority.serverPid(), + authority.startTime()); + } + } + + private record Endpoint(String realm, Object fileKey) { + + static Endpoint capture(String realm, String socket, String operation) { + try { + BasicFileAttributes attributes = Files.readAttributes(Path.of(socket), BasicFileAttributes.class); + Object key = attributes.fileKey(); + if (!attributes.isOther() || key == null) { + throw new IOException("selected tmux socket had no stable physical identity"); + } + return new Endpoint(realm, key); + } catch (IOException | RuntimeException failure) { + throw new IllegalStateException( + operation + " could not authenticate the selected tmux socket", failure); + } + } + } +} diff --git a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Typing.java b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Typing.java index 6966a26..cae9e1a 100644 --- a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Typing.java +++ b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Typing.java @@ -52,14 +52,17 @@ static Sent sendKeys(Pane pane, List keys, boolean literal) { } static Sent sendKeys(Pane pane, List keys, boolean literal, PaneInputCohort.Resolution cohort) { - List resolved = cohort.requireKeyRecipients("send_keys"); - pane.sendKeys(keys, literal); - return new Sent( - pane.id().value(), - keys.size(), - literal, - resolved, - "Sent, not waited for. Call capture_since or wait_for_text on this pane to see " + "what it did."); + try (PaneInputReservations.Lease lease = PaneInputReservations.keys(cohort, "send_keys")) { + PaneInputCohort.Resolution fresh = PaneInputCohort.resolve(pane, cohort.caller()); + List resolved = lease.requireSameKeys(fresh); + pane.sendKeys(keys, literal); + return new Sent( + pane.id().value(), + keys.size(), + literal, + resolved, + "Sent, not waited for. Call capture_since or wait_for_text on this pane to see " + "what it did."); + } } /** @@ -76,19 +79,21 @@ static Pasted pasteText(Call call) { Pane pane = Targets.pane(call.server(), call.string("pane_id")); String text = call.stringIncludingEmpty("text"); boolean enter = call.flag("enter", false); - PaneInputCohort.resolve(pane, call.caller()).requirePasteTarget("paste_text"); - if (text.isEmpty() && !enter) { - return new Pasted(pane.id().value(), 0, 0, "Empty text without Enter; nothing was sent."); + PaneInputCohort.Resolution initial = PaneInputCohort.resolve(pane, call.caller()); + try (PaneInputReservations.Lease lease = PaneInputReservations.paste(initial, "paste_text")) { + if (text.isEmpty() && !enter) { + return new Pasted(pane.id().value(), 0, 0, "Empty text without Enter; nothing was sent."); + } + // tmux turns the line feeds in a buffer into carriage returns as it pastes, so a trailing + // newline is what submits the text — there is no flag that means "and Enter". + pane.paste( + enter ? text + "\n" : text, + () -> lease.requireSamePaste(PaneInputCohort.resolve(pane, call.caller()))); + return new Pasted( + pane.id().value(), + text.length(), + (int) text.lines().count(), + enter ? null : "Pasted without a trailing newline; pass 'enter' to submit it."); } - // tmux turns the line feeds in a buffer into carriage returns as it pastes, so a trailing - // newline is what submits the text — there is no flag that means "and Enter". - pane.paste( - enter ? text + "\n" : text, - () -> PaneInputCohort.resolve(pane, call.caller()).requirePasteTarget("paste_text")); - return new Pasted( - pane.id().value(), - text.length(), - (int) text.lines().count(), - enter ? null : "Pasted without a trailing newline; pass 'enter' to submit it."); } } diff --git a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/TypingTest.java b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/TypingTest.java index 5f8e1f8..324b3ed 100644 --- a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/TypingTest.java +++ b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/TypingTest.java @@ -9,6 +9,7 @@ import io.github.libtmux.LibTmuxException; import io.github.libtmux.Server; +import io.github.libtmux.ServerEndpoint; import io.github.libtmux.SplitSpec; import io.github.libtmux.TmuxVersion; import io.github.libtmux.junit5.TmuxExtension; @@ -16,6 +17,8 @@ import io.github.libtmux.transport.CommandResult; import io.github.libtmux.transport.ProcessTransport; import io.github.libtmux.transport.TmuxTransport; +import java.nio.file.Files; +import java.nio.file.Path; import java.util.ArrayList; import java.util.List; import java.util.Map; @@ -24,9 +27,13 @@ import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; import java.util.function.BooleanSupplier; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; @ExtendWith(TmuxExtension.class) final class TypingTest { @@ -90,6 +97,221 @@ void synchronizedInputDisclosesEveryResolvedPane(Server server) { assertEquals(Set.of(source.id().value(), other.id().value()), Set.copyOf(sent.resolvedPaneIds())); } + @Test + void sendReservationCoversItsFinalPreflight(Server server) throws Exception { + String pane = server.panes().getFirst().id().value(); + CountDownLatch checked = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + AtomicInteger snapshots = new AtomicInteger(); + List requests = new ArrayList<>(); + var owner = new java.util.concurrent.atomic.AtomicReference(); + try (ProcessTransport processes = new ProcessTransport()) { + TmuxTransport pausing = borrowing(request -> { + CommandResult result = processes.execute(request); + if (Thread.currentThread().equals(owner.get())) { + requests.add(request); + } + if (isPaneInputSnapshot(request) && snapshots.incrementAndGet() == 2) { + checked.countDown(); + await(release); + } + return result; + }); + try (Server measured = Server.using(server.config(), pausing); + var calls = Executors.newVirtualThreadPerTaskExecutor()) { + var first = calls.submit(() -> { + owner.set(Thread.currentThread()); + return Typing.sendKeys( + TestCalls.on(measured, "pane_id", pane, "keys", List.of("first-lease"), "literal", true)); + }); + await(checked); + try { + assertThrows( + IllegalStateException.class, + () -> Typing.sendKeys(TestCalls.on( + measured, "pane_id", pane, "keys", List.of("second-lease"), "literal", true))); + } finally { + release.countDown(); + } + first.get(10, TimeUnit.SECONDS); + } + } + + List preflights = indexes(requests, TypingTest::isPaneInputSnapshot); + List sends = indexes(requests, request -> hasCommand(request, "send-keys")); + assertEquals(2, preflights.size()); + assertEquals(1, sends.size()); + assertEquals(preflights.getLast() + 1, sends.getFirst()); + } + + @Test + void pasteReservationCoversStagingAndEmptyPaste(Server server) throws Exception { + assumeTrue(server.version().atLeast(SAFE_PASTE_CLEANUP)); + String pane = server.panes().getFirst().id().value(); + CountDownLatch checked = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + AtomicInteger snapshots = new AtomicInteger(); + try (ProcessTransport processes = new ProcessTransport()) { + TmuxTransport pausing = borrowing(request -> { + CommandResult result = processes.execute(request); + if (isPaneInputSnapshot(request) && snapshots.incrementAndGet() == 2) { + checked.countDown(); + await(release); + } + return result; + }); + try (Server measured = Server.using(server.config(), pausing); + var calls = Executors.newVirtualThreadPerTaskExecutor()) { + var first = calls.submit( + () -> Typing.pasteText(TestCalls.on(measured, "pane_id", pane, "text", "held-paste"))); + await(checked); + try { + assertThrows( + IllegalStateException.class, + () -> Typing.pasteText(TestCalls.on(measured, "pane_id", pane, "text", ""))); + } finally { + release.countDown(); + } + first.get(10, TimeUnit.SECONDS); + } + } + + assertNoOwnedBuffers(server); + } + + @ParameterizedTest(name = "{0} socket alias") + @ValueSource(strings = {"symbolic", "hard"}) + void reservationsContendAcrossPhysicalSocketAliases(String kind, Server server, @TempDir Path temporary) + throws Exception { + String pane = server.panes().getFirst().id().value(); + String socket = server.expand("#{socket_path}"); + Path alias = temporary.resolve("tmux-socket-alias"); + if (kind.equals("symbolic")) { + Files.createSymbolicLink(alias, Path.of(socket)); + } else { + Files.createLink(alias, Path.of(socket)); + } + CountDownLatch sending = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + try (ProcessTransport firstProcesses = new ProcessTransport(); + ProcessTransport aliasProcesses = new ProcessTransport()) { + TmuxTransport blocked = borrowing(request -> { + if (hasCommand(request, "send-keys")) { + sending.countDown(); + await(release); + } + return firstProcesses.execute(request); + }); + TmuxTransport aliased = borrowing(request -> { + CommandResult result = aliasProcesses.execute(request); + return isPaneInputSnapshot(request) + ? new CommandResult( + result.exitCode(), + result.stdout().stream() + .map(line -> line.replace(socket, alias.toString())) + .toList(), + result.stderr()) + : result; + }); + var aliasConfig = server.config().toBuilder() + .endpoint(ServerEndpoint.socketPath(alias)) + .build(); + try (Server first = Server.using(server.config(), blocked); + Server second = Server.using(aliasConfig, aliased); + var calls = Executors.newVirtualThreadPerTaskExecutor()) { + var held = calls.submit(() -> Typing.sendKeys( + TestCalls.on(first, "pane_id", pane, "keys", List.of("alias-held"), "literal", true))); + await(sending); + try { + assertThrows( + IllegalStateException.class, + () -> Typing.sendKeys(TestCalls.on( + second, "pane_id", pane, "keys", List.of("alias-refused"), "literal", true))); + } finally { + release.countDown(); + } + held.get(10, TimeUnit.SECONDS); + } + } + } + + @Test + void reservationCoversEveryInitiallyConfiguredMember(Server server) throws Exception { + var source = server.panes().getFirst(); + var peer = source.split(); + source.window().setSynchronizePanes(true); + CountDownLatch sending = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + try (ProcessTransport processes = new ProcessTransport()) { + TmuxTransport blocked = borrowing(request -> { + if (hasCommand(request, "send-keys")) { + sending.countDown(); + await(release); + } + return processes.execute(request); + }); + try (Server measured = Server.using(server.config(), blocked); + var calls = Executors.newVirtualThreadPerTaskExecutor()) { + var held = calls.submit(() -> Typing.sendKeys(TestCalls.on( + measured, "pane_id", source.id().value(), "keys", List.of("cohort-held"), "literal", true))); + await(sending); + peer.options().set("synchronize-panes", "off"); + try { + assertThrows( + IllegalStateException.class, + () -> Typing.sendKeys(TestCalls.on( + server, + "pane_id", + peer.id().value(), + "keys", + List.of("peer-refused"), + "literal", + true))); + } finally { + release.countDown(); + } + held.get(10, TimeUnit.SECONDS); + } + } + } + + @Test + void changedPanePlacementRefusesTheFinalSend(Server server) { + String pane = server.panes().getFirst().id().value(); + String window = server.panes().getFirst().window().id().value(); + AtomicInteger snapshots = new AtomicInteger(); + AtomicBoolean sent = new AtomicBoolean(); + try (ProcessTransport processes = new ProcessTransport()) { + TmuxTransport changing = borrowing(request -> { + CommandResult result = processes.execute(request); + if (isPaneInputSnapshot(request) && snapshots.incrementAndGet() == 2) { + return new CommandResult( + result.exitCode(), + result.stdout().stream() + .map(line -> line.replace(window, "@4294967295")) + .toList(), + result.stderr()); + } + if (hasCommand(request, "send-keys")) { + sent.set(true); + } + return result; + }); + try (Server measured = Server.using(server.config(), changing)) { + IllegalStateException refused = assertThrows( + IllegalStateException.class, + () -> Typing.sendKeys(TestCalls.on( + measured, "pane_id", pane, "keys", List.of("stale-placement"), "literal", true))); + assertTrue(String.valueOf(refused.getMessage()).contains("changed"), refused.getMessage()); + } + } + + assertEquals(2, snapshots.get()); + assertFalse(sent.get()); + Typing.sendKeys( + TestCalls.on(server, "pane_id", pane, "keys", List.of("released-after-stale"), "literal", true)); + } + @Test void callerPaneRefusesDirectKeys(Server server) { String pane = server.panes().getFirst().id().value(); @@ -666,10 +888,18 @@ private static boolean isPaneInputSnapshot(CommandRequest request) { && argv.stream().anyMatch(part -> part.contains("pane_in_mode"))); } + private static List indexes( + List requests, java.util.function.Predicate predicate) { + return java.util.stream.IntStream.range(0, requests.size()) + .filter(index -> predicate.test(requests.get(index))) + .boxed() + .toList(); + } + private static void await(CountDownLatch latch) { try { if (!latch.await(5, TimeUnit.SECONDS)) { - throw new IllegalStateException("timed out arranging concurrent pastes"); + throw new IllegalStateException("timed out arranging concurrent input"); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); From 5083e65c9123571677ab74aaec86d91170f1e886 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 6 Sep 2026 00:38:49 -0500 Subject: [PATCH 48/65] Mcp(fix[run]): Retain uncertain ownership why: Timeout, cancellation, or ambiguous delivery released ownership while the dispatched shell frame could still be running. what: - Hold one nonqueueing generation lease across both run checks - Retain uncertainty until valid status or authenticated disappearance - Reject concurrent input and malformed completion without queuing --- .../github/libtmux/mcp/PaneInputCohort.java | 80 +++++-- .../libtmux/mcp/PaneInputReservations.java | 60 ++++- .../github/libtmux/mcp/PaneRunSettlement.java | 66 ++++++ .../github/libtmux/mcp/RunningCommands.java | 143 ++++++++---- .../libtmux/mcp/PaneInputCohortTest.java | 74 ++++++ .../libtmux/mcp/RunningCommandsTest.java | 218 ++++++++++++++++-- 6 files changed, 542 insertions(+), 99 deletions(-) create mode 100644 libtmux-mcp/src/main/java/io/github/libtmux/mcp/PaneRunSettlement.java diff --git a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/PaneInputCohort.java b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/PaneInputCohort.java index 15847f8..015ac68 100644 --- a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/PaneInputCohort.java +++ b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/PaneInputCohort.java @@ -70,6 +70,52 @@ static Resolution parse(String sourcePaneId, CommandResult answer, CommandResult private static Resolution parse( String realm, String sourcePaneId, CommandResult answer, CommandResult clientAnswer, Caller caller) { + PaneSnapshot paneSnapshot = panes(realm, sourcePaneId, answer); + Map members = paneSnapshot.members(); + Authority generation = paneSnapshot.authority(); + Member source = members.get(sourcePaneId); + if (source == null) { + throw new LibTmuxException("tmux returned no pane input state for " + sourcePaneId); + } + Map windowMembers = members.values().stream() + .filter(member -> member.windowId().equals(source.windowId())) + .collect(java.util.stream.Collectors.toMap( + Member::paneId, + java.util.function.Function.identity(), + (left, right) -> left, + LinkedHashMap::new)); + List recipients = source.synchronizedPane() + ? windowMembers.values().stream() + .filter(Member::synchronizedPane) + .sorted(java.util.Comparator.comparing(Member::paneId)) + .toList() + : List.of(source); + Set attended = attended(clientAnswer, members, source.windowId()); + caller.requireConsistent( + generation.serverPid(), + generation.socketPath(), + members.values().stream() + .collect(java.util.stream.Collectors.toMap(Member::paneId, Member::sessionIds))); + return new Resolution(generation, source, recipients, caller, attended); + } + + static Presence presence(Pane pane, Authority expected) { + try { + CommandResult answer = pane.server().cmd("list-panes", "-a", "-F", PANES.template()); + PaneSnapshot snapshot = + panes(pane.server().identity().realm(), pane.id().value(), answer); + Authority observed = snapshot.authority(); + if (!observed.equals(expected)) { + return Presence.GONE; + } + Member target = snapshot.members().get(pane.id().value()); + return target == null || target.dead() ? Presence.GONE : Presence.PRESENT; + } catch (RuntimeException failure) { + return Presence.UNKNOWN; + } + } + + private static PaneSnapshot panes(String realm, String sourcePaneId, CommandResult answer) { if (!answer.succeeded()) { throw new LibTmuxException("tmux could not resolve pane input state"); } @@ -100,31 +146,7 @@ private static Resolution parse( members.put(member.paneId(), prior.withSessions(member.sessionIds())); } } - Member source = members.get(sourcePaneId); - if (source == null) { - throw new LibTmuxException("tmux returned no pane input state for " + sourcePaneId); - } - Map windowMembers = members.values().stream() - .filter(member -> member.windowId().equals(source.windowId())) - .collect(java.util.stream.Collectors.toMap( - Member::paneId, - java.util.function.Function.identity(), - (left, right) -> left, - LinkedHashMap::new)); - List recipients = source.synchronizedPane() - ? windowMembers.values().stream() - .filter(Member::synchronizedPane) - .sorted(java.util.Comparator.comparing(Member::paneId)) - .toList() - : List.of(source); - Set attended = attended(clientAnswer, members, source.windowId()); - Authority generation = java.util.Objects.requireNonNull(authority); - caller.requireConsistent( - generation.serverPid(), - generation.socketPath(), - members.values().stream() - .collect(java.util.stream.Collectors.toMap(Member::paneId, Member::sessionIds))); - return new Resolution(generation, source, recipients, caller, attended); + return new PaneSnapshot(java.util.Objects.requireNonNull(authority), Map.copyOf(members)); } private static CommandResult result(OperationResult operation) { @@ -271,6 +293,14 @@ private static String targetId(String value, char sigil, String field) { record Authority(String realm, String socketPath, long serverPid, long startTime) {} + enum Presence { + PRESENT, + GONE, + UNKNOWN + } + + private record PaneSnapshot(Authority authority, Map members) {} + record Member( String paneId, boolean synchronizedPane, diff --git a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/PaneInputReservations.java b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/PaneInputReservations.java index b538883..3f507e2 100644 --- a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/PaneInputReservations.java +++ b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/PaneInputReservations.java @@ -1,11 +1,14 @@ package io.github.libtmux.mcp; +import io.github.libtmux.Pane; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.attribute.BasicFileAttributes; +import java.time.Instant; import java.util.HashSet; import java.util.List; +import java.util.Optional; import java.util.Set; /** Process-wide ownership of the panes an MCP input operation may reach. */ @@ -26,28 +29,43 @@ static Lease paste(PaneInputCohort.Resolution initial, String operation) { return acquire(initial, List.of(initial.source()), operation); } + static Lease run(PaneInputCohort.Resolution initial, String operation) { + initial.requireSingularCommandPane(operation); + return acquire(initial, initial.keyRecipients(), operation); + } + private static Lease acquire( PaneInputCohort.Resolution initial, List members, String operation) { Signature signature = Signature.capture(initial, members, operation); Set panes = signature.keys(members); + DaemonIdentity daemon = DaemonIdentity.capture(initial.authority()); synchronized (MONITOR) { if (panes.stream().anyMatch(HELD::contains)) { throw new IllegalStateException(operation + " refuses pane input already owned by another operation"); } HELD.addAll(panes); } - return new Lease(operation, signature, panes); + return new Lease(operation, initial.authority(), daemon, signature, panes); } static final class Lease implements AutoCloseable { private final String operation; + private final PaneInputCohort.Authority authority; + private final DaemonIdentity daemon; private final Signature initial; private final Set panes; private boolean closed; - private Lease(String operation, Signature initial, Set panes) { + private Lease( + String operation, + PaneInputCohort.Authority authority, + DaemonIdentity daemon, + Signature initial, + Set panes) { this.operation = operation; + this.authority = authority; + this.daemon = daemon; this.initial = initial; this.panes = Set.copyOf(panes); } @@ -63,6 +81,19 @@ void requireSamePaste(PaneInputCohort.Resolution fresh) { requireSame(Signature.capture(fresh, List.of(fresh.source()), operation)); } + String requireSameRun(PaneInputCohort.Resolution fresh) { + String command = fresh.requireSingularCommandPane(operation); + requireSame(Signature.capture(fresh, fresh.keyRecipients(), operation)); + return command; + } + + PaneInputCohort.Presence presence(Pane pane) { + PaneInputCohort.Presence observed = PaneInputCohort.presence(pane, authority); + return observed == PaneInputCohort.Presence.UNKNOWN && daemon.gone() + ? PaneInputCohort.Presence.GONE + : observed; + } + private void requireSame(Signature fresh) { synchronized (MONITOR) { if (closed || !HELD.containsAll(panes)) { @@ -116,6 +147,31 @@ Set keys(List selected) { private record PaneKey(Generation generation, String paneId) {} + record DaemonIdentity(String realm, long pid, Optional started) { + + static DaemonIdentity capture(PaneInputCohort.Authority authority) { + Optional started = authority.realm().equals("local") + ? ProcessHandle.of(authority.serverPid()) + .filter(ProcessHandle::isAlive) + .flatMap(process -> process.info().startInstant()) + : Optional.empty(); + return new DaemonIdentity(authority.realm(), authority.serverPid(), started); + } + + boolean gone() { + if (!realm.equals("local")) { + return false; + } + Optional current = ProcessHandle.of(pid).filter(ProcessHandle::isAlive); + if (current.isEmpty()) { + return true; + } + return started.flatMap(known -> + current.orElseThrow().info().startInstant().map(observed -> !observed.equals(known))) + .orElse(false); + } + } + private record Generation(Endpoint endpoint, long serverPid, long startTime) { static Generation capture(PaneInputCohort.Authority authority, String operation) { diff --git a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/PaneRunSettlement.java b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/PaneRunSettlement.java new file mode 100644 index 0000000..2b9f006 --- /dev/null +++ b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/PaneRunSettlement.java @@ -0,0 +1,66 @@ +package io.github.libtmux.mcp; + +import io.github.libtmux.Pane; +import java.time.Duration; + +/** Retains a dispatched run's pane ownership until its outcome is no longer uncertain. */ +final class PaneRunSettlement { + + private static final Duration FIRST_PROBE = Duration.ofMillis(100); + private static final Duration MAX_PROBE = Duration.ofSeconds(5); + + private PaneRunSettlement() {} + + static void retain(PaneInputReservations.Lease lease, Pane pane, String channel, String endMarker) { + Thread.ofVirtual().name("libtmux-run-settlement").start(() -> settle(lease, pane, channel, endMarker)); + } + + private static void settle(PaneInputReservations.Lease lease, Pane pane, String channel, String endMarker) { + Duration delay = FIRST_PROBE; + while (true) { + boolean waited = true; + try { + pane.server().channel(channel).await(delay); + } catch (RuntimeException failure) { + if (Thread.currentThread().isInterrupted()) { + return; + } + waited = false; + } + + PaneInputCohort.Presence presence = lease.presence(pane); + if (presence == PaneInputCohort.Presence.GONE + || (presence == PaneInputCohort.Presence.PRESENT && hasStatusMarker(pane, endMarker))) { + lease.close(); + return; + } + if (!waited && !pause(delay)) { + return; + } + delay = Duration.ofMillis(Math.min(MAX_PROBE.toMillis(), delay.toMillis() * 2)); + } + } + + private static boolean hasStatusMarker(Pane pane, String endMarker) { + try { + for (String line : pane.capture()) { + if (RunningCommands.parseStatus(line.trim(), endMarker) != null) { + return true; + } + } + } catch (RuntimeException ignored) { + // Ambiguous observation retains ownership for the next probe. + } + return false; + } + + private static boolean pause(Duration delay) { + try { + Thread.sleep(delay); + return true; + } catch (InterruptedException failure) { + Thread.currentThread().interrupt(); + return false; + } + } +} diff --git a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/RunningCommands.java b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/RunningCommands.java index a679958..2567524 100644 --- a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/RunningCommands.java +++ b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/RunningCommands.java @@ -3,6 +3,8 @@ import io.github.libtmux.Pane; import io.github.libtmux.Server; import io.github.libtmux.WakeReason; +import io.github.libtmux.transport.DispatchOutcome; +import io.github.libtmux.transport.TmuxTransportException; import java.security.SecureRandom; import java.time.Duration; import java.util.ArrayList; @@ -75,45 +77,75 @@ static Ran run(Call call) { String command = call.string("command"); Duration timeout = Waits.requested(call); boolean suppressHistory = call.flag("suppress_history", true); - String currentCommand = - PaneInputCohort.resolve(pane, call.caller()).requireSingularCommandPane("run_shell_command"); + PaneInputCohort.Resolution initial = PaneInputCohort.resolve(pane, call.caller()); + String currentCommand = initial.requireSingularCommandPane("run_shell_command"); requirePosixShell(currentCommand); - PaneCommandFrame commandFrame = PaneCommandFrame.resolve(call); - - String nonce = "lt" + HexFormat.of().formatHex(bytes()); - String startMark = nonce + "-s"; - String endMark = nonce + "-e"; - String channel = "ch_" + nonce; - - Cursor before = Screen.from(pane).cursor(); - String typed = payload(commandFrame, command, startMark, endMark, channel, suppressHistory); - Pane freshPane = Targets.pane(server, pane.id().value()); - String freshCommand = - PaneInputCohort.resolve(freshPane, call.caller()).requireSingularCommandPane("run_shell_command"); - requirePosixShell(freshCommand); - freshPane.sendLine(typed); - - long started = System.nanoTime(); - WakeReason wake = server.channel(channel).await(timeout); - double seconds = (System.nanoTime() - started) / 1_000_000_000.0; - - Screen.Fresh fresh = - wake == WakeReason.SERVER_GONE ? null : Screen.since(freshPane, before, Trim.lineBudget(call)); - Framed framed = fresh == null ? new Framed(List.of(), false, null) : frame(fresh.lines(), startMark, endMark); - Integer status = wake == WakeReason.SIGNALLED ? framed.status() : null; - Trim.Trimmed trimmed = Trim.tail(framed.lines(), Trim.lineBudget(call)); - - return new Ran( - freshPane.id().value(), - wake.name(), - status, - trimmed.lines(), - trimmed.truncated(), - trimmed.dropped(), - framed.exact(), - Math.round(seconds * 100) / 100.0, - Waits.asSeconds(timeout), - note(wake, framed)); + PaneInputReservations.Lease lease = PaneInputReservations.run(initial, "run_shell_command"); + boolean retained = false; + boolean possiblyDispatched = false; + Pane freshPane = pane; + String channel = ""; + String endMark = ""; + try { + PaneCommandFrame commandFrame = PaneCommandFrame.resolve(call); + + String nonce = "lt" + HexFormat.of().formatHex(bytes()); + String startMark = nonce + "-s"; + endMark = nonce + "-e"; + channel = "ch_" + nonce; + + Cursor before = Screen.from(pane).cursor(); + String typed = payload(commandFrame, command, startMark, endMark, channel, suppressHistory); + freshPane = Targets.pane(server, pane.id().value()); + String freshCommand = lease.requireSameRun(PaneInputCohort.resolve(freshPane, call.caller())); + requirePosixShell(freshCommand); + possiblyDispatched = true; + try { + freshPane.sendLine(typed); + } catch (TmuxTransportException failure) { + if (failure.outcome() == DispatchOutcome.NOT_DISPATCHED) { + possiblyDispatched = false; + } + throw failure; + } + + long started = System.nanoTime(); + WakeReason wake = server.channel(channel).await(timeout); + double seconds = (System.nanoTime() - started) / 1_000_000_000.0; + + Screen.Fresh fresh = + wake == WakeReason.SERVER_GONE ? null : Screen.since(freshPane, before, Trim.lineBudget(call)); + Framed framed = + fresh == null ? new Framed(List.of(), false, null) : frame(fresh.lines(), startMark, endMark); + Integer status = wake == WakeReason.SIGNALLED ? framed.status() : null; + Trim.Trimmed trimmed = Trim.tail(framed.lines(), Trim.lineBudget(call)); + if (status == null) { + retained = true; + PaneRunSettlement.retain(lease, freshPane, channel, endMark); + } + + return new Ran( + freshPane.id().value(), + wake.name(), + status, + trimmed.lines(), + trimmed.truncated(), + trimmed.dropped(), + framed.exact(), + Math.round(seconds * 100) / 100.0, + Waits.asSeconds(timeout), + note(wake, framed)); + } catch (RuntimeException failure) { + if (possiblyDispatched && !retained) { + retained = true; + PaneRunSettlement.retain(lease, freshPane, channel, endMark); + } + throw failure; + } finally { + if (!retained) { + lease.close(); + } + } } private static @Nullable String note(WakeReason wake, Framed framed) { @@ -193,21 +225,16 @@ private static Framed frame(List lines, String startMark, String endMark int end = -1; Integer status = null; - String endPrefix = endMark + ":"; for (int index = start < 0 ? 0 : start + 1; index < lines.size(); index++) { String line = lines.get(index).trim(); - if (!line.startsWith(endPrefix)) { + Integer candidate = parseStatus(line, endMark); + if (candidate == null) { continue; } - try { - int candidate = Integer.parseInt(line.substring(endPrefix.length())); - end = index; - status = candidate; - if (start >= 0) { - break; - } - } catch (NumberFormatException ignored) { - // A wrapped echo can begin with the prefix; only the numeric marker is plumbing. + end = index; + status = candidate; + if (start >= 0) { + break; } } if (start < 0) { @@ -224,6 +251,24 @@ private static Framed frame(List lines, String startMark, String endMark return new Framed(List.copyOf(lines.subList(start + 1, last)), end >= 0, status); } + static @Nullable Integer parseStatus(String line, String endMarker) { + String prefix = endMarker + ":"; + if (!line.startsWith(prefix)) { + return null; + } + String encoded = line.substring(prefix.length()); + if (encoded.isEmpty() || encoded.chars().anyMatch(value -> value < '0' || value > '9')) { + return null; + } + try { + int status = Integer.parseInt(encoded); + return status <= 255 && Integer.toString(status).equals(encoded) ? status : null; + } catch (NumberFormatException ignored) { + // A wrapped echo can begin with the prefix; only the numeric marker is plumbing. + return null; + } + } + private static byte[] bytes() { byte[] value = new byte[16]; RANDOM.nextBytes(value); diff --git a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/PaneInputCohortTest.java b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/PaneInputCohortTest.java index 2f1ee90..b618f40 100644 --- a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/PaneInputCohortTest.java +++ b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/PaneInputCohortTest.java @@ -1,6 +1,7 @@ package io.github.libtmux.mcp; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -16,6 +17,7 @@ import java.util.List; import java.util.Map; import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.stream.Stream; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -301,6 +303,78 @@ public void close() {} } } + @Test + void retainedOwnershipNeedsAuthenticatedPaneOrGenerationAbsence(Server server) { + var source = server.panes().getFirst(); + source.split(); + try (var lease = PaneInputReservations.run(PaneInputCohort.resolve(source), "retained_test")) { + assertEquals(PaneInputCohort.Presence.PRESENT, lease.presence(source)); + + AtomicBoolean unavailable = new AtomicBoolean(); + try (ProcessTransport processes = new ProcessTransport()) { + TmuxTransport ambiguous = new TmuxTransport() { + @Override + public CommandResult execute(CommandRequest request) { + return unavailable.get() + ? new CommandResult(1, List.of(), List.of("temporarily unavailable")) + : processes.execute(request); + } + + @Override + public void close() {} + }; + try (Server measured = Server.using(server.config(), ambiguous)) { + var measuredSource = measured.panes().stream() + .filter(pane -> pane.id().equals(source.id())) + .findFirst() + .orElseThrow(); + unavailable.set(true); + + assertEquals(PaneInputCohort.Presence.UNKNOWN, lease.presence(measuredSource)); + } + } + + server.cmd("kill-pane", "-t", source.id().value()); + assertEquals(PaneInputCohort.Presence.GONE, lease.presence(source)); + } + + var survivor = server.panes().getFirst(); + try (var lease = PaneInputReservations.run(PaneInputCohort.resolve(survivor), "retained_test")) { + server.killServer(); + + assertEquals(PaneInputCohort.Presence.GONE, lease.presence(survivor)); + } + } + + @Test + void tmuxSecondRoundingDoesNotImplyDaemonDisappearance() { + ProcessHandle process = ProcessHandle.current(); + var started = process.info().startInstant().orElseThrow(); + var roundedLater = + new PaneInputCohort.Authority("local", "/not-used", process.pid(), started.getEpochSecond() + 1); + + var identity = PaneInputReservations.DaemonIdentity.capture(roundedLater); + + assertEquals(java.util.Optional.of(started), identity.started()); + assertFalse(identity.gone()); + } + + @Test + void successfulReplacementAuthorityIsGone(Server server) { + var source = server.panes().getFirst(); + var live = PaneInputCohort.resolve(source).authority(); + var replacements = List.of( + new PaneInputCohort.Authority("remote-test", live.socketPath(), live.serverPid(), live.startTime()), + new PaneInputCohort.Authority(live.realm(), "/different/socket", live.serverPid(), live.startTime()), + new PaneInputCohort.Authority(live.realm(), live.socketPath(), live.serverPid() + 1, live.startTime()), + new PaneInputCohort.Authority(live.realm(), live.socketPath(), live.serverPid(), live.startTime() + 1)); + + for (var replaced : replacements) { + assertEquals( + PaneInputCohort.Presence.GONE, PaneInputCohort.presence(source, replaced), replaced.toString()); + } + } + private static Stream authorityFailures() { return Stream.of( Arguments.of("command failed", "%0", new CommandResult(1, List.of(), List.of("gone"))), diff --git a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/RunningCommandsTest.java b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/RunningCommandsTest.java index bb7877e..803ed08 100644 --- a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/RunningCommandsTest.java +++ b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/RunningCommandsTest.java @@ -35,7 +35,9 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.io.TempDir; import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.EnumSource; +import org.junit.jupiter.params.provider.MethodSource; import org.junit.jupiter.params.provider.ValueSource; /** @@ -135,6 +137,7 @@ void newlyAttendedPaneRefusesAtFinalPreflight(Server server) throws Exception { assertEquals(0, commandCount(requests, "send-keys")); assertEquals(0, commandCount(requests, "wait-for")); assertFalse(capture(server, pane.id().value()).contains(marker)); + assertTrue(inputAvailable(server, pane.id().value()), "the refused run retained pane ownership"); } @ParameterizedTest @@ -311,6 +314,9 @@ void stateTransitionAfterSetupRefusesRun(RunTransition transition, Server server assertTrue(server.panes().stream() .flatMap(pane -> pane.options().all().keySet().stream()) .noneMatch(name -> name.startsWith("@st_"))); + if (transition != RunTransition.DISAPPEAR) { + assertTrue(inputAvailable(server, source.id().value()), "the refused run retained pane ownership"); + } } @Test @@ -478,10 +484,106 @@ void aTimedOutCommandLeavesNoStatusWhenItEventuallyFinishes(Server server) throw "the eventual exit status was left on the pane"); } + @Test + void timedOutRunRetainsItsPaneUntilTheStatusMarker(Server server, @TempDir Path temporary) throws Exception { + String pane = server.panes().getFirst().id().value(); + Path ready = temporary.resolve("ready"); + Path release = temporary.resolve("release"); + String command = ": > " + Shell.quote(ready.toString()) + "; while [ ! -e " + Shell.quote(release.toString()) + + " ]; do sleep 0.05; done"; + + RunningCommands.Ran ran = + RunningCommands.run(TestCalls.on(server, "pane_id", pane, "command", command, "timeout", 0.1)); + assertEquals("TIMED_OUT", ran.outcome()); + assertTrue(await(() -> Files.exists(ready)), "the retained command did not start"); + try { + assertInputOwned(server, pane); + } finally { + Files.writeString(release, "release"); + } + + assertTrue(await(() -> inputAvailable(server, pane)), "the valid status marker did not release the pane"); + } + + @Test + void aDeadPaneProvesRetainedOwnershipEnded(Server server) throws Exception { + Pane pane = server.panes().getFirst(); + try (var lease = PaneInputReservations.run(PaneInputCohort.resolve(pane), "retained_test")) { + pane.options().set("remain-on-exit", "on"); + pane.sendLine("exit"); + assertTrue(await(() -> "1".equals(pane.expand("#{pane_dead}"))), "the pane did not become dead"); + + assertEquals(PaneInputCohort.Presence.GONE, lease.presence(pane)); + } + } + + @ParameterizedTest(name = "{0}") + @MethodSource("invalidCompletionStatuses") + void completionSignalWithoutAValidStatusRetainsThePane( + String label, String forgedStatus, Server server, @TempDir Path temporary) throws Exception { + String pane = server.panes().getFirst().id().value(); + Path release = temporary.resolve("release"); + AtomicBoolean signalled = new AtomicBoolean(); + try (ProcessTransport processes = new ProcessTransport()) { + TmuxTransport premature = borrowing(request -> { + CommandResult result = processes.execute(request); + if (hasCommand(request, "send-keys") && signalled.compareAndSet(false, true)) { + nonce(request).ifPresent(value -> { + if (!forgedStatus.isEmpty()) { + String marker = value + "-e:" + forgedStatus; + server.cmd("send-keys", "-t", pane, "-l", marker); + server.cmd("send-keys", "-t", pane, "Enter"); + awaitUnchecked(() -> capture(server, pane).contains(marker)); + } + server.cmd("wait-for", "-S", "ch_" + value); + }); + } + return result; + }); + try (Server measured = Server.using(server.config(), premature)) { + String command = "while [ ! -e " + Shell.quote(release.toString()) + " ]; do sleep 0.05; done"; + RunningCommands.Ran ran = + RunningCommands.run(TestCalls.on(measured, "pane_id", pane, "command", command)); + assertEquals("SIGNALLED", ran.outcome(), label); + assertNull(ran.exitStatus(), label); + try { + assertInputOwned(server, pane); + } finally { + Files.writeString(release, "release"); + } + assertTrue(await(() -> inputAvailable(server, pane)), "the eventual status marker did not release"); + } + } + } + + @Test + void cancelledRunRetainsItsPaneUntilTheStatusMarker(Server server, @TempDir Path temporary) throws Exception { + String pane = server.panes().getFirst().id().value(); + Path ready = temporary.resolve("ready"); + Path release = temporary.resolve("release"); + String command = ": > " + Shell.quote(ready.toString()) + "; while [ ! -e " + Shell.quote(release.toString()) + + " ]; do sleep 0.05; done"; + try (ProcessTransport processes = new ProcessTransport(); + Server measured = Server.using(server.config(), processes); + var calls = Executors.newVirtualThreadPerTaskExecutor()) { + var running = calls.submit(() -> + RunningCommands.run(TestCalls.on(measured, "pane_id", pane, "command", command, "timeout", 30))); + assertTrue(await(() -> Files.exists(ready)), "the cancellable command did not start"); + assertTrue(running.cancel(true), "the run had already ended"); + try { + assertInputOwned(server, pane); + } finally { + Files.writeString(release, "release"); + } + assertTrue(await(() -> inputAvailable(server, pane)), "the cancelled run did not settle after its marker"); + } + } + @Test void uncertainCommandDeliveryStillRunsTheAcceptedCommand(Server server, @TempDir Path temporary) throws Exception { String pane = server.panes().get(0).id().value(); Path accepted = temporary.resolve("accepted"); + Path release = temporary.resolve("release"); try (ProcessTransport processes = new ProcessTransport()) { TmuxTransport uncertain = borrowing(request -> { CommandResult result = processes.execute(request); @@ -498,16 +600,41 @@ void uncertainCommandDeliveryStillRunsTheAcceptedCommand(Server server, @TempDir "pane_id", pane, "command", - "printf ran > " + Shell.quote(accepted.toString())))); - server.panes().get(0).sendLine("printf 'uncertain-cleanup-%s\\n' finished"); + "printf ran > " + Shell.quote(accepted.toString()) + "; while [ ! -e " + + Shell.quote(release.toString()) + " ]; do sleep 0.05; done"))); assertTrue( await(() -> Files.exists(accepted)), "an accepted command was lost after ambiguous delivery"); assertEquals("ran", Files.readString(accepted)); - assertTrue( - await(() -> server.panes().get(0).capture().stream() - .anyMatch(line -> line.contains("uncertain-cleanup-finished"))), - "an ambiguously delivered command left the pane's shell waiting for cleanup"); + try { + assertInputOwned(server, pane); + } finally { + Files.writeString(release, "release"); + } + assertTrue(await(() -> inputAvailable(server, pane)), "the ambiguous delivery did not settle"); + } + } + } + + @Test + void definitiveSendFailureReleasesPaneOwnership(Server server) { + String pane = server.panes().getFirst().id().value(); + AtomicBoolean refused = new AtomicBoolean(); + try (ProcessTransport processes = new ProcessTransport()) { + TmuxTransport notDispatched = borrowing(request -> { + if (nonce(request).isPresent() && refused.compareAndSet(false, true)) { + throw new TmuxTransportException( + "simulated refusal before delivery", DispatchOutcome.NOT_DISPATCHED, null); + } + return processes.execute(request); + }); + try (Server measured = Server.using(server.config(), notDispatched)) { + assertThrows( + TmuxTransportException.class, + () -> RunningCommands.run( + TestCalls.on(measured, "pane_id", pane, "command", "printf 'must-not-run\\n'"))); + + assertTrue(inputAvailable(server, pane), "definitive non-delivery retained pane ownership"); } } } @@ -605,34 +732,61 @@ void aPaneThatIsNotThereSaysWhichToolFindsOne(Server server) { } @Test - void concurrentRunsDoNotMergeTheirCommandLines(Server server) throws Exception { + void aSecondRunRefusesRatherThanQueueingOrMerging(Server server) throws Exception { String pane = server.panes().get(0).id().value(); - CountDownLatch bothLinesSent = new CountDownLatch(2); + CountDownLatch checked = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + AtomicInteger snapshots = new AtomicInteger(); + AtomicReference owner = new AtomicReference<>(); + List requests = new CopyOnWriteArrayList<>(); try (ProcessTransport processes = new ProcessTransport()) { TmuxTransport interleaving = borrowing(request -> { CommandResult result = processes.execute(request); - String argv = String.join("\0", request.commands().get(0)); - if (argv.contains("send-keys") && argv.contains("ch_lt")) { - bothLinesSent.countDown(); - await(bothLinesSent); + if (Thread.currentThread().equals(owner.get())) { + requests.add(request); + if (isCohortListing(request) && snapshots.incrementAndGet() == 2) { + checked.countDown(); + await(release); + } } return result; }); try (Server measured = Server.using(server.config(), interleaving); var calls = Executors.newVirtualThreadPerTaskExecutor()) { - var first = calls.submit(() -> RunningCommands.run( - TestCalls.on(measured, "pane_id", pane, "command", "printf 'first-run-marker\\n'"))); - var second = calls.submit(() -> RunningCommands.run( - TestCalls.on(measured, "pane_id", pane, "command", "printf 'second-run-marker\\n'"))); - - assertEquals( - java.util.List.of("first-run-marker"), - first.get(Waits.DEFAULT.toSeconds(), TimeUnit.SECONDS).output()); - assertEquals( - java.util.List.of("second-run-marker"), - second.get(Waits.DEFAULT.toSeconds(), TimeUnit.SECONDS).output()); + var first = calls.submit(() -> { + owner.set(Thread.currentThread()); + return RunningCommands.run( + TestCalls.on(measured, "pane_id", pane, "command", "printf 'first-run-marker\\n'")); + }); + await(checked); + try { + IllegalStateException refused = assertThrows( + IllegalStateException.class, + () -> RunningCommands.run(TestCalls.on( + measured, "pane_id", pane, "command", "printf 'second-run-marker\\n'"))); + assertTrue(String.valueOf(refused.getMessage()).contains("owned"), refused.getMessage()); + } finally { + release.countDown(); + } + RunningCommands.Ran ran = first.get(Waits.DEFAULT.toSeconds(), TimeUnit.SECONDS); + assertEquals(java.util.List.of("first-run-marker"), ran.output()); } } + + List preflights = requestIndexes(requests, RunningCommandsTest::isCohortListing); + List sends = requestIndexes(requests, request -> hasCommand(request, "send-keys")); + assertEquals(2, preflights.size()); + assertEquals(1, sends.size()); + assertEquals(preflights.getLast() + 1, sends.getFirst()); + } + + private static java.util.stream.Stream invalidCompletionStatuses() { + return java.util.stream.Stream.of( + Arguments.of("missing status", ""), + Arguments.of("out-of-range status", "256"), + Arguments.of("leading plus", "+0"), + Arguments.of("negative zero", "-0"), + Arguments.of("leading zero", "01")); } private static void await(CountDownLatch latch) { @@ -646,6 +800,24 @@ private static void await(CountDownLatch latch) { } } + private static void assertInputOwned(Server server, String pane) { + IllegalStateException refused = assertThrows( + IllegalStateException.class, () -> Typing.pasteText(TestCalls.on(server, "pane_id", pane, "text", ""))); + assertTrue(String.valueOf(refused.getMessage()).contains("owned"), refused.getMessage()); + } + + private static boolean inputAvailable(Server server, String pane) { + try { + Typing.pasteText(TestCalls.on(server, "pane_id", pane, "text", "")); + return true; + } catch (IllegalStateException refusal) { + if (String.valueOf(refusal.getMessage()).contains("owned")) { + return false; + } + throw refusal; + } + } + private static Process attachClient(Server server, Pane pane) { String socket = server.cmd("display-message", "-p", "#{socket_path}").stdout().getFirst(); From 25f892c5446df815162ce6727a405db6d3d8def1 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 6 Sep 2026 00:45:49 -0500 Subject: [PATCH 49/65] Mcp(fix[input]): Bind linked placements why: Pane input omitted window indexes and reduced terminal clients to attended pane IDs, so linked topology or client relocation could change without invalidating the final guard. what: - Parse canonical window indexes for pane and terminal-client placements - Reject incomplete linked-window rectangles and inconsistent clients - Bind full canonical client placement records into input transitions --- .../github/libtmux/mcp/PaneInputCohort.java | 82 +++++++++++++----- .../libtmux/mcp/PaneInputReservations.java | 6 +- .../libtmux/mcp/PaneInputCohortTest.java | 85 +++++++++++++++++-- 3 files changed, 144 insertions(+), 29 deletions(-) diff --git a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/PaneInputCohort.java b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/PaneInputCohort.java index 015ac68..e824dff 100644 --- a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/PaneInputCohort.java +++ b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/PaneInputCohort.java @@ -9,6 +9,8 @@ import io.github.libtmux.format.RowFormat; import io.github.libtmux.format.TmuxFormatException; import io.github.libtmux.transport.CommandResult; +import java.util.ArrayList; +import java.util.Comparator; import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; @@ -18,7 +20,7 @@ /** One authoritative view of the panes tmux may receive input through. */ final class PaneInputCohort { - private static final long MAX_TMUX_PANE_ID = 4_294_967_295L; + private static final long MAX_TMUX_ID_COMPONENT = 4_294_967_295L; private static final RowFormat PANES = RowFormat.of( "pane_id", @@ -29,12 +31,19 @@ final class PaneInputCohort { "pane_input_off", "session_id", "window_id", + "window_index", "pid", "start_time", "socket_path"); - private static final RowFormat CLIENTS = - RowFormat.of("client_control_mode", "session_id", "window_id", "pane_id", "window_zoomed_flag"); + private static final RowFormat CLIENTS = RowFormat.of( + "client_control_mode", "session_id", "window_id", "window_index", "pane_id", "window_zoomed_flag"); + + private static final Comparator CLIENT_ORDER = Comparator.comparing(ClientPlacement::sessionId) + .thenComparing(ClientPlacement::windowId) + .thenComparingLong(ClientPlacement::windowIndex) + .thenComparing(ClientPlacement::paneId) + .thenComparingInt(client -> client.zoomed() ? 1 : 0); private PaneInputCohort() {} @@ -90,13 +99,14 @@ private static Resolution parse( .sorted(java.util.Comparator.comparing(Member::paneId)) .toList() : List.of(source); - Set attended = attended(clientAnswer, members, source.windowId()); + ClientSnapshot clients = clients(clientAnswer, members, source.windowId()); caller.requireConsistent( generation.serverPid(), generation.socketPath(), members.values().stream() .collect(java.util.stream.Collectors.toMap(Member::paneId, Member::sessionIds))); - return new Resolution(generation, source, recipients, caller, attended); + return new Resolution( + generation, source, recipients, caller, clients.attendedPaneIds(), clients.terminalClients()); } static Presence presence(Pane pane, Authority expected) { @@ -142,8 +152,17 @@ private static PaneSnapshot panes(String realm, String sourcePaneId, CommandResu members.put(member.paneId(), member); } else if (!prior.samePane(member)) { throw new LibTmuxException("tmux returned inconsistent duplicate pane input state"); + } else if (prior.placements().containsAll(member.placements())) { + throw new LibTmuxException("tmux returned a duplicate pane placement"); } else { - members.put(member.paneId(), prior.withSessions(member.sessionIds())); + members.put(member.paneId(), prior.withPlacements(member.placements())); + } + } + Map> windowPlacements = new LinkedHashMap<>(); + for (Member member : members.values()) { + Set prior = windowPlacements.putIfAbsent(member.windowId(), member.placements()); + if (prior != null && !prior.equals(member.placements())) { + throw new TmuxFormatException("tmux returned incomplete linked-window pane placements"); } } return new PaneSnapshot(java.util.Objects.requireNonNull(authority), Map.copyOf(members)); @@ -153,12 +172,12 @@ private static CommandResult result(OperationResult operation) { return new CommandResult(operation.succeeded() ? 0 : 1, operation.stdout(), operation.stderr()); } - private static Set attended(CommandResult answer, Map members, String sourceWindowId) { + private static ClientSnapshot clients(CommandResult answer, Map members, String sourceWindowId) { if (!answer.succeeded()) { throw new LibTmuxException("tmux could not resolve client attention state"); } if (answer.stdout().isEmpty()) { - return Set.of(); + return new ClientSnapshot(List.of(), Set.of()); } int terminators = validateFraming(CLIENTS, answer.stdout()); List rows = CLIENTS.rows(answer.stdout()); @@ -166,6 +185,7 @@ private static Set attended(CommandResult answer, Map me throw new TmuxFormatException("tmux returned an incomplete client attention listing"); } Set attended = new LinkedHashSet<>(); + List terminalClients = new ArrayList<>(); for (RowFormat.Row row : rows) { boolean controlMode = row.flag("client_control_mode"); if (controlMode) { @@ -173,15 +193,18 @@ private static Set attended(CommandResult answer, Map me } String clientSession = targetId(row.text("session_id"), '$', "session_id"); String clientWindow = targetId(row.text("window_id"), '@', "window_id"); + long clientWindowIndex = unsignedCanonical(row.text("window_index"), "window_index"); String activePane = paneId(row.text("pane_id")); boolean zoomed = row.flag("window_zoomed_flag"); Member active = members.get(activePane); if (active == null) { throw new TmuxFormatException("a terminal client reported an unknown active pane"); } - if (!active.windowId().equals(clientWindow) || !active.sessionIds().contains(clientSession)) { + if (!active.placements().contains(new Placement(clientSession, clientWindow, clientWindowIndex))) { throw new TmuxFormatException("a terminal client reported inconsistent active pane placement"); } + terminalClients.add( + new ClientPlacement(clientSession, clientWindow, clientWindowIndex, activePane, zoomed)); if (!active.windowId().equals(sourceWindowId)) { continue; } @@ -194,7 +217,8 @@ private static Set attended(CommandResult answer, Map me .forEach(attended::add); } } - return Set.copyOf(attended); + terminalClients.sort(CLIENT_ORDER); + return new ClientSnapshot(List.copyOf(terminalClients), Set.copyOf(attended)); } private static int validateFraming(RowFormat format, List lines) { @@ -233,6 +257,7 @@ private static Member member(RowFormat.Row row) { boolean inputDisabled = row.flag("pane_input_off"); String sessionId = targetId(row.text("session_id"), '$', "session_id"); String windowId = targetId(row.text("window_id"), '@', "window_id"); + long windowIndex = unsignedCanonical(row.text("window_index"), "window_index"); return new Member( paneId, synchronizedPane, @@ -242,7 +267,7 @@ private static Member member(RowFormat.Row row) { command, inputDisabled, windowId, - Set.of(sessionId)); + Set.of(new Placement(sessionId, windowId, windowIndex))); } private static Authority authority(String realm, RowFormat.Row row) { @@ -280,12 +305,17 @@ private static String targetId(String value, char sigil, String field) { if (value.length() == 1 || value.isEmpty() || value.charAt(0) != sigil) { throw new TmuxFormatException(field + " was invalid"); } + unsignedCanonical(value.substring(1), field); + return value; + } + + private static long unsignedCanonical(String value, String field) { try { - long id = Long.parseLong(value.substring(1)); - if (id < 0 || id > MAX_TMUX_PANE_ID || !value.equals(sigil + Long.toString(id))) { + long id = Long.parseLong(value); + if (id < 0 || id > MAX_TMUX_ID_COMPONENT || !value.equals(Long.toString(id))) { throw new TmuxFormatException(field + " was invalid"); } - return value; + return id; } catch (NumberFormatException failure) { throw new TmuxFormatException(field + " was invalid", failure); } @@ -301,6 +331,12 @@ enum Presence { private record PaneSnapshot(Authority authority, Map members) {} + private record ClientSnapshot(List terminalClients, Set attendedPaneIds) {} + + record Placement(String sessionId, String windowId, long windowIndex) {} + + record ClientPlacement(String sessionId, String windowId, long windowIndex, String paneId, boolean zoomed) {} + record Member( String paneId, boolean synchronizedPane, @@ -310,10 +346,16 @@ record Member( String currentCommand, boolean inputDisabled, String windowId, - Set sessionIds) { + Set placements) { Member { - sessionIds = Set.copyOf(sessionIds); + placements = Set.copyOf(placements); + } + + Set sessionIds() { + return placements.stream() + .map(Placement::sessionId) + .collect(java.util.stream.Collectors.toUnmodifiableSet()); } boolean writable() { @@ -331,8 +373,8 @@ boolean samePane(Member other) { && windowId.equals(other.windowId); } - Member withSessions(Set more) { - Set merged = new LinkedHashSet<>(sessionIds); + Member withPlacements(Set more) { + Set merged = new LinkedHashSet<>(placements); merged.addAll(more); return new Member( paneId, @@ -352,11 +394,13 @@ record Resolution( Member source, List keyRecipients, Caller caller, - Set attendedPaneIds) { + Set attendedPaneIds, + List terminalClients) { Resolution { keyRecipients = List.copyOf(keyRecipients); attendedPaneIds = Set.copyOf(attendedPaneIds); + terminalClients = List.copyOf(terminalClients); } List configuredKeyRecipientIds() { diff --git a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/PaneInputReservations.java b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/PaneInputReservations.java index 3f507e2..09fd40b 100644 --- a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/PaneInputReservations.java +++ b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/PaneInputReservations.java @@ -120,11 +120,11 @@ private record Signature( Generation generation, PaneInputCohort.Member source, List members, - Set attendedPaneIds) { + List terminalClients) { private Signature { members = List.copyOf(members); - attendedPaneIds = Set.copyOf(attendedPaneIds); + terminalClients = List.copyOf(terminalClients); } static Signature capture( @@ -133,7 +133,7 @@ static Signature capture( Generation.capture(resolution.authority(), operation), resolution.source(), members, - resolution.attendedPaneIds()); + resolution.terminalClients()); } Set keys(List selected) { diff --git a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/PaneInputCohortTest.java b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/PaneInputCohortTest.java index b618f40..369f8f7 100644 --- a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/PaneInputCohortTest.java +++ b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/PaneInputCohortTest.java @@ -211,8 +211,8 @@ void linkedPanePlacementIsAcceptedForTheClientsSession() { "%0", answer( row("%0", "0", "0", "0", "sh"), - row("%0", "0", "0", "0", "sh", "0", "$1", "@0", "1", "1", "/tmp/test-tmux")), - answer(clientRow("0", "$1", "@0", "%0", "1")), + row("%0", "0", "0", "0", "sh", "0", "$1", "@0", "7", "1", "1", "/tmp/test-tmux")), + answer(clientRow("0", "$1", "@0", "7", "%0", "1")), Caller.nowhere()); IllegalStateException refused = @@ -221,6 +221,64 @@ void linkedPanePlacementIsAcceptedForTheClientsSession() { assertTrue(String.valueOf(refused.getMessage()).contains("attended"), refused.getMessage()); } + @ParameterizedTest + @ValueSource(strings = {"", "-1", "+0", "01", "4294967296"}) + void windowIndexesMustBeCanonicalUnsigned32BitValues(String index) { + assertThrows( + TmuxFormatException.class, + () -> PaneInputCohort.parse( + "%0", + answer(row("%0", "0", "0", "0", "sh", "0", "$0", "@0", index, "1", "1", "/tmp/test-tmux")))); + assertThrows( + TmuxFormatException.class, + () -> PaneInputCohort.parse( + "%0", + answer(row("%0", "0", "0", "0", "sh")), + answer(clientRow("0", "$0", "@0", index, "%0", "0")), + Caller.nowhere())); + } + + @Test + void everyPaneInALinkedWindowNeedsTheSamePlacementRectangle() { + assertThrows( + TmuxFormatException.class, + () -> PaneInputCohort.parse( + "%0", + answer( + row("%0", "1", "0", "0", "sh"), + row("%0", "1", "0", "0", "sh", "0", "$1", "@0", "7", "1", "1", "/tmp/test-tmux"), + row("%1", "1", "0", "0", "sh")))); + } + + @Test + void aWindowIndexMoveChangesTheGuardedResolution() { + var initial = PaneInputCohort.parse( + "%0", answer(row("%0", "0", "0", "0", "sh", "0", "$0", "@0", "0", "1", "1", "/tmp/test-tmux"))); + var moved = PaneInputCohort.parse( + "%0", answer(row("%0", "0", "0", "0", "sh", "0", "$0", "@0", "9", "1", "1", "/tmp/test-tmux"))); + + assertFalse(initial.equals(moved)); + } + + @Test + void aTerminalClientMoveBetweenValidLinksChangesTheGuard(Server server) { + String pid = server.expand("#{pid}"); + String started = server.expand("#{start_time}"); + String socket = server.expand("#{socket_path}"); + CommandResult panes = answer( + row("%0", "0", "0", "0", "sh", "0", "$0", "@0", "0", pid, started, socket), + row("%1", "0", "0", "0", "sh", "0", "$0", "@1", "1", pid, started, socket), + row("%1", "0", "0", "0", "sh", "0", "$1", "@1", "7", pid, started, socket)); + var initial = PaneInputCohort.parse( + "%0", panes, answer(clientRow("0", "$0", "@1", "1", "%1", "0")), Caller.nowhere()); + var moved = PaneInputCohort.parse( + "%0", panes, answer(clientRow("0", "$1", "@1", "7", "%1", "0")), Caller.nowhere()); + + try (var lease = PaneInputReservations.run(initial, "run_shell_command")) { + assertThrows(IllegalStateException.class, () -> lease.requireSameRun(moved)); + } + } + @Test void validClientInAnotherWindowDoesNotAttendTheTarget() { var resolved = PaneInputCohort.parse( @@ -287,6 +345,7 @@ public void close() {} "pane_dead", "pane_current_command", "pane_input_off", + "window_index", "session_id", "window_id", "pid", @@ -297,8 +356,8 @@ public void close() {} List clients = commands.get(1); assertEquals("list-clients", clients.getFirst()); String clientFormat = clients.get(clients.indexOf("-F") + 1); - for (String field : - List.of("client_control_mode", "session_id", "window_id", "pane_id", "window_zoomed_flag")) { + for (String field : List.of( + "client_control_mode", "session_id", "window_id", "window_index", "pane_id", "window_zoomed_flag")) { assertEquals(1, occurrences(clientFormat, "#{" + field + "}")); } } @@ -438,7 +497,8 @@ private static Stream invalidTerminalClientPlacements() { Arguments.of("missing window", clientRow("0", "$0", "", "%0", "0")), Arguments.of("invalid window", clientRow("0", "$0", "0", "%0", "0")), Arguments.of("session mismatch", clientRow("0", "$1", "@0", "%0", "0")), - Arguments.of("window mismatch", clientRow("0", "$0", "@1", "%0", "0"))); + Arguments.of("window mismatch", clientRow("0", "$0", "@1", "%0", "0")), + Arguments.of("window index mismatch", clientRow("0", "$0", "@0", "7", "%0", "0"))); } private static CommandResult answer(String... rows) { @@ -451,7 +511,12 @@ private static String row(String... fields) { if (complete.size() == 5) { complete.add("0"); } - complete.addAll(List.of("$0", "@0", "1", "1", "/tmp/test-tmux")); + complete.addAll(List.of("$0", "@0", "0", "1", "1", "/tmp/test-tmux")); + return fields(complete.toArray(String[]::new)) + TERMINATOR; + } + if (fields.length == 11) { + List complete = new java.util.ArrayList<>(List.of(fields)); + complete.add(8, "0"); return fields(complete.toArray(String[]::new)) + TERMINATOR; } return fields(fields) + TERMINATOR; @@ -459,7 +524,12 @@ private static String row(String... fields) { private static String clientRow( String control, String sessionId, String windowId, String activePane, String zoomed) { - return fields(control, sessionId, windowId, activePane, zoomed) + TERMINATOR; + return clientRow(control, sessionId, windowId, "0", activePane, zoomed); + } + + private static String clientRow( + String control, String sessionId, String windowId, String windowIndex, String activePane, String zoomed) { + return fields(control, sessionId, windowId, windowIndex, activePane, zoomed) + TERMINATOR; } private static String liveRow( @@ -477,6 +547,7 @@ private static String liveRow( "0", handle.window().session().id().value(), handle.window().id().value(), + handle.window().index().toString(), server.expand("#{pid}"), server.expand("#{start_time}"), server.expand("#{socket_path}")); From eeaf447cbec6897ba9a5a864cc07b3c6e294e496 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 6 Sep 2026 01:22:53 -0500 Subject: [PATCH 50/65] Build(feat[mcp-swap]): Add native client table why: The repository-native swap utility needs one deterministic model of every supported client before its config transaction can replace the Python script. what: - Add a non-published Gradle application module - Model all eight client config routes in canonical order - Parse repeated and comma-separated selectors with the agy alias - Prove every client ordering normalizes to the same selection --- settings.gradle.kts | 1 + tools/mcp-swap/build.gradle.kts | 13 +++ .../github/libtmux/tools/mcpswap/Client.java | 5 ++ .../libtmux/tools/mcpswap/ClientRegistry.java | 52 +++++++++++ .../libtmux/tools/mcpswap/ConfigFormat.java | 7 ++ .../libtmux/tools/mcpswap/package-info.java | 2 + .../tools/mcpswap/ClientRegistryTest.java | 90 +++++++++++++++++++ 7 files changed, 170 insertions(+) create mode 100644 tools/mcp-swap/build.gradle.kts create mode 100644 tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/Client.java create mode 100644 tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/ClientRegistry.java create mode 100644 tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/ConfigFormat.java create mode 100644 tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/package-info.java create mode 100644 tools/mcp-swap/src/test/java/io/github/libtmux/tools/mcpswap/ClientRegistryTest.java diff --git a/settings.gradle.kts b/settings.gradle.kts index 89da86c..74d8a92 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -27,3 +27,4 @@ include("benchmarks") include("docs-tests") include("examples") include("integration-tests") +include("tools:mcp-swap") diff --git a/tools/mcp-swap/build.gradle.kts b/tools/mcp-swap/build.gradle.kts new file mode 100644 index 0000000..46b1512 --- /dev/null +++ b/tools/mcp-swap/build.gradle.kts @@ -0,0 +1,13 @@ +plugins { + id("libtmux.java-library") + application +} + +application { + mainClass = "io.github.libtmux.tools.mcpswap.McpSwap" + applicationName = "mcp-swap" +} + +dependencies { implementation(libs.jackson.databind) } + +tasks.withType().configureEach { enabled = false } diff --git a/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/Client.java b/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/Client.java new file mode 100644 index 0000000..ab58de4 --- /dev/null +++ b/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/Client.java @@ -0,0 +1,5 @@ +package io.github.libtmux.tools.mcpswap; + +import java.nio.file.Path; + +record Client(String name, Path configPath, String serverTable, ConfigFormat format, boolean openCode) {} diff --git a/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/ClientRegistry.java b/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/ClientRegistry.java new file mode 100644 index 0000000..8395f2a --- /dev/null +++ b/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/ClientRegistry.java @@ -0,0 +1,52 @@ +package io.github.libtmux.tools.mcpswap; + +import java.nio.file.Path; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +final class ClientRegistry { + private ClientRegistry() {} + + static List knownClients(Path home, Map environment) { + var xdg = environment.get("XDG_CONFIG_HOME"); + var configHome = + xdg != null && !xdg.isBlank() && Path.of(xdg).isAbsolute() ? Path.of(xdg) : home.resolve(".config"); + return List.of( + new Client("claude", home.resolve(".claude.json"), "mcpServers", ConfigFormat.JSON, false), + new Client("codex", home.resolve(".codex/config.toml"), "mcp_servers", ConfigFormat.TOML, false), + new Client("cursor", home.resolve(".cursor/mcp.json"), "mcpServers", ConfigFormat.JSON, false), + new Client("gemini", home.resolve(".gemini/settings.json"), "mcpServers", ConfigFormat.JSON, false), + new Client("grok", home.resolve(".grok/config.toml"), "mcp_servers", ConfigFormat.TOML, false), + new Client( + "agy", home.resolve(".gemini/config/mcp_config.json"), "mcpServers", ConfigFormat.JSON, false), + new Client("opencode", configHome.resolve("opencode/opencode.jsonc"), "mcp", ConfigFormat.JSONC, true), + new Client("pi", home.resolve(".pi/agent/mcp.json"), "mcpServers", ConfigFormat.JSONC, false)); + } + + static List select(List clients, List selectors) { + if (selectors.isEmpty()) { + return clients; + } + Set known = new HashSet<>(); + clients.forEach(client -> known.add(client.name())); + Set wanted = new HashSet<>(); + for (var selector : selectors) { + for (var part : selector.split(",", -1)) { + var name = part.trim(); + if (name.equals("antigravity")) { + name = "agy"; + } + if (name.isEmpty()) { + throw new IllegalArgumentException("client name was empty"); + } + if (!known.contains(name)) { + throw new IllegalArgumentException("unknown client " + name); + } + wanted.add(name); + } + } + return clients.stream().filter(client -> wanted.contains(client.name())).toList(); + } +} diff --git a/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/ConfigFormat.java b/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/ConfigFormat.java new file mode 100644 index 0000000..7e8d575 --- /dev/null +++ b/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/ConfigFormat.java @@ -0,0 +1,7 @@ +package io.github.libtmux.tools.mcpswap; + +enum ConfigFormat { + JSON, + JSONC, + TOML +} diff --git a/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/package-info.java b/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/package-info.java new file mode 100644 index 0000000..428297c --- /dev/null +++ b/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/package-info.java @@ -0,0 +1,2 @@ +@org.jspecify.annotations.NullMarked +package io.github.libtmux.tools.mcpswap; diff --git a/tools/mcp-swap/src/test/java/io/github/libtmux/tools/mcpswap/ClientRegistryTest.java b/tools/mcp-swap/src/test/java/io/github/libtmux/tools/mcpswap/ClientRegistryTest.java new file mode 100644 index 0000000..e02e942 --- /dev/null +++ b/tools/mcp-swap/src/test/java/io/github/libtmux/tools/mcpswap/ClientRegistryTest.java @@ -0,0 +1,90 @@ +package io.github.libtmux.tools.mcpswap; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +final class ClientRegistryTest { + private static final List NAMES = + List.of("claude", "codex", "cursor", "gemini", "grok", "agy", "opencode", "pi"); + + @Test + void resolvesEveryClientUnderTheSuppliedHome() { + var home = Path.of("/test/home"); + var clients = ClientRegistry.knownClients(home, Map.of("XDG_CONFIG_HOME", "/test/config")); + + assertEquals(NAMES, clients.stream().map(Client::name).toList()); + assertEquals(home.resolve(".claude.json"), clients.getFirst().configPath()); + assertEquals( + Path.of("/test/config/opencode/opencode.jsonc"), clients.get(6).configPath()); + assertEquals(home.resolve(".pi/agent/mcp.json"), clients.getLast().configPath()); + } + + @Test + void ignoresARelativeXdgConfigHome() { + var home = Path.of("/test/home"); + + var clients = ClientRegistry.knownClients(home, Map.of("XDG_CONFIG_HOME", "relative")); + + assertEquals( + home.resolve(".config/opencode/opencode.jsonc"), clients.get(6).configPath()); + } + + @Test + void selectsInCanonicalOrderWithAliasesAndDuplicates() { + var clients = ClientRegistry.knownClients(Path.of("/test/home"), Map.of()); + + var selected = ClientRegistry.select(clients, List.of("cursor,antigravity", "claude", "cursor")); + + assertEquals( + List.of("claude", "cursor", "agy"), + selected.stream().map(Client::name).toList()); + } + + @Test + void rejectsEmptyAndUnknownSelectors() { + var clients = ClientRegistry.knownClients(Path.of("/test/home"), Map.of()); + + assertThrows(IllegalArgumentException.class, () -> ClientRegistry.select(clients, List.of("claude,"))); + assertThrows(IllegalArgumentException.class, () -> ClientRegistry.select(clients, List.of("clod"))); + } + + @Test + void normalizesAllClientOrderings() { + var clients = ClientRegistry.knownClients(Path.of("/test/home"), Map.of()); + var orderings = new int[] {0}; + + permutations(new ArrayList<>(NAMES), ordering -> { + assertEquals( + NAMES, + ClientRegistry.select(clients, ordering).stream() + .map(Client::name) + .toList()); + orderings[0]++; + }); + + assertEquals(40_320, orderings[0]); + } + + private static void permutations(List remaining, java.util.function.Consumer> check) { + if (remaining.isEmpty()) { + check.accept(List.of()); + return; + } + for (int index = 0; index < remaining.size(); index++) { + var suffix = new ArrayList<>(remaining); + var first = suffix.remove(index); + permutations(suffix, rest -> { + var ordering = new ArrayList(rest.size() + 1); + ordering.add(first); + ordering.addAll(rest); + check.accept(ordering); + }); + } + } +} From cc8c1e9dce88a4d65b68f7b6bcdd2000454b40ee Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 6 Sep 2026 01:34:21 -0500 Subject: [PATCH 51/65] Build(feat[mcp-swap]): Preserve client configs why: A native swapper must update JSON, JSONC, and TOML without turning developer convenience into destructive config reformatting. what: - Render the standard and OpenCode server entry shapes - Splice JSONC while retaining comments and trailing commas - Replace only the selected TOML table and keep unrelated bytes - Round-trip every client and quoted server name --- gradle/libs.versions.toml | 2 + tools/mcp-swap/build.gradle.kts | 5 +- .../libtmux/tools/mcpswap/ConfigCodec.java | 125 +++++++ .../libtmux/tools/mcpswap/JsoncEditor.java | 330 ++++++++++++++++++ .../libtmux/tools/mcpswap/ServerSpec.java | 9 + .../libtmux/tools/mcpswap/TomlEditor.java | 230 ++++++++++++ .../tools/mcpswap/ConfigCodecTest.java | 162 +++++++++ 7 files changed, 862 insertions(+), 1 deletion(-) create mode 100644 tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/ConfigCodec.java create mode 100644 tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/JsoncEditor.java create mode 100644 tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/ServerSpec.java create mode 100644 tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/TomlEditor.java create mode 100644 tools/mcp-swap/src/test/java/io/github/libtmux/tools/mcpswap/ConfigCodecTest.java diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a664cd8..2cd4fab 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -6,6 +6,7 @@ jackson = "2.21.5" mcp = "2.0.1" re2j = "1.8" slf4j = "2.0.17" +tomlj = "1.1.1" errorprone = "2.50.0" nullaway = "0.13.8" errorprone-plugin = "5.1.0" @@ -26,6 +27,7 @@ mcp-core = { module = "io.modelcontextprotocol.sdk:mcp-core", version.ref = "mcp mcp-json-jackson2 = { module = "io.modelcontextprotocol.sdk:mcp-json-jackson2", version.ref = "mcp" } re2j = { module = "com.google.re2j:re2j", version.ref = "re2j" } slf4j-nop = { module = "org.slf4j:slf4j-nop", version.ref = "slf4j" } +tomlj = { module = "org.tomlj:tomlj", version.ref = "tomlj" } errorprone-core = { module = "com.google.errorprone:error_prone_core", version.ref = "errorprone" } nullaway = { module = "com.uber.nullaway:nullaway", version.ref = "nullaway" } diff --git a/tools/mcp-swap/build.gradle.kts b/tools/mcp-swap/build.gradle.kts index 46b1512..284dac5 100644 --- a/tools/mcp-swap/build.gradle.kts +++ b/tools/mcp-swap/build.gradle.kts @@ -8,6 +8,9 @@ application { applicationName = "mcp-swap" } -dependencies { implementation(libs.jackson.databind) } +dependencies { + implementation(libs.jackson.databind) + implementation(libs.tomlj) +} tasks.withType().configureEach { enabled = false } diff --git a/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/ConfigCodec.java b/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/ConfigCodec.java new file mode 100644 index 0000000..a8f977a --- /dev/null +++ b/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/ConfigCodec.java @@ -0,0 +1,125 @@ +package io.github.libtmux.tools.mcpswap; + +import com.fasterxml.jackson.core.JsonFactory; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.json.JsonReadFeature; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Optional; + +final class ConfigCodec { + private static final ObjectMapper JSON = new ObjectMapper(); + private static final ObjectMapper JSONC = new ObjectMapper(JsonFactory.builder() + .enable(JsonReadFeature.ALLOW_JAVA_COMMENTS) + .enable(JsonReadFeature.ALLOW_TRAILING_COMMA) + .build()); + + private ConfigCodec() {} + + static byte[] update(Client client, byte[] original, String serverName, ServerSpec server) { + return switch (client.format()) { + case JSON -> updateJson(client, original, serverName, server, false); + case JSONC -> updateJson(client, original, serverName, server, true); + case TOML -> TomlEditor.update(original, client.serverTable(), serverName, server); + }; + } + + static Optional read(Client client, byte[] raw, String serverName) { + return switch (client.format()) { + case JSON -> readJson(client, raw, serverName, JSON); + case JSONC -> readJson(client, raw, serverName, JSONC); + case TOML -> TomlEditor.read(raw, client.serverTable(), serverName); + }; + } + + private static byte[] updateJson( + Client client, byte[] original, String serverName, ServerSpec server, boolean comments) { + try { + var mapper = comments ? JSONC : JSON; + var text = new String(original, StandardCharsets.UTF_8); + JsonNode parsed = text.isBlank() ? mapper.createObjectNode() : mapper.readTree(text); + if (!(parsed instanceof ObjectNode root)) { + throw new IllegalArgumentException(client.name() + " config root is not an object"); + } + var tableNode = root.get(client.serverTable()); + ObjectNode table; + if (tableNode == null) { + table = mapper.createObjectNode(); + root.set(client.serverTable(), table); + } else if (tableNode instanceof ObjectNode object) { + table = object; + } else { + throw new IllegalArgumentException(client.name() + " server table is not an object"); + } + table.set(serverName, entry(mapper, client, server)); + if (comments) { + return JsoncEditor.merge(text, root, mapper).getBytes(StandardCharsets.UTF_8); + } + return (JSON.writerWithDefaultPrettyPrinter().writeValueAsString(root) + "\n") + .getBytes(StandardCharsets.UTF_8); + } catch (JsonProcessingException error) { + throw new IllegalArgumentException(client.name() + " config is not valid JSON", error); + } + } + + private static ObjectNode entry(ObjectMapper mapper, Client client, ServerSpec server) { + var entry = mapper.createObjectNode(); + if (client.openCode()) { + entry.put("type", "local"); + var command = entry.putArray("command"); + command.add(server.command()); + server.arguments().forEach(command::add); + } else { + entry.put("command", server.command()); + var arguments = entry.putArray("args"); + server.arguments().forEach(arguments::add); + } + return entry; + } + + private static Optional readJson(Client client, byte[] raw, String serverName, ObjectMapper mapper) { + try { + var root = mapper.readTree(raw); + var entry = root.path(client.serverTable()).path(serverName); + if (entry.isMissingNode()) { + return Optional.empty(); + } + if (client.openCode()) { + var command = entry.path("command"); + if (!(command instanceof ArrayNode array) + || array.isEmpty() + || !array.get(0).isTextual()) { + throw new IllegalArgumentException(client.name() + " server command is not a string array"); + } + return Optional.of(new ServerSpec( + array.get(0).textValue(), + java.util.stream.IntStream.range(1, array.size()) + .mapToObj(index -> text(array.get(index), client)) + .toList())); + } + var command = text(entry.get("command"), client); + var arguments = entry.path("args"); + if (!arguments.isArray()) { + throw new IllegalArgumentException(client.name() + " server args is not an array"); + } + return Optional.of(new ServerSpec( + command, + java.util.stream.IntStream.range(0, arguments.size()) + .mapToObj(index -> text(arguments.get(index), client)) + .toList())); + } catch (IOException error) { + throw new IllegalArgumentException(client.name() + " config is not valid JSON", error); + } + } + + private static String text(JsonNode node, Client client) { + if (node == null || !node.isTextual()) { + throw new IllegalArgumentException(client.name() + " server value is not a string"); + } + return node.textValue(); + } +} diff --git a/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/JsoncEditor.java b/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/JsoncEditor.java new file mode 100644 index 0000000..805d2cc --- /dev/null +++ b/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/JsoncEditor.java @@ -0,0 +1,330 @@ +package io.github.libtmux.tools.mcpswap; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.jspecify.annotations.Nullable; + +final class JsoncEditor { + private static final String WHITESPACE = " \t\n\r"; + + private JsoncEditor() {} + + static String merge(String original, ObjectNode desired, ObjectMapper mapper) { + if (original.isBlank()) { + return render(desired, 0, mapper) + "\n"; + } + var text = original; + for (int attempt = 0; attempt < 10_000; attempt++) { + var edit = nextEdit(text, desired, List.of(), mapper); + if (edit == null) { + return text; + } + text = text.substring(0, edit.start()) + edit.replacement() + text.substring(edit.end()); + } + throw new IllegalStateException("JSONC merge did not converge"); + } + + private static @Nullable Edit nextEdit(String text, ObjectNode desired, List path, ObjectMapper mapper) { + var blanked = blankComments(text); + var span = objectSpan(blanked, path, mapper); + if (span == null) { + return null; + } + var members = new Scanner(blanked, mapper).readMembers(span.start()); + Map byKey = new LinkedHashMap<>(); + members.forEach(member -> byKey.put(member.key(), member)); + var depth = path.size() + 1; + var pad = " ".repeat(depth); + + for (var field : desired.properties()) { + var member = byKey.get(field.getKey()); + if (member == null) { + var body = render(field.getValue(), depth, mapper); + var name = jsonString(field.getKey(), mapper); + if (!members.isEmpty()) { + var tail = members.getLast().end(); + return new Edit(tail, tail, ",\n" + pad + name + ": " + body); + } + if (!blanked.substring(span.start() + 1, span.end() - 1).trim().isEmpty()) { + return null; + } + var interior = text.substring(span.start() + 1, span.end() - 1); + var trailingWhitespace = + interior.length() - interior.stripTrailing().length(); + var anchor = span.end() - 1 - trailingWhitespace; + var closing = " ".repeat(depth - 1); + return new Edit(anchor, span.end() - 1, "\n" + pad + name + ": " + body + "\n" + closing); + } + + var current = parseValue(blanked.substring(member.valueStart(), member.valueEnd()), mapper); + if (field.getValue() instanceof ObjectNode child && current instanceof ObjectNode) { + var nestedPath = new ArrayList<>(path); + nestedPath.add(field.getKey()); + var nested = nextEdit(text, child, List.copyOf(nestedPath), mapper); + if (nested != null) { + return nested; + } + } else if (!current.equals(field.getValue())) { + return new Edit(member.valueStart(), member.valueEnd(), render(field.getValue(), depth, mapper)); + } + } + + for (int index = 0; index < members.size(); index++) { + var member = members.get(index); + if (desired.has(member.key())) { + continue; + } + if (index > 0) { + return new Edit(members.get(index - 1).end(), member.end(), ""); + } + var trailing = blanked.substring(member.end(), span.end()); + var dropTo = member.end(); + var stripped = trailing.stripLeading(); + if (stripped.startsWith(",")) { + dropTo += trailing.indexOf(',') + 1; + } + return new Edit(span.start() + 1, dropTo, ""); + } + return null; + } + + private static @Nullable Span objectSpan(String text, List path, ObjectMapper mapper) { + var scanner = new Scanner(text, mapper); + scanner.skipWhitespace(); + if (scanner.position() >= text.length() || text.charAt(scanner.position()) != '{') { + return null; + } + var cursor = scanner.position(); + for (var key : path) { + Member match = null; + for (var member : new Scanner(text, mapper).readMembers(cursor)) { + if (member.key().equals(key)) { + match = member; + break; + } + } + if (match == null || text.charAt(match.valueStart()) != '{') { + return null; + } + cursor = match.valueStart(); + } + var tail = new Scanner(text, mapper); + tail.setPosition(cursor); + return tail.readValue(); + } + + private static String render(JsonNode value, int depth, ObjectMapper mapper) { + try { + return mapper.writerWithDefaultPrettyPrinter() + .writeValueAsString(value) + .replace("\n", "\n" + " ".repeat(depth)); + } catch (JsonProcessingException error) { + throw new IllegalArgumentException("cannot render JSONC", error); + } + } + + private static JsonNode parseValue(String text, ObjectMapper mapper) { + try { + return mapper.readTree(blankTrailingCommas(text)); + } catch (JsonProcessingException error) { + throw new IllegalArgumentException("cannot parse JSONC value", error); + } + } + + private static String jsonString(String value, ObjectMapper mapper) { + try { + return mapper.writeValueAsString(value); + } catch (JsonProcessingException error) { + throw new IllegalArgumentException("cannot render JSONC key", error); + } + } + + private static String blankComments(String text) { + var output = text.toCharArray(); + var position = 0; + var inString = false; + while (position < text.length()) { + var character = text.charAt(position); + if (inString) { + if (character == '\\') { + position = Math.min(text.length(), position + 2); + } else { + if (character == '"') { + inString = false; + } + position++; + } + } else if (character == '"') { + inString = true; + position++; + } else if (character == '/' && position + 1 < text.length() && text.charAt(position + 1) == '/') { + while (position < text.length() && text.charAt(position) != '\n') { + output[position++] = ' '; + } + } else if (character == '/' && position + 1 < text.length() && text.charAt(position + 1) == '*') { + var closing = text.indexOf("*/", position + 2); + var end = closing == -1 ? text.length() : closing + 2; + while (position < end) { + if (output[position] != '\n') { + output[position] = ' '; + } + position++; + } + } else { + position++; + } + } + return new String(output); + } + + private static String blankTrailingCommas(String text) { + var output = text.toCharArray(); + var position = 0; + var inString = false; + var lastComma = -1; + while (position < text.length()) { + var character = text.charAt(position); + if (inString) { + if (character == '\\') { + position = Math.min(text.length(), position + 2); + continue; + } + if (character == '"') { + inString = false; + } + position++; + continue; + } + if (character == '"') { + inString = true; + lastComma = -1; + } else if (character == ',') { + lastComma = position; + } else if (character == '}' || character == ']') { + if (lastComma != -1) { + output[lastComma] = ' '; + } + lastComma = -1; + } else if (WHITESPACE.indexOf(character) == -1) { + lastComma = -1; + } + position++; + } + return new String(output); + } + + private record Edit(int start, int end, String replacement) {} + + private record Span(int start, int end) {} + + private record Member(String key, int start, int end, int valueStart, int valueEnd) {} + + private static final class Scanner { + private final String text; + private final ObjectMapper mapper; + private int position; + + Scanner(String text, ObjectMapper mapper) { + this.text = text; + this.mapper = mapper; + } + + int position() { + return position; + } + + void setPosition(int position) { + this.position = position; + } + + void skipWhitespace() { + while (position < text.length() && WHITESPACE.indexOf(text.charAt(position)) != -1) { + position++; + } + } + + String readString() { + var start = position++; + while (position < text.length()) { + var character = text.charAt(position++); + if (character == '\\') { + position++; + } else if (character == '"') { + break; + } + } + return text.substring(start, position); + } + + Span readValue() { + skipWhitespace(); + var start = position; + var character = text.charAt(position); + if (character == '"') { + readString(); + } else if (character == '{' || character == '[') { + readContainer(); + } else { + while (position < text.length() + && ",}]".indexOf(text.charAt(position)) == -1 + && WHITESPACE.indexOf(text.charAt(position)) == -1) { + position++; + } + } + return new Span(start, position); + } + + List readMembers(int start) { + position = start + 1; + List found = new ArrayList<>(); + while (true) { + skipWhitespace(); + if (position >= text.length() || text.charAt(position) == '}') { + return List.copyOf(found); + } + if (text.charAt(position) == ',') { + position++; + continue; + } + var memberStart = position; + var rawKey = readString(); + skipWhitespace(); + position++; + var value = readValue(); + found.add(new Member(decodeKey(rawKey), memberStart, value.end(), value.start(), value.end())); + } + } + + private void readContainer() { + position++; + var depth = 1; + while (position < text.length() && depth > 0) { + var character = text.charAt(position); + if (character == '"') { + readString(); + continue; + } + if (character == '{' || character == '[') { + depth++; + } else if (character == '}' || character == ']') { + depth--; + } + position++; + } + } + + private String decodeKey(String raw) { + try { + return mapper.readValue(raw, String.class); + } catch (JsonProcessingException error) { + throw new IllegalArgumentException("invalid JSONC object key", error); + } + } + } +} diff --git a/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/ServerSpec.java b/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/ServerSpec.java new file mode 100644 index 0000000..1bd110b --- /dev/null +++ b/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/ServerSpec.java @@ -0,0 +1,9 @@ +package io.github.libtmux.tools.mcpswap; + +import java.util.List; + +record ServerSpec(String command, List arguments) { + ServerSpec { + arguments = List.copyOf(arguments); + } +} diff --git a/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/TomlEditor.java b/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/TomlEditor.java new file mode 100644 index 0000000..b3c1908 --- /dev/null +++ b/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/TomlEditor.java @@ -0,0 +1,230 @@ +package io.github.libtmux.tools.mcpswap; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import org.jspecify.annotations.Nullable; +import org.tomlj.Toml; +import org.tomlj.TomlParseResult; + +final class TomlEditor { + private static final ObjectMapper STRINGS = new ObjectMapper(); + + private TomlEditor() {} + + static byte[] update(byte[] original, String tableName, String serverName, ServerSpec server) { + var text = new String(original, StandardCharsets.UTF_8); + requireValid(text); + var newline = text.contains("\r\n") ? "\r\n" : "\n"; + var sections = sections(text); + Section selected = null; + for (var section : sections) { + if (section.path().equals(List.of(tableName, serverName))) { + selected = section; + break; + } + } + var body = "command = " + string(server.command()) + newline + "args = " + array(server.arguments()) + newline; + String updated; + if (selected == null) { + var separator = text.isEmpty() || text.endsWith(newline + newline) + ? "" + : text.endsWith(newline) ? newline : newline + newline; + updated = text + separator + "[" + tableName + "." + string(serverName) + "]" + newline + body; + } else { + var comments = commentsOnly(text.substring(selected.headerEnd(), selected.end())); + var replacement = text.substring(selected.start(), selected.headerEnd()) + body + comments; + if (!replacement.endsWith(newline)) { + replacement += newline; + } + updated = text.substring(0, selected.start()) + replacement + text.substring(selected.end()); + } + requireValid(updated); + return updated.getBytes(StandardCharsets.UTF_8); + } + + static Optional read(byte[] raw, String tableName, String serverName) { + var result = requireValid(new String(raw, StandardCharsets.UTF_8)); + var table = result.getTable(tableName); + if (table == null) { + return Optional.empty(); + } + var server = table.getTable(List.of(serverName)); + if (server == null) { + return Optional.empty(); + } + var command = server.getString("command"); + var arguments = server.getArray("args"); + if (command == null || arguments == null) { + throw new IllegalArgumentException("server command and args must be present"); + } + List values = new ArrayList<>(); + for (int index = 0; index < arguments.size(); index++) { + var value = arguments.getString(index); + if (value == null) { + throw new IllegalArgumentException("server args must contain only strings"); + } + values.add(value); + } + return Optional.of(new ServerSpec(command, values)); + } + + private static TomlParseResult requireValid(String text) { + var result = Toml.parse(text); + if (result.hasErrors()) { + throw new IllegalArgumentException( + "config is not valid TOML: " + result.errors().getFirst()); + } + return result; + } + + private static List

sections(String text) { + List
found = new ArrayList<>(); + var offset = 0; + while (offset < text.length()) { + var lineEnd = text.indexOf('\n', offset); + var afterLine = lineEnd == -1 ? text.length() : lineEnd + 1; + var path = tablePath(text.substring(offset, lineEnd == -1 ? text.length() : lineEnd)); + if (path != null) { + found.add(new Section(path, offset, afterLine, text.length())); + } + offset = afterLine; + } + for (int index = 0; index + 1 < found.size(); index++) { + var current = found.get(index); + found.set( + index, + new Section( + current.path(), + current.start(), + current.headerEnd(), + found.get(index + 1).start())); + } + return List.copyOf(found); + } + + private static @Nullable List tablePath(String line) { + var stripped = line.strip(); + if (!stripped.startsWith("[") || stripped.startsWith("[[")) { + return null; + } + var closing = closingBracket(stripped); + if (closing == -1 || !stripped.substring(closing + 1).strip().matches("(?:#.*)?")) { + return null; + } + return dottedKeys(stripped.substring(1, closing)); + } + + private static int closingBracket(String text) { + var quote = '\0'; + var escaped = false; + for (int index = 1; index < text.length(); index++) { + var character = text.charAt(index); + if (escaped) { + escaped = false; + } else if (quote == '"' && character == '\\') { + escaped = true; + } else if (quote != '\0' && character == quote) { + quote = '\0'; + } else if (quote == '\0' && (character == '"' || character == '\'')) { + quote = character; + } else if (quote == '\0' && character == ']') { + return index; + } + } + return -1; + } + + private static List dottedKeys(String raw) { + List keys = new ArrayList<>(); + var offset = 0; + while (offset < raw.length()) { + while (offset < raw.length() && Character.isWhitespace(raw.charAt(offset))) { + offset++; + } + if (offset >= raw.length()) { + return List.of(); + } + var start = offset; + var quote = raw.charAt(offset) == '"' || raw.charAt(offset) == '\'' ? raw.charAt(offset++) : '\0'; + var escaped = false; + while (offset < raw.length()) { + var character = raw.charAt(offset); + if (escaped) { + escaped = false; + } else if (quote == '"' && character == '\\') { + escaped = true; + } else if (quote != '\0' && character == quote) { + offset++; + break; + } else if (quote == '\0' && (character == '.' || Character.isWhitespace(character))) { + break; + } + offset++; + } + var token = raw.substring(start, offset).strip(); + keys.add(decodeKey(token)); + while (offset < raw.length() && Character.isWhitespace(raw.charAt(offset))) { + offset++; + } + if (offset == raw.length()) { + break; + } + if (raw.charAt(offset++) != '.') { + return List.of(); + } + } + return List.copyOf(keys); + } + + private static String decodeKey(String token) { + if (token.startsWith("\"") && token.endsWith("\"")) { + try { + return STRINGS.readValue(token, String.class); + } catch (JsonProcessingException error) { + throw new IllegalArgumentException("invalid quoted TOML key", error); + } + } + if (token.startsWith("'") && token.endsWith("'")) { + return token.substring(1, token.length() - 1); + } + return token; + } + + private static String commentsOnly(String body) { + var kept = new StringBuilder(); + var offset = 0; + while (offset < body.length()) { + var lineEnd = body.indexOf('\n', offset); + var afterLine = lineEnd == -1 ? body.length() : lineEnd + 1; + var line = body.substring(offset, afterLine); + var stripped = line.strip(); + if (stripped.isEmpty() || stripped.startsWith("#")) { + kept.append(line); + } + offset = afterLine; + } + return kept.toString(); + } + + private static String array(List values) { + return values.stream().map(TomlEditor::string).collect(java.util.stream.Collectors.joining(", ", "[", "]")); + } + + private static String string(String value) { + try { + return STRINGS.writeValueAsString(value); + } catch (JsonProcessingException error) { + throw new IllegalArgumentException("cannot quote TOML string", error); + } + } + + private record Section(List path, int start, int headerEnd, int end) { + Section { + path = List.copyOf(path); + } + } +} diff --git a/tools/mcp-swap/src/test/java/io/github/libtmux/tools/mcpswap/ConfigCodecTest.java b/tools/mcp-swap/src/test/java/io/github/libtmux/tools/mcpswap/ConfigCodecTest.java new file mode 100644 index 0000000..3cf56d0 --- /dev/null +++ b/tools/mcp-swap/src/test/java/io/github/libtmux/tools/mcpswap/ConfigCodecTest.java @@ -0,0 +1,162 @@ +package io.github.libtmux.tools.mcpswap; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.fasterxml.jackson.core.JsonFactory; +import com.fasterxml.jackson.core.json.JsonReadFeature; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.tomlj.Toml; + +final class ConfigCodecTest { + private static final ObjectMapper JSONC = new ObjectMapper(JsonFactory.builder() + .enable(JsonReadFeature.ALLOW_JAVA_COMMENTS) + .enable(JsonReadFeature.ALLOW_TRAILING_COMMA) + .build()); + private static final ServerSpec SERVER = + new ServerSpec("/opt/libtmux-java/bin/libtmux-mcp", List.of("--socket", "/tmp/test/s")); + + @Test + void roundTripsTheServerRouteForAllEightClients() { + var clients = ClientRegistry.knownClients(Path.of("/test/home"), Map.of()); + var seen = new ArrayList(); + + for (var client : clients) { + var original = + switch (client.format()) { + case JSON -> "{}\n"; + case JSONC -> "{\n // keep\n}\n"; + case TOML -> "title = \"keep\"\n"; + }; + var updated = + ConfigCodec.update(client, original.getBytes(StandardCharsets.UTF_8), "name.with.dot", SERVER); + assertEquals( + SERVER, ConfigCodec.read(client, updated, "name.with.dot").orElseThrow()); + seen.add(client.name()); + } + + assertEquals(List.of("claude", "codex", "cursor", "gemini", "grok", "agy", "opencode", "pi"), seen); + } + + @Test + void updatesJsonWithoutDroppingUnrelatedValues() throws Exception { + var client = client("claude", ConfigFormat.JSON, false); + var raw = "{\n \"unrelated\": {\"keep\": true}\n}\n".getBytes(StandardCharsets.UTF_8); + + var updated = ConfigCodec.update(client, raw, "tmux", SERVER); + + var root = new ObjectMapper().readTree(updated); + assertTrue(root.path("unrelated").path("keep").asBoolean()); + assertStandardEntry(root.path("mcpServers").path("tmux")); + assertEquals('\n', updated[updated.length - 1]); + } + + @Test + void addsJsoncTableWithoutChangingCommentsOrTrailingComma() throws Exception { + var client = client("opencode", ConfigFormat.JSONC, true); + var original = "{\n // root comment stays\n \"unrelated\": {\"keep\": true},\n}\n"; + + var updated = new String( + ConfigCodec.update(client, original.getBytes(StandardCharsets.UTF_8), "tmux", SERVER), + StandardCharsets.UTF_8); + + assertTrue(updated.contains("// root comment stays")); + assertTrue(updated.endsWith(",\n}\n")); + var root = JSONC.readTree(updated); + assertTrue(root.path("unrelated").path("keep").asBoolean()); + assertEquals("local", root.path("mcp").path("tmux").path("type").asText()); + assertEquals( + List.of("/opt/libtmux-java/bin/libtmux-mcp", "--socket", "/tmp/test/s"), + JSONC.convertValue(root.path("mcp").path("tmux").path("command"), List.class)); + } + + @Test + void replacesJsoncEntryWithoutDroppingItsComment() throws Exception { + var client = client("opencode", ConfigFormat.JSONC, true); + var original = """ + { + "mcp": { + "tmux": { + "type": "local", + // pinned locally; keep this rationale + "command": ["old", "server"], + }, + "other": {"type": "local", "command": ["echo", "keep"]}, + }, + } + """; + + var updated = new String( + ConfigCodec.update(client, original.getBytes(StandardCharsets.UTF_8), "tmux", SERVER), + StandardCharsets.UTF_8); + + assertTrue(updated.contains("// pinned locally; keep this rationale")); + var root = JSONC.readTree(updated); + assertEquals( + "echo", root.path("mcp").path("other").path("command").get(0).asText()); + assertEquals( + "/opt/libtmux-java/bin/libtmux-mcp", + root.path("mcp").path("tmux").path("command").get(0).asText()); + } + + @Test + void updatesTomlWithoutDroppingUnrelatedBytesOrComments() { + var client = client("codex", ConfigFormat.TOML, false); + var original = "title = \"keep\"\n\n# unrelated comment\n[other]\nvalue = 1\n"; + + var updated = new String( + ConfigCodec.update(client, original.getBytes(StandardCharsets.UTF_8), "tmux", SERVER), + StandardCharsets.UTF_8); + + assertTrue(updated.startsWith(original)); + assertTrue(updated.contains("[mcp_servers.\"tmux\"]")); + assertEquals("keep", Toml.parse(updated).getString("title")); + assertEquals("/opt/libtmux-java/bin/libtmux-mcp", Toml.parse(updated).getString("mcp_servers.tmux.command")); + } + + @Test + void replacesTomlEntryAndKeepsItsComment() { + var client = client("codex", ConfigFormat.TOML, false); + var original = """ + title = "keep" + + [mcp_servers.tmux] + # keep this rationale + command = "old" + args = ["server"] + + [mcp_servers.other] + command = "echo" + """; + + var updated = new String( + ConfigCodec.update(client, original.getBytes(StandardCharsets.UTF_8), "tmux", SERVER), + StandardCharsets.UTF_8); + + assertTrue(updated.contains("# keep this rationale")); + assertEquals("echo", Toml.parse(updated).getString("mcp_servers.other.command")); + assertEquals("/opt/libtmux-java/bin/libtmux-mcp", Toml.parse(updated).getString("mcp_servers.tmux.command")); + var arguments = Toml.parse(updated).getArray("mcp_servers.tmux.args"); + assertNotNull(arguments); + assertEquals("--socket", arguments.getString(0)); + } + + private static Client client(String name, ConfigFormat format, boolean openCode) { + var table = openCode ? "mcp" : format == ConfigFormat.TOML ? "mcp_servers" : "mcpServers"; + return new Client(name, Path.of("/test/config"), table, format, openCode); + } + + private static void assertStandardEntry(JsonNode entry) { + assertEquals("/opt/libtmux-java/bin/libtmux-mcp", entry.path("command").asText()); + assertEquals("--socket", entry.path("args").get(0).asText()); + assertEquals("/tmp/test/s", entry.path("args").get(1).asText()); + } +} From 06a17a5bd386e7b2fcadc253cc18c4f4f0596f35 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 6 Sep 2026 01:42:06 -0500 Subject: [PATCH 52/65] Build(feat[mcp-swap]): Add exact transactions why: Client config rewrites need one fail-closed transaction; otherwise a late path change or partial failure can mix server routes and destroy a person's config or recovery copy. what: - Hold a private authenticated interprocess lock - Bind logical routes to physical files across every client - Stage create-new config, backup, and recovery replacements - Roll back exact identities in reverse and retain uncertain recovery --- .../libtmux/tools/mcpswap/AtomicChange.java | 229 ++++++++++++++++ .../libtmux/tools/mcpswap/FileContent.java | 37 +++ .../libtmux/tools/mcpswap/FileSnapshot.java | 147 ++++++++++ .../libtmux/tools/mcpswap/PathRoute.java | 76 ++++++ .../libtmux/tools/mcpswap/RecoveryRecord.java | 235 ++++++++++++++++ .../libtmux/tools/mcpswap/SwapHook.java | 11 + .../libtmux/tools/mcpswap/SwapLock.java | 150 ++++++++++ .../libtmux/tools/mcpswap/SwapPaths.java | 36 +++ .../libtmux/tools/mcpswap/SwapService.java | 256 ++++++++++++++++++ .../tools/mcpswap/TransactionGuard.java | 106 ++++++++ .../tools/mcpswap/SwapServiceTest.java | 209 ++++++++++++++ 11 files changed, 1492 insertions(+) create mode 100644 tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/AtomicChange.java create mode 100644 tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/FileContent.java create mode 100644 tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/FileSnapshot.java create mode 100644 tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/PathRoute.java create mode 100644 tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/RecoveryRecord.java create mode 100644 tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/SwapHook.java create mode 100644 tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/SwapLock.java create mode 100644 tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/SwapPaths.java create mode 100644 tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/SwapService.java create mode 100644 tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/TransactionGuard.java create mode 100644 tools/mcp-swap/src/test/java/io/github/libtmux/tools/mcpswap/SwapServiceTest.java diff --git a/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/AtomicChange.java b/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/AtomicChange.java new file mode 100644 index 0000000..f06e13f --- /dev/null +++ b/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/AtomicChange.java @@ -0,0 +1,229 @@ +package io.github.libtmux.tools.mcpswap; + +import java.io.IOException; +import java.nio.channels.FileChannel; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.nio.file.StandardOpenOption; +import java.nio.file.attribute.BasicFileAttributes; +import java.nio.file.attribute.PosixFilePermissions; +import java.util.UUID; +import org.jspecify.annotations.Nullable; + +final class AtomicChange { + private final String role; + private final PathRoute route; + private final FileSnapshot before; + private final FileContent desired; + private @Nullable Path stage; + private @Nullable FileSnapshot staged; + private @Nullable Path aside; + private @Nullable FileSnapshot committed; + + AtomicChange(String role, PathRoute route, FileSnapshot before, FileContent desired) { + this.role = role; + this.route = route; + this.before = before; + this.desired = desired; + } + + void stage() throws IOException { + if (!desired.exists()) { + return; + } + Files.createDirectories(route.target().getParent()); + stage = Files.createTempFile(route.target().getParent(), ".mcp-swap-new-", ""); + try { + Files.write(stage, desired.bytes(), StandardOpenOption.TRUNCATE_EXISTING); + Files.setPosixFilePermissions(stage, PosixFilePermissions.fromString(desired.permissions())); + try (var channel = FileChannel.open(stage, StandardOpenOption.WRITE)) { + channel.force(true); + } + staged = FileSnapshot.capture(stage); + } catch (IOException error) { + Files.deleteIfExists(stage); + throw error; + } + } + + void commit(TransactionGuard guard, SwapHook hook) throws IOException { + try { + if (before.exists()) { + hook.before(role + "-take-aside", route.logical()); + guard.verifyExcept(route.target()); + route.verify(); + before.verify(); + aside = unique(route.target(), "old"); + move(route.target(), aside); + if (!FileSnapshot.capture(aside).sameFile(before) + || FileSnapshot.capture(route.target()).exists()) { + throw new IOException("take-aside changed identity: " + route.logical()); + } + } + hook.before(role + "-publish", route.logical()); + guard.verifyExcept(route.target()); + verifyTransition(); + if (desired.exists()) { + if (stage == null || staged == null) { + throw new IOException("change was not staged: " + route.logical()); + } + staged.verify(); + Files.createLink(route.target(), stage); + Files.delete(stage); + stage = null; + } + syncDirectory(route.physicalParent()); + committed = FileSnapshot.capture(route.target()); + verifyDesired(committed); + guard.update(route.logical()); + } catch (IOException | RuntimeException error) { + try { + rollbackPartial(guard); + } catch (IOException rollback) { + error.addSuppressed(rollback); + } + throw error; + } + } + + void rollback(TransactionGuard guard) throws IOException { + if (committed == null) { + return; + } + guard.verifyLock(); + verifyRouteTransition(); + var current = FileSnapshot.capture(route.target()); + if (!current.same(committed)) { + throw new IOException("cannot roll back a changed destination: " + route.logical()); + } + Path discard = null; + if (current.exists()) { + discard = unique(route.target(), "rollback"); + move(route.target(), discard); + } + if (before.exists()) { + if (aside == null || !FileSnapshot.capture(aside).sameFile(before)) { + throw new IOException("rollback source changed: " + route.logical()); + } + Files.createLink(route.target(), aside); + Files.delete(aside); + aside = null; + } + if (discard != null) { + Files.delete(discard); + } + syncDirectory(route.physicalParent()); + committed = null; + before.verify(); + guard.update(route.logical()); + } + + void cleanup() throws IOException { + cleanupStage(); + if (aside != null) { + if (!FileSnapshot.capture(aside).sameFile(before)) { + throw new IOException("take-aside file changed: " + aside); + } + Files.delete(aside); + aside = null; + } + } + + void cleanupStage() throws IOException { + if (stage != null) { + if (staged == null || !FileSnapshot.capture(stage).same(staged)) { + throw new IOException("staged file changed: " + stage); + } + Files.delete(stage); + stage = null; + } + } + + private void rollbackPartial(TransactionGuard guard) throws IOException { + if (committed != null) { + rollback(guard); + return; + } + if (aside == null) { + return; + } + guard.verifyLock(); + var current = FileSnapshot.capture(route.target()); + if (current.exists()) { + throw new IOException("late destination preserved; original retained at " + aside); + } + if (!FileSnapshot.capture(aside).sameFile(before)) { + throw new IOException("take-aside source changed: " + aside); + } + Files.createLink(route.target(), aside); + Files.delete(aside); + aside = null; + before.verify(); + guard.update(route.logical()); + } + + private void verifyTransition() throws IOException { + verifyRouteTransition(); + var current = FileSnapshot.capture(route.target()); + if (before.exists()) { + if (aside == null + || current.exists() + || !FileSnapshot.capture(aside).sameFile(before)) { + throw new IOException("replacement route changed: " + route.logical()); + } + } else if (!current.same(before)) { + throw new IOException("destination appeared: " + route.logical()); + } + } + + private void verifyRouteTransition() throws IOException { + if (route.symbolicLink()) { + var attributes = + Files.readAttributes(route.logical(), BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS); + if (!attributes.isSymbolicLink() + || !Files.readSymbolicLink(route.logical()).toString().equals(route.linkTarget()) + || !String.valueOf(attributes.fileKey()).equals(route.linkIdentity())) { + throw new IOException("path route changed: " + route.logical()); + } + return; + } + var current = PathRoute.inspect(route.logical()); + if (!current.target().equals(route.target()) + || !current.physicalParent().equals(route.physicalParent()) + || current.symbolicLink() != route.symbolicLink() + || !current.linkTarget().equals(route.linkTarget()) + || !current.linkIdentity().equals(route.linkIdentity())) { + throw new IOException("path route changed: " + route.logical()); + } + } + + private void verifyDesired(FileSnapshot current) throws IOException { + if (current.exists() != desired.exists() + || (desired.exists() + && (!current.digest().equals(desired.digest()) + || !current.permissions().equals(desired.permissions())))) { + throw new IOException("published file does not match staged bytes: " + route.logical()); + } + } + + private static Path unique(Path target, String role) { + return target.resolveSibling("." + target.getFileName() + ".mcp-swap-" + role + "-" + UUID.randomUUID()); + } + + private static void move(Path source, Path target) throws IOException { + try { + Files.move(source, target, StandardCopyOption.ATOMIC_MOVE); + } catch (AtomicMoveNotSupportedException unsupported) { + throw new IOException("filesystem does not support atomic config replacement", unsupported); + } + } + + private static void syncDirectory(Path directory) throws IOException { + try (var channel = FileChannel.open(directory, StandardOpenOption.READ)) { + channel.force(true); + } + } +} diff --git a/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/FileContent.java b/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/FileContent.java new file mode 100644 index 0000000..c2fe92a --- /dev/null +++ b/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/FileContent.java @@ -0,0 +1,37 @@ +package io.github.libtmux.tools.mcpswap; + +final class FileContent { + private final boolean exists; + private final byte[] data; + private final String permissions; + + private FileContent(boolean exists, byte[] data, String permissions) { + this.exists = exists; + this.data = data.clone(); + this.permissions = permissions; + } + + static FileContent absent() { + return new FileContent(false, new byte[0], ""); + } + + static FileContent of(byte[] data, String permissions) { + return new FileContent(true, data, permissions); + } + + byte[] bytes() { + return data.clone(); + } + + boolean exists() { + return exists; + } + + String permissions() { + return permissions; + } + + String digest() { + return exists ? FileSnapshot.sha256(data) : ""; + } +} diff --git a/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/FileSnapshot.java b/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/FileSnapshot.java new file mode 100644 index 0000000..3477f71 --- /dev/null +++ b/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/FileSnapshot.java @@ -0,0 +1,147 @@ +package io.github.libtmux.tools.mcpswap; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.nio.file.attribute.BasicFileAttributes; +import java.nio.file.attribute.PosixFileAttributes; +import java.nio.file.attribute.PosixFilePermissions; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Arrays; +import java.util.HexFormat; + +final class FileSnapshot { + private static final long MAX_CONFIG_BYTES = 16L * 1024 * 1024; + + private final Path path; + private final boolean exists; + private final String identity; + private final String permissions; + private final int links; + private final long size; + private final String modified; + private final String digest; + private final byte[] data; + + FileSnapshot( + Path path, + boolean exists, + String identity, + String permissions, + int links, + long size, + String modified, + String digest, + byte[] data) { + this.path = path; + this.exists = exists; + this.identity = identity; + this.permissions = permissions; + this.links = links; + this.size = size; + this.modified = modified; + this.digest = digest; + this.data = data.clone(); + } + + static FileSnapshot capture(Path path) throws IOException { + var normalized = path.toAbsolutePath().normalize(); + if (!Files.exists(normalized, LinkOption.NOFOLLOW_LINKS)) { + return new FileSnapshot(normalized, false, "", "", 0, 0, "", "", new byte[0]); + } + var basic = Files.readAttributes(normalized, BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS); + if (!basic.isRegularFile()) { + throw new IOException("path is not a regular file: " + normalized); + } + if (basic.size() > MAX_CONFIG_BYTES) { + throw new IOException("file exceeds 16 MiB: " + normalized); + } + var posix = Files.readAttributes(normalized, PosixFileAttributes.class, LinkOption.NOFOLLOW_LINKS); + var rawLinks = Files.getAttribute(normalized, "unix:nlink", LinkOption.NOFOLLOW_LINKS); + var links = rawLinks instanceof Number number ? number.intValue() : 0; + var data = Files.readAllBytes(normalized); + var after = Files.readAttributes(normalized, BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS); + if (!String.valueOf(basic.fileKey()).equals(String.valueOf(after.fileKey())) + || basic.size() != after.size() + || !basic.lastModifiedTime().equals(after.lastModifiedTime())) { + throw new IOException("file changed while it was read: " + normalized); + } + return new FileSnapshot( + normalized, + true, + String.valueOf(basic.fileKey()), + PosixFilePermissions.toString(posix.permissions()), + links, + basic.size(), + basic.lastModifiedTime().toInstant().toString(), + sha256(data), + data); + } + + byte[] bytes() { + return data.clone(); + } + + Path path() { + return path; + } + + boolean exists() { + return exists; + } + + String identity() { + return identity; + } + + String permissions() { + return permissions; + } + + int links() { + return links; + } + + long size() { + return size; + } + + String modified() { + return modified; + } + + String digest() { + return digest; + } + + boolean same(FileSnapshot other) { + return path.equals(other.path) && sameFile(other); + } + + boolean sameFile(FileSnapshot other) { + return exists == other.exists + && identity.equals(other.identity) + && permissions.equals(other.permissions) + && links == other.links + && size == other.size + && modified.equals(other.modified) + && digest.equals(other.digest) + && Arrays.equals(data, other.data); + } + + void verify() throws IOException { + if (!same(capture(path))) { + throw new IOException("file changed: " + path); + } + } + + static String sha256(byte[] data) { + try { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(data)); + } catch (NoSuchAlgorithmException impossible) { + throw new AssertionError(impossible); + } + } +} diff --git a/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/PathRoute.java b/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/PathRoute.java new file mode 100644 index 0000000..8d49d30 --- /dev/null +++ b/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/PathRoute.java @@ -0,0 +1,76 @@ +package io.github.libtmux.tools.mcpswap; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.nio.file.attribute.BasicFileAttributes; + +record PathRoute( + Path logical, + Path target, + Path physicalParent, + boolean exists, + boolean symbolicLink, + String linkTarget, + String linkIdentity) { + static PathRoute inspect(Path path) throws IOException { + var logical = path.toAbsolutePath().normalize(); + var exists = Files.exists(logical, LinkOption.NOFOLLOW_LINKS); + if (!exists) { + var target = prospectiveTarget(logical); + return new PathRoute(logical, target, parent(target), false, false, "", ""); + } + var attributes = Files.readAttributes(logical, BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS); + if (attributes.isSymbolicLink()) { + var link = Files.readSymbolicLink(logical).toString(); + var target = logical.toRealPath(); + requireRegular(target); + return new PathRoute( + logical, target, parent(target), true, true, link, String.valueOf(attributes.fileKey())); + } + if (!attributes.isRegularFile()) { + throw new IOException("path is not a regular file: " + logical); + } + var target = logical.toRealPath(); + return new PathRoute(logical, target, parent(target), true, false, "", ""); + } + + void verify() throws IOException { + if (!equals(inspect(logical))) { + throw new IOException("path route changed: " + logical); + } + } + + private static Path prospectiveTarget(Path logical) throws IOException { + var ancestor = logical.getParent(); + if (ancestor == null) { + throw new IOException("path has no parent: " + logical); + } + while (!Files.exists(ancestor, LinkOption.NOFOLLOW_LINKS)) { + ancestor = ancestor.getParent(); + if (ancestor == null) { + throw new IOException("path has no existing ancestor: " + logical); + } + } + if (!Files.isDirectory(ancestor)) { + throw new IOException("path ancestor is not a directory: " + ancestor); + } + var relative = ancestor.relativize(logical); + return ancestor.toRealPath().resolve(relative).normalize(); + } + + private static void requireRegular(Path target) throws IOException { + if (!Files.isRegularFile(target, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("symbolic link target is not a regular file: " + target); + } + } + + private static Path parent(Path target) throws IOException { + var parent = target.getParent(); + if (parent == null) { + throw new IOException("path has no parent: " + target); + } + return parent; + } +} diff --git a/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/RecoveryRecord.java b/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/RecoveryRecord.java new file mode 100644 index 0000000..a8fafcc --- /dev/null +++ b/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/RecoveryRecord.java @@ -0,0 +1,235 @@ +package io.github.libtmux.tools.mcpswap; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; + +record RecoveryRecord( + int version, + String client, + String server, + String logical, + String target, + String physicalParent, + boolean symbolicLink, + String linkTarget, + boolean originalExists, + String originalDigest, + String originalPermissions, + String currentDigest, + String currentPermissions, + String command, + List arguments) { + private static final int VERSION = 1; + private static final int MAX_BYTES = 16 * 1024; + private static final ObjectMapper JSON = new ObjectMapper(); + private static final Set FIELDS = Set.of( + "version", + "client", + "server", + "logical", + "target", + "physicalParent", + "symbolicLink", + "linkTarget", + "originalExists", + "originalDigest", + "originalPermissions", + "currentDigest", + "currentPermissions", + "command", + "arguments", + "checksum"); + + RecoveryRecord { + arguments = List.copyOf(arguments); + } + + static RecoveryRecord create( + Client client, + String server, + PathRoute route, + FileSnapshot original, + FileContent current, + ServerSpec spec) { + return new RecoveryRecord( + VERSION, + client.name(), + server, + route.logical().toString(), + route.target().toString(), + route.physicalParent().toString(), + route.symbolicLink(), + route.linkTarget(), + original.exists(), + original.digest(), + original.permissions(), + current.digest(), + current.permissions(), + spec.command(), + spec.arguments()); + } + + RecoveryRecord withCurrent(FileContent current, ServerSpec spec) { + return new RecoveryRecord( + version, + client, + server, + logical, + target, + physicalParent, + symbolicLink, + linkTarget, + originalExists, + originalDigest, + originalPermissions, + current.digest(), + current.permissions(), + spec.command(), + spec.arguments()); + } + + byte[] encode() { + try { + var body = body(); + var checksum = FileSnapshot.sha256(JSON.writeValueAsBytes(body)); + body.put("checksum", checksum); + var encoded = JSON.writeValueAsBytes(body); + if (encoded.length > MAX_BYTES) { + throw new IllegalArgumentException("recovery state exceeds 16 KiB"); + } + return encoded; + } catch (JsonProcessingException impossible) { + throw new IllegalArgumentException("cannot encode recovery state", impossible); + } + } + + static RecoveryRecord decode(byte[] encoded) throws IOException { + if (encoded.length > MAX_BYTES) { + throw new IOException("recovery state exceeds 16 KiB"); + } + final ObjectNode root; + try { + var parsed = JSON.readTree(encoded); + if (!(parsed instanceof ObjectNode object)) { + throw new IOException("recovery state is not an object"); + } + root = object; + } catch (JsonProcessingException error) { + throw new IOException("recovery state is not valid JSON", error); + } + var fields = root.properties().stream() + .map(java.util.Map.Entry::getKey) + .collect(java.util.stream.Collectors.toSet()); + if (!fields.equals(FIELDS)) { + throw new IOException("recovery state fields are not canonical"); + } + var checksum = requiredText(root, "checksum"); + root.remove("checksum"); + try { + if (!checksum.equals(FileSnapshot.sha256(JSON.writeValueAsBytes(root)))) { + throw new IOException("recovery state checksum changed"); + } + } catch (JsonProcessingException impossible) { + throw new IOException("cannot verify recovery state", impossible); + } + var version = root.path("version").asInt(-1); + if (version != VERSION) { + throw new IOException("unsupported recovery state version " + version); + } + var rawArguments = root.path("arguments"); + if (!rawArguments.isArray()) { + throw new IOException("recovery arguments are not an array"); + } + List arguments = new ArrayList<>(); + for (var value : rawArguments) { + if (!value.isTextual()) { + throw new IOException("recovery arguments must contain strings"); + } + arguments.add(value.textValue()); + } + return new RecoveryRecord( + version, + requiredText(root, "client"), + requiredText(root, "server"), + requiredText(root, "logical"), + requiredText(root, "target"), + requiredText(root, "physicalParent"), + root.path("symbolicLink").asBoolean(), + requiredText(root, "linkTarget"), + root.path("originalExists").asBoolean(), + requiredText(root, "originalDigest"), + requiredText(root, "originalPermissions"), + requiredText(root, "currentDigest"), + requiredText(root, "currentPermissions"), + requiredText(root, "command"), + arguments); + } + + void verify( + Client expectedClient, String expectedServer, PathRoute route, FileSnapshot current, FileSnapshot backup) + throws IOException { + if (!client.equals(expectedClient.name()) || !server.equals(expectedServer)) { + throw new IOException("recovery state belongs to another client or server"); + } + if (!logical.equals(route.logical().toString()) + || !target.equals(route.target().toString()) + || !physicalParent.equals(route.physicalParent().toString()) + || symbolicLink != route.symbolicLink() + || !linkTarget.equals(route.linkTarget())) { + throw new IOException("recovery config route changed for " + client); + } + if (!current.exists() + || !current.digest().equals(currentDigest) + || !current.permissions().equals(currentPermissions)) { + throw new IOException("swapped config changed for " + client); + } + if (originalExists) { + if (!backup.exists() + || backup.links() != 1 + || !backup.digest().equals(originalDigest) + || !backup.permissions().equals(originalPermissions)) { + throw new IOException("recovery backup changed for " + client); + } + } else if (backup.exists()) { + throw new IOException("unexpected recovery backup for " + client); + } + } + + FileContent original(FileSnapshot backup) { + return originalExists ? FileContent.of(backup.bytes(), originalPermissions) : FileContent.absent(); + } + + private ObjectNode body() { + var root = JSON.createObjectNode(); + root.put("version", version); + root.put("client", client); + root.put("server", server); + root.put("logical", logical); + root.put("target", target); + root.put("physicalParent", physicalParent); + root.put("symbolicLink", symbolicLink); + root.put("linkTarget", linkTarget); + root.put("originalExists", originalExists); + root.put("originalDigest", originalDigest); + root.put("originalPermissions", originalPermissions); + root.put("currentDigest", currentDigest); + root.put("currentPermissions", currentPermissions); + root.put("command", command); + var values = root.putArray("arguments"); + arguments.forEach(values::add); + return root; + } + + private static String requiredText(ObjectNode root, String name) throws IOException { + var value = root.get(name); + if (value == null || !value.isTextual()) { + throw new IOException("recovery field " + name + " is not a string"); + } + return value.textValue(); + } +} diff --git a/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/SwapHook.java b/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/SwapHook.java new file mode 100644 index 0000000..6bf812a --- /dev/null +++ b/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/SwapHook.java @@ -0,0 +1,11 @@ +package io.github.libtmux.tools.mcpswap; + +import java.io.IOException; +import java.nio.file.Path; + +@FunctionalInterface +interface SwapHook { + SwapHook NONE = (boundary, path) -> {}; + + void before(String boundary, Path path) throws IOException; +} diff --git a/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/SwapLock.java b/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/SwapLock.java new file mode 100644 index 0000000..383e83e --- /dev/null +++ b/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/SwapLock.java @@ -0,0 +1,150 @@ +package io.github.libtmux.tools.mcpswap; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.channels.FileChannel; +import java.nio.channels.FileLock; +import java.nio.channels.OverlappingFileLockException; +import java.nio.charset.StandardCharsets; +import java.nio.file.FileAlreadyExistsException; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.OpenOption; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.nio.file.attribute.PosixFilePermissions; +import java.util.Map; +import java.util.Set; +import java.util.UUID; + +final class SwapLock implements AutoCloseable { + private static final Set DIRECTORY_MODE = + PosixFilePermissions.fromString("rwx------"); + private static final Set FILE_MODE = + PosixFilePermissions.fromString("rw-------"); + + private final FileChannel channel; + private final FileLock lock; + private final FileSnapshot state; + + private SwapLock(FileChannel channel, FileLock lock, FileSnapshot state) { + this.channel = channel; + this.lock = lock; + this.state = state; + } + + static SwapLock acquire(Path home, Map environment) throws IOException { + var path = SwapPaths.lock(home, environment).toAbsolutePath().normalize(); + var parent = path.getParent(); + if (parent == null) { + throw new IOException("swap lock has no parent"); + } + secureDirectory(parent); + try { + Files.createFile(path, PosixFilePermissions.asFileAttribute(FILE_MODE)); + } catch (FileAlreadyExistsException ignored) { + // Validated below before the file is opened. + } + var before = FileSnapshot.capture(path); + requireSafe(before, path); + Set options = Set.of(StandardOpenOption.READ, StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS); + var channel = FileChannel.open(path, options); + FileLock lock = null; + try { + try { + lock = channel.tryLock(); + } catch (OverlappingFileLockException busy) { + throw new IOException("another mcp-swap transaction is running", busy); + } + if (lock == null) { + throw new IOException("another mcp-swap transaction is running"); + } + var token = UUID.randomUUID().toString().getBytes(StandardCharsets.US_ASCII); + var buffer = ByteBuffer.wrap(token); + channel.truncate(0); + channel.position(0); + while (buffer.hasRemaining()) { + channel.write(buffer); + // FileChannel may consume the buffer in more than one write. + } + channel.force(true); + var state = FileSnapshot.capture(path); + requireSafe(state, path); + if (!state.identity().equals(before.identity()) || !java.util.Arrays.equals(token, state.bytes())) { + throw new IOException("swap lock changed while it was acquired"); + } + return new SwapLock(channel, lock, state); + } catch (IOException | RuntimeException error) { + if (lock != null) { + lock.close(); + } + channel.close(); + throw error; + } + } + + Path path() { + return state.path(); + } + + void verify() throws IOException { + if (!lock.isValid()) { + throw new IOException("swap lock is no longer held"); + } + state.verify(); + } + + @Override + public void close() throws IOException { + try { + lock.close(); + } finally { + channel.close(); + } + } + + private static void secureDirectory(Path directory) throws IOException { + if (directory == null) { + throw new IOException("swap lock has no parent"); + } + var missing = new java.util.ArrayDeque(); + var cursor = directory; + while (!Files.exists(cursor, LinkOption.NOFOLLOW_LINKS)) { + missing.push(cursor); + cursor = cursor.getParent(); + if (cursor == null) { + throw new IOException("swap lock has no existing ancestor"); + } + } + while (!missing.isEmpty()) { + var next = missing.pop(); + try { + Files.createDirectory(next, PosixFilePermissions.asFileAttribute(DIRECTORY_MODE)); + } catch (FileAlreadyExistsException ignored) { + // A concurrent creator still has to pass the same validation. + } + } + var checked = directory; + for (int depth = 0; depth < 2; depth++) { + if (Files.isSymbolicLink(checked) || !Files.isDirectory(checked, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("unsafe swap lock directory: " + checked); + } + var permissions = Files.getPosixFilePermissions(checked, LinkOption.NOFOLLOW_LINKS); + if (!permissions.equals(DIRECTORY_MODE)) { + throw new IOException("swap lock directory must have mode 0700: " + checked); + } + checked = checked.getParent(); + if (checked == null) { + throw new IOException("swap lock directory has no parent"); + } + } + } + + private static void requireSafe(FileSnapshot snapshot, Path path) throws IOException { + if (!snapshot.exists() + || snapshot.links() != 1 + || !snapshot.permissions().equals("rw-------")) { + throw new IOException("swap lock must be a private unlinked regular file: " + path); + } + } +} diff --git a/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/SwapPaths.java b/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/SwapPaths.java new file mode 100644 index 0000000..da8ffb2 --- /dev/null +++ b/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/SwapPaths.java @@ -0,0 +1,36 @@ +package io.github.libtmux.tools.mcpswap; + +import java.nio.file.Path; + +final class SwapPaths { + private static final String BACKUP_SUFFIX = ".mcp-swap-backup"; + + private SwapPaths() {} + + static Path backup(Client client) { + return sibling(client.configPath(), client.configPath().getFileName() + BACKUP_SUFFIX); + } + + static Path state(Client client) { + var backup = backup(client); + return sibling(backup, backup.getFileName() + ".state"); + } + + static Path lock(Path home, java.util.Map environment) { + var configured = environment.get("XDG_STATE_HOME"); + var stateHome = configured != null + && !configured.isBlank() + && Path.of(configured).isAbsolute() + ? Path.of(configured) + : home.resolve(".local/state"); + return stateHome.resolve("libtmux-mcp-dev/swap/state.lock"); + } + + private static Path sibling(Path path, String name) { + var parent = path.getParent(); + if (parent == null) { + throw new IllegalArgumentException("config path has no parent: " + path); + } + return parent.resolve(name); + } +} diff --git a/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/SwapService.java b/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/SwapService.java new file mode 100644 index 0000000..18aba31 --- /dev/null +++ b/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/SwapService.java @@ -0,0 +1,256 @@ +package io.github.libtmux.tools.mcpswap; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import org.jspecify.annotations.Nullable; + +final class SwapService { + private static final String PRIVATE_MODE = "rw-------"; + + private final Path home; + private final Map environment; + private final SwapHook hook; + + SwapService(Path home, Map environment) { + this(home, environment, SwapHook.NONE); + } + + SwapService(Path home, Map environment, SwapHook hook) { + this.home = home.toAbsolutePath().normalize(); + this.environment = Map.copyOf(environment); + this.hook = hook; + } + + void use(List selected, List allClients, String serverName, ServerSpec server, boolean dryRun) + throws IOException { + validateSelection(selected, allClients); + if (dryRun) { + TransactionGuard.preflight(allClients, SwapPaths.lock(home, environment)); + planUse(selected, serverName, server); + return; + } + try (var lock = SwapLock.acquire(home, environment)) { + var guard = TransactionGuard.capture(allClients, lock); + var plans = planUse(selected, serverName, server); + List changes = new ArrayList<>(); + for (var plan : plans) { + if (!plan.active() && plan.config().exists()) { + changes.add(new AtomicChange( + "backup", + plan.backupRoute(), + plan.backup(), + FileContent.of(plan.config().bytes(), plan.config().permissions()))); + } + } + for (var plan : plans) { + changes.add(new AtomicChange( + "state", + plan.stateRoute(), + plan.state(), + FileContent.of(plan.record().encode(), PRIVATE_MODE))); + } + for (var plan : plans) { + changes.add(new AtomicChange("config", plan.configRoute(), plan.config(), plan.desired())); + } + execute(changes, guard); + } + } + + void revert(List selected, List allClients, String serverName, boolean dryRun) throws IOException { + validateSelection(selected, allClients); + if (dryRun) { + TransactionGuard.preflight(allClients, SwapPaths.lock(home, environment)); + planRevert(selected, serverName); + return; + } + try (var lock = SwapLock.acquire(home, environment)) { + var guard = TransactionGuard.capture(allClients, lock); + var plans = planRevert(selected, serverName); + List changes = new ArrayList<>(); + for (var plan : plans) { + changes.add(new AtomicChange( + "config", + plan.configRoute(), + plan.config(), + plan.record().original(plan.backup()))); + } + for (var plan : plans) { + changes.add(new AtomicChange("state", plan.stateRoute(), plan.state(), FileContent.absent())); + } + for (var plan : plans) { + if (plan.backup().exists()) { + changes.add(new AtomicChange("backup", plan.backupRoute(), plan.backup(), FileContent.absent())); + } + } + execute(changes, guard); + } + } + + private List planUse(List selected, String serverName, ServerSpec server) throws IOException { + List plans = new ArrayList<>(); + for (var client : selected) { + var configRoute = PathRoute.inspect(client.configPath()); + var backupRoute = PathRoute.inspect(SwapPaths.backup(client)); + var stateRoute = PathRoute.inspect(SwapPaths.state(client)); + var config = FileSnapshot.capture(configRoute.target()); + var backup = FileSnapshot.capture(backupRoute.target()); + var state = FileSnapshot.capture(stateRoute.target()); + var active = recovery(client, serverName, configRoute, config, backup, state); + var raw = config.exists() ? config.bytes() : new byte[0]; + final byte[] rendered; + try { + rendered = ConfigCodec.update(client, raw, serverName, server); + } catch (IllegalArgumentException error) { + throw new IOException(client.name() + " config cannot be updated", error); + } + var desired = FileContent.of(rendered, config.exists() ? config.permissions() : PRIVATE_MODE); + var record = active == null + ? RecoveryRecord.create(client, serverName, configRoute, config, desired, server) + : active.withCurrent(desired, server); + plans.add(new UsePlan( + client, + active != null, + configRoute, + config, + backupRoute, + backup, + stateRoute, + state, + desired, + record)); + } + return List.copyOf(plans); + } + + private List planRevert(List selected, String serverName) throws IOException { + List plans = new ArrayList<>(); + for (var client : selected) { + var configRoute = PathRoute.inspect(client.configPath()); + var backupRoute = PathRoute.inspect(SwapPaths.backup(client)); + var stateRoute = PathRoute.inspect(SwapPaths.state(client)); + var config = FileSnapshot.capture(configRoute.target()); + var backup = FileSnapshot.capture(backupRoute.target()); + var state = FileSnapshot.capture(stateRoute.target()); + if (!backup.exists() && !state.exists()) { + continue; + } + var record = recovery(client, serverName, configRoute, config, backup, state); + if (record == null) { + throw new IOException("recovery pair is incomplete for " + client.name()); + } + plans.add(new RevertPlan(client, configRoute, config, backupRoute, backup, stateRoute, state, record)); + } + return List.copyOf(plans); + } + + private static @Nullable RecoveryRecord recovery( + Client client, + String serverName, + PathRoute configRoute, + FileSnapshot config, + FileSnapshot backup, + FileSnapshot state) + throws IOException { + if (!state.exists()) { + if (backup.exists()) { + throw new IOException("recovery backup has no state for " + client.name()); + } + return null; + } + if (state.links() != 1 || !state.permissions().equals(PRIVATE_MODE)) { + throw new IOException("recovery state is not a private file for " + client.name()); + } + var record = RecoveryRecord.decode(state.bytes()); + record.verify(client, serverName, configRoute, config, backup); + return record; + } + + private void execute(List changes, TransactionGuard guard) throws IOException { + List committed = new ArrayList<>(); + try { + for (var change : changes) { + change.stage(); + } + for (var change : changes) { + change.commit(guard, hook); + committed.add(change); + } + } catch (IOException | RuntimeException failure) { + var blocked = failure.getSuppressed().length != 0; + for (var change : committed.reversed()) { + if (blocked) { + break; + } + try { + change.rollback(guard); + } catch (IOException rollback) { + failure.addSuppressed(rollback); + blocked = true; + } + } + for (var change : changes.reversed()) { + try { + if (blocked) { + change.cleanupStage(); + } else { + change.cleanup(); + } + } catch (IOException cleanup) { + failure.addSuppressed(cleanup); + } + } + throw failure; + } + IOException cleanupFailure = null; + for (var change : changes.reversed()) { + try { + change.cleanup(); + } catch (IOException error) { + if (cleanupFailure == null) { + cleanupFailure = error; + } else { + cleanupFailure.addSuppressed(error); + } + } + } + if (cleanupFailure != null) { + throw cleanupFailure; + } + } + + private static void validateSelection(List selected, List allClients) { + var known = new HashSet<>(allClients); + var distinct = new HashSet(); + for (var client : selected) { + if (!known.contains(client) || !distinct.add(client)) { + throw new IllegalArgumentException("client selection is not a distinct known subset"); + } + } + } + + private record UsePlan( + Client client, + boolean active, + PathRoute configRoute, + FileSnapshot config, + PathRoute backupRoute, + FileSnapshot backup, + PathRoute stateRoute, + FileSnapshot state, + FileContent desired, + RecoveryRecord record) {} + + private record RevertPlan( + Client client, + PathRoute configRoute, + FileSnapshot config, + PathRoute backupRoute, + FileSnapshot backup, + PathRoute stateRoute, + FileSnapshot state, + RecoveryRecord record) {} +} diff --git a/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/TransactionGuard.java b/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/TransactionGuard.java new file mode 100644 index 0000000..afa4035 --- /dev/null +++ b/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/TransactionGuard.java @@ -0,0 +1,106 @@ +package io.github.libtmux.tools.mcpswap; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +final class TransactionGuard { + private final SwapLock lock; + private final List paths; + + private TransactionGuard(SwapLock lock, List paths) { + this.lock = lock; + this.paths = paths; + } + + static TransactionGuard capture(List clients, SwapLock lock) throws IOException { + var paths = inspect(clients); + rejectAliases(paths, lock.path()); + return new TransactionGuard(lock, paths); + } + + static void preflight(List clients, Path lockPath) throws IOException { + rejectAliases(inspect(clients), lockPath); + } + + private static List inspect(List clients) throws IOException { + List paths = new ArrayList<>(); + for (var client : clients) { + paths.add(capture(client.name() + " config", client.configPath(), true)); + paths.add(capture(client.name() + " backup", SwapPaths.backup(client), false)); + paths.add(capture(client.name() + " state", SwapPaths.state(client), false)); + } + return paths; + } + + void verifyExcept(Path changingTarget) throws IOException { + lock.verify(); + for (var path : paths) { + if (path.route().target().equals(changingTarget)) { + continue; + } + path.route().verify(); + path.snapshot().verify(); + } + } + + void verifyLock() throws IOException { + lock.verify(); + } + + void update(Path logical) throws IOException { + for (int index = 0; index < paths.size(); index++) { + var path = paths.get(index); + if (!path.route().logical().equals(logical.toAbsolutePath().normalize())) { + continue; + } + paths.set(index, capture(path.label(), logical, path.config())); + return; + } + throw new IOException("transaction path is not protected: " + logical); + } + + private static ProtectedPath capture(String label, Path logical, boolean config) throws IOException { + var route = PathRoute.inspect(logical); + if (!config && route.symbolicLink()) { + throw new IOException(label + " must not be a symbolic link"); + } + var snapshot = FileSnapshot.capture(route.target()); + if (!config && snapshot.exists() && snapshot.links() != 1) { + throw new IOException(label + " must not be hard linked"); + } + return new ProtectedPath(label, config, route, snapshot); + } + + private static void rejectAliases(List paths, Path lockPath) throws IOException { + Map targets = new HashMap<>(); + Map identities = new HashMap<>(); + for (var path : paths) { + var previous = targets.putIfAbsent(path.route().target(), path.label()); + if (previous != null) { + throw new IOException(path.label() + " aliases " + previous); + } + if (path.snapshot().exists()) { + previous = identities.putIfAbsent(path.snapshot().identity(), path.label()); + if (previous != null) { + throw new IOException(path.label() + " is hard linked to " + previous); + } + } + } + var lockRoute = PathRoute.inspect(lockPath); + var previous = targets.putIfAbsent(lockRoute.target(), "swap lock"); + if (previous != null) { + throw new IOException(previous + " aliases the swap lock"); + } + var lockSnapshot = FileSnapshot.capture(lockRoute.target()); + previous = identities.putIfAbsent(lockSnapshot.identity(), "swap lock"); + if (previous != null) { + throw new IOException(previous + " is hard linked to the swap lock"); + } + } + + private record ProtectedPath(String label, boolean config, PathRoute route, FileSnapshot snapshot) {} +} diff --git a/tools/mcp-swap/src/test/java/io/github/libtmux/tools/mcpswap/SwapServiceTest.java b/tools/mcp-swap/src/test/java/io/github/libtmux/tools/mcpswap/SwapServiceTest.java new file mode 100644 index 0000000..53dc0cd --- /dev/null +++ b/tools/mcp-swap/src/test/java/io/github/libtmux/tools/mcpswap/SwapServiceTest.java @@ -0,0 +1,209 @@ +package io.github.libtmux.tools.mcpswap; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.PosixFilePermissions; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +final class SwapServiceTest { + private static final ServerSpec FIRST = new ServerSpec("/opt/first/libtmux-mcp", List.of("--socket", "/tmp/a")); + private static final ServerSpec SECOND = + new ServerSpec("/opt/second/libtmux-mcp", List.of("--socket-name", "demo")); + + @TempDir + Path temporary; + + private Path home; + private Map environment; + private List clients; + + @BeforeEach + void setUp() throws IOException { + home = temporary.resolve("home"); + Files.createDirectory(home); + environment = Map.of( + "XDG_CONFIG_HOME", home.resolve(".config").toString(), + "XDG_STATE_HOME", home.resolve(".state").toString()); + clients = ClientRegistry.knownClients(home, environment); + } + + @Test + void swapsAndRestoresAllEightClientsAsOneTransaction() throws IOException { + var originals = seedAll(); + var service = new SwapService(home, environment); + + service.use(clients, clients, "tmux", FIRST, false); + for (var client : clients) { + assertEquals( + FIRST, + ConfigCodec.read(client, Files.readAllBytes(client.configPath()), "tmux") + .orElseThrow()); + assertTrue(Files.isRegularFile(SwapPaths.backup(client))); + assertTrue(Files.isRegularFile(SwapPaths.state(client))); + } + + service.use(clients.reversed(), clients, "tmux", SECOND, false); + for (var client : clients) { + assertEquals( + SECOND, + ConfigCodec.read(client, Files.readAllBytes(client.configPath()), "tmux") + .orElseThrow()); + assertArrayEquals(originals.get(client.name()), Files.readAllBytes(SwapPaths.backup(client))); + } + + service.revert(clients.reversed(), clients, "tmux", false); + for (var client : clients) { + assertArrayEquals(originals.get(client.name()), Files.readAllBytes(client.configPath())); + assertFalse(Files.exists(SwapPaths.backup(client))); + assertFalse(Files.exists(SwapPaths.state(client))); + } + } + + @Test + void rollsBackEveryClientWhenALaterCommitFails() throws IOException { + var originals = seedAll(); + var failed = new boolean[] {false}; + var service = new SwapService(home, environment, (boundary, path) -> { + if (!failed[0] + && boundary.equals("config-publish") + && path.equals(clients.get(4).configPath())) { + failed[0] = true; + throw new IOException("synthetic commit failure"); + } + }); + + assertThrows(IOException.class, () -> service.use(clients, clients, "tmux", FIRST, false)); + + assertTrue(failed[0]); + for (var client : clients) { + assertArrayEquals(originals.get(client.name()), Files.readAllBytes(client.configPath())); + assertFalse(Files.exists(SwapPaths.backup(client))); + assertFalse(Files.exists(SwapPaths.state(client))); + } + } + + @Test + void rejectsASelectedConfigAliasedToAnUnselectedClient() throws IOException { + var originals = seedAll(); + var selected = clients.get(2); + var unselected = clients.get(3); + Files.delete(selected.configPath()); + Files.createSymbolicLink(selected.configPath(), unselected.configPath()); + var before = Files.readAllBytes(unselected.configPath()); + + var service = new SwapService(home, environment); + assertThrows(IOException.class, () -> service.use(List.of(selected), clients, "tmux", FIRST, false)); + + assertTrue(Files.isSymbolicLink(selected.configPath())); + assertArrayEquals(before, Files.readAllBytes(unselected.configPath())); + for (var client : clients) { + assertFalse(Files.exists(SwapPaths.backup(client))); + assertFalse(Files.exists(SwapPaths.state(client))); + } + assertArrayEquals(originals.get(unselected.name()), before); + } + + @Test + void preservesALateFileAtAnAbsentConfigDestination() throws IOException { + var selected = clients.get(2); + Files.createDirectories(selected.configPath().getParent()); + var raced = new boolean[] {false}; + var service = new SwapService(home, environment, (boundary, path) -> { + if (!raced[0] && boundary.equals("config-publish") && path.equals(selected.configPath())) { + raced[0] = true; + Files.writeString(path, "human\n", StandardCharsets.UTF_8); + } + }); + + assertThrows(IOException.class, () -> service.use(List.of(selected), clients, "tmux", FIRST, false)); + + assertTrue(raced[0]); + assertEquals("human\n", Files.readString(selected.configPath(), StandardCharsets.UTF_8)); + assertFalse(Files.exists(SwapPaths.backup(selected))); + assertFalse(Files.exists(SwapPaths.state(selected))); + } + + @Test + void refusesRevertAfterAHumanEditAndKeepsRecovery() throws IOException { + seedAll(); + var selected = clients.getFirst(); + var service = new SwapService(home, environment); + service.use(List.of(selected), clients, "tmux", FIRST, false); + var backup = Files.readAllBytes(SwapPaths.backup(selected)); + Files.writeString(selected.configPath(), "{\"human\":true}\n", StandardCharsets.UTF_8); + + assertThrows(IOException.class, () -> service.revert(List.of(selected), clients, "tmux", false)); + + assertEquals("{\"human\":true}\n", Files.readString(selected.configPath(), StandardCharsets.UTF_8)); + assertArrayEquals(backup, Files.readAllBytes(SwapPaths.backup(selected))); + assertTrue(Files.isRegularFile(SwapPaths.state(selected))); + } + + @Test + void keepsAConfigSymlinkAcrossUseAndRevert() throws IOException { + var selected = clients.getFirst(); + Files.createDirectories(selected.configPath().getParent()); + var target = home.resolve("actual-claude.json"); + var original = "{\"keep\":true}\n".getBytes(StandardCharsets.UTF_8); + Files.write(target, original); + Files.createSymbolicLink(selected.configPath(), target); + var service = new SwapService(home, environment); + + service.use(List.of(selected), clients, "tmux", FIRST, false); + assertTrue(Files.isSymbolicLink(selected.configPath())); + assertEquals( + FIRST, + ConfigCodec.read(selected, Files.readAllBytes(target), "tmux").orElseThrow()); + + service.revert(List.of(selected), clients, "tmux", false); + assertTrue(Files.isSymbolicLink(selected.configPath())); + assertArrayEquals(original, Files.readAllBytes(target)); + } + + @Test + void dryRunCreatesNoFilesOrDirectories() throws IOException { + var service = new SwapService(home, environment); + var before = tree(); + + service.use(clients, clients, "tmux", FIRST, true); + + assertEquals(before, tree()); + } + + private Map seedAll() throws IOException { + Map originals = new LinkedHashMap<>(); + for (var client : clients) { + Files.createDirectories(client.configPath().getParent()); + var raw = + switch (client.format()) { + case JSON -> "{\n \"unrelated\": true\n}\n"; + case JSONC -> "{\n // keep\n \"unrelated\": true,\n}\n"; + case TOML -> "title = \"keep\"\n"; + }; + var bytes = raw.getBytes(StandardCharsets.UTF_8); + Files.write(client.configPath(), bytes); + Files.setPosixFilePermissions(client.configPath(), PosixFilePermissions.fromString("rw-r-----")); + originals.put(client.name(), bytes); + } + return originals; + } + + private List tree() throws IOException { + try (var paths = Files.walk(home)) { + return paths.map(home::relativize).map(Path::toString).sorted().toList(); + } + } +} From 43cf514ea11019d11cc2c3655f93aec7815f582d Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 6 Sep 2026 01:46:45 -0500 Subject: [PATCH 53/65] Build(fix[mcp-swap]): Bind recovery identity why: A byte-identical backup replacement or late route change could be accepted across invocations or erase the only exact recovery source. what: - Link first backups to the authenticated original inode - Bind backup identity into checksummed recovery state - Recheck unselected clients and lock ownership at every boundary - Retain recovery artifacts when exact rollback becomes uncertain --- .../libtmux/tools/mcpswap/AtomicChange.java | 47 +++++-- .../libtmux/tools/mcpswap/FileContent.java | 19 ++- .../libtmux/tools/mcpswap/FileSnapshot.java | 10 ++ .../libtmux/tools/mcpswap/RecoveryRecord.java | 7 ++ .../libtmux/tools/mcpswap/SwapService.java | 8 +- .../tools/mcpswap/TransactionGuard.java | 33 ++++- .../tools/mcpswap/SwapServiceTest.java | 116 ++++++++++++++++++ 7 files changed, 219 insertions(+), 21 deletions(-) diff --git a/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/AtomicChange.java b/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/AtomicChange.java index f06e13f..1e78138 100644 --- a/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/AtomicChange.java +++ b/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/AtomicChange.java @@ -16,7 +16,7 @@ final class AtomicChange { private final String role; private final PathRoute route; - private final FileSnapshot before; + private FileSnapshot before; private final FileContent desired; private @Nullable Path stage; private @Nullable FileSnapshot staged; @@ -35,6 +35,11 @@ void stage() throws IOException { return; } Files.createDirectories(route.target().getParent()); + var source = desired.linkSource(); + if (source.isPresent()) { + source.orElseThrow().verify(); + return; + } stage = Files.createTempFile(route.target().getParent(), ".mcp-swap-new-", ""); try { Files.write(stage, desired.bytes(), StandardOpenOption.TRUNCATE_EXISTING); @@ -55,7 +60,16 @@ void commit(TransactionGuard guard, SwapHook hook) throws IOException { hook.before(role + "-take-aside", route.logical()); guard.verifyExcept(route.target()); route.verify(); - before.verify(); + var protectedBefore = guard.snapshot(route.logical()); + protectedBefore.verify(); + if (!before.same(protectedBefore)) { + if (!role.equals("config") + || !before.sameExceptLinks(protectedBefore) + || protectedBefore.links() != before.links() + 1) { + throw new IOException("file changed: " + route.logical()); + } + before = protectedBefore; + } aside = unique(route.target(), "old"); move(route.target(), aside); if (!FileSnapshot.capture(aside).sameFile(before) @@ -67,18 +81,27 @@ void commit(TransactionGuard guard, SwapHook hook) throws IOException { guard.verifyExcept(route.target()); verifyTransition(); if (desired.exists()) { - if (stage == null || staged == null) { - throw new IOException("change was not staged: " + route.logical()); + var source = desired.linkSource(); + if (source.isPresent()) { + source.orElseThrow().verify(); + Files.createLink(route.target(), source.orElseThrow().path()); + } else { + if (stage == null || staged == null) { + throw new IOException("change was not staged: " + route.logical()); + } + staged.verify(); + Files.createLink(route.target(), stage); + Files.delete(stage); + stage = null; } - staged.verify(); - Files.createLink(route.target(), stage); - Files.delete(stage); - stage = null; } syncDirectory(route.physicalParent()); committed = FileSnapshot.capture(route.target()); verifyDesired(committed); guard.update(route.logical()); + if (desired.linkSource().isPresent()) { + guard.updateTarget(desired.linkSource().orElseThrow().path()); + } } catch (IOException | RuntimeException error) { try { rollbackPartial(guard); @@ -119,6 +142,9 @@ void rollback(TransactionGuard guard) throws IOException { committed = null; before.verify(); guard.update(route.logical()); + if (desired.linkSource().isPresent()) { + guard.updateTarget(desired.linkSource().orElseThrow().path()); + } } void cleanup() throws IOException { @@ -151,6 +177,7 @@ private void rollbackPartial(TransactionGuard guard) throws IOException { return; } guard.verifyLock(); + verifyRouteTransition(); var current = FileSnapshot.capture(route.target()); if (current.exists()) { throw new IOException("late destination preserved; original retained at " + aside); @@ -207,6 +234,10 @@ private void verifyDesired(FileSnapshot current) throws IOException { || !current.permissions().equals(desired.permissions())))) { throw new IOException("published file does not match staged bytes: " + route.logical()); } + if (desired.linkSource().isPresent() + && !current.identity().equals(desired.linkSource().orElseThrow().identity())) { + throw new IOException("published hard link changed identity: " + route.logical()); + } } private static Path unique(Path target, String role) { diff --git a/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/FileContent.java b/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/FileContent.java index c2fe92a..7fc5c6d 100644 --- a/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/FileContent.java +++ b/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/FileContent.java @@ -4,19 +4,28 @@ final class FileContent { private final boolean exists; private final byte[] data; private final String permissions; + private final java.util.Optional linkSource; - private FileContent(boolean exists, byte[] data, String permissions) { + private FileContent(boolean exists, byte[] data, String permissions, java.util.Optional linkSource) { this.exists = exists; this.data = data.clone(); this.permissions = permissions; + this.linkSource = linkSource; } static FileContent absent() { - return new FileContent(false, new byte[0], ""); + return new FileContent(false, new byte[0], "", java.util.Optional.empty()); } static FileContent of(byte[] data, String permissions) { - return new FileContent(true, data, permissions); + return new FileContent(true, data, permissions, java.util.Optional.empty()); + } + + static FileContent linked(FileSnapshot source) { + if (!source.exists()) { + throw new IllegalArgumentException("link source is absent"); + } + return new FileContent(true, source.bytes(), source.permissions(), java.util.Optional.of(source)); } byte[] bytes() { @@ -31,6 +40,10 @@ String permissions() { return permissions; } + java.util.Optional linkSource() { + return linkSource; + } + String digest() { return exists ? FileSnapshot.sha256(data) : ""; } diff --git a/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/FileSnapshot.java b/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/FileSnapshot.java index 3477f71..f7d99b8 100644 --- a/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/FileSnapshot.java +++ b/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/FileSnapshot.java @@ -131,6 +131,16 @@ boolean sameFile(FileSnapshot other) { && Arrays.equals(data, other.data); } + boolean sameExceptLinks(FileSnapshot other) { + return exists == other.exists + && identity.equals(other.identity) + && permissions.equals(other.permissions) + && size == other.size + && modified.equals(other.modified) + && digest.equals(other.digest) + && Arrays.equals(data, other.data); + } + void verify() throws IOException { if (!same(capture(path))) { throw new IOException("file changed: " + path); diff --git a/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/RecoveryRecord.java b/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/RecoveryRecord.java index a8fafcc..b79c7ac 100644 --- a/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/RecoveryRecord.java +++ b/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/RecoveryRecord.java @@ -18,6 +18,7 @@ record RecoveryRecord( boolean symbolicLink, String linkTarget, boolean originalExists, + String backupIdentity, String originalDigest, String originalPermissions, String currentDigest, @@ -37,6 +38,7 @@ record RecoveryRecord( "symbolicLink", "linkTarget", "originalExists", + "backupIdentity", "originalDigest", "originalPermissions", "currentDigest", @@ -66,6 +68,7 @@ static RecoveryRecord create( route.symbolicLink(), route.linkTarget(), original.exists(), + original.identity(), original.digest(), original.permissions(), current.digest(), @@ -85,6 +88,7 @@ RecoveryRecord withCurrent(FileContent current, ServerSpec spec) { symbolicLink, linkTarget, originalExists, + backupIdentity, originalDigest, originalPermissions, current.digest(), @@ -162,6 +166,7 @@ static RecoveryRecord decode(byte[] encoded) throws IOException { root.path("symbolicLink").asBoolean(), requiredText(root, "linkTarget"), root.path("originalExists").asBoolean(), + requiredText(root, "backupIdentity"), requiredText(root, "originalDigest"), requiredText(root, "originalPermissions"), requiredText(root, "currentDigest"), @@ -191,6 +196,7 @@ void verify( if (originalExists) { if (!backup.exists() || backup.links() != 1 + || !backup.identity().equals(backupIdentity) || !backup.digest().equals(originalDigest) || !backup.permissions().equals(originalPermissions)) { throw new IOException("recovery backup changed for " + client); @@ -215,6 +221,7 @@ private ObjectNode body() { root.put("symbolicLink", symbolicLink); root.put("linkTarget", linkTarget); root.put("originalExists", originalExists); + root.put("backupIdentity", backupIdentity); root.put("originalDigest", originalDigest); root.put("originalPermissions", originalPermissions); root.put("currentDigest", currentDigest); diff --git a/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/SwapService.java b/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/SwapService.java index 18aba31..b1dba46 100644 --- a/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/SwapService.java +++ b/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/SwapService.java @@ -40,10 +40,7 @@ void use(List selected, List allClients, String serverName, Serv for (var plan : plans) { if (!plan.active() && plan.config().exists()) { changes.add(new AtomicChange( - "backup", - plan.backupRoute(), - plan.backup(), - FileContent.of(plan.config().bytes(), plan.config().permissions()))); + "backup", plan.backupRoute(), plan.backup(), FileContent.linked(plan.config()))); } } for (var plan : plans) { @@ -97,6 +94,9 @@ private List planUse(List selected, String serverName, ServerSp var backupRoute = PathRoute.inspect(SwapPaths.backup(client)); var stateRoute = PathRoute.inspect(SwapPaths.state(client)); var config = FileSnapshot.capture(configRoute.target()); + if (config.exists() && config.links() != 1) { + throw new IOException("config must not be hard linked for " + client.name()); + } var backup = FileSnapshot.capture(backupRoute.target()); var state = FileSnapshot.capture(stateRoute.target()); var active = recovery(client, serverName, configRoute, config, backup, state); diff --git a/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/TransactionGuard.java b/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/TransactionGuard.java index afa4035..747d9b0 100644 --- a/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/TransactionGuard.java +++ b/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/TransactionGuard.java @@ -29,9 +29,9 @@ static void preflight(List clients, Path lockPath) throws IOException { private static List inspect(List clients) throws IOException { List paths = new ArrayList<>(); for (var client : clients) { - paths.add(capture(client.name() + " config", client.configPath(), true)); - paths.add(capture(client.name() + " backup", SwapPaths.backup(client), false)); - paths.add(capture(client.name() + " state", SwapPaths.state(client), false)); + paths.add(capture(client.name() + " config", client.configPath(), true, true)); + paths.add(capture(client.name() + " backup", SwapPaths.backup(client), false, true)); + paths.add(capture(client.name() + " state", SwapPaths.state(client), false, true)); } return paths; } @@ -57,19 +57,40 @@ void update(Path logical) throws IOException { if (!path.route().logical().equals(logical.toAbsolutePath().normalize())) { continue; } - paths.set(index, capture(path.label(), logical, path.config())); + paths.set(index, capture(path.label(), logical, path.config(), false)); return; } throw new IOException("transaction path is not protected: " + logical); } - private static ProtectedPath capture(String label, Path logical, boolean config) throws IOException { + void updateTarget(Path target) throws IOException { + var normalized = target.toAbsolutePath().normalize(); + for (int index = 0; index < paths.size(); index++) { + var path = paths.get(index); + if (path.route().target().equals(normalized)) { + paths.set(index, capture(path.label(), path.route().logical(), path.config(), false)); + } + } + } + + FileSnapshot snapshot(Path logical) throws IOException { + var normalized = logical.toAbsolutePath().normalize(); + for (var path : paths) { + if (path.route().logical().equals(normalized)) { + return path.snapshot(); + } + } + throw new IOException("transaction path is not protected: " + logical); + } + + private static ProtectedPath capture(String label, Path logical, boolean config, boolean requireSingleLink) + throws IOException { var route = PathRoute.inspect(logical); if (!config && route.symbolicLink()) { throw new IOException(label + " must not be a symbolic link"); } var snapshot = FileSnapshot.capture(route.target()); - if (!config && snapshot.exists() && snapshot.links() != 1) { + if (!config && requireSingleLink && snapshot.exists() && snapshot.links() != 1) { throw new IOException(label + " must not be hard linked"); } return new ProtectedPath(label, config, route, snapshot); diff --git a/tools/mcp-swap/src/test/java/io/github/libtmux/tools/mcpswap/SwapServiceTest.java b/tools/mcp-swap/src/test/java/io/github/libtmux/tools/mcpswap/SwapServiceTest.java index 53dc0cd..5d28931 100644 --- a/tools/mcp-swap/src/test/java/io/github/libtmux/tools/mcpswap/SwapServiceTest.java +++ b/tools/mcp-swap/src/test/java/io/github/libtmux/tools/mcpswap/SwapServiceTest.java @@ -10,10 +10,12 @@ import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; +import java.nio.file.StandardCopyOption; import java.nio.file.attribute.PosixFilePermissions; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Objects; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; @@ -116,6 +118,48 @@ void rejectsASelectedConfigAliasedToAnUnselectedClient() throws IOException { assertArrayEquals(originals.get(unselected.name()), before); } + @Test + void rejectsASelectedConfigHardLinkedToAnUnselectedClient() throws IOException { + seedAll(); + var selected = clients.get(2); + var unselected = clients.get(3); + Files.delete(selected.configPath()); + Files.createLink(selected.configPath(), unselected.configPath()); + var before = Files.readAllBytes(unselected.configPath()); + + var service = new SwapService(home, environment); + assertThrows(IOException.class, () -> service.use(List.of(selected), clients, "tmux", FIRST, false)); + + assertArrayEquals(before, Files.readAllBytes(selected.configPath())); + assertArrayEquals(before, Files.readAllBytes(unselected.configPath())); + assertFalse(Files.exists(SwapPaths.backup(selected))); + assertFalse(Files.exists(SwapPaths.state(selected))); + } + + @Test + void rechecksUnselectedClientsBeforeEveryPublication() throws IOException { + var originals = seedAll(); + var selected = clients.get(2); + var unselected = clients.get(3); + var raced = new boolean[] {false}; + var service = new SwapService(home, environment, (boundary, path) -> { + if (!raced[0] && boundary.equals("state-publish")) { + raced[0] = true; + var human = unselected.configPath().resolveSibling("human.json"); + Files.writeString(human, "{\"human\":true}\n", StandardCharsets.UTF_8); + Files.move(human, unselected.configPath(), StandardCopyOption.REPLACE_EXISTING); + } + }); + + assertThrows(IOException.class, () -> service.use(List.of(selected), clients, "tmux", FIRST, false)); + + assertTrue(raced[0]); + assertArrayEquals(originals.get(selected.name()), Files.readAllBytes(selected.configPath())); + assertEquals("{\"human\":true}\n", Files.readString(unselected.configPath(), StandardCharsets.UTF_8)); + assertFalse(Files.exists(SwapPaths.backup(selected))); + assertFalse(Files.exists(SwapPaths.state(selected))); + } + @Test void preservesALateFileAtAnAbsentConfigDestination() throws IOException { var selected = clients.get(2); @@ -152,6 +196,28 @@ void refusesRevertAfterAHumanEditAndKeepsRecovery() throws IOException { assertTrue(Files.isRegularFile(SwapPaths.state(selected))); } + @Test + void refusesAByteIdenticalReplacementOfTheRecoveryBackup() throws IOException { + seedAll(); + var selected = clients.getFirst(); + var service = new SwapService(home, environment); + service.use(List.of(selected), clients, "tmux", FIRST, false); + var backup = SwapPaths.backup(selected); + var replacement = backup.resolveSibling("replacement"); + Files.copy(backup, replacement); + Files.setPosixFilePermissions(replacement, Files.getPosixFilePermissions(backup)); + Files.move(replacement, backup, StandardCopyOption.REPLACE_EXISTING); + + assertThrows(IOException.class, () -> service.revert(List.of(selected), clients, "tmux", false)); + + assertEquals( + FIRST, + ConfigCodec.read(selected, Files.readAllBytes(selected.configPath()), "tmux") + .orElseThrow()); + assertTrue(Files.isRegularFile(backup)); + assertTrue(Files.isRegularFile(SwapPaths.state(selected))); + } + @Test void keepsAConfigSymlinkAcrossUseAndRevert() throws IOException { var selected = clients.getFirst(); @@ -173,6 +239,56 @@ void keepsAConfigSymlinkAcrossUseAndRevert() throws IOException { assertArrayEquals(original, Files.readAllBytes(target)); } + @Test + void retainsRecoveryWhenAConfigSymlinkIsRetargetedDuringPublish() throws IOException { + var selected = clients.getFirst(); + Files.createDirectories(selected.configPath().getParent()); + var originalTarget = home.resolve("original.json"); + var humanTarget = home.resolve("human.json"); + Files.writeString(originalTarget, "{\"original\":true}\n", StandardCharsets.UTF_8); + Files.writeString(humanTarget, "{\"human\":true}\n", StandardCharsets.UTF_8); + Files.createSymbolicLink(selected.configPath(), originalTarget); + var retargeted = new boolean[] {false}; + var service = new SwapService(home, environment, (boundary, path) -> { + if (!retargeted[0] && boundary.equals("config-publish")) { + retargeted[0] = true; + Files.delete(path); + Files.createSymbolicLink(path, humanTarget); + } + }); + + assertThrows(IOException.class, () -> service.use(List.of(selected), clients, "tmux", FIRST, false)); + + assertTrue(retargeted[0]); + assertEquals(humanTarget, selected.configPath().toRealPath()); + assertEquals("{\"human\":true}\n", Files.readString(humanTarget, StandardCharsets.UTF_8)); + assertTrue(Files.isRegularFile(SwapPaths.backup(selected))); + assertTrue(Files.isRegularFile(SwapPaths.state(selected))); + try (var paths = Files.list(home)) { + assertTrue(paths.anyMatch(path -> path.getFileName().toString().contains(".mcp-swap-old-"))); + } + } + + @Test + void refusesAnUnsafeLockAndASecondOwner() throws IOException { + var lockPath = SwapPaths.lock(home, environment); + var lockDirectory = Objects.requireNonNull(lockPath.getParent()); + var stateDirectory = Objects.requireNonNull(lockDirectory.getParent()); + Files.createDirectories(lockDirectory); + Files.setPosixFilePermissions(stateDirectory, PosixFilePermissions.fromString("rwx------")); + Files.setPosixFilePermissions(lockDirectory, PosixFilePermissions.fromString("rwx------")); + var target = home.resolve("lock-target"); + Files.writeString(target, "not a lock"); + Files.createSymbolicLink(lockPath, target); + assertThrows(IOException.class, () -> SwapLock.acquire(home, environment)); + + Files.delete(lockPath); + try (var first = SwapLock.acquire(home, environment)) { + assertThrows(IOException.class, () -> SwapLock.acquire(home, environment)); + first.verify(); + } + } + @Test void dryRunCreatesNoFilesOrDirectories() throws IOException { var service = new SwapService(home, environment); From 9d0ed8d1a7124e69c8142628e53b37e3b64b8d9a Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 6 Sep 2026 01:51:52 -0500 Subject: [PATCH 54/65] Build(feat[mcp-swap]): Add native command line why: The internal swap transaction had no Java entry point, leaving developers dependent on Python to select launchers and operate configs. what: - Add native detect, status, use, revert, and doctor commands - Support dist, Gradle, and explicit executable launch modes - Preflight dry runs and build the server before transactional writes - Exercise all eight client formats through use and byte-exact revert --- .../libtmux/tools/mcpswap/ConfigCodec.java | 20 +- .../github/libtmux/tools/mcpswap/McpSwap.java | 475 ++++++++++++++++++ .../libtmux/tools/mcpswap/McpSwapTest.java | 212 ++++++++ 3 files changed, 704 insertions(+), 3 deletions(-) create mode 100644 tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/McpSwap.java create mode 100644 tools/mcp-swap/src/test/java/io/github/libtmux/tools/mcpswap/McpSwapTest.java diff --git a/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/ConfigCodec.java b/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/ConfigCodec.java index a8f977a..b7e1cc9 100644 --- a/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/ConfigCodec.java +++ b/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/ConfigCodec.java @@ -83,11 +83,25 @@ private static ObjectNode entry(ObjectMapper mapper, Client client, ServerSpec s private static Optional readJson(Client client, byte[] raw, String serverName, ObjectMapper mapper) { try { - var root = mapper.readTree(raw); - var entry = root.path(client.serverTable()).path(serverName); - if (entry.isMissingNode()) { + var text = new String(raw, StandardCharsets.UTF_8); + JsonNode parsed = text.isBlank() ? mapper.createObjectNode() : mapper.readTree(text); + if (!(parsed instanceof ObjectNode root)) { + throw new IllegalArgumentException(client.name() + " config root is not an object"); + } + var table = root.get(client.serverTable()); + if (table == null) { return Optional.empty(); } + if (!(table instanceof ObjectNode servers)) { + throw new IllegalArgumentException(client.name() + " server table is not an object"); + } + var entry = servers.get(serverName); + if (entry == null) { + return Optional.empty(); + } + if (!(entry instanceof ObjectNode)) { + throw new IllegalArgumentException(client.name() + " server entry is not an object"); + } if (client.openCode()) { var command = entry.path("command"); if (!(command instanceof ArrayNode array) diff --git a/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/McpSwap.java b/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/McpSwap.java new file mode 100644 index 0000000..e4e4ff6 --- /dev/null +++ b/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/McpSwap.java @@ -0,0 +1,475 @@ +package io.github.libtmux.tools.mcpswap; + +import java.io.IOException; +import java.io.PrintStream; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import org.jspecify.annotations.Nullable; + +/** Internal command-line entry point for swapping agent MCP configurations. */ +public final class McpSwap { + private static final int CLIENT_COLUMN = 9; + private static final String PI_ADAPTER_HINT = "needs the pi-mcp-adapter; Pi has no built-in MCP client"; + + private McpSwap() {} + + /** Run the command-line utility. */ + public static void main(String[] arguments) { + System.exit(run(arguments, Context.system())); + } + + static int run(String[] arguments, Context context) { + try { + var options = Arguments.parse(arguments); + if (options.help()) { + printHelp(context.out(), options.command()); + return 0; + } + var clients = ClientRegistry.knownClients(context.home(), context.environment()); + return switch (Objects.requireNonNull(options.command())) { + case DETECT -> detect(clients, context); + case STATUS -> status(options, clients, context); + case USE -> use(options, clients, context); + case REVERT -> revert(options, clients, context); + case DOCTOR -> doctor(options, clients, context); + }; + } catch (UsageException error) { + context.err().println("mcp-swap: " + error.getMessage()); + context.err().println("Try 'mcp-swap --help'."); + return 2; + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + context.err().println("mcp-swap: interrupted"); + return 130; + } catch (IOException | IllegalArgumentException error) { + context.err().println("mcp-swap: " + error.getMessage()); + return 1; + } + } + + private static int detect(List clients, Context context) { + for (var client : clients) { + var present = exists(client.configPath()); + var backup = exists(SwapPaths.backup(client)); + var state = exists(SwapPaths.state(client)); + var recovery = backup && state ? " (swapped)" : backup || state ? " (incomplete recovery)" : ""; + var caveat = client.name().equals("pi") && !Files.isDirectory(piAdapter(context.home())) + ? " -- " + PI_ADAPTER_HINT + : ""; + context.out() + .printf( + Locale.ROOT, + "%-" + CLIENT_COLUMN + "s %-8s %s%s%s%n", + client.name(), + present ? "present" : "missing", + client.configPath(), + recovery, + caveat); + } + return 0; + } + + private static int status(Arguments options, List clients, Context context) { + var failed = false; + for (var client : selected(options, clients)) { + if (!exists(client.configPath())) { + continue; + } + try { + var spec = ConfigCodec.read(client, readConfig(client), options.name()); + if (spec.isEmpty()) { + context.out() + .printf( + Locale.ROOT, + "%-" + CLIENT_COLUMN + "s no '%s' server%n", + client.name(), + options.name()); + } else { + context.out() + .printf( + Locale.ROOT, + "%-" + CLIENT_COLUMN + "s %s%n", + client.name(), + describe(spec.orElseThrow())); + } + } catch (IOException | IllegalArgumentException error) { + context.err().println(client.name() + " unreadable: " + error.getMessage()); + failed = true; + } + } + return failed ? 1 : 0; + } + + private static int use(Arguments options, List clients, Context context) + throws IOException, InterruptedException { + var requested = selected(options, clients); + var targets = new ArrayList(); + for (var client : requested) { + if (exists(client.configPath())) { + targets.add(client); + } else { + context.out().printf(Locale.ROOT, "%-" + CLIENT_COLUMN + "s skipped, no config%n", client.name()); + } + } + if (targets.isEmpty()) { + return 0; + } + var spec = launcher(options, context.repository()); + var service = new SwapService(context.home(), context.environment()); + service.use(targets, clients, options.name(), spec, true); + context.err().println("pointing '" + options.name() + "' at: " + describe(spec)); + if (options.dryRun()) { + for (var client : targets) { + context.out() + .printf( + Locale.ROOT, + "%-" + CLIENT_COLUMN + "s would set %s = %s%n", + client.name(), + options.name(), + describe(spec)); + } + return 0; + } + if (options.source() == Source.DIST) { + buildDistribution(context); + } + service.use(targets, clients, options.name(), spec, false); + for (var client : targets) { + context.out().printf(Locale.ROOT, "%-" + CLIENT_COLUMN + "s set %s%n", client.name(), options.name()); + } + return 0; + } + + private static int revert(Arguments options, List clients, Context context) throws IOException { + var targets = selected(options, clients).stream() + .filter(client -> exists(SwapPaths.backup(client)) || exists(SwapPaths.state(client))) + .toList(); + if (targets.isEmpty()) { + for (var client : selected(options, clients)) { + context.out().printf(Locale.ROOT, "%-" + CLIENT_COLUMN + "s nothing to revert%n", client.name()); + } + return 0; + } + var service = new SwapService(context.home(), context.environment()); + service.revert(targets, clients, options.name(), options.dryRun()); + for (var client : targets) { + context.out() + .printf( + Locale.ROOT, + "%-" + CLIENT_COLUMN + "s %s%n", + client.name(), + options.dryRun() ? "would restore" : "restored"); + } + return 0; + } + + private static int doctor(Arguments options, List clients, Context context) { + var ready = true; + var gradle = context.repository().resolve("gradlew"); + if (!Files.isRegularFile(gradle) || !Files.isExecutable(gradle)) { + context.out().println("no executable gradlew: is this the repository root?"); + ready = false; + } + try { + launcher(options, context.repository()); + } catch (UsageException error) { + context.out().println(error.getMessage()); + ready = false; + } + if (options.source() == Source.DIST && !Files.isExecutable(distributionLauncher(context.repository()))) { + context.out().println("no built MCP launcher; run './gradlew :libtmux-mcp:installDist'"); + ready = false; + } + for (var client : selected(options, clients)) { + if (!exists(client.configPath())) { + continue; + } + try { + ConfigCodec.read(client, readConfig(client), options.name()); + } catch (IOException | IllegalArgumentException error) { + context.out().println(client.name() + " will not parse: " + error.getMessage()); + ready = false; + } + } + if (exists(clients.getLast().configPath()) && !Files.isDirectory(piAdapter(context.home()))) { + context.out().println("pi " + PI_ADAPTER_HINT); + ready = false; + } + context.out().println(ready ? "ready" : "not ready"); + return ready ? 0 : 1; + } + + private static List selected(Arguments options, List clients) { + return ClientRegistry.select(clients, options.clients()); + } + + private static ServerSpec launcher(Arguments options, Path repository) { + List flags = new ArrayList<>(); + addFlag(flags, "--socket", options.socket()); + addFlag(flags, "--socket-name", options.socketName()); + addFlag(flags, "--tmux", options.tmux()); + return switch (options.source()) { + case DIST -> new ServerSpec(distributionLauncher(repository).toString(), flags); + case GRADLE -> { + var gradle = repository.resolve("gradlew").toAbsolutePath().normalize(); + if (!Files.isRegularFile(gradle) || !Files.isExecutable(gradle)) { + throw new UsageException("--source gradle needs an executable gradlew"); + } + yield new ServerSpec( + gradle.toString(), + List.of("--quiet", "--console=plain", ":libtmux-mcp:run", "--args", gradleArguments(flags))); + } + case PATH -> { + if (options.binary() == null) { + throw new UsageException("--source path needs --bin"); + } + var candidate = Path.of(options.binary()); + var binary = (candidate.isAbsolute() ? candidate : repository.resolve(candidate)) + .toAbsolutePath() + .normalize(); + if (!Files.isRegularFile(binary) || !Files.isExecutable(binary)) { + throw new UsageException("--bin must name an executable file: " + binary); + } + yield new ServerSpec(binary.toString(), flags); + } + }; + } + + private static void buildDistribution(Context context) throws IOException, InterruptedException { + var command = List.of( + context.repository().resolve("gradlew").toString(), + "--quiet", + "--console=plain", + "--max-workers=5", + ":libtmux-mcp:installDist"); + context.err().println("building :libtmux-mcp:installDist ..."); + var status = context.process().run(command, context.repository()); + if (status != 0) { + throw new IOException("Gradle installDist failed with status " + status); + } + var launcher = distributionLauncher(context.repository()); + if (!Files.isRegularFile(launcher) || !Files.isExecutable(launcher)) { + throw new IOException("installDist did not write an executable launcher: " + launcher); + } + } + + private static Path distributionLauncher(Path repository) { + return repository + .resolve("libtmux-mcp/build/install/libtmux-mcp/bin/libtmux-mcp") + .toAbsolutePath() + .normalize(); + } + + private static Path piAdapter(Path home) { + return home.resolve(".pi/agent/npm/node_modules/pi-mcp-adapter"); + } + + private static byte[] readConfig(Client client) throws IOException { + var route = PathRoute.inspect(client.configPath()); + return FileSnapshot.capture(route.target()).bytes(); + } + + private static boolean exists(Path path) { + return Files.exists(path, LinkOption.NOFOLLOW_LINKS); + } + + private static void addFlag(List flags, String name, @Nullable String value) { + if (value != null) { + flags.add(name); + flags.add(value); + } + } + + private static String gradleArguments(List arguments) { + return arguments.stream().map(McpSwap::quoteArgument).collect(java.util.stream.Collectors.joining(" ")); + } + + private static String quoteArgument(String argument) { + if (argument.matches("[A-Za-z0-9_./:@%+=,-]+")) { + return argument; + } + return '"' + argument.replace("\\", "\\\\").replace("\"", "\\\"") + '"'; + } + + private static String describe(ServerSpec spec) { + return String.join( + " ", + java.util.stream.Stream.concat(java.util.stream.Stream.of(spec.command()), spec.arguments().stream()) + .toList()); + } + + private static void printHelp(PrintStream out, @Nullable Command command) { + if (command == Command.USE) { + out.println("usage: mcp-swap use [--source dist|gradle|path] [--bin FILE]"); + out.println(" [--socket PATH | --socket-name NAME] [--tmux FILE]"); + out.println(" [--name NAME] [--cli CLIENT] [--dry-run]"); + return; + } + out.println("usage: mcp-swap [options]"); + out.println("Point installed agent CLI configs at this repository's MCP build."); + } + + @FunctionalInterface + interface ProcessRunner { + int run(List command, Path directory) throws IOException, InterruptedException; + } + + record Context( + Path home, + Map environment, + Path repository, + PrintStream out, + PrintStream err, + ProcessRunner process) { + Context { + home = home.toAbsolutePath().normalize(); + environment = Map.copyOf(environment); + repository = repository.toAbsolutePath().normalize(); + } + + static Context system() { + return new Context( + Path.of(System.getProperty("user.home")), + System.getenv(), + locateRepository(), + System.out, + System.err, + (command, directory) -> new ProcessBuilder(command) + .directory(directory.toFile()) + .inheritIO() + .start() + .waitFor()); + } + } + + private enum Command { + DETECT, + STATUS, + USE, + REVERT, + DOCTOR + } + + private enum Source { + DIST, + GRADLE, + PATH + } + + private record Arguments( + @Nullable Command command, + boolean help, + String name, + List clients, + boolean dryRun, + Source source, + @Nullable String binary, + @Nullable String socket, + @Nullable String socketName, + @Nullable String tmux) { + Arguments { + clients = List.copyOf(clients); + } + + static Arguments parse(String[] raw) { + if (raw.length == 0 || (raw.length == 1 && (raw[0].equals("--help") || raw[0].equals("-h")))) { + return defaults(null, true); + } + final Command command; + try { + command = Command.valueOf(raw[0].replace('-', '_').toUpperCase(Locale.ROOT)); + } catch (IllegalArgumentException unknown) { + throw new UsageException("unknown command: " + raw[0]); + } + var name = "tmux"; + List clients = new ArrayList<>(); + var dryRun = false; + var source = Source.DIST; + String binary = null; + String socket = null; + String socketName = null; + String tmux = null; + var help = false; + for (int index = 1; index < raw.length; index++) { + var option = raw[index]; + switch (option) { + case "-h", "--help" -> help = true; + case "--dry-run" -> dryRun = true; + case "--name" -> name = value(raw, ++index, option); + case "--cli" -> clients.add(value(raw, ++index, option)); + case "--source" -> { + var value = value(raw, ++index, option); + try { + source = Source.valueOf(value.toUpperCase(Locale.ROOT)); + } catch (IllegalArgumentException unknown) { + throw new UsageException("--source must be dist, gradle, or path"); + } + } + case "--bin" -> binary = value(raw, ++index, option); + case "--socket" -> socket = value(raw, ++index, option); + case "--socket-name" -> socketName = value(raw, ++index, option); + case "--tmux" -> tmux = value(raw, ++index, option); + case "--safety", "--watch" -> + throw new UsageException( + "LIBTMUX_SAFETY and the old safety/watch controls are retired; use LIBTMUX_TOOLSETS"); + default -> throw new UsageException("unknown option: " + option); + } + } + if (socket != null && socketName != null) { + throw new UsageException("--socket and --socket-name are mutually exclusive"); + } + if (source == Source.PATH && binary == null) { + throw new UsageException("--source path needs --bin"); + } + if (binary != null && source != Source.PATH) { + throw new UsageException("--bin requires --source path"); + } + if ((command == Command.DETECT || command == Command.STATUS || command == Command.REVERT) + && (source != Source.DIST + || binary != null + || socket != null + || socketName != null + || tmux != null)) { + throw new UsageException("launcher options apply only to use or doctor"); + } + return new Arguments(command, help, name, clients, dryRun, source, binary, socket, socketName, tmux); + } + + private static Arguments defaults(@Nullable Command command, boolean help) { + return new Arguments(command, help, "tmux", List.of(), false, Source.DIST, null, null, null, null); + } + + private static String value(String[] raw, int index, String option) { + if (index >= raw.length || raw[index].isBlank()) { + throw new UsageException(option + " needs a value"); + } + return raw[index]; + } + } + + private static Path locateRepository() { + var candidate = Path.of("").toAbsolutePath().normalize(); + while (candidate != null) { + if (Files.isRegularFile(candidate.resolve("gradlew")) + && Files.isDirectory(candidate.resolve("libtmux-mcp"))) { + return candidate; + } + candidate = candidate.getParent(); + } + return Path.of("").toAbsolutePath().normalize(); + } + + private static final class UsageException extends IllegalArgumentException { + private static final long serialVersionUID = 1L; + + UsageException(String message) { + super(message); + } + } +} diff --git a/tools/mcp-swap/src/test/java/io/github/libtmux/tools/mcpswap/McpSwapTest.java b/tools/mcp-swap/src/test/java/io/github/libtmux/tools/mcpswap/McpSwapTest.java new file mode 100644 index 0000000..b40b74b --- /dev/null +++ b/tools/mcp-swap/src/test/java/io/github/libtmux/tools/mcpswap/McpSwapTest.java @@ -0,0 +1,212 @@ +package io.github.libtmux.tools.mcpswap; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.PrintStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +final class McpSwapTest { + @TempDir + Path temporary; + + private Path home; + private Path repository; + private Map environment; + private List clients; + private ByteArrayOutputStream stdout; + private ByteArrayOutputStream stderr; + private RecordingProcess process; + + @BeforeEach + void setUp() throws IOException { + home = temporary.resolve("home"); + repository = temporary.resolve("repo"); + Files.createDirectories(home); + Files.createDirectories(repository.resolve("libtmux-mcp/build/install/libtmux-mcp/bin")); + Files.writeString(repository.resolve("gradlew"), "#!/bin/sh\n", StandardCharsets.UTF_8); + repository.resolve("gradlew").toFile().setExecutable(true); + environment = Map.of("XDG_CONFIG_HOME", home.resolve("xdg").toString()); + clients = ClientRegistry.knownClients(home, environment); + stdout = new ByteArrayOutputStream(); + stderr = new ByteArrayOutputStream(); + process = new RecordingProcess(); + } + + @Test + void noArgumentsAndHelpDescribeTheNativeCommands() { + assertEquals(0, run()); + assertTrue(output().contains("usage: mcp-swap")); + + stdout.reset(); + assertEquals(0, run("use", "--help")); + assertTrue(output().contains("--source")); + assertTrue(output().contains("--socket-name")); + } + + @Test + void dryRunPreflightsEveryClientWithoutBuildingOrWriting() throws IOException { + var originals = seedAll(); + + assertEquals( + 0, + run( + "use", + "--dry-run", + "--source", + "gradle", + "--socket", + "/tmp/demo/s", + "--cli", + "pi,antigravity,cursor,claude,codex,gemini,grok,opencode")); + + assertTrue(process.commands.isEmpty()); + for (var client : clients) { + assertArrayEquals(originals.get(client.name()), Files.readAllBytes(client.configPath())); + assertTrue(output().lines() + .anyMatch(line -> line.startsWith(client.name()) && line.contains("would set tmux"))); + assertFalse(Files.exists(SwapPaths.backup(client))); + assertFalse(Files.exists(SwapPaths.state(client))); + } + } + + @Test + void distributionUseBuildsOnceAndRevertRestoresEveryByte() throws IOException { + var originals = seedAll(); + var launcher = repository.resolve("libtmux-mcp/build/install/libtmux-mcp/bin/libtmux-mcp"); + process.afterRun = () -> { + try { + Files.writeString(launcher, "#!/bin/sh\n", StandardCharsets.UTF_8); + launcher.toFile().setExecutable(true); + } catch (IOException error) { + throw new RuntimeException(error); + } + }; + + assertEquals(0, run("use", "--socket-name", "demo")); + assertEquals(1, process.commands.size()); + assertTrue(process.commands.getFirst().contains(":libtmux-mcp:installDist")); + for (var client : clients) { + assertEquals( + new ServerSpec(launcher.toString(), List.of("--socket-name", "demo")), + ConfigCodec.read(client, Files.readAllBytes(client.configPath()), "tmux") + .orElseThrow()); + } + + assertEquals(0, run("revert")); + for (var client : clients) { + assertArrayEquals(originals.get(client.name()), Files.readAllBytes(client.configPath())); + assertFalse(Files.exists(SwapPaths.backup(client))); + assertFalse(Files.exists(SwapPaths.state(client))); + } + } + + @Test + void statusAndDetectRemainReadOnlyWhenOneConfigIsMalformed() throws IOException { + seedAll(); + Files.writeString(clients.getFirst().configPath(), "[]\n", StandardCharsets.UTF_8); + var before = tree(); + + assertEquals(1, run("status")); + assertTrue(error().contains("claude")); + assertTrue(output().contains("codex")); + + stdout.reset(); + stderr.reset(); + assertEquals(0, run("detect")); + assertTrue(output().contains("pi")); + assertTrue(output().contains("needs the pi-mcp-adapter")); + assertTreeEquals(before); + } + + @Test + void rejectsRetiredAndAmbiguousLauncherArguments() { + assertEquals(2, run("use", "--safety", "destructive")); + assertTrue(error().contains("LIBTMUX_SAFETY")); + + stderr.reset(); + assertEquals(2, run("use", "--source", "path")); + assertTrue(error().contains("--bin")); + + stderr.reset(); + assertEquals(2, run("use", "--socket", "/tmp/s", "--socket-name", "demo")); + assertTrue(error().contains("mutually exclusive")); + } + + private int run(String... arguments) { + return McpSwap.run( + arguments, + new McpSwap.Context( + home, + environment, + repository, + new PrintStream(stdout, true, StandardCharsets.UTF_8), + new PrintStream(stderr, true, StandardCharsets.UTF_8), + process)); + } + + private Map seedAll() throws IOException { + Map originals = new LinkedHashMap<>(); + for (var client : clients) { + Files.createDirectories(client.configPath().getParent()); + var raw = + switch (client.format()) { + case JSON -> "{\n \"unrelated\": true\n}\n"; + case JSONC -> "{\n // keep\n \"unrelated\": true,\n}\n"; + case TOML -> "title = \"keep\"\n"; + }; + var bytes = raw.getBytes(StandardCharsets.UTF_8); + Files.write(client.configPath(), bytes); + originals.put(client.name(), bytes); + } + return originals; + } + + private Map tree() throws IOException { + Map found = new LinkedHashMap<>(); + try (var paths = Files.walk(temporary)) { + for (var path : paths.filter(Files::isRegularFile).sorted().toList()) { + found.put(temporary.relativize(path).toString(), Files.readAllBytes(path)); + } + } + return found; + } + + private void assertTreeEquals(Map expected) throws IOException { + var actual = tree(); + assertEquals(expected.keySet(), actual.keySet()); + expected.forEach((path, bytes) -> assertArrayEquals(bytes, actual.get(path), path)); + } + + private String output() { + return stdout.toString(StandardCharsets.UTF_8); + } + + private String error() { + return stderr.toString(StandardCharsets.UTF_8); + } + + private static final class RecordingProcess implements McpSwap.ProcessRunner { + private final java.util.ArrayList> commands = new java.util.ArrayList<>(); + private Runnable afterRun = () -> {}; + + @Override + public int run(List command, Path directory) { + commands.add(List.copyOf(command)); + afterRun.run(); + return 0; + } + } +} From 3b6c775d644327a6bd24385d278b62f0b6ce61eb Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 6 Sep 2026 01:55:24 -0500 Subject: [PATCH 55/65] Build(fix[mcp-swap]): Keep capability environment why: Replacing a server entry could discard LIBTMUX_TOOLSETS or leave the retired safety setting active in nested TOML configuration. what: - Preserve existing JSON, JSONC, and TOML environment values - Remove LIBTMUX_SAFETY only with an explicit toolset replacement - Accept repeatable KEY=VALUE overrides from the native command - Keep comments and unrelated server entries during environment updates --- .../libtmux/tools/mcpswap/ConfigCodec.java | 43 +++++++++- .../github/libtmux/tools/mcpswap/McpSwap.java | 63 +++++++++++++-- .../libtmux/tools/mcpswap/ServerSpec.java | 21 ++++- .../libtmux/tools/mcpswap/TomlEditor.java | 52 +++++++++--- .../tools/mcpswap/ConfigCodecTest.java | 80 ++++++++++++++++++- .../libtmux/tools/mcpswap/McpSwapTest.java | 29 +++++++ 6 files changed, 264 insertions(+), 24 deletions(-) diff --git a/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/ConfigCodec.java b/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/ConfigCodec.java index b7e1cc9..986b4f3 100644 --- a/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/ConfigCodec.java +++ b/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/ConfigCodec.java @@ -9,6 +9,8 @@ import com.fasterxml.jackson.databind.node.ObjectNode; import java.io.IOException; import java.nio.charset.StandardCharsets; +import java.util.LinkedHashMap; +import java.util.Map; import java.util.Optional; final class ConfigCodec { @@ -55,7 +57,9 @@ private static byte[] updateJson( } else { throw new IllegalArgumentException(client.name() + " server table is not an object"); } - table.set(serverName, entry(mapper, client, server)); + var current = table.get(serverName); + var merged = server.withEnvironment(environment(client, current)); + table.set(serverName, entry(mapper, client, merged)); if (comments) { return JsoncEditor.merge(text, root, mapper).getBytes(StandardCharsets.UTF_8); } @@ -74,13 +78,44 @@ private static ObjectNode entry(ObjectMapper mapper, Client client, ServerSpec s command.add(server.command()); server.arguments().forEach(command::add); } else { + if (client.name().equals("claude")) { + entry.put("type", "stdio"); + } entry.put("command", server.command()); var arguments = entry.putArray("args"); server.arguments().forEach(arguments::add); } + if (!server.environment().isEmpty() || client.name().equals("claude")) { + var values = entry.putObject(client.openCode() ? "environment" : "env"); + server.environment().forEach(values::put); + } return entry; } + private static Map environment(Client client, JsonNode entry) { + if (entry == null || entry.isMissingNode()) { + return Map.of(); + } + if (!(entry instanceof ObjectNode object)) { + throw new IllegalArgumentException(client.name() + " server entry is not an object"); + } + var raw = object.get(client.openCode() ? "environment" : "env"); + if (raw == null) { + return Map.of(); + } + if (!(raw instanceof ObjectNode values)) { + throw new IllegalArgumentException(client.name() + " server environment is not an object"); + } + Map found = new LinkedHashMap<>(); + for (var field : values.properties()) { + if (!field.getValue().isTextual()) { + throw new IllegalArgumentException(client.name() + " server environment values must be strings"); + } + found.put(field.getKey(), field.getValue().textValue()); + } + return found; + } + private static Optional readJson(Client client, byte[] raw, String serverName, ObjectMapper mapper) { try { var text = new String(raw, StandardCharsets.UTF_8); @@ -113,7 +148,8 @@ private static Optional readJson(Client client, byte[] raw, String s array.get(0).textValue(), java.util.stream.IntStream.range(1, array.size()) .mapToObj(index -> text(array.get(index), client)) - .toList())); + .toList(), + environment(client, entry))); } var command = text(entry.get("command"), client); var arguments = entry.path("args"); @@ -124,7 +160,8 @@ private static Optional readJson(Client client, byte[] raw, String s command, java.util.stream.IntStream.range(0, arguments.size()) .mapToObj(index -> text(arguments.get(index), client)) - .toList())); + .toList(), + environment(client, entry))); } catch (IOException error) { throw new IllegalArgumentException(client.name() + " config is not valid JSON", error); } diff --git a/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/McpSwap.java b/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/McpSwap.java index e4e4ff6..77d3b43 100644 --- a/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/McpSwap.java +++ b/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/McpSwap.java @@ -6,6 +6,8 @@ import java.nio.file.LinkOption; import java.nio.file.Path; import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; import java.util.Map; @@ -48,7 +50,7 @@ static int run(String[] arguments, Context context) { context.err().println("mcp-swap: interrupted"); return 130; } catch (IOException | IllegalArgumentException error) { - context.err().println("mcp-swap: " + error.getMessage()); + context.err().println("mcp-swap: " + detail(error)); return 1; } } @@ -171,13 +173,14 @@ private static int revert(Arguments options, List clients, Context conte private static int doctor(Arguments options, List clients, Context context) { var ready = true; + ServerSpec spec = null; var gradle = context.repository().resolve("gradlew"); if (!Files.isRegularFile(gradle) || !Files.isExecutable(gradle)) { context.out().println("no executable gradlew: is this the repository root?"); ready = false; } try { - launcher(options, context.repository()); + spec = launcher(options, context.repository()); } catch (UsageException error) { context.out().println(error.getMessage()); ready = false; @@ -197,6 +200,18 @@ private static int doctor(Arguments options, List clients, Context conte ready = false; } } + if (spec != null) { + var targets = selected(options, clients).stream() + .filter(client -> exists(client.configPath())) + .toList(); + try { + new SwapService(context.home(), context.environment()) + .use(targets, clients, options.name(), spec, true); + } catch (IOException | IllegalArgumentException error) { + context.out().println("swap plan is not safe: " + detail(error)); + ready = false; + } + } if (exists(clients.getLast().configPath()) && !Files.isDirectory(piAdapter(context.home()))) { context.out().println("pi " + PI_ADAPTER_HINT); ready = false; @@ -215,7 +230,7 @@ private static ServerSpec launcher(Arguments options, Path repository) { addFlag(flags, "--socket-name", options.socketName()); addFlag(flags, "--tmux", options.tmux()); return switch (options.source()) { - case DIST -> new ServerSpec(distributionLauncher(repository).toString(), flags); + case DIST -> new ServerSpec(distributionLauncher(repository).toString(), flags, options.environment()); case GRADLE -> { var gradle = repository.resolve("gradlew").toAbsolutePath().normalize(); if (!Files.isRegularFile(gradle) || !Files.isExecutable(gradle)) { @@ -223,7 +238,8 @@ private static ServerSpec launcher(Arguments options, Path repository) { } yield new ServerSpec( gradle.toString(), - List.of("--quiet", "--console=plain", ":libtmux-mcp:run", "--args", gradleArguments(flags))); + List.of("--quiet", "--console=plain", ":libtmux-mcp:run", "--args", gradleArguments(flags)), + options.environment()); } case PATH -> { if (options.binary() == null) { @@ -236,7 +252,7 @@ yield new ServerSpec( if (!Files.isRegularFile(binary) || !Files.isExecutable(binary)) { throw new UsageException("--bin must name an executable file: " + binary); } - yield new ServerSpec(binary.toString(), flags); + yield new ServerSpec(binary.toString(), flags, options.environment()); } }; } @@ -304,11 +320,24 @@ private static String describe(ServerSpec spec) { .toList()); } + private static String detail(Throwable error) { + var message = String.valueOf(error.getMessage()); + var cause = error.getCause(); + while (cause != null) { + var next = String.valueOf(cause.getMessage()); + if (!next.equals(message)) { + message += ": " + next; + } + cause = cause.getCause(); + } + return message; + } + private static void printHelp(PrintStream out, @Nullable Command command) { if (command == Command.USE) { out.println("usage: mcp-swap use [--source dist|gradle|path] [--bin FILE]"); out.println(" [--socket PATH | --socket-name NAME] [--tmux FILE]"); - out.println(" [--name NAME] [--cli CLIENT] [--dry-run]"); + out.println(" [--env KEY=VALUE] [--name NAME] [--cli CLIENT] [--dry-run]"); return; } out.println("usage: mcp-swap [options]"); @@ -369,12 +398,14 @@ private record Arguments( List clients, boolean dryRun, Source source, + Map environment, @Nullable String binary, @Nullable String socket, @Nullable String socketName, @Nullable String tmux) { Arguments { clients = List.copyOf(clients); + environment = Collections.unmodifiableMap(new LinkedHashMap<>(environment)); } static Arguments parse(String[] raw) { @@ -391,6 +422,7 @@ static Arguments parse(String[] raw) { List clients = new ArrayList<>(); var dryRun = false; var source = Source.DIST; + Map environment = new LinkedHashMap<>(); String binary = null; String socket = null; String socketName = null; @@ -411,6 +443,18 @@ static Arguments parse(String[] raw) { throw new UsageException("--source must be dist, gradle, or path"); } } + case "--env" -> { + var value = value(raw, ++index, option); + var separator = value.indexOf('='); + if (separator < 1 || !value.substring(0, separator).matches("[A-Za-z_][A-Za-z0-9_]*")) { + throw new UsageException("--env expects KEY=VALUE"); + } + var key = value.substring(0, separator); + if (key.equals("LIBTMUX_SAFETY")) { + throw new UsageException("LIBTMUX_SAFETY is retired; use LIBTMUX_TOOLSETS"); + } + environment.put(key, value.substring(separator + 1)); + } case "--bin" -> binary = value(raw, ++index, option); case "--socket" -> socket = value(raw, ++index, option); case "--socket-name" -> socketName = value(raw, ++index, option); @@ -432,17 +476,20 @@ static Arguments parse(String[] raw) { } if ((command == Command.DETECT || command == Command.STATUS || command == Command.REVERT) && (source != Source.DIST + || !environment.isEmpty() || binary != null || socket != null || socketName != null || tmux != null)) { throw new UsageException("launcher options apply only to use or doctor"); } - return new Arguments(command, help, name, clients, dryRun, source, binary, socket, socketName, tmux); + return new Arguments( + command, help, name, clients, dryRun, source, environment, binary, socket, socketName, tmux); } private static Arguments defaults(@Nullable Command command, boolean help) { - return new Arguments(command, help, "tmux", List.of(), false, Source.DIST, null, null, null, null); + return new Arguments( + command, help, "tmux", List.of(), false, Source.DIST, Map.of(), null, null, null, null); } private static String value(String[] raw, int index, String option) { diff --git a/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/ServerSpec.java b/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/ServerSpec.java index 1bd110b..9591c26 100644 --- a/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/ServerSpec.java +++ b/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/ServerSpec.java @@ -1,9 +1,28 @@ package io.github.libtmux.tools.mcpswap; +import java.util.Collections; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; -record ServerSpec(String command, List arguments) { +record ServerSpec(String command, List arguments, Map environment) { ServerSpec { arguments = List.copyOf(arguments); + environment = Collections.unmodifiableMap(new LinkedHashMap<>(environment)); + } + + ServerSpec(String command, List arguments) { + this(command, arguments, Map.of()); + } + + ServerSpec withEnvironment(Map existing) { + Map merged = new LinkedHashMap<>(existing); + var retired = merged.remove("LIBTMUX_SAFETY") != null; + merged.putAll(environment); + retired |= merged.remove("LIBTMUX_SAFETY") != null; + if (retired && !merged.containsKey("LIBTMUX_TOOLSETS")) { + throw new IllegalArgumentException("LIBTMUX_SAFETY has been removed; supply LIBTMUX_TOOLSETS explicitly"); + } + return new ServerSpec(command, arguments, merged); } } diff --git a/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/TomlEditor.java b/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/TomlEditor.java index b3c1908..244f352 100644 --- a/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/TomlEditor.java +++ b/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/TomlEditor.java @@ -4,7 +4,9 @@ import com.fasterxml.jackson.databind.ObjectMapper; import java.nio.charset.StandardCharsets; import java.util.ArrayList; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import java.util.Optional; import org.jspecify.annotations.Nullable; import org.tomlj.Toml; @@ -17,30 +19,45 @@ private TomlEditor() {} static byte[] update(byte[] original, String tableName, String serverName, ServerSpec server) { var text = new String(original, StandardCharsets.UTF_8); - requireValid(text); + var existing = read(original, tableName, serverName) + .map(ServerSpec::environment) + .orElseGet(Map::of); + var merged = server.withEnvironment(existing); var newline = text.contains("\r\n") ? "\r\n" : "\n"; var sections = sections(text); - Section selected = null; + var target = List.of(tableName, serverName); + var start = -1; + var end = -1; for (var section : sections) { - if (section.path().equals(List.of(tableName, serverName))) { - selected = section; + if (startsWith(section.path(), target)) { + if (start == -1) { + start = section.start(); + } + end = section.end(); + } else if (start != -1) { break; } } - var body = "command = " + string(server.command()) + newline + "args = " + array(server.arguments()) + newline; + var body = "command = " + string(merged.command()) + newline + "args = " + array(merged.arguments()) + newline; + if (!merged.environment().isEmpty()) { + body += newline + "[" + tableName + "." + string(serverName) + ".env]" + newline; + for (var value : merged.environment().entrySet()) { + body += string(value.getKey()) + " = " + string(value.getValue()) + newline; + } + } String updated; - if (selected == null) { + if (start == -1) { var separator = text.isEmpty() || text.endsWith(newline + newline) ? "" : text.endsWith(newline) ? newline : newline + newline; updated = text + separator + "[" + tableName + "." + string(serverName) + "]" + newline + body; } else { - var comments = commentsOnly(text.substring(selected.headerEnd(), selected.end())); - var replacement = text.substring(selected.start(), selected.headerEnd()) + body + comments; + var comments = commentsOnly(text.substring(start, end)); + var replacement = "[" + tableName + "." + string(serverName) + "]" + newline + body + comments; if (!replacement.endsWith(newline)) { replacement += newline; } - updated = text.substring(0, selected.start()) + replacement + text.substring(selected.end()); + updated = text.substring(0, start) + replacement + text.substring(end); } requireValid(updated); return updated.getBytes(StandardCharsets.UTF_8); @@ -69,7 +86,18 @@ static Optional read(byte[] raw, String tableName, String serverName } values.add(value); } - return Optional.of(new ServerSpec(command, values)); + Map environment = new LinkedHashMap<>(); + var rawEnvironment = server.getTable("env"); + if (rawEnvironment != null) { + for (var key : rawEnvironment.keySet()) { + var value = rawEnvironment.getString(key); + if (value == null) { + throw new IllegalArgumentException("server environment values must be strings"); + } + environment.put(key, value); + } + } + return Optional.of(new ServerSpec(command, values, environment)); } private static TomlParseResult requireValid(String text) { @@ -210,6 +238,10 @@ private static String commentsOnly(String body) { return kept.toString(); } + private static boolean startsWith(List path, List prefix) { + return path.size() >= prefix.size() && path.subList(0, prefix.size()).equals(prefix); + } + private static String array(List values) { return values.stream().map(TomlEditor::string).collect(java.util.stream.Collectors.joining(", ", "[", "]")); } diff --git a/tools/mcp-swap/src/test/java/io/github/libtmux/tools/mcpswap/ConfigCodecTest.java b/tools/mcp-swap/src/test/java/io/github/libtmux/tools/mcpswap/ConfigCodecTest.java index 3cf56d0..ea8814f 100644 --- a/tools/mcp-swap/src/test/java/io/github/libtmux/tools/mcpswap/ConfigCodecTest.java +++ b/tools/mcp-swap/src/test/java/io/github/libtmux/tools/mcpswap/ConfigCodecTest.java @@ -1,6 +1,7 @@ package io.github.libtmux.tools.mcpswap; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -28,6 +29,8 @@ final class ConfigCodecTest { void roundTripsTheServerRouteForAllEightClients() { var clients = ClientRegistry.knownClients(Path.of("/test/home"), Map.of()); var seen = new ArrayList(); + var routed = + new ServerSpec(SERVER.command(), SERVER.arguments(), Map.of("LIBTMUX_TOOLSETS", "inspect,execute")); for (var client : clients) { var original = @@ -37,9 +40,9 @@ void roundTripsTheServerRouteForAllEightClients() { case TOML -> "title = \"keep\"\n"; }; var updated = - ConfigCodec.update(client, original.getBytes(StandardCharsets.UTF_8), "name.with.dot", SERVER); + ConfigCodec.update(client, original.getBytes(StandardCharsets.UTF_8), "name.with.dot", routed); assertEquals( - SERVER, ConfigCodec.read(client, updated, "name.with.dot").orElseThrow()); + routed, ConfigCodec.read(client, updated, "name.with.dot").orElseThrow()); seen.add(client.name()); } @@ -149,6 +152,79 @@ void replacesTomlEntryAndKeepsItsComment() { assertEquals("--socket", arguments.getString(0)); } + @Test + void replacesRetiredSafetyWithoutDroppingJsonEnvironment() throws Exception { + var client = client("claude", ConfigFormat.JSON, false); + var original = """ + { + "mcpServers": { + "tmux": { + "command": "old", + "args": [], + "env": { + "LIBTMUX_SAFETY": "destructive", + "LIBTMUX_TOOLSETS": "inspect,execute", + "KEEP": "yes" + } + } + } + } + """; + + var updated = ConfigCodec.update(client, original.getBytes(StandardCharsets.UTF_8), "tmux", SERVER); + + var entry = new ObjectMapper().readTree(updated).path("mcpServers").path("tmux"); + assertFalse(entry.path("env").has("LIBTMUX_SAFETY")); + assertEquals( + "inspect,execute", entry.path("env").path("LIBTMUX_TOOLSETS").asText()); + assertEquals("yes", entry.path("env").path("KEEP").asText()); + } + + @Test + void preservesTomlEnvironmentAndItsCommentsWhileRemovingSafety() { + var client = client("codex", ConfigFormat.TOML, false); + var original = """ + [mcp_servers.tmux] + command = "old" + args = [] + + [mcp_servers.tmux.env] + # keep this environment rationale + LIBTMUX_SAFETY = "readonly" + LIBTMUX_TOOLSETS = "inspect" + KEEP = "yes" + + [mcp_servers.other] + command = "echo" + args = [] + """; + + var updated = new String( + ConfigCodec.update(client, original.getBytes(StandardCharsets.UTF_8), "tmux", SERVER), + StandardCharsets.UTF_8); + + var parsed = Toml.parse(updated); + assertTrue(updated.contains("# keep this environment rationale")); + assertFalse(updated.contains("LIBTMUX_SAFETY")); + assertEquals("inspect", parsed.getString("mcp_servers.tmux.env.LIBTMUX_TOOLSETS")); + assertEquals("yes", parsed.getString("mcp_servers.tmux.env.KEEP")); + assertEquals("echo", parsed.getString("mcp_servers.other.command")); + } + + @Test + void refusesToGuessAToolsetWhenOnlyRetiredSafetyExists() { + var client = client("cursor", ConfigFormat.JSON, false); + var original = """ + {"mcpServers":{"tmux":{"command":"old","args":[],"env":{"LIBTMUX_SAFETY":"destructive"}}}} + """; + + var failure = org.junit.jupiter.api.Assertions.assertThrows( + IllegalArgumentException.class, + () -> ConfigCodec.update(client, original.getBytes(StandardCharsets.UTF_8), "tmux", SERVER)); + + assertTrue(String.valueOf(failure.getMessage()).contains("LIBTMUX_TOOLSETS")); + } + private static Client client(String name, ConfigFormat format, boolean openCode) { var table = openCode ? "mcp" : format == ConfigFormat.TOML ? "mcp_servers" : "mcpServers"; return new Client(name, Path.of("/test/config"), table, format, openCode); diff --git a/tools/mcp-swap/src/test/java/io/github/libtmux/tools/mcpswap/McpSwapTest.java b/tools/mcp-swap/src/test/java/io/github/libtmux/tools/mcpswap/McpSwapTest.java index b40b74b..e7727e8 100644 --- a/tools/mcp-swap/src/test/java/io/github/libtmux/tools/mcpswap/McpSwapTest.java +++ b/tools/mcp-swap/src/test/java/io/github/libtmux/tools/mcpswap/McpSwapTest.java @@ -145,6 +145,35 @@ void rejectsRetiredAndAmbiguousLauncherArguments() { assertTrue(error().contains("mutually exclusive")); } + @Test + void explicitEnvironmentReplacesRetiredSafety() throws IOException { + var claude = clients.getFirst(); + Files.createDirectories(claude.configPath().getParent()); + Files.writeString(claude.configPath(), """ + {"mcpServers":{"tmux":{"command":"old","args":[],"env":{"LIBTMUX_SAFETY":"readonly"}}}} + """, StandardCharsets.UTF_8); + + assertEquals(1, run("use", "--dry-run", "--source", "gradle", "--cli", "claude")); + assertTrue(error().contains("LIBTMUX_TOOLSETS")); + stdout.reset(); + stderr.reset(); + + assertEquals( + 0, + run( + "use", + "--dry-run", + "--source", + "gradle", + "--cli", + "claude", + "--env", + "LIBTMUX_TOOLSETS=inspect,manage")); + + assertTrue(error().contains("pointing 'tmux'")); + assertFalse(error().contains("LIBTMUX_SAFETY")); + } + private int run(String... arguments) { return McpSwap.run( arguments, From 399c9d5f4c52a93a05d42159823fe52f1d051fa6 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 6 Sep 2026 01:58:25 -0500 Subject: [PATCH 56/65] Build(fix[mcp-swap]): Tighten preflight why: Dry runs accepted unsafe lock routes, and coercive recovery reads could treat malformed fields as valid state. what: - Validate lock files and private directories without creating them - Require canonical integer and boolean recovery fields - Bracket file reads with mode and link-count checks - Retain recovery after checksummed type tampering --- .../libtmux/tools/mcpswap/FileSnapshot.java | 14 ++++-- .../libtmux/tools/mcpswap/RecoveryRecord.java | 22 +++++++-- .../libtmux/tools/mcpswap/SwapLock.java | 30 +++++++++--- .../tools/mcpswap/TransactionGuard.java | 1 + .../tools/mcpswap/SwapServiceTest.java | 47 +++++++++++++++++++ 5 files changed, 102 insertions(+), 12 deletions(-) diff --git a/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/FileSnapshot.java b/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/FileSnapshot.java index f7d99b8..51941a9 100644 --- a/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/FileSnapshot.java +++ b/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/FileSnapshot.java @@ -59,13 +59,16 @@ static FileSnapshot capture(Path path) throws IOException { throw new IOException("file exceeds 16 MiB: " + normalized); } var posix = Files.readAttributes(normalized, PosixFileAttributes.class, LinkOption.NOFOLLOW_LINKS); - var rawLinks = Files.getAttribute(normalized, "unix:nlink", LinkOption.NOFOLLOW_LINKS); - var links = rawLinks instanceof Number number ? number.intValue() : 0; + var links = links(normalized); var data = Files.readAllBytes(normalized); var after = Files.readAttributes(normalized, BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS); + var posixAfter = Files.readAttributes(normalized, PosixFileAttributes.class, LinkOption.NOFOLLOW_LINKS); + var linksAfter = links(normalized); if (!String.valueOf(basic.fileKey()).equals(String.valueOf(after.fileKey())) || basic.size() != after.size() - || !basic.lastModifiedTime().equals(after.lastModifiedTime())) { + || !basic.lastModifiedTime().equals(after.lastModifiedTime()) + || !posix.permissions().equals(posixAfter.permissions()) + || links != linksAfter) { throw new IOException("file changed while it was read: " + normalized); } return new FileSnapshot( @@ -154,4 +157,9 @@ static String sha256(byte[] data) { throw new AssertionError(impossible); } } + + private static int links(Path path) throws IOException { + var raw = Files.getAttribute(path, "unix:nlink", LinkOption.NOFOLLOW_LINKS); + return raw instanceof Number number ? number.intValue() : 0; + } } diff --git a/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/RecoveryRecord.java b/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/RecoveryRecord.java index b79c7ac..ddc9e81 100644 --- a/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/RecoveryRecord.java +++ b/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/RecoveryRecord.java @@ -141,7 +141,7 @@ static RecoveryRecord decode(byte[] encoded) throws IOException { } catch (JsonProcessingException impossible) { throw new IOException("cannot verify recovery state", impossible); } - var version = root.path("version").asInt(-1); + var version = requiredInteger(root, "version"); if (version != VERSION) { throw new IOException("unsupported recovery state version " + version); } @@ -163,9 +163,9 @@ static RecoveryRecord decode(byte[] encoded) throws IOException { requiredText(root, "logical"), requiredText(root, "target"), requiredText(root, "physicalParent"), - root.path("symbolicLink").asBoolean(), + requiredBoolean(root, "symbolicLink"), requiredText(root, "linkTarget"), - root.path("originalExists").asBoolean(), + requiredBoolean(root, "originalExists"), requiredText(root, "backupIdentity"), requiredText(root, "originalDigest"), requiredText(root, "originalPermissions"), @@ -239,4 +239,20 @@ private static String requiredText(ObjectNode root, String name) throws IOExcept } return value.textValue(); } + + private static int requiredInteger(ObjectNode root, String name) throws IOException { + var value = root.get(name); + if (value == null || !value.isIntegralNumber() || !value.canConvertToInt()) { + throw new IOException("recovery field " + name + " is not an integer"); + } + return value.intValue(); + } + + private static boolean requiredBoolean(ObjectNode root, String name) throws IOException { + var value = root.get(name); + if (value == null || !value.isBoolean()) { + throw new IOException("recovery field " + name + " is not a boolean"); + } + return value.booleanValue(); + } } diff --git a/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/SwapLock.java b/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/SwapLock.java index 383e83e..0c77868 100644 --- a/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/SwapLock.java +++ b/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/SwapLock.java @@ -83,6 +83,18 @@ static SwapLock acquire(Path home, Map environment) throws IOExc } } + static void preflight(Path lockPath) throws IOException { + var path = lockPath.toAbsolutePath().normalize(); + var parent = path.getParent(); + if (parent == null) { + throw new IOException("swap lock has no parent"); + } + validateDirectories(parent); + if (Files.exists(path, LinkOption.NOFOLLOW_LINKS)) { + requireSafe(FileSnapshot.capture(path), path); + } + } + Path path() { return state.path(); } @@ -124,14 +136,20 @@ private static void secureDirectory(Path directory) throws IOException { // A concurrent creator still has to pass the same validation. } } + validateDirectories(directory); + } + + private static void validateDirectories(Path directory) throws IOException { var checked = directory; for (int depth = 0; depth < 2; depth++) { - if (Files.isSymbolicLink(checked) || !Files.isDirectory(checked, LinkOption.NOFOLLOW_LINKS)) { - throw new IOException("unsafe swap lock directory: " + checked); - } - var permissions = Files.getPosixFilePermissions(checked, LinkOption.NOFOLLOW_LINKS); - if (!permissions.equals(DIRECTORY_MODE)) { - throw new IOException("swap lock directory must have mode 0700: " + checked); + if (Files.exists(checked, LinkOption.NOFOLLOW_LINKS)) { + if (Files.isSymbolicLink(checked) || !Files.isDirectory(checked, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("unsafe swap lock directory: " + checked); + } + var permissions = Files.getPosixFilePermissions(checked, LinkOption.NOFOLLOW_LINKS); + if (!permissions.equals(DIRECTORY_MODE)) { + throw new IOException("swap lock directory must have mode 0700: " + checked); + } } checked = checked.getParent(); if (checked == null) { diff --git a/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/TransactionGuard.java b/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/TransactionGuard.java index 747d9b0..bda6734 100644 --- a/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/TransactionGuard.java +++ b/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/TransactionGuard.java @@ -23,6 +23,7 @@ static TransactionGuard capture(List clients, SwapLock lock) throws IOEx } static void preflight(List clients, Path lockPath) throws IOException { + SwapLock.preflight(lockPath); rejectAliases(inspect(clients), lockPath); } diff --git a/tools/mcp-swap/src/test/java/io/github/libtmux/tools/mcpswap/SwapServiceTest.java b/tools/mcp-swap/src/test/java/io/github/libtmux/tools/mcpswap/SwapServiceTest.java index 5d28931..c5a49a2 100644 --- a/tools/mcp-swap/src/test/java/io/github/libtmux/tools/mcpswap/SwapServiceTest.java +++ b/tools/mcp-swap/src/test/java/io/github/libtmux/tools/mcpswap/SwapServiceTest.java @@ -6,6 +6,8 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.nio.file.Files; @@ -21,6 +23,7 @@ import org.junit.jupiter.api.io.TempDir; final class SwapServiceTest { + private static final ObjectMapper JSON = new ObjectMapper(); private static final ServerSpec FIRST = new ServerSpec("/opt/first/libtmux-mcp", List.of("--socket", "/tmp/a")); private static final ServerSpec SECOND = new ServerSpec("/opt/second/libtmux-mcp", List.of("--socket-name", "demo")); @@ -218,6 +221,29 @@ void refusesAByteIdenticalReplacementOfTheRecoveryBackup() throws IOException { assertTrue(Files.isRegularFile(SwapPaths.state(selected))); } + @Test + void rejectsARecoveryRecordWithAChecksummedWrongType() throws IOException { + seedAll(); + var selected = clients.getFirst(); + var service = new SwapService(home, environment); + service.use(List.of(selected), clients, "tmux", FIRST, false); + var state = SwapPaths.state(selected); + var root = (ObjectNode) JSON.readTree(state.toFile()); + root.remove("checksum"); + root.put("symbolicLink", "false"); + root.put("checksum", FileSnapshot.sha256(JSON.writeValueAsBytes(root))); + Files.write(state, JSON.writeValueAsBytes(root)); + + assertThrows(IOException.class, () -> service.revert(List.of(selected), clients, "tmux", false)); + + assertEquals( + FIRST, + ConfigCodec.read(selected, Files.readAllBytes(selected.configPath()), "tmux") + .orElseThrow()); + assertTrue(Files.isRegularFile(SwapPaths.backup(selected))); + assertTrue(Files.isRegularFile(state)); + } + @Test void keepsAConfigSymlinkAcrossUseAndRevert() throws IOException { var selected = clients.getFirst(); @@ -289,6 +315,27 @@ void refusesAnUnsafeLockAndASecondOwner() throws IOException { } } + @Test + void dryRunRejectsAnUnsafeLockWithoutChangingIt() throws IOException { + seedAll(); + var lockPath = SwapPaths.lock(home, environment); + var lockDirectory = Objects.requireNonNull(lockPath.getParent()); + var stateDirectory = Objects.requireNonNull(lockDirectory.getParent()); + Files.createDirectories(lockDirectory); + Files.setPosixFilePermissions(stateDirectory, PosixFilePermissions.fromString("rwx------")); + Files.setPosixFilePermissions(lockDirectory, PosixFilePermissions.fromString("rwx------")); + var target = home.resolve("lock-target"); + Files.writeString(target, "not a lock"); + Files.createSymbolicLink(lockPath, target); + var before = tree(); + + assertThrows( + IOException.class, () -> new SwapService(home, environment).use(clients, clients, "tmux", FIRST, true)); + + assertEquals(before, tree()); + assertTrue(Files.isSymbolicLink(lockPath)); + } + @Test void dryRunCreatesNoFilesOrDirectories() throws IOException { var service = new SwapService(home, environment); From 76d9878cda994c1ef2852eec5388b135c8c15caf Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 6 Sep 2026 02:00:23 -0500 Subject: [PATCH 57/65] Build(fix[mcp-swap]): Accept omitted arguments why: Valid MCP entries may omit an empty argument list, but status and replacement rejected those JSON and TOML configurations. what: - Treat an absent args field as an empty list - Keep rejecting present argument fields with the wrong type - Cover both standard JSON and TOML client shapes --- .../libtmux/tools/mcpswap/ConfigCodec.java | 12 ++++++----- .../libtmux/tools/mcpswap/TomlEditor.java | 16 +++++++------- .../tools/mcpswap/ConfigCodecTest.java | 21 +++++++++++++++++++ 3 files changed, 37 insertions(+), 12 deletions(-) diff --git a/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/ConfigCodec.java b/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/ConfigCodec.java index 986b4f3..da50d7d 100644 --- a/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/ConfigCodec.java +++ b/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/ConfigCodec.java @@ -152,15 +152,17 @@ private static Optional readJson(Client client, byte[] raw, String s environment(client, entry))); } var command = text(entry.get("command"), client); - var arguments = entry.path("args"); - if (!arguments.isArray()) { + var arguments = entry.get("args"); + if (arguments != null && !arguments.isArray()) { throw new IllegalArgumentException(client.name() + " server args is not an array"); } return Optional.of(new ServerSpec( command, - java.util.stream.IntStream.range(0, arguments.size()) - .mapToObj(index -> text(arguments.get(index), client)) - .toList(), + arguments == null + ? java.util.List.of() + : java.util.stream.IntStream.range(0, arguments.size()) + .mapToObj(index -> text(arguments.get(index), client)) + .toList(), environment(client, entry))); } catch (IOException error) { throw new IllegalArgumentException(client.name() + " config is not valid JSON", error); diff --git a/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/TomlEditor.java b/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/TomlEditor.java index 244f352..adf661a 100644 --- a/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/TomlEditor.java +++ b/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/TomlEditor.java @@ -75,16 +75,18 @@ static Optional read(byte[] raw, String tableName, String serverName } var command = server.getString("command"); var arguments = server.getArray("args"); - if (command == null || arguments == null) { - throw new IllegalArgumentException("server command and args must be present"); + if (command == null) { + throw new IllegalArgumentException("server command must be present"); } List values = new ArrayList<>(); - for (int index = 0; index < arguments.size(); index++) { - var value = arguments.getString(index); - if (value == null) { - throw new IllegalArgumentException("server args must contain only strings"); + if (arguments != null) { + for (int index = 0; index < arguments.size(); index++) { + var value = arguments.getString(index); + if (value == null) { + throw new IllegalArgumentException("server args must contain only strings"); + } + values.add(value); } - values.add(value); } Map environment = new LinkedHashMap<>(); var rawEnvironment = server.getTable("env"); diff --git a/tools/mcp-swap/src/test/java/io/github/libtmux/tools/mcpswap/ConfigCodecTest.java b/tools/mcp-swap/src/test/java/io/github/libtmux/tools/mcpswap/ConfigCodecTest.java index ea8814f..db118f1 100644 --- a/tools/mcp-swap/src/test/java/io/github/libtmux/tools/mcpswap/ConfigCodecTest.java +++ b/tools/mcp-swap/src/test/java/io/github/libtmux/tools/mcpswap/ConfigCodecTest.java @@ -225,6 +225,27 @@ void refusesToGuessAToolsetWhenOnlyRetiredSafetyExists() { assertTrue(String.valueOf(failure.getMessage()).contains("LIBTMUX_TOOLSETS")); } + @Test + void readsTheOptionalArgumentListAsEmpty() { + var json = client("cursor", ConfigFormat.JSON, false); + var toml = client("codex", ConfigFormat.TOML, false); + + assertEquals( + new ServerSpec("server", List.of()), + ConfigCodec.read( + json, + "{\"mcpServers\":{\"tmux\":{\"command\":\"server\"}}}".getBytes(StandardCharsets.UTF_8), + "tmux") + .orElseThrow()); + assertEquals( + new ServerSpec("server", List.of()), + ConfigCodec.read( + toml, + "[mcp_servers.tmux]\ncommand = \"server\"\n".getBytes(StandardCharsets.UTF_8), + "tmux") + .orElseThrow()); + } + private static Client client(String name, ConfigFormat format, boolean openCode) { var table = openCode ? "mcp" : format == ConfigFormat.TOML ? "mcp_servers" : "mcpServers"; return new Client(name, Path.of("/test/config"), table, format, openCode); From 70804ece5251af69a57cfe8789b3e515e144cc9c Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 6 Sep 2026 02:06:55 -0500 Subject: [PATCH 58/65] Mcp(fix[mcp-swap]): Bind directory identity why: Path strings and file identities do not detect a parent directory replacement that moves every protected file back into place. what: - Bind each live route to its nearest existing directory inode - Persist the directory binding in recovery state - Reject replacements during commit and across a later revert - Cover both races with inode-preserving directory swaps --- .../libtmux/tools/mcpswap/PathRoute.java | 53 ++++++++++++++++--- .../libtmux/tools/mcpswap/RecoveryRecord.java | 14 +++++ .../tools/mcpswap/SwapServiceTest.java | 51 ++++++++++++++++++ 3 files changed, 112 insertions(+), 6 deletions(-) diff --git a/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/PathRoute.java b/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/PathRoute.java index 8d49d30..5837a33 100644 --- a/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/PathRoute.java +++ b/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/PathRoute.java @@ -10,6 +10,8 @@ record PathRoute( Path logical, Path target, Path physicalParent, + Path anchor, + String anchorIdentity, boolean exists, boolean symbolicLink, String linkTarget, @@ -18,22 +20,50 @@ static PathRoute inspect(Path path) throws IOException { var logical = path.toAbsolutePath().normalize(); var exists = Files.exists(logical, LinkOption.NOFOLLOW_LINKS); if (!exists) { - var target = prospectiveTarget(logical); - return new PathRoute(logical, target, parent(target), false, false, "", ""); + var prospective = prospectiveTarget(logical); + return new PathRoute( + logical, + prospective.target(), + parent(prospective.target()), + prospective.anchor(), + directoryIdentity(prospective.anchor()), + false, + false, + "", + ""); } var attributes = Files.readAttributes(logical, BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS); if (attributes.isSymbolicLink()) { var link = Files.readSymbolicLink(logical).toString(); var target = logical.toRealPath(); requireRegular(target); + var physicalParent = parent(target); return new PathRoute( - logical, target, parent(target), true, true, link, String.valueOf(attributes.fileKey())); + logical, + target, + physicalParent, + physicalParent, + directoryIdentity(physicalParent), + true, + true, + link, + String.valueOf(attributes.fileKey())); } if (!attributes.isRegularFile()) { throw new IOException("path is not a regular file: " + logical); } var target = logical.toRealPath(); - return new PathRoute(logical, target, parent(target), true, false, "", ""); + var physicalParent = parent(target); + return new PathRoute( + logical, + target, + physicalParent, + physicalParent, + directoryIdentity(physicalParent), + true, + false, + "", + ""); } void verify() throws IOException { @@ -42,7 +72,7 @@ void verify() throws IOException { } } - private static Path prospectiveTarget(Path logical) throws IOException { + private static ProspectiveTarget prospectiveTarget(Path logical) throws IOException { var ancestor = logical.getParent(); if (ancestor == null) { throw new IOException("path has no parent: " + logical); @@ -57,7 +87,8 @@ private static Path prospectiveTarget(Path logical) throws IOException { throw new IOException("path ancestor is not a directory: " + ancestor); } var relative = ancestor.relativize(logical); - return ancestor.toRealPath().resolve(relative).normalize(); + var physicalAncestor = ancestor.toRealPath(); + return new ProspectiveTarget(physicalAncestor.resolve(relative).normalize(), physicalAncestor); } private static void requireRegular(Path target) throws IOException { @@ -73,4 +104,14 @@ private static Path parent(Path target) throws IOException { } return parent; } + + private static String directoryIdentity(Path directory) throws IOException { + var attributes = Files.readAttributes(directory, BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS); + if (!attributes.isDirectory() || attributes.fileKey() == null) { + throw new IOException("directory identity is unavailable: " + directory); + } + return attributes.fileKey().toString(); + } + + private record ProspectiveTarget(Path target, Path anchor) {} } diff --git a/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/RecoveryRecord.java b/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/RecoveryRecord.java index ddc9e81..0b2a560 100644 --- a/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/RecoveryRecord.java +++ b/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/RecoveryRecord.java @@ -15,6 +15,8 @@ record RecoveryRecord( String logical, String target, String physicalParent, + String anchor, + String anchorIdentity, boolean symbolicLink, String linkTarget, boolean originalExists, @@ -35,6 +37,8 @@ record RecoveryRecord( "logical", "target", "physicalParent", + "anchor", + "anchorIdentity", "symbolicLink", "linkTarget", "originalExists", @@ -65,6 +69,8 @@ static RecoveryRecord create( route.logical().toString(), route.target().toString(), route.physicalParent().toString(), + route.anchor().toString(), + route.anchorIdentity(), route.symbolicLink(), route.linkTarget(), original.exists(), @@ -85,6 +91,8 @@ RecoveryRecord withCurrent(FileContent current, ServerSpec spec) { logical, target, physicalParent, + anchor, + anchorIdentity, symbolicLink, linkTarget, originalExists, @@ -163,6 +171,8 @@ static RecoveryRecord decode(byte[] encoded) throws IOException { requiredText(root, "logical"), requiredText(root, "target"), requiredText(root, "physicalParent"), + requiredText(root, "anchor"), + requiredText(root, "anchorIdentity"), requiredBoolean(root, "symbolicLink"), requiredText(root, "linkTarget"), requiredBoolean(root, "originalExists"), @@ -184,6 +194,8 @@ void verify( if (!logical.equals(route.logical().toString()) || !target.equals(route.target().toString()) || !physicalParent.equals(route.physicalParent().toString()) + || !anchor.equals(route.anchor().toString()) + || !anchorIdentity.equals(route.anchorIdentity()) || symbolicLink != route.symbolicLink() || !linkTarget.equals(route.linkTarget())) { throw new IOException("recovery config route changed for " + client); @@ -218,6 +230,8 @@ private ObjectNode body() { root.put("logical", logical); root.put("target", target); root.put("physicalParent", physicalParent); + root.put("anchor", anchor); + root.put("anchorIdentity", anchorIdentity); root.put("symbolicLink", symbolicLink); root.put("linkTarget", linkTarget); root.put("originalExists", originalExists); diff --git a/tools/mcp-swap/src/test/java/io/github/libtmux/tools/mcpswap/SwapServiceTest.java b/tools/mcp-swap/src/test/java/io/github/libtmux/tools/mcpswap/SwapServiceTest.java index c5a49a2..73709a9 100644 --- a/tools/mcp-swap/src/test/java/io/github/libtmux/tools/mcpswap/SwapServiceTest.java +++ b/tools/mcp-swap/src/test/java/io/github/libtmux/tools/mcpswap/SwapServiceTest.java @@ -163,6 +163,47 @@ void rechecksUnselectedClientsBeforeEveryPublication() throws IOException { assertFalse(Files.exists(SwapPaths.state(selected))); } + @Test + void rejectsAConfigDirectoryReplacementThatKeepsEveryFile() throws IOException { + var originals = seedAll(); + var selected = clients.get(1); + var parent = Objects.requireNonNull(selected.configPath().getParent()); + var displaced = parent.resolveSibling(".codex-displaced"); + var replaced = new boolean[] {false}; + var service = new SwapService(home, environment, (boundary, path) -> { + if (!replaced[0] && boundary.equals("backup-publish") && path.equals(SwapPaths.backup(selected))) { + replaced[0] = true; + replaceDirectoryKeepingChildren(parent, displaced); + } + }); + + assertThrows(IOException.class, () -> service.use(List.of(selected), clients, "tmux", FIRST, false)); + + assertTrue(replaced[0]); + assertArrayEquals(originals.get(selected.name()), Files.readAllBytes(selected.configPath())); + assertFalse(Files.exists(SwapPaths.backup(selected))); + assertFalse(Files.exists(SwapPaths.state(selected))); + } + + @Test + void refusesRevertAfterAConfigDirectoryReplacementThatKeepsEveryFile() throws IOException { + seedAll(); + var selected = clients.get(1); + var parent = Objects.requireNonNull(selected.configPath().getParent()); + var service = new SwapService(home, environment); + service.use(List.of(selected), clients, "tmux", FIRST, false); + + replaceDirectoryKeepingChildren(parent, parent.resolveSibling(".codex-displaced")); + + assertThrows(IOException.class, () -> service.revert(List.of(selected), clients, "tmux", false)); + assertEquals( + FIRST, + ConfigCodec.read(selected, Files.readAllBytes(selected.configPath()), "tmux") + .orElseThrow()); + assertTrue(Files.isRegularFile(SwapPaths.backup(selected))); + assertTrue(Files.isRegularFile(SwapPaths.state(selected))); + } + @Test void preservesALateFileAtAnAbsentConfigDestination() throws IOException { var selected = clients.get(2); @@ -364,6 +405,16 @@ private Map seedAll() throws IOException { return originals; } + private static void replaceDirectoryKeepingChildren(Path directory, Path displaced) throws IOException { + Files.move(directory, displaced); + Files.createDirectory(directory); + try (var children = Files.list(displaced)) { + for (var child : children.toList()) { + Files.move(child, directory.resolve(child.getFileName())); + } + } + } + private List tree() throws IOException { try (var paths = Files.walk(home)) { return paths.map(home::relativize).map(Path::toString).sorted().toList(); From d0240424e7bcd0fb207961e7798e1d96f16fab1f Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 6 Sep 2026 02:09:21 -0500 Subject: [PATCH 59/65] Mcp(fix[mcp-swap]): Reject malformed UTF-8 why: Java's permissive decoder can replace invalid config bytes and let a subsequent swap persist silent corruption. what: - Decode JSON, JSONC, and TOML with strict UTF-8 error reporting - Refuse malformed bytes during both reads and updates - Cover invalid bytes in parseable strings and comments --- .../libtmux/tools/mcpswap/ConfigCodec.java | 20 +++++++++++-- .../libtmux/tools/mcpswap/TomlEditor.java | 4 +-- .../tools/mcpswap/ConfigCodecTest.java | 29 +++++++++++++++++++ 3 files changed, 49 insertions(+), 4 deletions(-) diff --git a/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/ConfigCodec.java b/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/ConfigCodec.java index da50d7d..392a5fa 100644 --- a/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/ConfigCodec.java +++ b/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/ConfigCodec.java @@ -8,6 +8,9 @@ import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.ObjectNode; import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.charset.CharacterCodingException; +import java.nio.charset.CodingErrorAction; import java.nio.charset.StandardCharsets; import java.util.LinkedHashMap; import java.util.Map; @@ -42,7 +45,7 @@ private static byte[] updateJson( Client client, byte[] original, String serverName, ServerSpec server, boolean comments) { try { var mapper = comments ? JSONC : JSON; - var text = new String(original, StandardCharsets.UTF_8); + var text = decodeUtf8(original, client.name() + " config"); JsonNode parsed = text.isBlank() ? mapper.createObjectNode() : mapper.readTree(text); if (!(parsed instanceof ObjectNode root)) { throw new IllegalArgumentException(client.name() + " config root is not an object"); @@ -118,7 +121,7 @@ private static Map environment(Client client, JsonNode entry) { private static Optional readJson(Client client, byte[] raw, String serverName, ObjectMapper mapper) { try { - var text = new String(raw, StandardCharsets.UTF_8); + var text = decodeUtf8(raw, client.name() + " config"); JsonNode parsed = text.isBlank() ? mapper.createObjectNode() : mapper.readTree(text); if (!(parsed instanceof ObjectNode root)) { throw new IllegalArgumentException(client.name() + " config root is not an object"); @@ -175,4 +178,17 @@ private static String text(JsonNode node, Client client) { } return node.textValue(); } + + static String decodeUtf8(byte[] raw, String subject) { + try { + return StandardCharsets.UTF_8 + .newDecoder() + .onMalformedInput(CodingErrorAction.REPORT) + .onUnmappableCharacter(CodingErrorAction.REPORT) + .decode(ByteBuffer.wrap(raw)) + .toString(); + } catch (CharacterCodingException error) { + throw new IllegalArgumentException(subject + " is not valid UTF-8", error); + } + } } diff --git a/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/TomlEditor.java b/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/TomlEditor.java index adf661a..a56a89f 100644 --- a/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/TomlEditor.java +++ b/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/TomlEditor.java @@ -18,7 +18,7 @@ final class TomlEditor { private TomlEditor() {} static byte[] update(byte[] original, String tableName, String serverName, ServerSpec server) { - var text = new String(original, StandardCharsets.UTF_8); + var text = ConfigCodec.decodeUtf8(original, "TOML config"); var existing = read(original, tableName, serverName) .map(ServerSpec::environment) .orElseGet(Map::of); @@ -64,7 +64,7 @@ static byte[] update(byte[] original, String tableName, String serverName, Serve } static Optional read(byte[] raw, String tableName, String serverName) { - var result = requireValid(new String(raw, StandardCharsets.UTF_8)); + var result = requireValid(ConfigCodec.decodeUtf8(raw, "TOML config")); var table = result.getTable(tableName); if (table == null) { return Optional.empty(); diff --git a/tools/mcp-swap/src/test/java/io/github/libtmux/tools/mcpswap/ConfigCodecTest.java b/tools/mcp-swap/src/test/java/io/github/libtmux/tools/mcpswap/ConfigCodecTest.java index db118f1..d0fbc53 100644 --- a/tools/mcp-swap/src/test/java/io/github/libtmux/tools/mcpswap/ConfigCodecTest.java +++ b/tools/mcp-swap/src/test/java/io/github/libtmux/tools/mcpswap/ConfigCodecTest.java @@ -3,6 +3,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import com.fasterxml.jackson.core.JsonFactory; @@ -246,11 +247,39 @@ void readsTheOptionalArgumentListAsEmpty() { .orElseThrow()); } + @Test + void rejectsMalformedUtf8InsteadOfRewritingReplacementCharacters() { + var clients = List.of( + client("claude", ConfigFormat.JSON, false), + client("opencode", ConfigFormat.JSONC, true), + client("codex", ConfigFormat.TOML, false)); + var prefixes = List.of("{\"keep\":\"", "{// keep ", "# keep "); + var suffixes = List.of("\"}\n", "\n}\n", "\nvalue = 1\n"); + + for (int index = 0; index < clients.size(); index++) { + var client = clients.get(index); + var malformed = malformedUtf8(prefixes.get(index), suffixes.get(index)); + assertThrows(IllegalArgumentException.class, () -> ConfigCodec.update(client, malformed, "tmux", SERVER)); + assertThrows(IllegalArgumentException.class, () -> ConfigCodec.read(client, malformed, "tmux")); + } + } + private static Client client(String name, ConfigFormat format, boolean openCode) { var table = openCode ? "mcp" : format == ConfigFormat.TOML ? "mcp_servers" : "mcpServers"; return new Client(name, Path.of("/test/config"), table, format, openCode); } + private static byte[] malformedUtf8(String prefix, String suffix) { + var before = prefix.getBytes(StandardCharsets.UTF_8); + var after = suffix.getBytes(StandardCharsets.UTF_8); + var result = new byte[before.length + 2 + after.length]; + System.arraycopy(before, 0, result, 0, before.length); + result[before.length] = (byte) 0xc3; + result[before.length + 1] = 0x28; + System.arraycopy(after, 0, result, before.length + 2, after.length); + return result; + } + private static void assertStandardEntry(JsonNode entry) { assertEquals("/opt/libtmux-java/bin/libtmux-mcp", entry.path("command").asText()); assertEquals("--socket", entry.path("args").get(0).asText()); From 8167d54faba8bbc377c04e9dc6ded71c8c529615 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 6 Sep 2026 04:02:16 -0500 Subject: [PATCH 60/65] Tools(feat[swap]): Complete native switcher why: The private Java utility must cover every supported client and Claude scope without relying on the retired Python implementation. what: - Add exact preflight, cross-port locking, and layered recovery - Harden eight-client transactions, formats, routes, and cleanup --- build.gradle.kts | 2 +- .../libtmux/tools/mcpswap/AtomicChange.java | 182 ++++++--- .../github/libtmux/tools/mcpswap/Client.java | 39 +- .../libtmux/tools/mcpswap/ClientRegistry.java | 69 +++- .../libtmux/tools/mcpswap/ConfigCodec.java | 92 +++-- .../libtmux/tools/mcpswap/FileContent.java | 19 +- .../libtmux/tools/mcpswap/FileSnapshot.java | 42 +- .../libtmux/tools/mcpswap/McpPreflight.java | 284 +++++++++++++ .../github/libtmux/tools/mcpswap/McpSwap.java | 335 +++++++++++++--- .../libtmux/tools/mcpswap/PathRoute.java | 23 ++ .../libtmux/tools/mcpswap/RecoveryRecord.java | 151 ++++++- .../github/libtmux/tools/mcpswap/Scope.java | 20 + .../libtmux/tools/mcpswap/ServerSpec.java | 11 +- .../libtmux/tools/mcpswap/SwapLock.java | 120 ++++-- .../libtmux/tools/mcpswap/SwapPaths.java | 7 +- .../libtmux/tools/mcpswap/SwapService.java | 354 +++++++++++++---- .../libtmux/tools/mcpswap/TomlEditor.java | 286 ++++++++++++- .../tools/mcpswap/TransactionGuard.java | 39 +- .../tools/mcpswap/ClientRegistryTest.java | 3 + .../tools/mcpswap/ConfigCodecTest.java | 139 ++++++- .../tools/mcpswap/McpPreflightTest.java | 167 ++++++++ .../libtmux/tools/mcpswap/McpSwapTest.java | 281 ++++++++++++- .../tools/mcpswap/SwapServiceTest.java | 376 +++++++++++++++++- 23 files changed, 2683 insertions(+), 358 deletions(-) create mode 100644 tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/McpPreflight.java create mode 100644 tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/Scope.java create mode 100644 tools/mcp-swap/src/test/java/io/github/libtmux/tools/mcpswap/McpPreflightTest.java diff --git a/build.gradle.kts b/build.gradle.kts index 845f02e..4df0a75 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -105,6 +105,6 @@ val kotlinStaysDownstream = tasks.register("check") { group = "verification" description = "Every gate that must hold before publication." - dependsOn(subprojects.map { "${it.path}:check" }) + dependsOn(subprojects.filter { it.buildFile.exists() }.map { "${it.path}:check" }) dependsOn(platformCoversEveryPublishedModule, kotlinStaysDownstream) } diff --git a/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/AtomicChange.java b/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/AtomicChange.java index 1e78138..2a6452e 100644 --- a/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/AtomicChange.java +++ b/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/AtomicChange.java @@ -21,6 +21,7 @@ final class AtomicChange { private @Nullable Path stage; private @Nullable FileSnapshot staged; private @Nullable Path aside; + private @Nullable FileSnapshot published; private @Nullable FileSnapshot committed; AtomicChange(String role, PathRoute route, FileSnapshot before, FileContent desired) { @@ -34,12 +35,11 @@ void stage() throws IOException { if (!desired.exists()) { return; } - Files.createDirectories(route.target().getParent()); - var source = desired.linkSource(); - if (source.isPresent()) { - source.orElseThrow().verify(); + if (staged != null) { + staged.verify(); return; } + Files.createDirectories(route.target().getParent()); stage = Files.createTempFile(route.target().getParent(), ".mcp-swap-new-", ""); try { Files.write(stage, desired.bytes(), StandardOpenOption.TRUNCATE_EXISTING); @@ -54,6 +54,13 @@ void stage() throws IOException { } } + String stagedIdentity() { + if (staged == null) { + throw new IllegalStateException("change was not staged: %s".formatted(route.logical())); + } + return staged.identity(); + } + void commit(TransactionGuard guard, SwapHook hook) throws IOException { try { if (before.exists()) { @@ -81,30 +88,34 @@ void commit(TransactionGuard guard, SwapHook hook) throws IOException { guard.verifyExcept(route.target()); verifyTransition(); if (desired.exists()) { - var source = desired.linkSource(); - if (source.isPresent()) { - source.orElseThrow().verify(); - Files.createLink(route.target(), source.orElseThrow().path()); - } else { - if (stage == null || staged == null) { - throw new IOException("change was not staged: " + route.logical()); - } - staged.verify(); - Files.createLink(route.target(), stage); - Files.delete(stage); - stage = null; + if (stage == null || staged == null) { + throw new IOException("change was not staged: " + route.logical()); } + staged.verify(); + Files.createLink(route.target(), stage); } + hook.before(role + "-post-publish", route.logical()); syncDirectory(route.physicalParent()); - committed = FileSnapshot.capture(route.target()); - verifyDesired(committed); - guard.update(route.logical()); - if (desired.linkSource().isPresent()) { - guard.updateTarget(desired.linkSource().orElseThrow().path()); + var publishedCandidate = FileSnapshot.capture(route.target()); + verifyDesired(publishedCandidate, desired.exists() && stage != null ? 2 : 1); + published = publishedCandidate; + if (stage != null && staged != null) { + var linkedStage = FileSnapshot.capture(stage); + if (!linkedStage.sameFile(published)) { + throw new IOException("published stage identity changed: " + route.logical()); + } + removeExact(stage, linkedStage, guard, hook, role + "-stage-remove"); + stage = null; } + syncDirectory(route.physicalParent()); + var candidate = FileSnapshot.capture(route.target()); + verifyDesired(candidate, desired.exists() ? 1 : 0); + committed = candidate; + hook.before(role + "-pre-update", route.logical()); + guard.update(route.logical(), committed); } catch (IOException | RuntimeException error) { try { - rollbackPartial(guard); + rollbackPartial(guard, hook); } catch (IOException rollback) { error.addSuppressed(rollback); } @@ -112,7 +123,7 @@ void commit(TransactionGuard guard, SwapHook hook) throws IOException { } } - void rollback(TransactionGuard guard) throws IOException { + void rollback(TransactionGuard guard, SwapHook hook) throws IOException { if (committed == null) { return; } @@ -122,55 +133,61 @@ void rollback(TransactionGuard guard) throws IOException { if (!current.same(committed)) { throw new IOException("cannot roll back a changed destination: " + route.logical()); } - Path discard = null; if (current.exists()) { - discard = unique(route.target(), "rollback"); - move(route.target(), discard); + removeExact(route.target(), current, guard, hook, role + "-rollback-remove"); } if (before.exists()) { - if (aside == null || !FileSnapshot.capture(aside).sameFile(before)) { - throw new IOException("rollback source changed: " + route.logical()); - } - Files.createLink(route.target(), aside); - Files.delete(aside); - aside = null; - } - if (discard != null) { - Files.delete(discard); + restoreAside(guard, hook); } syncDirectory(route.physicalParent()); committed = null; + published = null; before.verify(); - guard.update(route.logical()); - if (desired.linkSource().isPresent()) { - guard.updateTarget(desired.linkSource().orElseThrow().path()); - } + guard.update(route.logical(), before); } - void cleanup() throws IOException { - cleanupStage(); + void cleanup(TransactionGuard guard, SwapHook hook) throws IOException { + cleanupStage(guard, hook); if (aside != null) { - if (!FileSnapshot.capture(aside).sameFile(before)) { + if (committed == null) { + throw new IOException("original retained after incomplete change: " + aside); + } + verifyRouteTransition(); + if (!FileSnapshot.capture(route.target()).same(committed)) { + throw new IOException("destination changed before cleanup: " + route.logical()); + } + var retained = FileSnapshot.capture(aside); + if (!retained.sameFile(before)) { throw new IOException("take-aside file changed: " + aside); } - Files.delete(aside); + removeExact(aside, retained, guard, hook, role + "-cleanup-remove"); aside = null; } } - void cleanupStage() throws IOException { + void cleanupStage(TransactionGuard guard, SwapHook hook) throws IOException { if (stage != null) { - if (staged == null || !FileSnapshot.capture(stage).same(staged)) { + if (staged == null) { throw new IOException("staged file changed: " + stage); } - Files.delete(stage); + var current = FileSnapshot.capture(stage); + if (!current.same(staged)) { + throw new IOException("staged file changed: " + stage); + } + removeExact(stage, current, guard, hook, role + "-stage-cleanup-remove"); stage = null; } } - private void rollbackPartial(TransactionGuard guard) throws IOException { + private void rollbackPartial(TransactionGuard guard, SwapHook hook) throws IOException { if (committed != null) { - rollback(guard); + rollback(guard, hook); + return; + } + var current = FileSnapshot.capture(route.target()); + if (published != null && current.sameExceptLinks(published)) { + committed = current; + rollback(guard, hook); return; } if (aside == null) { @@ -178,18 +195,33 @@ private void rollbackPartial(TransactionGuard guard) throws IOException { } guard.verifyLock(); verifyRouteTransition(); - var current = FileSnapshot.capture(route.target()); if (current.exists()) { throw new IOException("late destination preserved; original retained at " + aside); } - if (!FileSnapshot.capture(aside).sameFile(before)) { - throw new IOException("take-aside source changed: " + aside); + restoreAside(guard, hook); + before.verify(); + guard.update(route.logical(), before); + } + + private void restoreAside(TransactionGuard guard, SwapHook hook) throws IOException { + if (aside == null) { + throw new IOException("rollback source is missing: " + route.logical()); + } + var retained = FileSnapshot.capture(aside); + if (!retained.sameFile(before)) { + throw new IOException("rollback source changed: " + route.logical()); } Files.createLink(route.target(), aside); - Files.delete(aside); + var restored = FileSnapshot.capture(route.target()); + var linkedAside = FileSnapshot.capture(aside); + if (!restored.sameFile(linkedAside) || !restored.sameExceptLinks(before)) { + throw new IOException("rollback publication changed: " + route.logical()); + } + removeExact(aside, linkedAside, guard, hook, role + "-rollback-source-remove"); aside = null; - before.verify(); - guard.update(route.logical()); + if (!FileSnapshot.capture(route.target()).same(before)) { + throw new IOException("rollback destination changed: " + route.logical()); + } } private void verifyTransition() throws IOException { @@ -227,19 +259,53 @@ private void verifyRouteTransition() throws IOException { } } - private void verifyDesired(FileSnapshot current) throws IOException { + private void verifyDesired(FileSnapshot current, int links) throws IOException { if (current.exists() != desired.exists() || (desired.exists() && (!current.digest().equals(desired.digest()) - || !current.permissions().equals(desired.permissions())))) { + || !current.permissions().equals(desired.permissions()) + || current.links() != links))) { throw new IOException("published file does not match staged bytes: " + route.logical()); } - if (desired.linkSource().isPresent() - && !current.identity().equals(desired.linkSource().orElseThrow().identity())) { + if (desired.exists() && (staged == null || !current.sameExceptLinks(staged))) { throw new IOException("published hard link changed identity: " + route.logical()); } } + private static void removeExact( + Path path, FileSnapshot expected, TransactionGuard guard, SwapHook hook, String boundary) + throws IOException { + guard.verifyLock(); + hook.before(boundary, path); + guard.verifyLock(); + if (!FileSnapshot.capture(path).same(expected)) { + throw new IOException("file changed before removal: " + path); + } + var parent = path.getParent(); + if (parent == null) { + throw new IOException("file has no parent: " + path); + } + var retainedDirectory = Files.createTempDirectory( + parent, + "." + path.getFileName() + ".mcp-swap-retained-", + PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rwx------"))); + var retained = retainedDirectory.resolve("artifact"); + move(path, retained); + var moved = FileSnapshot.capture(retained); + if (!moved.sameFile(expected)) { + throw new IOException("file changed during removal; retained at " + retainedDirectory); + } + if (FileSnapshot.capture(path).exists()) { + throw new IOException("late destination preserved; recovery retained at " + retainedDirectory); + } + moved.verify(); + Files.delete(retained); + if (Files.exists(retained, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("removed file remains at " + retained); + } + Files.delete(retainedDirectory); + } + private static Path unique(Path target, String role) { return target.resolveSibling("." + target.getFileName() + ".mcp-swap-" + role + "-" + UUID.randomUUID()); } diff --git a/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/Client.java b/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/Client.java index ab58de4..2e051a3 100644 --- a/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/Client.java +++ b/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/Client.java @@ -2,4 +2,41 @@ import java.nio.file.Path; -record Client(String name, Path configPath, String serverTable, ConfigFormat format, boolean openCode) {} +record Client( + String name, + String binary, + Path configPath, + String serverTable, + ConfigFormat format, + boolean openCode, + Scope scope, + Path repository) { + Client(String name, Path configPath, String serverTable, ConfigFormat format, boolean openCode) { + this( + name, + name, + configPath, + serverTable, + format, + openCode, + Scope.USER, + Path.of("/").toAbsolutePath()); + } + + Client scoped(Scope requested, Path repo) { + var normalized = name.equals("claude") ? requested : Scope.USER; + return new Client( + name, + binary, + configPath, + serverTable, + format, + openCode, + normalized, + repo.toAbsolutePath().normalize()); + } + + String label() { + return name.equals("claude") ? name + ":" + scope.value() : name; + } +} diff --git a/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/ClientRegistry.java b/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/ClientRegistry.java index 8395f2a..e627d0a 100644 --- a/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/ClientRegistry.java +++ b/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/ClientRegistry.java @@ -14,15 +14,55 @@ static List knownClients(Path home, Map environment) { var configHome = xdg != null && !xdg.isBlank() && Path.of(xdg).isAbsolute() ? Path.of(xdg) : home.resolve(".config"); return List.of( - new Client("claude", home.resolve(".claude.json"), "mcpServers", ConfigFormat.JSON, false), - new Client("codex", home.resolve(".codex/config.toml"), "mcp_servers", ConfigFormat.TOML, false), - new Client("cursor", home.resolve(".cursor/mcp.json"), "mcpServers", ConfigFormat.JSON, false), - new Client("gemini", home.resolve(".gemini/settings.json"), "mcpServers", ConfigFormat.JSON, false), - new Client("grok", home.resolve(".grok/config.toml"), "mcp_servers", ConfigFormat.TOML, false), + client("claude", "claude", home.resolve(".claude.json"), "mcpServers", ConfigFormat.JSON, false), + client("codex", "codex", home.resolve(".codex/config.toml"), "mcp_servers", ConfigFormat.TOML, false), + client( + "cursor", + "cursor-agent", + home.resolve(".cursor/mcp.json"), + "mcpServers", + ConfigFormat.JSON, + false), + client( + "gemini", + "gemini", + home.resolve(".gemini/settings.json"), + "mcpServers", + ConfigFormat.JSON, + false), + client("grok", "grok", home.resolve(".grok/config.toml"), "mcp_servers", ConfigFormat.TOML, false), new Client( - "agy", home.resolve(".gemini/config/mcp_config.json"), "mcpServers", ConfigFormat.JSON, false), - new Client("opencode", configHome.resolve("opencode/opencode.jsonc"), "mcp", ConfigFormat.JSONC, true), - new Client("pi", home.resolve(".pi/agent/mcp.json"), "mcpServers", ConfigFormat.JSONC, false)); + "agy", + "agy", + home.resolve(".gemini/config/mcp_config.json"), + "mcpServers", + ConfigFormat.JSON, + false, + Scope.USER, + home), + client( + "opencode", + "opencode", + configHome.resolve("opencode/opencode.jsonc"), + "mcp", + ConfigFormat.JSONC, + true), + client("pi", "pi", home.resolve(".pi/agent/mcp.json"), "mcpServers", ConfigFormat.JSONC, false)); + } + + static List scoped(List clients, Scope requested, Path repository) { + return clients.stream() + .map(client -> client.scoped(requested, repository)) + .toList(); + } + + static List allScopes(List clients, Path repository) { + return clients.stream() + .flatMap(client -> client.name().equals("claude") + ? java.util.stream.Stream.of( + client.scoped(Scope.USER, repository), client.scoped(Scope.PROJECT, repository)) + : java.util.stream.Stream.of(client.scoped(Scope.USER, repository))) + .toList(); } static List select(List clients, List selectors) { @@ -49,4 +89,17 @@ static List select(List clients, List selectors) { } return clients.stream().filter(client -> wanted.contains(client.name())).toList(); } + + private static Client client( + String name, String binary, Path path, String table, ConfigFormat format, boolean openCode) { + return new Client( + name, + binary, + path, + table, + format, + openCode, + Scope.USER, + path.toAbsolutePath().getRoot()); + } } diff --git a/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/ConfigCodec.java b/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/ConfigCodec.java index 392a5fa..c48d41b 100644 --- a/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/ConfigCodec.java +++ b/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/ConfigCodec.java @@ -2,7 +2,9 @@ import com.fasterxml.jackson.core.JsonFactory; import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.StreamReadFeature; import com.fasterxml.jackson.core.json.JsonReadFeature; +import com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ArrayNode; @@ -14,22 +16,38 @@ import java.nio.charset.StandardCharsets; import java.util.LinkedHashMap; import java.util.Map; +import java.util.Objects; import java.util.Optional; +import org.jspecify.annotations.Nullable; final class ConfigCodec { - private static final ObjectMapper JSON = new ObjectMapper(); + private static final ObjectMapper JSON = new ObjectMapper(JsonFactory.builder() + .enable(StreamReadFeature.STRICT_DUPLICATE_DETECTION) + .build()) + .enable(DeserializationFeature.FAIL_ON_TRAILING_TOKENS); private static final ObjectMapper JSONC = new ObjectMapper(JsonFactory.builder() - .enable(JsonReadFeature.ALLOW_JAVA_COMMENTS) - .enable(JsonReadFeature.ALLOW_TRAILING_COMMA) - .build()); + .enable(StreamReadFeature.STRICT_DUPLICATE_DETECTION) + .enable(JsonReadFeature.ALLOW_JAVA_COMMENTS) + .enable(JsonReadFeature.ALLOW_TRAILING_COMMA) + .build()) + .enable(DeserializationFeature.FAIL_ON_TRAILING_TOKENS); private ConfigCodec() {} static byte[] update(Client client, byte[] original, String serverName, ServerSpec server) { + return update(client, original, serverName, server, true); + } + + static byte[] updateExact(Client client, byte[] original, String serverName, ServerSpec server) { + return update(client, original, serverName, server, false); + } + + private static byte[] update( + Client client, byte[] original, String serverName, ServerSpec server, boolean mergeEnvironment) { return switch (client.format()) { - case JSON -> updateJson(client, original, serverName, server, false); - case JSONC -> updateJson(client, original, serverName, server, true); - case TOML -> TomlEditor.update(original, client.serverTable(), serverName, server); + case JSON -> updateJson(client, original, serverName, server, false, mergeEnvironment); + case JSONC -> updateJson(client, original, serverName, server, true, mergeEnvironment); + case TOML -> TomlEditor.update(original, client.serverTable(), serverName, server, mergeEnvironment); }; } @@ -42,7 +60,12 @@ static Optional read(Client client, byte[] raw, String serverName) { } private static byte[] updateJson( - Client client, byte[] original, String serverName, ServerSpec server, boolean comments) { + Client client, + byte[] original, + String serverName, + ServerSpec server, + boolean comments, + boolean mergeEnvironment) { try { var mapper = comments ? JSONC : JSON; var text = decodeUtf8(original, client.name() + " config"); @@ -50,18 +73,9 @@ private static byte[] updateJson( if (!(parsed instanceof ObjectNode root)) { throw new IllegalArgumentException(client.name() + " config root is not an object"); } - var tableNode = root.get(client.serverTable()); - ObjectNode table; - if (tableNode == null) { - table = mapper.createObjectNode(); - root.set(client.serverTable(), table); - } else if (tableNode instanceof ObjectNode object) { - table = object; - } else { - throw new IllegalArgumentException(client.name() + " server table is not an object"); - } + var table = Objects.requireNonNull(serverTable(root, client, mapper, true)); var current = table.get(serverName); - var merged = server.withEnvironment(environment(client, current)); + var merged = mergeEnvironment ? server.withEnvironment(environment(client, current)) : server; table.set(serverName, entry(mapper, client, merged)); if (comments) { return JsoncEditor.merge(text, root, mapper).getBytes(StandardCharsets.UTF_8); @@ -126,14 +140,11 @@ private static Optional readJson(Client client, byte[] raw, String s if (!(parsed instanceof ObjectNode root)) { throw new IllegalArgumentException(client.name() + " config root is not an object"); } - var table = root.get(client.serverTable()); + var table = serverTable(root, client, mapper, false); if (table == null) { return Optional.empty(); } - if (!(table instanceof ObjectNode servers)) { - throw new IllegalArgumentException(client.name() + " server table is not an object"); - } - var entry = servers.get(serverName); + var entry = table.get(serverName); if (entry == null) { return Optional.empty(); } @@ -179,6 +190,39 @@ private static String text(JsonNode node, Client client) { return node.textValue(); } + private static @Nullable ObjectNode serverTable( + ObjectNode root, Client client, ObjectMapper mapper, boolean create) { + ObjectNode parent = root; + if (client.name().equals("claude") && client.scope() == Scope.PROJECT) { + var projects = objectChild(root, "projects", client, mapper, create); + if (projects == null) { + return null; + } + parent = objectChild(projects, client.repository().toString(), client, mapper, create); + if (parent == null) { + return null; + } + } + return objectChild(parent, client.serverTable(), client, mapper, create); + } + + private static @Nullable ObjectNode objectChild( + ObjectNode parent, String name, Client client, ObjectMapper mapper, boolean create) { + var child = parent.get(name); + if (child == null) { + if (!create) { + return null; + } + var created = mapper.createObjectNode(); + parent.set(name, created); + return created; + } + if (child instanceof ObjectNode object) { + return object; + } + throw new IllegalArgumentException(client.label() + " config node " + name + " is not an object"); + } + static String decodeUtf8(byte[] raw, String subject) { try { return StandardCharsets.UTF_8 diff --git a/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/FileContent.java b/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/FileContent.java index 7fc5c6d..c2fe92a 100644 --- a/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/FileContent.java +++ b/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/FileContent.java @@ -4,28 +4,19 @@ final class FileContent { private final boolean exists; private final byte[] data; private final String permissions; - private final java.util.Optional linkSource; - private FileContent(boolean exists, byte[] data, String permissions, java.util.Optional linkSource) { + private FileContent(boolean exists, byte[] data, String permissions) { this.exists = exists; this.data = data.clone(); this.permissions = permissions; - this.linkSource = linkSource; } static FileContent absent() { - return new FileContent(false, new byte[0], "", java.util.Optional.empty()); + return new FileContent(false, new byte[0], ""); } static FileContent of(byte[] data, String permissions) { - return new FileContent(true, data, permissions, java.util.Optional.empty()); - } - - static FileContent linked(FileSnapshot source) { - if (!source.exists()) { - throw new IllegalArgumentException("link source is absent"); - } - return new FileContent(true, source.bytes(), source.permissions(), java.util.Optional.of(source)); + return new FileContent(true, data, permissions); } byte[] bytes() { @@ -40,10 +31,6 @@ String permissions() { return permissions; } - java.util.Optional linkSource() { - return linkSource; - } - String digest() { return exists ? FileSnapshot.sha256(data) : ""; } diff --git a/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/FileSnapshot.java b/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/FileSnapshot.java index 51941a9..4626717 100644 --- a/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/FileSnapshot.java +++ b/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/FileSnapshot.java @@ -1,6 +1,8 @@ package io.github.libtmux.tools.mcpswap; import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.channels.FileChannel; import java.nio.file.Files; import java.nio.file.LinkOption; import java.nio.file.Path; @@ -11,6 +13,7 @@ import java.security.NoSuchAlgorithmException; import java.util.Arrays; import java.util.HexFormat; +import org.jspecify.annotations.Nullable; final class FileSnapshot { private static final long MAX_CONFIG_BYTES = 16L * 1024 * 1024; @@ -47,6 +50,10 @@ final class FileSnapshot { } static FileSnapshot capture(Path path) throws IOException { + return capture(path, null); + } + + static FileSnapshot capture(Path path, @Nullable FileChannel channel) throws IOException { var normalized = path.toAbsolutePath().normalize(); if (!Files.exists(normalized, LinkOption.NOFOLLOW_LINKS)) { return new FileSnapshot(normalized, false, "", "", 0, 0, "", "", new byte[0]); @@ -58,9 +65,12 @@ static FileSnapshot capture(Path path) throws IOException { if (basic.size() > MAX_CONFIG_BYTES) { throw new IOException("file exceeds 16 MiB: " + normalized); } + if (channel == null) { + SwapLock.rejectAlias(String.valueOf(basic.fileKey()), normalized); + } var posix = Files.readAttributes(normalized, PosixFileAttributes.class, LinkOption.NOFOLLOW_LINKS); var links = links(normalized); - var data = Files.readAllBytes(normalized); + var data = channel == null ? Files.readAllBytes(normalized) : read(channel, basic.size()); var after = Files.readAttributes(normalized, BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS); var posixAfter = Files.readAttributes(normalized, PosixFileAttributes.class, LinkOption.NOFOLLOW_LINKS); var linksAfter = links(normalized); @@ -150,6 +160,12 @@ void verify() throws IOException { } } + void verify(FileChannel channel) throws IOException { + if (!same(capture(path, channel))) { + throw new IOException("file changed: " + path); + } + } + static String sha256(byte[] data) { try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(data)); @@ -162,4 +178,28 @@ private static int links(Path path) throws IOException { var raw = Files.getAttribute(path, "unix:nlink", LinkOption.NOFOLLOW_LINKS); return raw instanceof Number number ? number.intValue() : 0; } + + private static byte[] read(FileChannel channel, long expectedSize) throws IOException { + if (channel.size() != expectedSize) { + throw new IOException("open file does not match path size"); + } + var data = new byte[Math.toIntExact(expectedSize)]; + var buffer = ByteBuffer.wrap(data); + long offset = 0; + while (buffer.hasRemaining()) { + var count = channel.read(buffer, offset); + if (count < 0) { + throw new IOException("open file ended before path size"); + } + if (count == 0) { + Thread.onSpinWait(); + continue; + } + offset += count; + } + if (channel.size() != expectedSize) { + throw new IOException("open file changed while it was read"); + } + return data; + } } diff --git a/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/McpPreflight.java b/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/McpPreflight.java new file mode 100644 index 0000000..90d3de6 --- /dev/null +++ b/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/McpPreflight.java @@ -0,0 +1,284 @@ +package io.github.libtmux.tools.mcpswap; + +import com.fasterxml.jackson.core.JsonFactory; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.StreamReadFeature; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.TimeUnit; +import org.jspecify.annotations.Nullable; + +final class McpPreflight { + private static final byte[] INITIALIZE = """ + {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"mcp-swap-preflight","version":"1"}}} + """.getBytes(StandardCharsets.UTF_8); + private static final Duration TIMEOUT = Duration.ofMinutes(5); + private static final int MAX_OUTPUT_BYTES = 1024 * 1024; + private static final ObjectMapper JSON = new ObjectMapper(JsonFactory.builder() + .enable(StreamReadFeature.STRICT_DUPLICATE_DETECTION) + .build()) + .enable(DeserializationFeature.FAIL_ON_TRAILING_TOKENS); + + private McpPreflight() {} + + static void run(ServerSpec spec, Map baseEnvironment, Path directory) + throws IOException, InterruptedException { + run(spec, baseEnvironment, directory, TIMEOUT, MAX_OUTPUT_BYTES); + } + + static void run(ServerSpec spec, Map baseEnvironment, Duration timeout, int maximumOutputBytes) + throws IOException, InterruptedException { + run(spec, baseEnvironment, null, timeout, maximumOutputBytes); + } + + private static void run( + ServerSpec spec, + Map baseEnvironment, + @Nullable Path directory, + Duration timeout, + int maximumOutputBytes) + throws IOException, InterruptedException { + if (timeout.isZero() || timeout.isNegative()) { + throw new IllegalArgumentException("preflight timeout must be positive"); + } + if (maximumOutputBytes < 1) { + throw new IllegalArgumentException("preflight output limit must be positive"); + } + var builder = new ProcessBuilder(command(spec)); + if (directory != null) { + builder.directory(directory.toFile()); + } + builder.environment().clear(); + builder.environment().putAll(baseEnvironment); + builder.environment().putAll(spec.environment()); + var process = builder.start(); + BlockingQueue events = new LinkedBlockingQueue<>(); + var stdout = reader(process.getInputStream(), Stream.STDOUT, maximumOutputBytes, events); + var stderr = reader(process.getErrorStream(), Stream.STDERR, maximumOutputBytes, events); + var diagnostics = new ByteArrayOutputStream(); + var input = process.getOutputStream(); + try { + input.write(INITIALIZE); + input.flush(); + var deadline = System.nanoTime() + timeout.toNanos(); + var eof = 0; + while (System.nanoTime() < deadline) { + var wait = Math.min(TimeUnit.NANOSECONDS.toMillis(deadline - System.nanoTime()), 25L); + var event = events.poll(Math.max(wait, 1L), TimeUnit.MILLISECONDS); + if (event == null) { + if (eof == 2 && !process.isAlive()) { + throw noResponse(process, diagnostics); + } + continue; + } + if (event.failure() != null) { + throw event.failure(); + } + if (event.eof()) { + eof++; + if (eof == 2 && !process.isAlive()) { + throw noResponse(process, diagnostics); + } + continue; + } + if (event.stream() == Stream.STDERR) { + diagnostics.writeBytes(event.data()); + continue; + } + if (initializeAccepted(event.data())) { + return; + } + } + throw new IOException("no MCP response within " + timeout.toSeconds() + "s"); + } finally { + try { + input.close(); + } catch (IOException ignored) { + // The process may already have closed its stdin; termination is authoritative. + } + terminate(process); + stdout.join(Duration.ofSeconds(2)); + stderr.join(Duration.ofSeconds(2)); + } + } + + private static List command(ServerSpec spec) { + List command = new ArrayList<>(spec.arguments().size() + 1); + command.add(spec.command()); + command.addAll(spec.arguments()); + return List.copyOf(command); + } + + private static Thread reader( + InputStream input, Stream stream, int maximumOutputBytes, BlockingQueue events) { + return Thread.ofPlatform() + .daemon() + .name("mcp-swap-preflight-" + stream.name().toLowerCase(java.util.Locale.ROOT)) + .start(() -> { + var pending = new ByteArrayOutputStream(); + var total = 0; + try (input) { + while (true) { + var value = input.read(); + if (value < 0) { + if (pending.size() != 0) { + events.put(Event.data(stream, pending.toByteArray())); + } + events.put(Event.eof(stream)); + return; + } + total++; + if (total > maximumOutputBytes) { + events.put(Event.failure( + stream, + new IOException( + "MCP " + stream.label + " exceeded " + maximumOutputBytes + " bytes"))); + return; + } + pending.write(value); + if (value == '\n') { + events.put(Event.data(stream, pending.toByteArray())); + pending.reset(); + } + } + } catch (IOException error) { + try { + events.put(Event.failure(stream, new IOException("read MCP " + stream.label, error))); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + } + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + } + }); + } + + private static boolean initializeAccepted(byte[] raw) throws IOException { + final ObjectNode message; + try { + var parsed = JSON.readTree(ConfigCodec.decodeUtf8(raw, "MCP preflight stdout")); + if (!(parsed instanceof ObjectNode object)) { + return false; + } + message = object; + } catch (JsonProcessingException invalid) { + return false; + } + if (!message.path("id").canConvertToInt() || message.path("id").intValue() != 1) { + return false; + } + if (message.has("error")) { + var detail = message.path("error").path("message"); + throw new IOException(detail.isTextual() ? detail.textValue() : "initialize returned an MCP error"); + } + var result = message.get("result"); + if (!message.path("jsonrpc").isTextual() + || !message.path("jsonrpc").textValue().equals("2.0") + || !(result instanceof ObjectNode object) + || !object.path("protocolVersion").isTextual() + || object.path("protocolVersion").textValue().isBlank()) { + throw new IOException("initialize response is incomplete"); + } + return true; + } + + private static IOException noResponse(Process process, ByteArrayOutputStream diagnostics) { + var text = diagnostics.toString(StandardCharsets.UTF_8).strip(); + if (!text.isEmpty()) { + var lines = text.lines().toList(); + return new IOException(String.join("\n", lines.subList(Math.max(0, lines.size() - 3), lines.size()))); + } + return new IOException("server exited with " + process.exitValue() + " without answering initialize"); + } + + private static void terminate(Process process) throws IOException, InterruptedException { + Set descendants = new HashSet<>(); + for (var attempt = 0; attempt < 4; attempt++) { + process.descendants().forEach(descendants::add); + descendants.stream() + .sorted(Comparator.comparingLong(ProcessHandle::pid).reversed()) + .filter(ProcessHandle::isAlive) + .forEach(ProcessHandle::destroyForcibly); + if (process.isAlive()) { + process.destroyForcibly(); + } + if (process.waitFor(250, TimeUnit.MILLISECONDS) + && descendants.stream().noneMatch(ProcessHandle::isAlive)) { + return; + } + } + process.waitFor(1, TimeUnit.SECONDS); + if (process.isAlive() || descendants.stream().anyMatch(ProcessHandle::isAlive)) { + throw new IOException("could not terminate the MCP preflight process tree"); + } + } + + private enum Stream { + STDOUT("stdout"), + STDERR("stderr"); + + private final String label; + + Stream(String label) { + this.label = label; + } + } + + private static final class Event { + private final Stream stream; + private final byte[] data; + private final boolean eof; + private final @Nullable IOException failure; + + private Event(Stream stream, byte[] data, boolean eof, @Nullable IOException failure) { + this.stream = stream; + this.data = data.clone(); + this.eof = eof; + this.failure = failure; + } + + static Event data(Stream stream, byte[] data) { + return new Event(stream, data, false, null); + } + + static Event eof(Stream stream) { + return new Event(stream, new byte[0], true, null); + } + + static Event failure(Stream stream, IOException failure) { + return new Event(stream, new byte[0], false, failure); + } + + Stream stream() { + return stream; + } + + byte[] data() { + return data.clone(); + } + + boolean eof() { + return eof; + } + + @Nullable + IOException failure() { + return failure; + } + } +} diff --git a/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/McpSwap.java b/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/McpSwap.java index 77d3b43..9a940d9 100644 --- a/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/McpSwap.java +++ b/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/McpSwap.java @@ -1,5 +1,6 @@ package io.github.libtmux.tools.mcpswap; +import java.io.File; import java.io.IOException; import java.io.PrintStream; import java.nio.file.Files; @@ -18,6 +19,13 @@ public final class McpSwap { private static final int CLIENT_COLUMN = 9; private static final String PI_ADAPTER_HINT = "needs the pi-mcp-adapter; Pi has no built-in MCP client"; + private static final Map AUTH_ENVIRONMENT = Map.of( + "ANTHROPIC_API_KEY", "claude", + "OPENAI_API_KEY", "codex", + "GEMINI_API_KEY", "gemini", + "GOOGLE_API_KEY", "gemini", + "XAI_API_KEY", "grok", + "GROK_API_KEY", "grok"); private McpSwap() {} @@ -57,20 +65,32 @@ static int run(String[] arguments, Context context) { private static int detect(List clients, Context context) { for (var client : clients) { - var present = exists(client.configPath()); - var backup = exists(SwapPaths.backup(client)); - var state = exists(SwapPaths.state(client)); - var recovery = backup && state ? " (swapped)" : backup || state ? " (incomplete recovery)" : ""; + var config = exists(client.configPath()); + var binary = executable(client, context.environment()) != null; + var targets = ClientRegistry.allScopes(List.of(client), context.repository()); + var swapped = targets.stream() + .anyMatch(target -> exists(SwapPaths.backup(target)) && exists(SwapPaths.state(target))); + var incomplete = targets.stream() + .anyMatch(target -> exists(SwapPaths.backup(target)) != exists(SwapPaths.state(target))); + var recovery = incomplete ? " (incomplete recovery)" : swapped ? " (swapped)" : ""; var caveat = client.name().equals("pi") && !Files.isDirectory(piAdapter(context.home())) ? " -- " + PI_ADAPTER_HINT : ""; + List missing = new ArrayList<>(); + if (!binary) { + missing.add("binary missing"); + } + if (!config) { + missing.add("config missing: " + client.configPath()); + } + var detail = missing.isEmpty() ? "" : " (" + String.join(", ", missing) + ")"; context.out() .printf( Locale.ROOT, - "%-" + CLIENT_COLUMN + "s %-8s %s%s%s%n", + "[%3s] %-" + CLIENT_COLUMN + "s%s%s%s%n", + binary && config ? "yes" : "no", client.name(), - present ? "present" : "missing", - client.configPath(), + detail, recovery, caveat); } @@ -79,7 +99,7 @@ private static int detect(List clients, Context context) { private static int status(Arguments options, List clients, Context context) { var failed = false; - for (var client : selected(options, clients)) { + for (var client : statusTargets(options, clients, context)) { if (!exists(client.configPath())) { continue; } @@ -90,18 +110,18 @@ private static int status(Arguments options, List clients, Context conte .printf( Locale.ROOT, "%-" + CLIENT_COLUMN + "s no '%s' server%n", - client.name(), + client.label(), options.name()); } else { context.out() .printf( Locale.ROOT, "%-" + CLIENT_COLUMN + "s %s%n", - client.name(), + client.label(), describe(spec.orElseThrow())); } } catch (IOException | IllegalArgumentException error) { - context.err().println(client.name() + " unreadable: " + error.getMessage()); + context.err().println(client.label() + " unreadable: " + error.getMessage()); failed = true; } } @@ -110,62 +130,77 @@ private static int status(Arguments options, List clients, Context conte private static int use(Arguments options, List clients, Context context) throws IOException, InterruptedException { - var requested = selected(options, clients); + var requested = scoped( + defaultSelected(options, clients, context), + options.scope() == null ? Scope.PROJECT : options.scope(), + context.repository()); var targets = new ArrayList(); for (var client : requested) { if (exists(client.configPath())) { targets.add(client); } else { - context.out().printf(Locale.ROOT, "%-" + CLIENT_COLUMN + "s skipped, no config%n", client.name()); + context.out().printf(Locale.ROOT, "%-" + CLIENT_COLUMN + "s skipped, no config%n", client.label()); } } if (targets.isEmpty()) { - return 0; + context.err().println("no CLIs detected; nothing to do"); + return 1; } var spec = launcher(options, context.repository()); var service = new SwapService(context.home(), context.environment()); - service.use(targets, clients, options.name(), spec, true); + var allTargets = ClientRegistry.allScopes(clients, context.repository()); + var preview = service.previewUse(targets, allTargets, options.name(), spec); context.err().println("pointing '" + options.name() + "' at: " + describe(spec)); if (options.dryRun()) { - for (var client : targets) { + for (var planned : preview) { context.out() .printf( Locale.ROOT, "%-" + CLIENT_COLUMN + "s would set %s = %s%n", - client.name(), + planned.label(), options.name(), - describe(spec)); + describe(planned.spec())); } return 0; } if (options.source() == Source.DIST) { buildDistribution(context); } - service.use(targets, clients, options.name(), spec, false); + List preflighted = null; + if (!options.noPreflight()) { + preflighted = service.previewUse(targets, allTargets, options.name(), spec); + for (var planned : preflighted) { + context.err().println("preflight: [" + planned.label() + "] " + describe(planned.spec())); + context.preflight().run(planned.spec()); + } + } + service.use(targets, allTargets, options.name(), spec, false, preflighted); for (var client : targets) { - context.out().printf(Locale.ROOT, "%-" + CLIENT_COLUMN + "s set %s%n", client.name(), options.name()); + context.out().printf(Locale.ROOT, "%-" + CLIENT_COLUMN + "s set %s%n", client.label(), options.name()); } return 0; } private static int revert(Arguments options, List clients, Context context) throws IOException { - var targets = selected(options, clients).stream() + var selected = revertSelected(options, clients, context); + var targets = revertTargets(options, selected, context.repository()).stream() .filter(client -> exists(SwapPaths.backup(client)) || exists(SwapPaths.state(client))) .toList(); if (targets.isEmpty()) { - for (var client : selected(options, clients)) { + for (var client : selected) { context.out().printf(Locale.ROOT, "%-" + CLIENT_COLUMN + "s nothing to revert%n", client.name()); } return 0; } var service = new SwapService(context.home(), context.environment()); - service.revert(targets, clients, options.name(), options.dryRun()); + service.revert( + targets, ClientRegistry.allScopes(clients, context.repository()), options.name(), options.dryRun()); for (var client : targets) { context.out() .printf( Locale.ROOT, "%-" + CLIENT_COLUMN + "s %s%n", - client.name(), + client.label(), options.dryRun() ? "would restore" : "restored"); } return 0; @@ -174,8 +209,10 @@ private static int revert(Arguments options, List clients, Context conte private static int doctor(Arguments options, List clients, Context context) { var ready = true; ServerSpec spec = null; + var chosen = selected(options, clients); + var allTargets = ClientRegistry.allScopes(clients, context.repository()); var gradle = context.repository().resolve("gradlew"); - if (!Files.isRegularFile(gradle) || !Files.isExecutable(gradle)) { + if (options.source() != Source.PATH && (!Files.isRegularFile(gradle) || !Files.isExecutable(gradle))) { context.out().println("no executable gradlew: is this the repository root?"); ready = false; } @@ -189,30 +226,58 @@ private static int doctor(Arguments options, List clients, Context conte context.out().println("no built MCP launcher; run './gradlew :libtmux-mcp:installDist'"); ready = false; } - for (var client : selected(options, clients)) { + for (var client : chosen) { + if (!exists(client.configPath())) { + continue; + } + if (executable(client, context.environment()) == null) { + context.out().println(client.name() + " binary missing from PATH"); + } + } + for (var client : ClientRegistry.allScopes(chosen, context.repository())) { if (!exists(client.configPath())) { continue; } try { ConfigCodec.read(client, readConfig(client), options.name()); } catch (IOException | IllegalArgumentException error) { - context.out().println(client.name() + " will not parse: " + error.getMessage()); + context.out().println(client.label() + " will not parse: " + error.getMessage()); ready = false; } } if (spec != null) { - var targets = selected(options, clients).stream() + var targets = scoped(chosen, Scope.PROJECT, context.repository()).stream() .filter(client -> exists(client.configPath())) .toList(); try { new SwapService(context.home(), context.environment()) - .use(targets, clients, options.name(), spec, true); + .previewUse(targets, allTargets, options.name(), spec); } catch (IOException | IllegalArgumentException error) { context.out().println("swap plan is not safe: " + detail(error)); ready = false; } } - if (exists(clients.getLast().configPath()) && !Files.isDirectory(piAdapter(context.home()))) { + for (var target : allTargets) { + if (!chosen.stream().anyMatch(client -> client.name().equals(target.name()))) { + continue; + } + var state = exists(SwapPaths.state(target)); + var backup = exists(SwapPaths.backup(target)); + if (state && backup) { + context.out().println("outstanding swap: " + target.label()); + } else if (state || backup) { + context.out().println("incomplete recovery: " + target.label()); + ready = false; + } + } + for (var entry : AUTH_ENVIRONMENT.entrySet()) { + if (context.environment().containsKey(entry.getKey())) { + context.out().println(entry.getKey() + " overrides " + entry.getValue() + " stored login"); + } + } + if (chosen.contains(clients.getLast()) + && exists(clients.getLast().configPath()) + && !Files.isDirectory(piAdapter(context.home()))) { context.out().println("pi " + PI_ADAPTER_HINT); ready = false; } @@ -224,6 +289,75 @@ private static List selected(Arguments options, List clients) { return ClientRegistry.select(clients, options.clients()); } + private static List defaultSelected(Arguments options, List clients, Context context) { + if (!options.clients().isEmpty()) { + return selected(options, clients); + } + return clients.stream() + .filter(client -> exists(client.configPath()) && executable(client, context.environment()) != null) + .toList(); + } + + private static List statusTargets(Arguments options, List clients, Context context) { + var selected = defaultSelected(options, clients, context); + return selected.stream() + .flatMap(client -> { + if (!client.name().equals("claude")) { + return java.util.stream.Stream.of(client.scoped(Scope.USER, context.repository())); + } + if (options.scope() != null) { + return java.util.stream.Stream.of(client.scoped(options.scope(), context.repository())); + } + return java.util.stream.Stream.of( + client.scoped(Scope.USER, context.repository()), + client.scoped(Scope.PROJECT, context.repository())); + }) + .toList(); + } + + private static List revertSelected(Arguments options, List clients, Context context) { + if (!options.clients().isEmpty()) { + return selected(options, clients); + } + return clients.stream() + .filter(client -> ClientRegistry.allScopes(List.of(client), context.repository()).stream() + .anyMatch(target -> exists(SwapPaths.state(target)) || exists(SwapPaths.backup(target)))) + .toList(); + } + + private static List revertTargets(Arguments options, List clients, Path repository) { + return clients.stream() + .flatMap(client -> { + if (!client.name().equals("claude")) { + return java.util.stream.Stream.of(client.scoped(Scope.USER, repository)); + } + if (options.scope() != null) { + return java.util.stream.Stream.of(client.scoped(options.scope(), repository)); + } + return java.util.stream.Stream.of( + client.scoped(Scope.USER, repository), client.scoped(Scope.PROJECT, repository)); + }) + .toList(); + } + + private static List scoped(List clients, Scope scope, Path repository) { + return ClientRegistry.scoped(clients, scope, repository); + } + + private static @Nullable Path executable(Client client, Map environment) { + var raw = environment.getOrDefault("PATH", ""); + for (var directory : raw.split(java.util.regex.Pattern.quote(File.pathSeparator), -1)) { + if (directory.isBlank()) { + continue; + } + var candidate = Path.of(directory).resolve(client.binary()); + if (Files.isRegularFile(candidate) && Files.isExecutable(candidate)) { + return candidate; + } + } + return null; + } + private static ServerSpec launcher(Arguments options, Path repository) { List flags = new ArrayList<>(); addFlag(flags, "--socket", options.socket()); @@ -238,7 +372,14 @@ private static ServerSpec launcher(Arguments options, Path repository) { } yield new ServerSpec( gradle.toString(), - List.of("--quiet", "--console=plain", ":libtmux-mcp:run", "--args", gradleArguments(flags)), + List.of( + "--quiet", + "--console=plain", + "--no-daemon", + "--max-workers=5", + ":libtmux-mcp:run", + "--args", + gradleArguments(flags)), options.environment()); } case PATH -> { @@ -334,14 +475,28 @@ private static String detail(Throwable error) { } private static void printHelp(PrintStream out, @Nullable Command command) { - if (command == Command.USE) { - out.println("usage: mcp-swap use [--source dist|gradle|path] [--bin FILE]"); - out.println(" [--socket PATH | --socket-name NAME] [--tmux FILE]"); - out.println(" [--env KEY=VALUE] [--name NAME] [--cli CLIENT] [--dry-run]"); - return; + if (command == null) { + out.println("usage: mcp-swap [options]"); + out.println("Point installed agent CLI configs at this repository's MCP build."); + } else { + switch (command) { + case DETECT -> out.println("usage: mcp-swap detect"); + case STATUS -> out.println("usage: mcp-swap status [--name NAME] [--cli CLIENT] [--scope SCOPE]"); + case USE -> { + out.println("usage: mcp-swap use [--source dist|gradle|path] [--bin FILE]"); + out.println(" [--socket PATH | --socket-name NAME] [--tmux FILE]"); + out.println(" [--env KEY=VALUE] [--name NAME] [--cli CLIENT] [--scope SCOPE]"); + out.println(" [--dry-run] [--no-preflight]"); + } + case REVERT -> + out.println("usage: mcp-swap revert [--name NAME] [--cli CLIENT] [--scope SCOPE] [--dry-run]"); + case DOCTOR -> { + out.println("usage: mcp-swap doctor [--source dist|gradle|path] [--bin FILE]"); + out.println(" [--socket PATH | --socket-name NAME] [--tmux FILE]"); + out.println(" [--env KEY=VALUE] [--name NAME] [--cli CLIENT]"); + } + } } - out.println("usage: mcp-swap [options]"); - out.println("Point installed agent CLI configs at this repository's MCP build."); } @FunctionalInterface @@ -349,13 +504,19 @@ interface ProcessRunner { int run(List command, Path directory) throws IOException, InterruptedException; } + @FunctionalInterface + interface PreflightRunner { + void run(ServerSpec spec) throws IOException, InterruptedException; + } + record Context( Path home, Map environment, Path repository, PrintStream out, PrintStream err, - ProcessRunner process) { + ProcessRunner process, + PreflightRunner preflight) { Context { home = home.toAbsolutePath().normalize(); environment = Map.copyOf(environment); @@ -363,17 +524,19 @@ record Context( } static Context system() { + var repository = locateRepository(); return new Context( Path.of(System.getProperty("user.home")), System.getenv(), - locateRepository(), + repository, System.out, System.err, (command, directory) -> new ProcessBuilder(command) .directory(directory.toFile()) .inheritIO() .start() - .waitFor()); + .waitFor(), + spec -> McpPreflight.run(spec, System.getenv(), repository)); } } @@ -396,13 +559,15 @@ private record Arguments( boolean help, String name, List clients, + @Nullable Scope scope, boolean dryRun, Source source, Map environment, @Nullable String binary, @Nullable String socket, @Nullable String socketName, - @Nullable String tmux) { + @Nullable String tmux, + boolean noPreflight) { Arguments { clients = List.copyOf(clients); environment = Collections.unmodifiableMap(new LinkedHashMap<>(environment)); @@ -421,6 +586,7 @@ static Arguments parse(String[] raw) { var name = "tmux"; List clients = new ArrayList<>(); var dryRun = false; + Scope scope = null; var source = Source.DIST; Map environment = new LinkedHashMap<>(); String binary = null; @@ -428,14 +594,37 @@ static Arguments parse(String[] raw) { String socketName = null; String tmux = null; var help = false; + var noPreflight = false; for (int index = 1; index < raw.length; index++) { var option = raw[index]; switch (option) { case "-h", "--help" -> help = true; - case "--dry-run" -> dryRun = true; - case "--name" -> name = value(raw, ++index, option); - case "--cli" -> clients.add(value(raw, ++index, option)); + case "--dry-run" -> { + requireOption(command, option, Command.USE, Command.REVERT); + dryRun = true; + } + case "--no-preflight" -> { + requireOption(command, option, Command.USE); + noPreflight = true; + } + case "--name" -> { + requireOption(command, option, Command.STATUS, Command.USE, Command.REVERT, Command.DOCTOR); + name = value(raw, ++index, option); + } + case "--cli" -> { + requireOption(command, option, Command.STATUS, Command.USE, Command.REVERT, Command.DOCTOR); + clients.add(value(raw, ++index, option)); + } + case "--scope" -> { + requireOption(command, option, Command.STATUS, Command.USE, Command.REVERT); + try { + scope = Scope.parse(value(raw, ++index, option)); + } catch (IllegalArgumentException error) { + throw new UsageException(String.valueOf(error.getMessage())); + } + } case "--source" -> { + requireOption(command, option, Command.USE, Command.DOCTOR); var value = value(raw, ++index, option); try { source = Source.valueOf(value.toUpperCase(Locale.ROOT)); @@ -444,6 +633,7 @@ static Arguments parse(String[] raw) { } } case "--env" -> { + requireOption(command, option, Command.USE, Command.DOCTOR); var value = value(raw, ++index, option); var separator = value.indexOf('='); if (separator < 1 || !value.substring(0, separator).matches("[A-Za-z_][A-Za-z0-9_]*")) { @@ -455,10 +645,22 @@ static Arguments parse(String[] raw) { } environment.put(key, value.substring(separator + 1)); } - case "--bin" -> binary = value(raw, ++index, option); - case "--socket" -> socket = value(raw, ++index, option); - case "--socket-name" -> socketName = value(raw, ++index, option); - case "--tmux" -> tmux = value(raw, ++index, option); + case "--bin" -> { + requireOption(command, option, Command.USE, Command.DOCTOR); + binary = value(raw, ++index, option); + } + case "--socket" -> { + requireOption(command, option, Command.USE, Command.DOCTOR); + socket = value(raw, ++index, option); + } + case "--socket-name" -> { + requireOption(command, option, Command.USE, Command.DOCTOR); + socketName = value(raw, ++index, option); + } + case "--tmux" -> { + requireOption(command, option, Command.USE, Command.DOCTOR); + tmux = value(raw, ++index, option); + } case "--safety", "--watch" -> throw new UsageException( "LIBTMUX_SAFETY and the old safety/watch controls are retired; use LIBTMUX_TOOLSETS"); @@ -484,12 +686,36 @@ static Arguments parse(String[] raw) { throw new UsageException("launcher options apply only to use or doctor"); } return new Arguments( - command, help, name, clients, dryRun, source, environment, binary, socket, socketName, tmux); + command, + help, + name, + clients, + scope, + dryRun, + source, + environment, + binary, + socket, + socketName, + tmux, + noPreflight); } private static Arguments defaults(@Nullable Command command, boolean help) { return new Arguments( - command, help, "tmux", List.of(), false, Source.DIST, Map.of(), null, null, null, null); + command, + help, + "tmux", + List.of(), + null, + false, + Source.DIST, + Map.of(), + null, + null, + null, + null, + false); } private static String value(String[] raw, int index, String option) { @@ -498,6 +724,13 @@ private static String value(String[] raw, int index, String option) { } return raw[index]; } + + private static void requireOption(Command command, String option, Command... allowed) { + if (java.util.Arrays.stream(allowed).noneMatch(command::equals)) { + throw new UsageException( + option + " does not apply to " + command.name().toLowerCase(Locale.ROOT)); + } + } } private static Path locateRepository() { diff --git a/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/PathRoute.java b/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/PathRoute.java index 5837a33..4154794 100644 --- a/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/PathRoute.java +++ b/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/PathRoute.java @@ -12,6 +12,8 @@ record PathRoute( Path physicalParent, Path anchor, String anchorIdentity, + Path logicalAnchor, + String logicalAnchorIdentity, boolean exists, boolean symbolicLink, String linkTarget, @@ -27,6 +29,8 @@ static PathRoute inspect(Path path) throws IOException { parent(prospective.target()), prospective.anchor(), directoryIdentity(prospective.anchor()), + prospective.anchor(), + directoryIdentity(prospective.anchor()), false, false, "", @@ -38,12 +42,15 @@ static PathRoute inspect(Path path) throws IOException { var target = logical.toRealPath(); requireRegular(target); var physicalParent = parent(target); + var logicalParent = parent(logical).toRealPath(); return new PathRoute( logical, target, physicalParent, physicalParent, directoryIdentity(physicalParent), + logicalParent, + directoryIdentity(logicalParent), true, true, link, @@ -54,12 +61,15 @@ static PathRoute inspect(Path path) throws IOException { } var target = logical.toRealPath(); var physicalParent = parent(target); + var logicalParent = parent(logical).toRealPath(); return new PathRoute( logical, target, physicalParent, physicalParent, directoryIdentity(physicalParent), + logicalParent, + directoryIdentity(logicalParent), true, false, "", @@ -72,6 +82,19 @@ void verify() throws IOException { } } + boolean sameTopology(PathRoute other) { + return logical.equals(other.logical) + && target.equals(other.target) + && physicalParent.equals(other.physicalParent) + && anchor.equals(other.anchor) + && anchorIdentity.equals(other.anchorIdentity) + && logicalAnchor.equals(other.logicalAnchor) + && logicalAnchorIdentity.equals(other.logicalAnchorIdentity) + && symbolicLink == other.symbolicLink + && linkTarget.equals(other.linkTarget) + && linkIdentity.equals(other.linkIdentity); + } + private static ProspectiveTarget prospectiveTarget(Path logical) throws IOException { var ancestor = logical.getParent(); if (ancestor == null) { diff --git a/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/RecoveryRecord.java b/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/RecoveryRecord.java index 0b2a560..a336387 100644 --- a/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/RecoveryRecord.java +++ b/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/RecoveryRecord.java @@ -1,6 +1,9 @@ package io.github.libtmux.tools.mcpswap; +import com.fasterxml.jackson.core.JsonFactory; import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.StreamReadFeature; +import com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ObjectNode; import java.io.IOException; @@ -11,42 +14,59 @@ record RecoveryRecord( int version, String client, + String scope, + String repository, + long sequence, String server, String logical, String target, String physicalParent, String anchor, String anchorIdentity, + String logicalAnchor, + String logicalAnchorIdentity, boolean symbolicLink, String linkTarget, + String linkIdentity, boolean originalExists, String backupIdentity, String originalDigest, String originalPermissions, String currentDigest, String currentPermissions, + String currentIdentity, String command, List arguments) { - private static final int VERSION = 1; + private static final int VERSION = 2; private static final int MAX_BYTES = 16 * 1024; - private static final ObjectMapper JSON = new ObjectMapper(); + private static final ObjectMapper JSON = new ObjectMapper(JsonFactory.builder() + .enable(StreamReadFeature.STRICT_DUPLICATE_DETECTION) + .build()) + .enable(DeserializationFeature.FAIL_ON_TRAILING_TOKENS); private static final Set FIELDS = Set.of( "version", "client", + "scope", + "repository", + "sequence", "server", "logical", "target", "physicalParent", "anchor", "anchorIdentity", + "logicalAnchor", + "logicalAnchorIdentity", "symbolicLink", "linkTarget", + "linkIdentity", "originalExists", "backupIdentity", "originalDigest", "originalPermissions", "currentDigest", "currentPermissions", + "currentIdentity", "command", "arguments", "checksum"); @@ -61,24 +81,32 @@ static RecoveryRecord create( PathRoute route, FileSnapshot original, FileContent current, - ServerSpec spec) { + ServerSpec spec, + long sequence) { return new RecoveryRecord( VERSION, client.name(), + client.scope().value(), + client.repository().toString(), + sequence, server, route.logical().toString(), route.target().toString(), route.physicalParent().toString(), route.anchor().toString(), route.anchorIdentity(), + route.logicalAnchor().toString(), + route.logicalAnchorIdentity(), route.symbolicLink(), route.linkTarget(), + route.linkIdentity(), original.exists(), original.identity(), original.digest(), original.permissions(), current.digest(), current.permissions(), + "", spec.command(), spec.arguments()); } @@ -87,24 +115,89 @@ RecoveryRecord withCurrent(FileContent current, ServerSpec spec) { return new RecoveryRecord( version, client, + scope, + repository, + sequence, server, logical, target, physicalParent, anchor, anchorIdentity, + logicalAnchor, + logicalAnchorIdentity, symbolicLink, linkTarget, + linkIdentity, originalExists, backupIdentity, originalDigest, originalPermissions, current.digest(), current.permissions(), + "", spec.command(), spec.arguments()); } + RecoveryRecord withCurrentIdentity(String identity) { + return new RecoveryRecord( + version, + client, + scope, + repository, + sequence, + server, + logical, + target, + physicalParent, + anchor, + anchorIdentity, + logicalAnchor, + logicalAnchorIdentity, + symbolicLink, + linkTarget, + linkIdentity, + originalExists, + backupIdentity, + originalDigest, + originalPermissions, + currentDigest, + currentPermissions, + identity, + command, + arguments); + } + + RecoveryRecord withBackupIdentity(String identity) { + return new RecoveryRecord( + version, + client, + scope, + repository, + sequence, + server, + logical, + target, + physicalParent, + anchor, + anchorIdentity, + logicalAnchor, + logicalAnchorIdentity, + symbolicLink, + linkTarget, + linkIdentity, + originalExists, + identity, + originalDigest, + originalPermissions, + currentDigest, + currentPermissions, + currentIdentity, + command, + arguments); + } + byte[] encode() { try { var body = body(); @@ -167,20 +260,27 @@ static RecoveryRecord decode(byte[] encoded) throws IOException { return new RecoveryRecord( version, requiredText(root, "client"), + requiredText(root, "scope"), + requiredText(root, "repository"), + requiredLong(root, "sequence"), requiredText(root, "server"), requiredText(root, "logical"), requiredText(root, "target"), requiredText(root, "physicalParent"), requiredText(root, "anchor"), requiredText(root, "anchorIdentity"), + requiredText(root, "logicalAnchor"), + requiredText(root, "logicalAnchorIdentity"), requiredBoolean(root, "symbolicLink"), requiredText(root, "linkTarget"), + requiredText(root, "linkIdentity"), requiredBoolean(root, "originalExists"), requiredText(root, "backupIdentity"), requiredText(root, "originalDigest"), requiredText(root, "originalPermissions"), requiredText(root, "currentDigest"), requiredText(root, "currentPermissions"), + requiredText(root, "currentIdentity"), requiredText(root, "command"), arguments); } @@ -188,7 +288,16 @@ static RecoveryRecord decode(byte[] encoded) throws IOException { void verify( Client expectedClient, String expectedServer, PathRoute route, FileSnapshot current, FileSnapshot backup) throws IOException { - if (!client.equals(expectedClient.name()) || !server.equals(expectedServer)) { + verifyStored(expectedClient, expectedServer, route, backup); + verifyCurrent(current); + } + + void verifyStored(Client expectedClient, String expectedServer, PathRoute route, FileSnapshot backup) + throws IOException { + if (!client.equals(expectedClient.name()) + || !scope.equals(expectedClient.scope().value()) + || !repository.equals(expectedClient.repository().toString()) + || !server.equals(expectedServer)) { throw new IOException("recovery state belongs to another client or server"); } if (!logical.equals(route.logical().toString()) @@ -196,15 +305,13 @@ void verify( || !physicalParent.equals(route.physicalParent().toString()) || !anchor.equals(route.anchor().toString()) || !anchorIdentity.equals(route.anchorIdentity()) + || !logicalAnchor.equals(route.logicalAnchor().toString()) + || !logicalAnchorIdentity.equals(route.logicalAnchorIdentity()) || symbolicLink != route.symbolicLink() - || !linkTarget.equals(route.linkTarget())) { + || !linkTarget.equals(route.linkTarget()) + || !linkIdentity.equals(route.linkIdentity())) { throw new IOException("recovery config route changed for " + client); } - if (!current.exists() - || !current.digest().equals(currentDigest) - || !current.permissions().equals(currentPermissions)) { - throw new IOException("swapped config changed for " + client); - } if (originalExists) { if (!backup.exists() || backup.links() != 1 @@ -218,6 +325,15 @@ void verify( } } + void verifyCurrent(FileSnapshot current) throws IOException { + if (!current.exists() + || !current.identity().equals(currentIdentity) + || !current.digest().equals(currentDigest) + || !current.permissions().equals(currentPermissions)) { + throw new IOException("swapped config changed for " + client + ":" + scope); + } + } + FileContent original(FileSnapshot backup) { return originalExists ? FileContent.of(backup.bytes(), originalPermissions) : FileContent.absent(); } @@ -226,20 +342,27 @@ private ObjectNode body() { var root = JSON.createObjectNode(); root.put("version", version); root.put("client", client); + root.put("scope", scope); + root.put("repository", repository); + root.put("sequence", sequence); root.put("server", server); root.put("logical", logical); root.put("target", target); root.put("physicalParent", physicalParent); root.put("anchor", anchor); root.put("anchorIdentity", anchorIdentity); + root.put("logicalAnchor", logicalAnchor); + root.put("logicalAnchorIdentity", logicalAnchorIdentity); root.put("symbolicLink", symbolicLink); root.put("linkTarget", linkTarget); + root.put("linkIdentity", linkIdentity); root.put("originalExists", originalExists); root.put("backupIdentity", backupIdentity); root.put("originalDigest", originalDigest); root.put("originalPermissions", originalPermissions); root.put("currentDigest", currentDigest); root.put("currentPermissions", currentPermissions); + root.put("currentIdentity", currentIdentity); root.put("command", command); var values = root.putArray("arguments"); arguments.forEach(values::add); @@ -262,6 +385,14 @@ private static int requiredInteger(ObjectNode root, String name) throws IOExcept return value.intValue(); } + private static long requiredLong(ObjectNode root, String name) throws IOException { + var value = root.get(name); + if (value == null || !value.isIntegralNumber() || !value.canConvertToLong() || value.longValue() < 0) { + throw new IOException("recovery field " + name + " is not a nonnegative integer"); + } + return value.longValue(); + } + private static boolean requiredBoolean(ObjectNode root, String name) throws IOException { var value = root.get(name); if (value == null || !value.isBoolean()) { diff --git a/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/Scope.java b/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/Scope.java new file mode 100644 index 0000000..79a5f49 --- /dev/null +++ b/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/Scope.java @@ -0,0 +1,20 @@ +package io.github.libtmux.tools.mcpswap; + +import java.util.Locale; + +enum Scope { + USER, + PROJECT; + + static Scope parse(String value) { + try { + return valueOf(value.toUpperCase(Locale.ROOT)); + } catch (IllegalArgumentException error) { + throw new IllegalArgumentException("--scope must be user or project", error); + } + } + + String value() { + return name().toLowerCase(Locale.ROOT); + } +} diff --git a/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/ServerSpec.java b/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/ServerSpec.java index 9591c26..918b927 100644 --- a/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/ServerSpec.java +++ b/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/ServerSpec.java @@ -16,13 +16,14 @@ record ServerSpec(String command, List arguments, Map en } ServerSpec withEnvironment(Map existing) { + if (environment.containsKey("LIBTMUX_SAFETY")) { + throw new IllegalArgumentException("LIBTMUX_SAFETY has been removed; use LIBTMUX_TOOLSETS"); + } Map merged = new LinkedHashMap<>(existing); - var retired = merged.remove("LIBTMUX_SAFETY") != null; - merged.putAll(environment); - retired |= merged.remove("LIBTMUX_SAFETY") != null; - if (retired && !merged.containsKey("LIBTMUX_TOOLSETS")) { - throw new IllegalArgumentException("LIBTMUX_SAFETY has been removed; supply LIBTMUX_TOOLSETS explicitly"); + if (environment.containsKey("LIBTMUX_TOOLSETS")) { + merged.remove("LIBTMUX_SAFETY"); } + merged.putAll(environment); return new ServerSpec(command, arguments, merged); } } diff --git a/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/SwapLock.java b/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/SwapLock.java index 0c77868..88984fe 100644 --- a/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/SwapLock.java +++ b/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/SwapLock.java @@ -16,12 +16,15 @@ import java.util.Map; import java.util.Set; import java.util.UUID; +import java.util.concurrent.Semaphore; final class SwapLock implements AutoCloseable { private static final Set DIRECTORY_MODE = PosixFilePermissions.fromString("rwx------"); private static final Set FILE_MODE = PosixFilePermissions.fromString("rw-------"); + private static final Semaphore PROCESS_GATE = new Semaphore(1, true); + private static final Set ACTIVE_IDENTITIES = new java.util.HashSet<>(); private final FileChannel channel; private final FileLock lock; @@ -34,52 +37,66 @@ private SwapLock(FileChannel channel, FileLock lock, FileSnapshot state) { } static SwapLock acquire(Path home, Map environment) throws IOException { - var path = SwapPaths.lock(home, environment).toAbsolutePath().normalize(); - var parent = path.getParent(); - if (parent == null) { - throw new IOException("swap lock has no parent"); - } - secureDirectory(parent); + PROCESS_GATE.acquireUninterruptibly(); + var transferred = false; try { - Files.createFile(path, PosixFilePermissions.asFileAttribute(FILE_MODE)); - } catch (FileAlreadyExistsException ignored) { - // Validated below before the file is opened. - } - var before = FileSnapshot.capture(path); - requireSafe(before, path); - Set options = Set.of(StandardOpenOption.READ, StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS); - var channel = FileChannel.open(path, options); - FileLock lock = null; - try { - try { - lock = channel.tryLock(); - } catch (OverlappingFileLockException busy) { - throw new IOException("another mcp-swap transaction is running", busy); + var path = SwapPaths.lock(home, environment).toAbsolutePath().normalize(); + var parent = path.getParent(); + if (parent == null) { + throw new IOException("swap lock has no parent"); } - if (lock == null) { - throw new IOException("another mcp-swap transaction is running"); - } - var token = UUID.randomUUID().toString().getBytes(StandardCharsets.US_ASCII); - var buffer = ByteBuffer.wrap(token); - channel.truncate(0); - channel.position(0); - while (buffer.hasRemaining()) { - channel.write(buffer); - // FileChannel may consume the buffer in more than one write. + secureDirectory(parent); + try { + Files.createFile(path, PosixFilePermissions.asFileAttribute(FILE_MODE)); + } catch (FileAlreadyExistsException ignored) { + // Validated below before the file is opened. } - channel.force(true); - var state = FileSnapshot.capture(path); - requireSafe(state, path); - if (!state.identity().equals(before.identity()) || !java.util.Arrays.equals(token, state.bytes())) { - throw new IOException("swap lock changed while it was acquired"); + var before = FileSnapshot.capture(path); + requireSafe(before, path); + Set options = + Set.of(StandardOpenOption.READ, StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS); + var channel = FileChannel.open(path, options); + FileLock lock = null; + try { + try { + lock = channel.lock(); + } catch (OverlappingFileLockException busy) { + throw new IOException("another mcp-swap transaction is running", busy); + } + var tokenText = new StringBuilder(UUID.randomUUID().toString()); + if (tokenText.length() == before.size()) { + tokenText.append('.'); + } + var token = tokenText.toString().getBytes(StandardCharsets.US_ASCII); + var buffer = ByteBuffer.wrap(token); + channel.truncate(0); + channel.position(0); + while (buffer.hasRemaining()) { + channel.write(buffer); + // FileChannel may consume the buffer in more than one write. + } + channel.force(true); + var state = FileSnapshot.capture(path, channel); + requireSafe(state, path); + if (!state.identity().equals(before.identity()) || !java.util.Arrays.equals(token, state.bytes())) { + throw new IOException("swap lock changed while it was acquired"); + } + synchronized (SwapLock.class) { + ACTIVE_IDENTITIES.add(state.identity()); + } + transferred = true; + return new SwapLock(channel, lock, state); + } catch (IOException | RuntimeException error) { + if (lock != null) { + lock.close(); + } + channel.close(); + throw error; } - return new SwapLock(channel, lock, state); - } catch (IOException | RuntimeException error) { - if (lock != null) { - lock.close(); + } finally { + if (!transferred) { + PROCESS_GATE.release(); } - channel.close(); - throw error; } } @@ -99,11 +116,21 @@ Path path() { return state.path(); } + String identity() { + return state.identity(); + } + + static synchronized void rejectAlias(String identity, Path path) throws IOException { + if (ACTIVE_IDENTITIES.contains(identity)) { + throw new IOException("transaction path aliases the active swap lock: " + path); + } + } + void verify() throws IOException { if (!lock.isValid()) { throw new IOException("swap lock is no longer held"); } - state.verify(); + state.verify(channel); } @Override @@ -111,7 +138,14 @@ public void close() throws IOException { try { lock.close(); } finally { - channel.close(); + try { + channel.close(); + } finally { + synchronized (SwapLock.class) { + ACTIVE_IDENTITIES.remove(state.identity()); + } + PROCESS_GATE.release(); + } } } diff --git a/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/SwapPaths.java b/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/SwapPaths.java index da8ffb2..9a56cd1 100644 --- a/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/SwapPaths.java +++ b/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/SwapPaths.java @@ -3,12 +3,15 @@ import java.nio.file.Path; final class SwapPaths { - private static final String BACKUP_SUFFIX = ".mcp-swap-backup"; + private static final String BACKUP_SUFFIX = ".mcp-swap-java-backup"; private SwapPaths() {} static Path backup(Client client) { - return sibling(client.configPath(), client.configPath().getFileName() + BACKUP_SUFFIX); + var suffix = client.name().equals("claude") + ? ".mcp-swap-java-" + client.scope().value() + "-backup" + : BACKUP_SUFFIX; + return sibling(client.configPath(), client.configPath().getFileName() + suffix); } static Path state(Client client) { diff --git a/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/SwapService.java b/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/SwapService.java index b1dba46..fa7691b 100644 --- a/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/SwapService.java +++ b/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/SwapService.java @@ -4,8 +4,10 @@ import java.nio.file.Path; import java.util.ArrayList; import java.util.HashSet; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Optional; import org.jspecify.annotations.Nullable; final class SwapService { @@ -27,93 +29,238 @@ final class SwapService { void use(List selected, List allClients, String serverName, ServerSpec server, boolean dryRun) throws IOException { + use(selected, allClients, serverName, server, dryRun, null); + } + + List previewUse(List selected, List allClients, String serverName, ServerSpec server) + throws IOException { + validateSelection(selected, allClients); + TransactionGuard.preflight(allClients, SwapPaths.lock(home, environment)); + return planUse(selected, allClients, serverName, server).preflights(); + } + + void use( + List selected, + List allClients, + String serverName, + ServerSpec server, + boolean dryRun, + @Nullable List preflighted) + throws IOException { validateSelection(selected, allClients); if (dryRun) { - TransactionGuard.preflight(allClients, SwapPaths.lock(home, environment)); - planUse(selected, serverName, server); + previewUse(selected, allClients, serverName, server); return; } try (var lock = SwapLock.acquire(home, environment)) { var guard = TransactionGuard.capture(allClients, lock); - var plans = planUse(selected, serverName, server); - List changes = new ArrayList<>(); + var planning = planUse(selected, allClients, serverName, server); + if (preflighted != null && !preflighted.equals(planning.preflights())) { + throw new IOException("selected configuration changed after preflight; retry the swap"); + } + var plans = planning.plans(); + Map backupChanges = new LinkedHashMap<>(); + List configChanges = new ArrayList<>(); for (var plan : plans) { if (!plan.active() && plan.config().exists()) { - changes.add(new AtomicChange( - "backup", plan.backupRoute(), plan.backup(), FileContent.linked(plan.config()))); + backupChanges.put( + plan.client(), + new AtomicChange( + "backup", + plan.backupRoute(), + plan.backup(), + FileContent.of( + plan.config().bytes(), plan.config().permissions()))); } + configChanges.add(new AtomicChange("config", plan.configRoute(), plan.config(), plan.desired())); } - for (var plan : plans) { + try { + for (var change : backupChanges.values()) { + change.stage(); + } + for (var change : configChanges) { + change.stage(); + } + executeUse(plans, backupChanges, configChanges, guard); + } catch (IOException | RuntimeException failure) { + for (var change : backupChanges.values().stream().toList().reversed()) { + try { + change.cleanupStage(guard, hook); + } catch (IOException cleanup) { + failure.addSuppressed(cleanup); + } + } + for (var change : configChanges.reversed()) { + try { + change.cleanupStage(guard, hook); + } catch (IOException cleanup) { + failure.addSuppressed(cleanup); + } + } + throw failure; + } + } + } + + private void executeUse( + List plans, + Map backupChanges, + List configChanges, + TransactionGuard guard) + throws IOException { + List changes = new ArrayList<>(backupChanges.values()); + for (var index = 0; index < plans.size(); index++) { + var plan = plans.get(index); + var backupChange = backupChanges.get(plan.client()); + if (plan.previousTop() != null) { + if (backupChange == null) { + throw new IOException( + "new recovery layer has no backup: " + plan.client().label()); + } + var previous = plan.previousTop(); changes.add(new AtomicChange( "state", - plan.stateRoute(), - plan.state(), - FileContent.of(plan.record().encode(), PRIVATE_MODE))); + previous.stateRoute(), + previous.state(), + FileContent.of( + previous.record() + .withCurrentIdentity(backupChange.stagedIdentity()) + .encode(), + PRIVATE_MODE))); } - for (var plan : plans) { - changes.add(new AtomicChange("config", plan.configRoute(), plan.config(), plan.desired())); + var record = plan.record(); + if (backupChange != null) { + record = record.withBackupIdentity(backupChange.stagedIdentity()); } - execute(changes, guard); + changes.add(new AtomicChange( + "state", + plan.stateRoute(), + plan.state(), + FileContent.of( + record.withCurrentIdentity(configChanges.get(index).stagedIdentity()) + .encode(), + PRIVATE_MODE))); } + changes.addAll(configChanges); + execute(changes, guard); } void revert(List selected, List allClients, String serverName, boolean dryRun) throws IOException { validateSelection(selected, allClients); if (dryRun) { TransactionGuard.preflight(allClients, SwapPaths.lock(home, environment)); - planRevert(selected, serverName); + planRevert(selected, allClients, serverName); return; } try (var lock = SwapLock.acquire(home, environment)) { var guard = TransactionGuard.capture(allClients, lock); - var plans = planRevert(selected, serverName); - List changes = new ArrayList<>(); - for (var plan : plans) { - changes.add(new AtomicChange( - "config", - plan.configRoute(), - plan.config(), - plan.record().original(plan.backup()))); - } - for (var plan : plans) { - changes.add(new AtomicChange("state", plan.stateRoute(), plan.state(), FileContent.absent())); - } - for (var plan : plans) { - if (plan.backup().exists()) { - changes.add(new AtomicChange("backup", plan.backupRoute(), plan.backup(), FileContent.absent())); + var plans = planRevert(selected, allClients, serverName); + List configChanges = new ArrayList<>(); + try { + for (var plan : plans) { + var change = new AtomicChange( + "config", + plan.configRoute(), + plan.config(), + plan.layers() + .getLast() + .record() + .original(plan.layers().getLast().backup())); + change.stage(); + configChanges.add(change); } + List changes = new ArrayList<>(configChanges); + for (var index = 0; index < plans.size(); index++) { + var plan = plans.get(index); + if (plan.remaining() != null) { + var remaining = plan.remaining(); + changes.add(new AtomicChange( + "state", + remaining.stateRoute(), + remaining.state(), + FileContent.of( + remaining + .record() + .withCurrentIdentity( + configChanges.get(index).stagedIdentity()) + .encode(), + PRIVATE_MODE))); + } + for (var layer : plan.layers()) { + changes.add(new AtomicChange("state", layer.stateRoute(), layer.state(), FileContent.absent())); + } + for (var layer : plan.layers()) { + if (layer.backup().exists()) { + changes.add(new AtomicChange( + "backup", layer.backupRoute(), layer.backup(), FileContent.absent())); + } + } + } + execute(changes, guard); + } catch (IOException | RuntimeException failure) { + for (var change : configChanges.reversed()) { + try { + change.cleanupStage(guard, hook); + } catch (IOException cleanup) { + failure.addSuppressed(cleanup); + } + } + throw failure; } - execute(changes, guard); } } - private List planUse(List selected, String serverName, ServerSpec server) throws IOException { + private UsePlanning planUse(List selected, List allClients, String serverName, ServerSpec server) + throws IOException { List plans = new ArrayList<>(); + List preflights = new ArrayList<>(); + var recovery = loadRecovery(allClients, serverName); + var sequence = recovery.nextSequence(); for (var client : selected) { - var configRoute = PathRoute.inspect(client.configPath()); - var backupRoute = PathRoute.inspect(SwapPaths.backup(client)); - var stateRoute = PathRoute.inspect(SwapPaths.state(client)); - var config = FileSnapshot.capture(configRoute.target()); + var group = recovery.groups().get(configKey(client)); + var active = recovery.layers().get(client); + if (active != null && (group == null || !group.layers().getFirst().equals(active))) { + throw new IOException(client.label() + " is not the active recovery layer"); + } + var configRoute = group == null ? PathRoute.inspect(client.configPath()) : group.configRoute(); + var config = group == null ? FileSnapshot.capture(configRoute.target()) : group.config(); if (config.exists() && config.links() != 1) { throw new IOException("config must not be hard linked for " + client.name()); } - var backup = FileSnapshot.capture(backupRoute.target()); - var state = FileSnapshot.capture(stateRoute.target()); - var active = recovery(client, serverName, configRoute, config, backup, state); + var backupRoute = active == null ? PathRoute.inspect(SwapPaths.backup(client)) : active.backupRoute(); + var stateRoute = active == null ? PathRoute.inspect(SwapPaths.state(client)) : active.stateRoute(); + var backup = active == null ? FileSnapshot.capture(backupRoute.target()) : active.backup(); + var state = active == null ? FileSnapshot.capture(stateRoute.target()) : active.state(); var raw = config.exists() ? config.bytes() : new byte[0]; + Optional current; final byte[] rendered; + final ServerSpec effective; try { - rendered = ConfigCodec.update(client, raw, serverName, server); + current = ConfigCodec.read(client, raw, serverName); + effective = server.withEnvironment( + current.map(ServerSpec::environment).orElse(Map.of())); + rendered = ConfigCodec.updateExact(client, raw, serverName, effective); } catch (IllegalArgumentException error) { throw new IOException(client.name() + " config cannot be updated", error); } + preflights.add(new PlannedSpec(client.label(), effective)); + if (current.isPresent() && current.orElseThrow().equals(effective)) { + continue; + } var desired = FileContent.of(rendered, config.exists() ? config.permissions() : PRIVATE_MODE); - var record = active == null - ? RecoveryRecord.create(client, serverName, configRoute, config, desired, server) - : active.withCurrent(desired, server); + final RecoveryRecord record; + if (active == null) { + if (sequence == Long.MAX_VALUE) { + throw new IOException("recovery state sequence is exhausted"); + } + record = RecoveryRecord.create(client, serverName, configRoute, config, desired, effective, sequence++); + } else { + record = active.record().withCurrent(desired, effective); + } plans.add(new UsePlan( client, active != null, + active == null && group != null ? group.layers().getFirst() : null, configRoute, config, backupRoute, @@ -123,50 +270,87 @@ var record = active == null desired, record)); } - return List.copyOf(plans); + return new UsePlanning(List.copyOf(plans), List.copyOf(preflights)); } - private List planRevert(List selected, String serverName) throws IOException { + private List planRevert(List selected, List allClients, String serverName) + throws IOException { + var recovery = loadRecovery(allClients, serverName); + var selectedSet = new HashSet<>(selected); List plans = new ArrayList<>(); - for (var client : selected) { - var configRoute = PathRoute.inspect(client.configPath()); - var backupRoute = PathRoute.inspect(SwapPaths.backup(client)); - var stateRoute = PathRoute.inspect(SwapPaths.state(client)); - var config = FileSnapshot.capture(configRoute.target()); - var backup = FileSnapshot.capture(backupRoute.target()); - var state = FileSnapshot.capture(stateRoute.target()); - if (!backup.exists() && !state.exists()) { + for (var group : recovery.groups().values()) { + var chosen = group.layers().stream() + .filter(layer -> selectedSet.contains(layer.client())) + .toList(); + if (chosen.isEmpty()) { continue; } - var record = recovery(client, serverName, configRoute, config, backup, state); - if (record == null) { - throw new IOException("recovery pair is incomplete for " + client.name()); + if (!chosen.equals(group.layers().subList(0, chosen.size()))) { + throw new IOException(chosen.getFirst().client().label() + " has a newer recovery layer"); } - plans.add(new RevertPlan(client, configRoute, config, backupRoute, backup, stateRoute, state, record)); + var remaining = chosen.size() == group.layers().size() + ? null + : group.layers().get(chosen.size()); + plans.add(new RevertPlan(group.configRoute(), group.config(), List.copyOf(chosen), remaining)); } + plans.sort(java.util.Comparator.comparingLong( + (RevertPlan plan) -> plan.layers().getFirst().record().sequence()) + .reversed()); return List.copyOf(plans); } - private static @Nullable RecoveryRecord recovery( - Client client, - String serverName, - PathRoute configRoute, - FileSnapshot config, - FileSnapshot backup, - FileSnapshot state) - throws IOException { - if (!state.exists()) { - if (backup.exists()) { - throw new IOException("recovery backup has no state for " + client.name()); + private static RecoveryWorld loadRecovery(List allClients, String serverName) throws IOException { + Map> grouped = new LinkedHashMap<>(); + Map layers = new LinkedHashMap<>(); + var sequences = new HashSet(); + long nextSequence = 0; + for (var client : allClients) { + var backupRoute = PathRoute.inspect(SwapPaths.backup(client)); + var stateRoute = PathRoute.inspect(SwapPaths.state(client)); + var backup = FileSnapshot.capture(backupRoute.target()); + var state = FileSnapshot.capture(stateRoute.target()); + if (!state.exists()) { + if (backup.exists()) { + throw new IOException("recovery backup has no state for " + client.label()); + } + continue; + } + if (state.links() != 1 || !state.permissions().equals(PRIVATE_MODE)) { + throw new IOException("recovery state is not a private file for " + client.label()); } - return null; + var configRoute = PathRoute.inspect(client.configPath()); + var record = RecoveryRecord.decode(state.bytes()); + record.verifyStored(client, serverName, configRoute, backup); + if (!sequences.add(record.sequence())) { + throw new IOException("recovery state has duplicate sequence numbers"); + } + nextSequence = record.sequence() == Long.MAX_VALUE + ? Long.MAX_VALUE + : Math.max(nextSequence, record.sequence() + 1); + var layer = new RecoveryLayer(client, backupRoute, backup, stateRoute, state, record); + layers.put(client, layer); + grouped.computeIfAbsent(configKey(client), ignored -> new ArrayList<>()) + .add(layer); } - if (state.links() != 1 || !state.permissions().equals(PRIVATE_MODE)) { - throw new IOException("recovery state is not a private file for " + client.name()); + Map groups = new LinkedHashMap<>(); + for (var entry : grouped.entrySet()) { + var ordered = entry.getValue(); + ordered.sort(java.util.Comparator.comparingLong( + (RecoveryLayer layer) -> layer.record().sequence()) + .reversed()); + var configRoute = PathRoute.inspect(ordered.getFirst().client().configPath()); + var config = FileSnapshot.capture(configRoute.target()); + ordered.getFirst().record().verifyCurrent(config); + for (var index = 1; index < ordered.size(); index++) { + ordered.get(index).record().verifyCurrent(ordered.get(index - 1).backup()); + } + groups.put(entry.getKey(), new RecoveryGroup(configRoute, config, List.copyOf(ordered))); } - var record = RecoveryRecord.decode(state.bytes()); - record.verify(client, serverName, configRoute, config, backup); - return record; + return new RecoveryWorld(Map.copyOf(groups), Map.copyOf(layers), nextSequence); + } + + private static Path configKey(Client client) { + return client.configPath().toAbsolutePath().normalize(); } private void execute(List changes, TransactionGuard guard) throws IOException { @@ -186,7 +370,7 @@ private void execute(List changes, TransactionGuard guard) throws break; } try { - change.rollback(guard); + change.rollback(guard, hook); } catch (IOException rollback) { failure.addSuppressed(rollback); blocked = true; @@ -195,9 +379,9 @@ private void execute(List changes, TransactionGuard guard) throws for (var change : changes.reversed()) { try { if (blocked) { - change.cleanupStage(); + change.cleanupStage(guard, hook); } else { - change.cleanup(); + change.cleanup(guard, hook); } } catch (IOException cleanup) { failure.addSuppressed(cleanup); @@ -208,7 +392,7 @@ private void execute(List changes, TransactionGuard guard) throws IOException cleanupFailure = null; for (var change : changes.reversed()) { try { - change.cleanup(); + change.cleanup(guard, hook); } catch (IOException error) { if (cleanupFailure == null) { cleanupFailure = error; @@ -235,6 +419,7 @@ private static void validateSelection(List selected, List allCli private record UsePlan( Client client, boolean active, + @Nullable RecoveryLayer previousTop, PathRoute configRoute, FileSnapshot config, PathRoute backupRoute, @@ -244,13 +429,26 @@ private record UsePlan( FileContent desired, RecoveryRecord record) {} + record PlannedSpec(String label, ServerSpec spec) {} + + private record UsePlanning(List plans, List preflights) {} + private record RevertPlan( - Client client, PathRoute configRoute, FileSnapshot config, + List layers, + @Nullable RecoveryLayer remaining) {} + + private record RecoveryLayer( + Client client, PathRoute backupRoute, FileSnapshot backup, PathRoute stateRoute, FileSnapshot state, RecoveryRecord record) {} + + private record RecoveryGroup(PathRoute configRoute, FileSnapshot config, List layers) {} + + private record RecoveryWorld( + Map groups, Map layers, long nextSequence) {} } diff --git a/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/TomlEditor.java b/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/TomlEditor.java index a56a89f..ea10a77 100644 --- a/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/TomlEditor.java +++ b/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/TomlEditor.java @@ -17,25 +17,35 @@ final class TomlEditor { private TomlEditor() {} - static byte[] update(byte[] original, String tableName, String serverName, ServerSpec server) { + static byte[] update( + byte[] original, String tableName, String serverName, ServerSpec server, boolean mergeEnvironment) { var text = ConfigCodec.decodeUtf8(original, "TOML config"); + var parsed = requireValid(text); var existing = read(original, tableName, serverName) .map(ServerSpec::environment) .orElseGet(Map::of); - var merged = server.withEnvironment(existing); + var merged = mergeEnvironment ? server.withEnvironment(existing) : server; + var inline = inlineValue(text, parsed, tableName, serverName); + if (inline != null) { + var updated = text.substring(0, inline.start()) + inlineEntry(merged) + text.substring(inline.end()); + requireValid(updated); + return updated.getBytes(StandardCharsets.UTF_8); + } + var dotted = dottedAssignments(text, parsed, tableName, serverName); + if (!dotted.isEmpty()) { + var edited = new StringBuilder(text); + for (var range : dotted.reversed()) { + edited.delete(range.start(), range.end()); + } + text = edited.toString(); + } var newline = text.contains("\r\n") ? "\r\n" : "\n"; var sections = sections(text); var target = List.of(tableName, serverName); - var start = -1; - var end = -1; + List
matches = new ArrayList<>(); for (var section : sections) { if (startsWith(section.path(), target)) { - if (start == -1) { - start = section.start(); - } - end = section.end(); - } else if (start != -1) { - break; + matches.add(section); } } var body = "command = " + string(merged.command()) + newline + "args = " + array(merged.arguments()) + newline; @@ -46,18 +56,24 @@ static byte[] update(byte[] original, String tableName, String serverName, Serve } } String updated; - if (start == -1) { + if (matches.isEmpty()) { var separator = text.isEmpty() || text.endsWith(newline + newline) ? "" : text.endsWith(newline) ? newline : newline + newline; updated = text + separator + "[" + tableName + "." + string(serverName) + "]" + newline + body; } else { - var comments = commentsOnly(text.substring(start, end)); - var replacement = "[" + tableName + "." + string(serverName) + "]" + newline + body + comments; - if (!replacement.endsWith(newline)) { - replacement += newline; + var replacement = "[" + tableName + "." + string(serverName) + "]" + newline + body; + var edited = new StringBuilder(text); + for (int index = matches.size() - 1; index >= 0; index--) { + var section = matches.get(index); + var comments = commentsOnly(text.substring(section.start(), section.end())); + var contents = index == 0 ? replacement + comments : comments; + if (index == 0 && !contents.endsWith(newline)) { + contents += newline; + } + edited.replace(section.start(), section.end(), contents); } - updated = text.substring(0, start) + replacement + text.substring(end); + updated = edited.toString(); } requireValid(updated); return updated.getBytes(StandardCharsets.UTF_8); @@ -111,6 +127,242 @@ private static TomlParseResult requireValid(String text) { return result; } + private static @Nullable Range inlineValue( + String text, TomlParseResult parsed, String tableName, String serverName) { + var position = parsed.inputPositionOf(List.of(tableName, serverName)); + if (position == null) { + return null; + } + var start = offset(text, position.line(), position.column()); + var keyEnd = simpleKeyEnd(text, start); + if (keyEnd == start || !decodeKey(text.substring(start, keyEnd)).equals(serverName)) { + return null; + } + var equals = skipWhitespace(text, keyEnd); + if (equals >= text.length() || text.charAt(equals) != '=') { + return null; + } + var valueStart = skipWhitespace(text, equals + 1); + if (valueStart >= text.length() || text.charAt(valueStart) != '{') { + return null; + } + return new Range(valueStart, closingDelimiter(text, valueStart, '{', '}') + 1); + } + + private static List dottedAssignments( + String text, TomlParseResult parsed, String tableName, String serverName) { + var table = parsed.getTable(tableName); + var server = table == null ? null : table.getTable(List.of(serverName)); + if (server == null) { + return List.of(); + } + Map found = new java.util.TreeMap<>(); + for (var suffix : server.keyPathSet(true)) { + List path = new ArrayList<>(List.of(tableName, serverName)); + path.addAll(suffix); + var position = parsed.inputPositionOf(path); + if (position == null) { + continue; + } + var start = offset(text, position.line(), position.column()); + var equals = assignmentEquals(text, start); + if (equals == -1 + || !startsWith(dottedKeys(text.substring(start, equals)), List.of(tableName, serverName))) { + continue; + } + found.put(start, new Range(start, statementEnd(text, equals + 1))); + } + return List.copyOf(found.values()); + } + + private static int assignmentEquals(String text, int start) { + var quote = '\0'; + var escaped = false; + for (int offset = start; offset < text.length(); offset++) { + var character = text.charAt(offset); + if (escaped) { + escaped = false; + } else if (quote == '"' && character == '\\') { + escaped = true; + } else if (quote != '\0' && character == quote) { + quote = '\0'; + } else if (quote == '\0' && (character == '"' || character == '\'')) { + quote = character; + } else if (quote == '\0' && character == '=') { + return offset; + } else if (quote == '\0' && (character == '\n' || character == '[')) { + return -1; + } + } + return -1; + } + + private static int statementEnd(String text, int start) { + var nesting = 0; + var quote = '\0'; + var triple = false; + var escaped = false; + for (int offset = start; offset < text.length(); offset++) { + var character = text.charAt(offset); + if (quote != '\0') { + if (escaped) { + escaped = false; + } else if (quote == '"' && character == '\\') { + escaped = true; + } else if (character == quote + && (!triple + || (offset + 2 < text.length() + && text.charAt(offset + 1) == quote + && text.charAt(offset + 2) == quote))) { + if (triple) { + offset += 2; + } + quote = '\0'; + triple = false; + } + continue; + } + if (character == '#') { + var newline = text.indexOf('\n', offset); + return newline == -1 ? text.length() : newline + 1; + } + if (character == '"' || character == '\'') { + quote = character; + triple = offset + 2 < text.length() + && text.charAt(offset + 1) == character + && text.charAt(offset + 2) == character; + if (triple) { + offset += 2; + } + } else if (character == '[' || character == '{') { + nesting++; + } else if (character == ']' || character == '}') { + nesting--; + } else if (character == '\n' && nesting == 0) { + return offset + 1; + } + } + return text.length(); + } + + private static int offset(String text, int line, int column) { + var offset = 0; + for (int current = 1; current < line; current++) { + var newline = text.indexOf('\n', offset); + if (newline == -1) { + throw new IllegalArgumentException("TOML position is outside the document"); + } + offset = newline + 1; + } + return offset + column - 1; + } + + private static int simpleKeyEnd(String text, int start) { + if (start >= text.length()) { + return start; + } + var quote = text.charAt(start); + if (quote != '"' && quote != '\'') { + var offset = start; + while (offset < text.length()) { + var character = text.charAt(offset); + if (!(Character.isLetterOrDigit(character) || character == '_' || character == '-')) { + break; + } + offset++; + } + return offset; + } + var escaped = false; + for (int offset = start + 1; offset < text.length(); offset++) { + var character = text.charAt(offset); + if (escaped) { + escaped = false; + } else if (quote == '"' && character == '\\') { + escaped = true; + } else if (character == quote) { + return offset + 1; + } + } + throw new IllegalArgumentException("unterminated quoted TOML key"); + } + + private static int skipWhitespace(String text, int start) { + var offset = start; + while (offset < text.length() && Character.isWhitespace(text.charAt(offset))) { + offset++; + } + return offset; + } + + private static int closingDelimiter(String text, int start, char opening, char closing) { + var depth = 0; + var quote = '\0'; + var triple = false; + var escaped = false; + for (int offset = start; offset < text.length(); offset++) { + var character = text.charAt(offset); + if (quote != '\0') { + if (escaped) { + escaped = false; + } else if (quote == '"' && character == '\\') { + escaped = true; + } else if (character == quote + && (!triple + || (offset + 2 < text.length() + && text.charAt(offset + 1) == quote + && text.charAt(offset + 2) == quote))) { + if (triple) { + offset += 2; + } + quote = '\0'; + triple = false; + } + continue; + } + if (character == '#') { + var newline = text.indexOf('\n', offset); + if (newline == -1) { + break; + } + offset = newline; + } else if (character == '"' || character == '\'') { + quote = character; + triple = offset + 2 < text.length() + && text.charAt(offset + 1) == character + && text.charAt(offset + 2) == character; + if (triple) { + offset += 2; + } + } else if (character == opening) { + depth++; + } else if (character == closing && --depth == 0) { + return offset; + } + } + throw new IllegalArgumentException("unterminated TOML value"); + } + + private static String inlineEntry(ServerSpec server) { + var entry = new StringBuilder("{ command = ") + .append(string(server.command())) + .append(", args = ") + .append(array(server.arguments())); + if (!server.environment().isEmpty()) { + entry.append(", env = { "); + var separator = ""; + for (var value : server.environment().entrySet()) { + entry.append(separator) + .append(string(value.getKey())) + .append(" = ") + .append(string(value.getValue())); + separator = ", "; + } + entry.append(" }"); + } + return entry.append(" }").toString(); + } + private static List
sections(String text) { List
found = new ArrayList<>(); var offset = 0; @@ -261,4 +513,6 @@ private record Section(List path, int start, int headerEnd, int end) { path = List.copyOf(path); } } + + private record Range(int start, int end) {} } diff --git a/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/TransactionGuard.java b/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/TransactionGuard.java index bda6734..0bdb26a 100644 --- a/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/TransactionGuard.java +++ b/tools/mcp-swap/src/main/java/io/github/libtmux/tools/mcpswap/TransactionGuard.java @@ -18,21 +18,26 @@ private TransactionGuard(SwapLock lock, List paths) { static TransactionGuard capture(List clients, SwapLock lock) throws IOException { var paths = inspect(clients); - rejectAliases(paths, lock.path()); + rejectAliases(paths, lock.path(), lock.identity()); return new TransactionGuard(lock, paths); } static void preflight(List clients, Path lockPath) throws IOException { SwapLock.preflight(lockPath); - rejectAliases(inspect(clients), lockPath); + var lockSnapshot = FileSnapshot.capture(PathRoute.inspect(lockPath).target()); + rejectAliases(inspect(clients), lockPath, lockSnapshot.identity()); } private static List inspect(List clients) throws IOException { List paths = new ArrayList<>(); + var configs = new java.util.HashSet(); for (var client : clients) { - paths.add(capture(client.name() + " config", client.configPath(), true, true)); - paths.add(capture(client.name() + " backup", SwapPaths.backup(client), false, true)); - paths.add(capture(client.name() + " state", SwapPaths.state(client), false, true)); + var config = client.configPath().toAbsolutePath().normalize(); + if (configs.add(config)) { + paths.add(capture(client.name() + " config", config, true, true)); + } + paths.add(capture(client.label() + " backup", SwapPaths.backup(client), false, true)); + paths.add(capture(client.label() + " state", SwapPaths.state(client), false, true)); } return paths; } @@ -52,28 +57,22 @@ void verifyLock() throws IOException { lock.verify(); } - void update(Path logical) throws IOException { + void update(Path logical, FileSnapshot expected) throws IOException { for (int index = 0; index < paths.size(); index++) { var path = paths.get(index); if (!path.route().logical().equals(logical.toAbsolutePath().normalize())) { continue; } - paths.set(index, capture(path.label(), logical, path.config(), false)); + var updated = capture(path.label(), logical, path.config(), false); + if (!path.route().sameTopology(updated.route()) || !expected.same(updated.snapshot())) { + throw new IOException("transaction path changed before update: " + logical); + } + paths.set(index, updated); return; } throw new IOException("transaction path is not protected: " + logical); } - void updateTarget(Path target) throws IOException { - var normalized = target.toAbsolutePath().normalize(); - for (int index = 0; index < paths.size(); index++) { - var path = paths.get(index); - if (path.route().target().equals(normalized)) { - paths.set(index, capture(path.label(), path.route().logical(), path.config(), false)); - } - } - } - FileSnapshot snapshot(Path logical) throws IOException { var normalized = logical.toAbsolutePath().normalize(); for (var path : paths) { @@ -97,7 +96,8 @@ private static ProtectedPath capture(String label, Path logical, boolean config, return new ProtectedPath(label, config, route, snapshot); } - private static void rejectAliases(List paths, Path lockPath) throws IOException { + private static void rejectAliases(List paths, Path lockPath, String lockIdentity) + throws IOException { Map targets = new HashMap<>(); Map identities = new HashMap<>(); for (var path : paths) { @@ -117,8 +117,7 @@ private static void rejectAliases(List paths, Path lockPath) thro if (previous != null) { throw new IOException(previous + " aliases the swap lock"); } - var lockSnapshot = FileSnapshot.capture(lockRoute.target()); - previous = identities.putIfAbsent(lockSnapshot.identity(), "swap lock"); + previous = identities.putIfAbsent(lockIdentity, "swap lock"); if (previous != null) { throw new IOException(previous + " is hard linked to the swap lock"); } diff --git a/tools/mcp-swap/src/test/java/io/github/libtmux/tools/mcpswap/ClientRegistryTest.java b/tools/mcp-swap/src/test/java/io/github/libtmux/tools/mcpswap/ClientRegistryTest.java index e02e942..c0d74f4 100644 --- a/tools/mcp-swap/src/test/java/io/github/libtmux/tools/mcpswap/ClientRegistryTest.java +++ b/tools/mcp-swap/src/test/java/io/github/libtmux/tools/mcpswap/ClientRegistryTest.java @@ -12,6 +12,8 @@ final class ClientRegistryTest { private static final List NAMES = List.of("claude", "codex", "cursor", "gemini", "grok", "agy", "opencode", "pi"); + private static final List BINARIES = + List.of("claude", "codex", "cursor-agent", "gemini", "grok", "agy", "opencode", "pi"); @Test void resolvesEveryClientUnderTheSuppliedHome() { @@ -19,6 +21,7 @@ void resolvesEveryClientUnderTheSuppliedHome() { var clients = ClientRegistry.knownClients(home, Map.of("XDG_CONFIG_HOME", "/test/config")); assertEquals(NAMES, clients.stream().map(Client::name).toList()); + assertEquals(BINARIES, clients.stream().map(Client::binary).toList()); assertEquals(home.resolve(".claude.json"), clients.getFirst().configPath()); assertEquals( Path.of("/test/config/opencode/opencode.jsonc"), clients.get(6).configPath()); diff --git a/tools/mcp-swap/src/test/java/io/github/libtmux/tools/mcpswap/ConfigCodecTest.java b/tools/mcp-swap/src/test/java/io/github/libtmux/tools/mcpswap/ConfigCodecTest.java index d0fbc53..19cc9e1 100644 --- a/tools/mcp-swap/src/test/java/io/github/libtmux/tools/mcpswap/ConfigCodecTest.java +++ b/tools/mcp-swap/src/test/java/io/github/libtmux/tools/mcpswap/ConfigCodecTest.java @@ -154,8 +154,97 @@ void replacesTomlEntryAndKeepsItsComment() { } @Test - void replacesRetiredSafetyWithoutDroppingJsonEnvironment() throws Exception { + void replacesAnInlineTomlEntryWithoutChangingItsNeighbors() { + var client = client("codex", ConfigFormat.TOML, false); + var neighbor = "other = { command = \"echo\", args = [\"keep\"] }"; + var original = "title = \"keep\"\n" + + "mcp_servers = { " + + neighbor + + ", tmux = { command = \"old\", args = [\"server\"], env = { KEEP = \"yes\" } } }" + + " # keep inline rationale\n"; + + var updated = new String( + ConfigCodec.update(client, original.getBytes(StandardCharsets.UTF_8), "tmux", SERVER), + StandardCharsets.UTF_8); + + assertTrue(updated.contains(neighbor)); + assertTrue(updated.contains("# keep inline rationale")); + assertEquals(SERVER.command(), Toml.parse(updated).getString("mcp_servers.tmux.command")); + assertEquals("yes", Toml.parse(updated).getString("mcp_servers.tmux.env.KEEP")); + } + + @Test + void addsAnEntryToAnInlineTomlServerTable() { + var client = client("codex", ConfigFormat.TOML, false); + var neighbor = "other = { command = \"echo\", args = [\"keep\"] }"; + var original = "mcp_servers = { " + neighbor + " } # keep inline rationale\n"; + + var updated = new String( + ConfigCodec.update(client, original.getBytes(StandardCharsets.UTF_8), "tmux", SERVER), + StandardCharsets.UTF_8); + + assertTrue(updated.contains(neighbor)); + assertTrue(updated.contains("# keep inline rationale")); + assertEquals(SERVER.command(), Toml.parse(updated).getString("mcp_servers.tmux.command")); + } + + @Test + void replacesInterleavedTomlChildTablesAndKeepsUnrelatedSections() { + var client = client("codex", ConfigFormat.TOML, false); + var original = """ + [mcp_servers.tmux] + command = "old" + args = ["server"] + + [mcp_servers.other] + # keep this unrelated server + command = "echo" + args = ["keep"] + + [mcp_servers.tmux.env] + # keep this environment rationale + KEEP = "yes" + """; + + var updated = new String( + ConfigCodec.update(client, original.getBytes(StandardCharsets.UTF_8), "tmux", SERVER), + StandardCharsets.UTF_8); + + assertTrue(updated.contains("# keep this unrelated server")); + assertTrue(updated.contains("# keep this environment rationale")); + assertEquals("echo", Toml.parse(updated).getString("mcp_servers.other.command")); + assertEquals(SERVER.command(), Toml.parse(updated).getString("mcp_servers.tmux.command")); + assertEquals("yes", Toml.parse(updated).getString("mcp_servers.tmux.env.KEEP")); + } + + @Test + void replacesDottedTomlAssignmentsAndKeepsTheirComments() { + var client = client("codex", ConfigFormat.TOML, false); + var original = """ + # keep target rationale + mcp_servers.tmux.command = "old" + mcp_servers.tmux.args = ["server"] + mcp_servers.tmux.env.KEEP = "yes" + # keep unrelated server + mcp_servers.other.command = "echo" + """; + + var updated = new String( + ConfigCodec.update(client, original.getBytes(StandardCharsets.UTF_8), "tmux", SERVER), + StandardCharsets.UTF_8); + + assertTrue(updated.contains("# keep target rationale")); + assertTrue(updated.contains("# keep unrelated server")); + assertEquals("echo", Toml.parse(updated).getString("mcp_servers.other.command")); + assertEquals(SERVER.command(), Toml.parse(updated).getString("mcp_servers.tmux.command")); + assertEquals("yes", Toml.parse(updated).getString("mcp_servers.tmux.env.KEEP")); + } + + @Test + void explicitToolsetsRetiresSafetyWithoutDroppingJsonEnvironment() throws Exception { var client = client("claude", ConfigFormat.JSON, false); + var replacement = + new ServerSpec(SERVER.command(), SERVER.arguments(), Map.of("LIBTMUX_TOOLSETS", "inspect,execute")); var original = """ { "mcpServers": { @@ -172,7 +261,7 @@ void replacesRetiredSafetyWithoutDroppingJsonEnvironment() throws Exception { } """; - var updated = ConfigCodec.update(client, original.getBytes(StandardCharsets.UTF_8), "tmux", SERVER); + var updated = ConfigCodec.update(client, original.getBytes(StandardCharsets.UTF_8), "tmux", replacement); var entry = new ObjectMapper().readTree(updated).path("mcpServers").path("tmux"); assertFalse(entry.path("env").has("LIBTMUX_SAFETY")); @@ -182,7 +271,7 @@ void replacesRetiredSafetyWithoutDroppingJsonEnvironment() throws Exception { } @Test - void preservesTomlEnvironmentAndItsCommentsWhileRemovingSafety() { + void preservesInheritedSafetyAndTomlEnvironmentUntilItIsExplicitlyReplaced() { var client = client("codex", ConfigFormat.TOML, false); var original = """ [mcp_servers.tmux] @@ -206,24 +295,27 @@ void preservesTomlEnvironmentAndItsCommentsWhileRemovingSafety() { var parsed = Toml.parse(updated); assertTrue(updated.contains("# keep this environment rationale")); - assertFalse(updated.contains("LIBTMUX_SAFETY")); + assertEquals("readonly", parsed.getString("mcp_servers.tmux.env.LIBTMUX_SAFETY")); assertEquals("inspect", parsed.getString("mcp_servers.tmux.env.LIBTMUX_TOOLSETS")); assertEquals("yes", parsed.getString("mcp_servers.tmux.env.KEEP")); assertEquals("echo", parsed.getString("mcp_servers.other.command")); } @Test - void refusesToGuessAToolsetWhenOnlyRetiredSafetyExists() { + void preservesRetiredSafetyWhenNoReplacementToolsetsWereRequested() { var client = client("cursor", ConfigFormat.JSON, false); var original = """ {"mcpServers":{"tmux":{"command":"old","args":[],"env":{"LIBTMUX_SAFETY":"destructive"}}}} """; - var failure = org.junit.jupiter.api.Assertions.assertThrows( - IllegalArgumentException.class, - () -> ConfigCodec.update(client, original.getBytes(StandardCharsets.UTF_8), "tmux", SERVER)); + var updated = ConfigCodec.update(client, original.getBytes(StandardCharsets.UTF_8), "tmux", SERVER); - assertTrue(String.valueOf(failure.getMessage()).contains("LIBTMUX_TOOLSETS")); + assertEquals( + "destructive", + ConfigCodec.read(client, updated, "tmux") + .orElseThrow() + .environment() + .get("LIBTMUX_SAFETY")); } @Test @@ -264,6 +356,35 @@ void rejectsMalformedUtf8InsteadOfRewritingReplacementCharacters() { } } + @Test + void rejectsTrailingJsonDocuments() { + var clients = List.of(client("claude", ConfigFormat.JSON, false), client("opencode", ConfigFormat.JSONC, true)); + + for (var client : clients) { + var malformed = "{\"keep\":true} {\"tail\":true}\n".getBytes(StandardCharsets.UTF_8); + + assertThrows(IllegalArgumentException.class, () -> ConfigCodec.update(client, malformed, "tmux", SERVER)); + assertThrows(IllegalArgumentException.class, () -> ConfigCodec.read(client, malformed, "tmux")); + } + } + + @Test + void rejectsDuplicateJsonAndJsoncObjectKeys() { + var inputs = Map.of( + client("claude", ConfigFormat.JSON, false), + "{\"mcpServers\":{},\"mcpServers\":{}}", + client("opencode", ConfigFormat.JSONC, true), + "{\"mcp\":{},/* keep */\"mcp\":{},}"); + + for (var input : inputs.entrySet()) { + var malformed = input.getValue().getBytes(StandardCharsets.UTF_8); + assertThrows( + IllegalArgumentException.class, + () -> ConfigCodec.update(input.getKey(), malformed, "tmux", SERVER)); + assertThrows(IllegalArgumentException.class, () -> ConfigCodec.read(input.getKey(), malformed, "tmux")); + } + } + private static Client client(String name, ConfigFormat format, boolean openCode) { var table = openCode ? "mcp" : format == ConfigFormat.TOML ? "mcp_servers" : "mcpServers"; return new Client(name, Path.of("/test/config"), table, format, openCode); diff --git a/tools/mcp-swap/src/test/java/io/github/libtmux/tools/mcpswap/McpPreflightTest.java b/tools/mcp-swap/src/test/java/io/github/libtmux/tools/mcpswap/McpPreflightTest.java new file mode 100644 index 0000000..09984fe --- /dev/null +++ b/tools/mcp-swap/src/test/java/io/github/libtmux/tools/mcpswap/McpPreflightTest.java @@ -0,0 +1,167 @@ +package io.github.libtmux.tools.mcpswap; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +final class McpPreflightTest { + @TempDir + Path temporary; + + @Test + void acceptsInitializeBeforeALongLivedServerExitsAndReapsItsTree() throws Exception { + var descendant = temporary.resolve("descendant.pid"); + var server = script("long-lived", """ + read request + sleep 300 & + child=$! + printf '%s' "$child" > "$DESCENDANT_PID" + printf '%s\n' '{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-06-18"}}' + while :; do sleep 300; done + """); + + McpPreflight.run( + new ServerSpec(server.toString(), List.of(), Map.of("DESCENDANT_PID", descendant.toString())), + System.getenv(), + Duration.ofSeconds(3), + 1024 * 1024); + + var pid = Long.parseLong(Files.readString(descendant, StandardCharsets.UTF_8)); + assertEventuallyDead(pid); + } + + @Test + void keepsStdinOpenUntilTheInitializeResponse() throws Exception { + var server = temporary.resolve("stdin-open.py"); + Files.writeString(server, """ + #!/usr/bin/env python3 + import select + import sys + import time + + sys.stdin.readline() + closed = select.poll() + closed.register(sys.stdin, select.POLLHUP) + time.sleep(0.2) + if closed.poll(0): + raise SystemExit(9) + print('{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-06-18"}}', flush=True) + time.sleep(300) + """, StandardCharsets.UTF_8); + assertTrue(server.toFile().setExecutable(true)); + + McpPreflight.run(new ServerSpec(server.toString(), List.of()), System.getenv(), Duration.ofSeconds(3), 1024); + } + + @Test + void rejectsOversizedLongLivedStdoutAndStderrAndReapsTheirTrees() throws Exception { + for (var stream : List.of("stdout", "stderr")) { + var descendant = temporary.resolve(stream + ".pid"); + var redirect = stream.equals("stderr") ? "1>&2" : ""; + var server = script("oversized-" + stream, """ + read request + sleep 300 & + child=$! + printf '%%s' "$child" > "$DESCENDANT_PID" + head -c 8192 /dev/zero | tr '\\000' x %s + while :; do sleep 300; done + """.formatted(redirect)); + + var failure = assertThrows( + IOException.class, + () -> McpPreflight.run( + new ServerSpec( + server.toString(), List.of(), Map.of("DESCENDANT_PID", descendant.toString())), + System.getenv(), + Duration.ofSeconds(3), + 1024)); + + assertTrue(String.valueOf(failure.getMessage()).contains("exceeded"), stream); + var pid = Long.parseLong(Files.readString(descendant, StandardCharsets.UTF_8)); + assertEventuallyDead(pid); + } + } + + @Test + void requiresTheCompleteInitializeResultEnvelope() throws Exception { + var invalid = List.of( + "{\"id\":1,\"result\":{\"protocolVersion\":\"2025-06-18\"}}", + "{\"jsonrpc\":\"1.0\",\"id\":1,\"result\":{\"protocolVersion\":\"2025-06-18\"}}", + "{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{\"protocolVersion\":\" \"}}", + "{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":true}"); + for (var response : invalid) { + var server = responseServer(response); + assertThrows( + IOException.class, + () -> McpPreflight.run( + new ServerSpec(server.toString(), List.of()), + System.getenv(), + Duration.ofSeconds(2), + 1024)); + } + + var valid = responseServer("{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{\"protocolVersion\":\"2025-06-18\"}}"); + McpPreflight.run(new ServerSpec(valid.toString(), List.of()), System.getenv(), Duration.ofSeconds(2), 1024); + } + + @Test + void rejectsDuplicateInitializeResponseFields() throws Exception { + var server = responseServer( + "{\"jsonrpc\":\"1.0\",\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{\"protocolVersion\":\"2025-06-18\"}}"); + + assertThrows( + IOException.class, + () -> McpPreflight.run( + new ServerSpec(server.toString(), List.of()), System.getenv(), Duration.ofSeconds(2), 1024)); + } + + @Test + void passesTheEffectiveSpecEnvironment() throws Exception { + var server = script("environment", """ + read request + test "$PREFLIGHT_VALUE" = expected || exit 9 + printf '%s\n' '{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-06-18"}}' + """); + + McpPreflight.run( + new ServerSpec(server.toString(), List.of(), Map.of("PREFLIGHT_VALUE", "expected")), + Map.of("PATH", System.getenv().getOrDefault("PATH", "")), + Duration.ofSeconds(2), + 1024); + } + + private Path responseServer(String response) throws IOException { + return script( + "response-" + Integer.toUnsignedString(response.hashCode()), + "read request\nprintf '%s\\n' '" + response + "'\n"); + } + + private Path script(String name, String body) throws IOException { + var path = temporary.resolve(name + ".sh"); + Files.writeString(path, "#!/bin/sh\n" + body, StandardCharsets.UTF_8); + assertTrue(path.toFile().setExecutable(true)); + return path; + } + + private static void assertEventuallyDead(long pid) throws InterruptedException { + for (var attempt = 0; attempt < 100; attempt++) { + if (ProcessHandle.of(pid).isEmpty() + || !ProcessHandle.of(pid).orElseThrow().isAlive()) { + return; + } + Thread.sleep(10); + } + assertFalse(ProcessHandle.of(pid).isPresent() + && ProcessHandle.of(pid).orElseThrow().isAlive()); + } +} diff --git a/tools/mcp-swap/src/test/java/io/github/libtmux/tools/mcpswap/McpSwapTest.java b/tools/mcp-swap/src/test/java/io/github/libtmux/tools/mcpswap/McpSwapTest.java index e7727e8..26df812 100644 --- a/tools/mcp-swap/src/test/java/io/github/libtmux/tools/mcpswap/McpSwapTest.java +++ b/tools/mcp-swap/src/test/java/io/github/libtmux/tools/mcpswap/McpSwapTest.java @@ -14,6 +14,7 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import org.jspecify.annotations.Nullable; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; @@ -29,6 +30,7 @@ final class McpSwapTest { private ByteArrayOutputStream stdout; private ByteArrayOutputStream stderr; private RecordingProcess process; + private RecordingPreflight preflight; @BeforeEach void setUp() throws IOException { @@ -38,11 +40,21 @@ void setUp() throws IOException { Files.createDirectories(repository.resolve("libtmux-mcp/build/install/libtmux-mcp/bin")); Files.writeString(repository.resolve("gradlew"), "#!/bin/sh\n", StandardCharsets.UTF_8); repository.resolve("gradlew").toFile().setExecutable(true); - environment = Map.of("XDG_CONFIG_HOME", home.resolve("xdg").toString()); + var binaries = home.resolve("bin"); + Files.createDirectories(binaries); + for (var binary : List.of("claude", "codex", "cursor-agent", "gemini", "grok", "agy", "opencode", "pi")) { + var executable = binaries.resolve(binary); + Files.writeString(executable, "#!/bin/sh\n", StandardCharsets.UTF_8); + executable.toFile().setExecutable(true); + } + environment = new LinkedHashMap<>(); + environment.put("XDG_CONFIG_HOME", home.resolve("xdg").toString()); + environment.put("PATH", binaries.toString()); clients = ClientRegistry.knownClients(home, environment); stdout = new ByteArrayOutputStream(); stderr = new ByteArrayOutputStream(); process = new RecordingProcess(); + preflight = new RecordingPreflight(); } @Test @@ -50,10 +62,36 @@ void noArgumentsAndHelpDescribeTheNativeCommands() { assertEquals(0, run()); assertTrue(output().contains("usage: mcp-swap")); + for (var command : List.of("detect", "status", "use", "revert", "doctor")) { + stdout.reset(); + assertEquals(0, run(command, "--help")); + assertTrue(output().contains("usage: mcp-swap " + command)); + } + stdout.reset(); assertEquals(0, run("use", "--help")); assertTrue(output().contains("--source")); assertTrue(output().contains("--socket-name")); + assertTrue(output().contains("--scope")); + assertTrue(output().contains("--no-preflight")); + } + + @Test + void rejectsOptionsThatACommandWouldIgnore() { + assertEquals(2, run("detect", "--cli", "claude")); + assertTrue(error().contains("--cli does not apply to detect")); + + stderr.reset(); + assertEquals(2, run("status", "--dry-run")); + assertTrue(error().contains("--dry-run does not apply to status")); + + stderr.reset(); + assertEquals(2, run("revert", "--source", "gradle")); + assertTrue(error().contains("--source does not apply to revert")); + + stderr.reset(); + assertEquals(2, run("doctor", "--dry-run")); + assertTrue(error().contains("--dry-run does not apply to doctor")); } @Test @@ -73,6 +111,7 @@ void dryRunPreflightsEveryClientWithoutBuildingOrWriting() throws IOException { "pi,antigravity,cursor,claude,codex,gemini,grok,opencode")); assertTrue(process.commands.isEmpty()); + assertTrue(preflight.specs.isEmpty()); for (var client : clients) { assertArrayEquals(originals.get(client.name()), Files.readAllBytes(client.configPath())); assertTrue(output().lines() @@ -97,11 +136,13 @@ void distributionUseBuildsOnceAndRevertRestoresEveryByte() throws IOException { assertEquals(0, run("use", "--socket-name", "demo")); assertEquals(1, process.commands.size()); + assertEquals(8, preflight.specs.size()); assertTrue(process.commands.getFirst().contains(":libtmux-mcp:installDist")); for (var client : clients) { + var target = client.scoped(client.name().equals("claude") ? Scope.PROJECT : Scope.USER, repository); assertEquals( new ServerSpec(launcher.toString(), List.of("--socket-name", "demo")), - ConfigCodec.read(client, Files.readAllBytes(client.configPath()), "tmux") + ConfigCodec.read(target, Files.readAllBytes(client.configPath()), "tmux") .orElseThrow()); } @@ -131,11 +172,200 @@ void statusAndDetectRemainReadOnlyWhenOneConfigIsMalformed() throws IOException assertTreeEquals(before); } + @Test + void detectRequiresBothTheBinaryAndConfigUnlessAClientIsExplicit() throws IOException { + seedAll(); + Files.delete(home.resolve("bin/claude")); + + assertEquals(0, run("detect")); + assertTrue(output().lines().anyMatch(line -> line.contains("claude") && line.contains("binary missing"))); + + stdout.reset(); + assertEquals(0, run("use", "--dry-run", "--source", "gradle")); + assertFalse(output().lines().anyMatch(line -> line.startsWith("claude"))); + + stdout.reset(); + assertEquals(0, run("use", "--dry-run", "--source", "gradle", "--cli", "claude")); + assertTrue(output().lines().anyMatch(line -> line.startsWith("claude:project"))); + } + + @Test + void claudeScopesCoexistAndFullRevertUnwindsThemInLifoOrder() throws IOException { + var claude = clients.getFirst(); + Files.createDirectories(claude.configPath().getParent()); + var original = """ + { + "mcpServers": { + "tmux": {"type":"stdio","command":"published","args":[],"env":{}} + }, + "projects": {} + } + """.getBytes(StandardCharsets.UTF_8); + Files.write(claude.configPath(), original); + + assertEquals(0, run("use", "--source", "gradle", "--cli", "claude", "--socket-name", "project")); + assertEquals( + 0, run("use", "--source", "gradle", "--cli", "claude", "--scope", "user", "--socket-name", "user")); + + stdout.reset(); + assertEquals(0, run("status", "--cli", "claude")); + assertTrue(output().contains("claude:user")); + assertTrue(output().contains("claude:project")); + + var swapped = Files.readAllBytes(claude.configPath()); + assertEquals(1, run("revert", "--cli", "claude", "--scope", "project")); + assertArrayEquals(swapped, Files.readAllBytes(claude.configPath())); + assertTrue(error().contains("newer recovery layer")); + + stderr.reset(); + assertEquals(0, run("revert", "--cli", "claude")); + assertArrayEquals(original, Files.readAllBytes(claude.configPath())); + try (var paths = Files.walk(home)) { + assertFalse(paths.anyMatch(path -> path.getFileName().toString().contains("mcp-swap-java"))); + } + } + + @Test + void claudeScopesCanBeRevertedIndependentlyFromTheNewestLayer() throws IOException { + var claude = clients.getFirst(); + Files.createDirectories(claude.configPath().getParent()); + var original = "{\"mcpServers\":{},\"projects\":{}}\n".getBytes(StandardCharsets.UTF_8); + Files.write(claude.configPath(), original); + + assertEquals(0, run("use", "--source", "gradle", "--cli", "claude", "--socket-name", "project")); + assertEquals( + 0, run("use", "--source", "gradle", "--cli", "claude", "--scope", "user", "--socket-name", "user")); + + assertEquals(0, run("revert", "--cli", "claude", "--scope", "user")); + var project = claude.scoped(Scope.PROJECT, repository); + var user = claude.scoped(Scope.USER, repository); + assertEquals( + new ServerSpec( + repository.resolve("gradlew").toString(), + List.of( + "--quiet", + "--console=plain", + "--no-daemon", + "--max-workers=5", + ":libtmux-mcp:run", + "--args", + "--socket-name project")), + ConfigCodec.read(project, Files.readAllBytes(claude.configPath()), "tmux") + .orElseThrow()); + assertTrue(Files.exists(SwapPaths.state(project))); + assertFalse(Files.exists(SwapPaths.state(user))); + + assertEquals(0, run("revert", "--cli", "claude", "--scope", "project")); + assertArrayEquals(original, Files.readAllBytes(claude.configPath())); + } + + @Test + void abortsWhenAnEffectiveSpecChangesAfterPreflight() throws IOException { + var claude = clients.getFirst(); + Files.createDirectories(claude.configPath().getParent()); + Files.writeString(claude.configPath(), "{\"projects\":{}}\n", StandardCharsets.UTF_8); + var human = """ + {"projects":{"%s":{"mcpServers":{"tmux":{ + "type":"stdio","command":"human","args":[],"env":{"KEEP":"yes"} + }}}}} + """.formatted(repository.toAbsolutePath().normalize()).getBytes(StandardCharsets.UTF_8); + preflight.afterRun = () -> { + try { + Files.write(claude.configPath(), human); + } catch (IOException error) { + throw new RuntimeException(error); + } + }; + + assertEquals(1, run("use", "--source", "gradle", "--cli", "claude")); + + assertArrayEquals(human, Files.readAllBytes(claude.configPath())); + assertTrue(error().contains("changed after preflight")); + assertEquals(1, preflight.specs.size()); + try (var paths = Files.walk(home)) { + assertFalse(paths.anyMatch(path -> path.getFileName().toString().contains("mcp-swap-java"))); + } + } + + @Test + void pathSourcePreflightsAndRecordsTheChosenExecutable() throws IOException { + seedAll(); + var executable = repository.resolve("build/custom-mcp"); + Files.createDirectories(executable.getParent()); + Files.writeString(executable, "#!/bin/sh\n", StandardCharsets.UTF_8); + executable.toFile().setExecutable(true); + + assertEquals( + 0, + run("use", "--source", "path", "--bin", "build/custom-mcp", "--cli", "codex", "--socket-name", "demo")); + + assertEquals( + new ServerSpec(executable.toString(), List.of("--socket-name", "demo")), preflight.specs.getFirst()); + assertEquals( + preflight.specs.getFirst(), + ConfigCodec.read( + clients.get(1), + Files.readAllBytes(clients.get(1).configPath()), + "tmux") + .orElseThrow()); + } + + @Test + void failedPreflightLeavesConfigsAndRecoveryUntouched() throws IOException { + var originals = seedAll(); + var before = tree(); + preflight.failure = new IOException("synthetic initialize failure"); + + assertEquals(1, run("use", "--source", "gradle", "--cli", "codex")); + + assertTrue(error().contains("synthetic initialize failure")); + assertArrayEquals( + originals.get("codex"), Files.readAllBytes(clients.get(1).configPath())); + assertTreeEquals(before); + } + + @Test + void doctorInspectsConfigOnlyClientsAndRedactsAuthEnvironmentValues() throws IOException { + var codex = clients.get(1); + Files.createDirectories(codex.configPath().getParent()); + Files.writeString(codex.configPath(), "title = \"keep\"\n", StandardCharsets.UTF_8); + Files.delete(home.resolve("bin/codex")); + environment.put("OPENAI_API_KEY", "do-not-print-this-value"); + var before = tree(); + + assertEquals(0, run("doctor", "--source", "gradle", "--cli", "codex")); + + assertTrue(output().contains("codex binary missing")); + assertTrue(output().contains("OPENAI_API_KEY")); + assertFalse(output().contains("do-not-print-this-value")); + assertTreeEquals(before); + } + + @Test + void doctorReportsOutstandingClaudeScopeWithoutWriting() throws IOException { + var claude = clients.getFirst(); + Files.createDirectories(claude.configPath().getParent()); + Files.writeString(claude.configPath(), "{\"projects\":{}}\n", StandardCharsets.UTF_8); + assertEquals(0, run("use", "--source", "gradle", "--cli", "claude")); + var before = tree(); + + stdout.reset(); + assertEquals(0, run("doctor", "--source", "gradle", "--cli", "claude")); + + assertTrue(output().contains("claude:project")); + assertTrue(output().contains("outstanding swap")); + assertTreeEquals(before); + } + @Test void rejectsRetiredAndAmbiguousLauncherArguments() { assertEquals(2, run("use", "--safety", "destructive")); assertTrue(error().contains("LIBTMUX_SAFETY")); + stderr.reset(); + assertEquals(2, run("use", "--env", "LIBTMUX_SAFETY=destructive")); + assertTrue(error().contains("LIBTMUX_SAFETY")); + stderr.reset(); assertEquals(2, run("use", "--source", "path")); assertTrue(error().contains("--bin")); @@ -146,32 +376,43 @@ void rejectsRetiredAndAmbiguousLauncherArguments() { } @Test - void explicitEnvironmentReplacesRetiredSafety() throws IOException { + void preflightSeesInheritedSafetyUntilExplicitToolsetsReplacesIt() throws IOException { var claude = clients.getFirst(); Files.createDirectories(claude.configPath().getParent()); - Files.writeString(claude.configPath(), """ - {"mcpServers":{"tmux":{"command":"old","args":[],"env":{"LIBTMUX_SAFETY":"readonly"}}}} - """, StandardCharsets.UTF_8); + var original = """ + {"mcpServers":{"tmux":{"command":"old","args":[],"env":{ + "LIBTMUX_SAFETY":"readonly","KEEP":"yes" + }}}} + """.getBytes(StandardCharsets.UTF_8); + Files.write(claude.configPath(), original); + preflight.failure = new IOException("LIBTMUX_SAFETY is not supported"); - assertEquals(1, run("use", "--dry-run", "--source", "gradle", "--cli", "claude")); - assertTrue(error().contains("LIBTMUX_TOOLSETS")); + assertEquals(1, run("use", "--source", "gradle", "--cli", "claude", "--scope", "user")); + assertEquals("readonly", preflight.specs.getFirst().environment().get("LIBTMUX_SAFETY")); + assertArrayEquals(original, Files.readAllBytes(claude.configPath())); + assertTrue(error().contains("LIBTMUX_SAFETY")); stdout.reset(); stderr.reset(); + preflight.specs.clear(); + preflight.failure = null; assertEquals( 0, run( "use", - "--dry-run", "--source", "gradle", "--cli", "claude", + "--scope", + "user", "--env", "LIBTMUX_TOOLSETS=inspect,manage")); - assertTrue(error().contains("pointing 'tmux'")); - assertFalse(error().contains("LIBTMUX_SAFETY")); + var effective = preflight.specs.getFirst().environment(); + assertEquals("inspect,manage", effective.get("LIBTMUX_TOOLSETS")); + assertEquals("yes", effective.get("KEEP")); + assertFalse(effective.containsKey("LIBTMUX_SAFETY")); } private int run(String... arguments) { @@ -183,7 +424,8 @@ private int run(String... arguments) { repository, new PrintStream(stdout, true, StandardCharsets.UTF_8), new PrintStream(stderr, true, StandardCharsets.UTF_8), - process)); + process, + preflight)); } private Map seedAll() throws IOException { @@ -238,4 +480,19 @@ public int run(List command, Path directory) { return 0; } } + + private static final class RecordingPreflight implements McpSwap.PreflightRunner { + private final java.util.ArrayList specs = new java.util.ArrayList<>(); + private Runnable afterRun = () -> {}; + private @Nullable IOException failure; + + @Override + public void run(ServerSpec spec) throws IOException { + specs.add(spec); + afterRun.run(); + if (failure != null) { + throw failure; + } + } + } } diff --git a/tools/mcp-swap/src/test/java/io/github/libtmux/tools/mcpswap/SwapServiceTest.java b/tools/mcp-swap/src/test/java/io/github/libtmux/tools/mcpswap/SwapServiceTest.java index 73709a9..444e36c 100644 --- a/tools/mcp-swap/src/test/java/io/github/libtmux/tools/mcpswap/SwapServiceTest.java +++ b/tools/mcp-swap/src/test/java/io/github/libtmux/tools/mcpswap/SwapServiceTest.java @@ -18,6 +18,9 @@ import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; @@ -45,6 +48,62 @@ void setUp() throws IOException { clients = ClientRegistry.knownClients(home, environment); } + @Test + void usesSharedCrossPortLockAndJavaSpecificRecoveryArtifacts() { + var client = clients.getFirst(); + + assertEquals(home.resolve(".state/libtmux-mcp-dev/swap/state.lock"), SwapPaths.lock(home, environment)); + assertTrue(SwapPaths.backup(client).getFileName().toString().endsWith(".mcp-swap-java-user-backup")); + assertTrue(SwapPaths.state(client).getFileName().toString().endsWith(".mcp-swap-java-user-backup.state")); + } + + @Test + void sharedLockExcludesPosixRecordLockClients() throws IOException, InterruptedException { + try (var lock = SwapLock.acquire(home, environment)) { + var guard = TransactionGuard.capture(clients, lock); + guard.verifyLock(); + assertRecordLockHeld(lock.path()); + } + } + + @Test + void rejectingAClientAliasDoesNotReleaseTheRecordLock() throws IOException, InterruptedException { + try (var lock = SwapLock.acquire(home, environment)) { + var config = clients.getFirst().configPath(); + Files.createDirectories(Objects.requireNonNull(config.getParent())); + Files.createLink(config, lock.path()); + var failure = new java.util.concurrent.atomic.AtomicReference(); + var probe = Thread.ofPlatform().start(() -> { + try { + TransactionGuard.capture(clients, lock); + failure.set(new AssertionError("lock alias was accepted")); + } catch (IOException expected) { + failure.set(expected); + } + }); + probe.join(); + + assertTrue(failure.get() instanceof IOException); + assertTrue(String.valueOf(failure.get().getMessage()).contains("active swap lock")); + assertRecordLockHeld(lock.path()); + } + } + + private static void assertRecordLockHeld(Path path) throws IOException, InterruptedException { + var script = """ + import fcntl, os, sys + handle = os.fdopen(os.open(sys.argv[1], os.O_RDWR), "r+") + try: + fcntl.lockf(handle, fcntl.LOCK_EX | fcntl.LOCK_NB) + except BlockingIOError: + sys.exit(0) + sys.exit(1) + """; + var probe = new ProcessBuilder("python3", "-c", script, path.toString()).start(); + assertTrue(probe.waitFor(5, TimeUnit.SECONDS), "record-lock probe timed out"); + assertEquals(0, probe.exitValue(), "external fcntl client acquired the shared lock"); + } + @Test void swapsAndRestoresAllEightClientsAsOneTransaction() throws IOException { var originals = seedAll(); @@ -224,6 +283,117 @@ void preservesALateFileAtAnAbsentConfigDestination() throws IOException { assertFalse(Files.exists(SwapPaths.state(selected))); } + @Test + void retainsAHumanReplacementAfterConfigPublication() throws IOException { + seedAll(); + var selected = clients.getFirst(); + var human = "{\"human\":\"after publication\"}\n".getBytes(StandardCharsets.UTF_8); + var replaced = new boolean[] {false}; + var service = new SwapService(home, environment, (boundary, path) -> { + if (!replaced[0] && boundary.equals("config-post-publish")) { + replaced[0] = true; + replaceWith(path, human); + } + }); + + assertThrows(IOException.class, () -> service.use(List.of(selected), clients, "tmux", FIRST, false)); + + assertTrue(replaced[0]); + assertArrayEquals(human, Files.readAllBytes(selected.configPath())); + assertTrue(treeContains(human)); + } + + @Test + void retainsAHumanReplacementBeforeTransactionRebaseline() throws IOException { + seedAll(); + var selected = clients.getFirst(); + var human = "{\"human\":\"before rebaseline\"}\n".getBytes(StandardCharsets.UTF_8); + var replaced = new boolean[] {false}; + var service = new SwapService(home, environment, (boundary, path) -> { + if (!replaced[0] && boundary.equals("config-pre-update")) { + replaced[0] = true; + replaceWith(path, human); + } + }); + + assertThrows(IOException.class, () -> service.use(List.of(selected), clients, "tmux", FIRST, false)); + + assertTrue(replaced[0]); + assertArrayEquals(human, Files.readAllBytes(selected.configPath())); + assertTrue(treeContains(human)); + } + + @Test + void retainsAHumanReplacementAfterStatePublication() throws IOException { + seedAll(); + var selected = clients.getFirst(); + var human = "human state replacement\n".getBytes(StandardCharsets.UTF_8); + var replaced = new boolean[] {false}; + var service = new SwapService(home, environment, (boundary, path) -> { + if (!replaced[0] && boundary.equals("state-post-publish")) { + replaced[0] = true; + replaceWith(path, human); + } + }); + + assertThrows(IOException.class, () -> service.use(List.of(selected), clients, "tmux", FIRST, false)); + + assertTrue(replaced[0]); + assertArrayEquals(human, Files.readAllBytes(SwapPaths.state(selected))); + assertTrue(treeContains(human)); + } + + @Test + void retainsAHumanReplacementOfACleanupArtifact() throws IOException { + seedAll(); + var selected = clients.getFirst(); + var human = "human cleanup replacement\n".getBytes(StandardCharsets.UTF_8); + var replaced = new boolean[] {false}; + var service = new SwapService(home, environment, (boundary, path) -> { + if (!replaced[0] && boundary.equals("config-cleanup-remove")) { + replaced[0] = true; + replaceWith(path, human); + } + }); + + assertThrows(IOException.class, () -> service.use(List.of(selected), clients, "tmux", FIRST, false)); + + assertTrue(replaced[0]); + assertEquals( + FIRST, + ConfigCodec.read(selected, Files.readAllBytes(selected.configPath()), "tmux") + .orElseThrow()); + assertTrue(treeContains(human)); + assertTrue(Files.isRegularFile(SwapPaths.backup(selected))); + assertTrue(Files.isRegularFile(SwapPaths.state(selected))); + } + + @Test + void retainsAHumanReplacementDuringRollbackRemoval() throws IOException { + seedAll(); + var first = clients.getFirst(); + var second = clients.get(1); + var human = "{\"human\":\"rollback replacement\"}\n".getBytes(StandardCharsets.UTF_8); + var replaced = new boolean[] {false}; + var service = new SwapService(home, environment, (boundary, path) -> { + if (boundary.equals("config-publish") && path.equals(second.configPath())) { + throw new IOException("synthetic later failure"); + } + if (!replaced[0] && boundary.equals("config-rollback-remove") && path.equals(first.configPath())) { + replaced[0] = true; + replaceWith(path, human); + } + }); + + assertThrows(IOException.class, () -> service.use(List.of(first, second), clients, "tmux", FIRST, false)); + + assertTrue(replaced[0]); + assertArrayEquals(human, Files.readAllBytes(first.configPath())); + assertTrue(treeContains(human)); + assertTrue(Files.isRegularFile(SwapPaths.backup(first))); + assertTrue(Files.isRegularFile(SwapPaths.state(first))); + } + @Test void refusesRevertAfterAHumanEditAndKeepsRecovery() throws IOException { seedAll(); @@ -262,6 +432,27 @@ void refusesAByteIdenticalReplacementOfTheRecoveryBackup() throws IOException { assertTrue(Files.isRegularFile(SwapPaths.state(selected))); } + @Test + void refusesAByteIdenticalReplacementOfTheSwappedConfig() throws IOException { + seedAll(); + var selected = clients.getFirst(); + var service = new SwapService(home, environment); + service.use(List.of(selected), clients, "tmux", FIRST, false); + var replacement = selected.configPath().resolveSibling("replacement.json"); + Files.copy(selected.configPath(), replacement); + Files.setPosixFilePermissions(replacement, Files.getPosixFilePermissions(selected.configPath())); + Files.move(replacement, selected.configPath(), StandardCopyOption.REPLACE_EXISTING); + + assertThrows(IOException.class, () -> service.revert(List.of(selected), clients, "tmux", false)); + + assertEquals( + FIRST, + ConfigCodec.read(selected, Files.readAllBytes(selected.configPath()), "tmux") + .orElseThrow()); + assertTrue(Files.isRegularFile(SwapPaths.backup(selected))); + assertTrue(Files.isRegularFile(SwapPaths.state(selected))); + } + @Test void rejectsARecoveryRecordWithAChecksummedWrongType() throws IOException { seedAll(); @@ -285,6 +476,62 @@ void rejectsARecoveryRecordWithAChecksummedWrongType() throws IOException { assertTrue(Files.isRegularFile(state)); } + @Test + void rejectsLegacyRecoverySchemasEvenWithAValidChecksum() throws IOException { + seedAll(); + var selected = clients.getFirst(); + var service = new SwapService(home, environment); + service.use(List.of(selected), clients, "tmux", FIRST, false); + var state = SwapPaths.state(selected); + var root = (ObjectNode) JSON.readTree(state.toFile()); + root.remove("checksum"); + root.put("version", 1); + root.put("checksum", FileSnapshot.sha256(JSON.writeValueAsBytes(root))); + Files.write(state, JSON.writeValueAsBytes(root)); + + var failure = assertThrows(IOException.class, () -> service.revert(List.of(selected), clients, "tmux", false)); + + assertTrue(String.valueOf(failure.getMessage()).contains("unsupported recovery state version")); + assertEquals( + FIRST, + ConfigCodec.read(selected, Files.readAllBytes(selected.configPath()), "tmux") + .orElseThrow()); + assertTrue(Files.isRegularFile(SwapPaths.backup(selected))); + assertTrue(Files.isRegularFile(state)); + } + + @Test + void rejectsTrailingDataInARecoveryRecord() throws IOException { + seedAll(); + var selected = clients.getFirst(); + var service = new SwapService(home, environment); + service.use(List.of(selected), clients, "tmux", FIRST, false); + var state = SwapPaths.state(selected); + Files.writeString(state, Files.readString(state, StandardCharsets.UTF_8) + " {}\n", StandardCharsets.UTF_8); + + assertThrows(IOException.class, () -> service.revert(List.of(selected), clients, "tmux", false)); + + assertEquals( + FIRST, + ConfigCodec.read(selected, Files.readAllBytes(selected.configPath()), "tmux") + .orElseThrow()); + assertTrue(Files.isRegularFile(SwapPaths.backup(selected))); + assertTrue(Files.isRegularFile(state)); + } + + @Test + void rejectsDuplicateRecoveryFieldsBeforeChecksumValidation() throws IOException { + seedAll(); + var selected = clients.getFirst(); + var service = new SwapService(home, environment); + service.use(List.of(selected), clients, "tmux", FIRST, false); + var state = SwapPaths.state(selected); + var encoded = Files.readString(state, StandardCharsets.UTF_8); + var duplicated = encoded.replaceFirst("\\\"client\\\":", "\\\"client\\\":\\\"attacker\\\",\\\"client\\\":"); + + assertThrows(IOException.class, () -> RecoveryRecord.decode(duplicated.getBytes(StandardCharsets.UTF_8))); + } + @Test void keepsAConfigSymlinkAcrossUseAndRevert() throws IOException { var selected = clients.getFirst(); @@ -306,6 +553,88 @@ void keepsAConfigSymlinkAcrossUseAndRevert() throws IOException { assertArrayEquals(original, Files.readAllBytes(target)); } + @Test + void backsUpASymlinkedConfigAcrossFilesystems() throws IOException { + var sharedMemory = Path.of("/dev/shm"); + org.junit.jupiter.api.Assumptions.assumeTrue(Files.isDirectory(sharedMemory)); + org.junit.jupiter.api.Assumptions.assumeFalse( + Files.getFileStore(sharedMemory).equals(Files.getFileStore(home))); + var foreignDirectory = Files.createTempDirectory(sharedMemory, "libtmux-java-mcp-swap-"); + var selected = clients.get(1); + var original = "title = \"foreign\"\n".getBytes(StandardCharsets.UTF_8); + var target = foreignDirectory.resolve("config.toml"); + try { + Files.write(target, original); + Files.setPosixFilePermissions(target, PosixFilePermissions.fromString("rw-r-----")); + Files.createDirectories(selected.configPath().getParent()); + Files.createSymbolicLink(selected.configPath(), target); + var service = new SwapService(home, environment); + + service.use(List.of(selected), clients, "tmux", FIRST, false); + assertEquals( + FIRST, + ConfigCodec.read(selected, Files.readAllBytes(target), "tmux") + .orElseThrow()); + + service.revert(List.of(selected), clients, "tmux", false); + assertArrayEquals(original, Files.readAllBytes(target)); + assertFalse(Files.exists(SwapPaths.backup(selected))); + assertFalse(Files.exists(SwapPaths.state(selected))); + } finally { + Files.deleteIfExists(selected.configPath()); + Files.deleteIfExists(target); + Files.deleteIfExists(foreignDirectory); + } + } + + @Test + void refusesRevertAfterAConfigSymlinkIsRecreatedWithTheSameTarget() throws IOException { + var originals = seedAll(); + var selected = clients.get(1); + var target = home.resolve("actual-codex.toml"); + Files.write(target, originals.get(selected.name())); + Files.delete(selected.configPath()); + Files.createSymbolicLink(selected.configPath(), target); + var service = new SwapService(home, environment); + service.use(List.of(selected), clients, "tmux", FIRST, false); + + var priorLink = selected.configPath().resolveSibling("prior-config-link"); + Files.move(selected.configPath(), priorLink); + Files.createSymbolicLink(selected.configPath(), target); + + assertThrows(IOException.class, () -> service.revert(List.of(selected), clients, "tmux", false)); + assertTrue(Files.isSymbolicLink(selected.configPath())); + assertEquals( + FIRST, + ConfigCodec.read(selected, Files.readAllBytes(target), "tmux").orElseThrow()); + assertTrue(Files.isRegularFile(SwapPaths.backup(selected))); + assertTrue(Files.isRegularFile(SwapPaths.state(selected))); + assertTrue(Files.isSymbolicLink(priorLink)); + } + + @Test + void refusesRevertAfterASymlinkedConfigsLogicalParentIsReplaced() throws IOException { + var originals = seedAll(); + var selected = clients.get(1); + var target = home.resolve("actual-codex.toml"); + Files.write(target, originals.get(selected.name())); + Files.delete(selected.configPath()); + Files.createSymbolicLink(selected.configPath(), target); + var service = new SwapService(home, environment); + service.use(List.of(selected), clients, "tmux", FIRST, false); + + var parent = Objects.requireNonNull(selected.configPath().getParent()); + replaceDirectoryKeepingChildren(parent, parent.resolveSibling(".codex-displaced")); + + assertThrows(IOException.class, () -> service.revert(List.of(selected), clients, "tmux", false)); + assertTrue(Files.isSymbolicLink(selected.configPath())); + assertEquals( + FIRST, + ConfigCodec.read(selected, Files.readAllBytes(target), "tmux").orElseThrow()); + assertTrue(Files.isRegularFile(SwapPaths.backup(selected))); + assertTrue(Files.isRegularFile(SwapPaths.state(selected))); + } + @Test void retainsRecoveryWhenAConfigSymlinkIsRetargetedDuringPublish() throws IOException { var selected = clients.getFirst(); @@ -337,7 +666,7 @@ void retainsRecoveryWhenAConfigSymlinkIsRetargetedDuringPublish() throws IOExcep } @Test - void refusesAnUnsafeLockAndASecondOwner() throws IOException { + void refusesAnUnsafeLockAndSerializesASecondOwner() throws IOException, InterruptedException { var lockPath = SwapPaths.lock(home, environment); var lockDirectory = Objects.requireNonNull(lockPath.getParent()); var stateDirectory = Objects.requireNonNull(lockDirectory.getParent()); @@ -350,9 +679,32 @@ void refusesAnUnsafeLockAndASecondOwner() throws IOException { assertThrows(IOException.class, () -> SwapLock.acquire(home, environment)); Files.delete(lockPath); - try (var first = SwapLock.acquire(home, environment)) { - assertThrows(IOException.class, () -> SwapLock.acquire(home, environment)); + var first = SwapLock.acquire(home, environment); + var attempted = new CountDownLatch(1); + var acquired = new AtomicReference(); + var failure = new AtomicReference(); + var contender = Thread.ofPlatform().start(() -> { + attempted.countDown(); + try { + acquired.set(SwapLock.acquire(home, environment)); + } catch (Throwable error) { + failure.set(error); + } + }); + try { + assertTrue(attempted.await(1, TimeUnit.SECONDS)); + Thread.sleep(100); + assertTrue(contender.isAlive()); first.verify(); + assertRecordLockHeld(first.path()); + } finally { + first.close(); + } + contender.join(TimeUnit.SECONDS.toMillis(3)); + assertFalse(contender.isAlive()); + assertTrue(failure.get() == null, String.valueOf(failure.get())); + try (var second = Objects.requireNonNull(acquired.get())) { + second.verify(); } } @@ -415,6 +767,24 @@ private static void replaceDirectoryKeepingChildren(Path directory, Path displac } } + private static void replaceWith(Path path, byte[] contents) throws IOException { + var replacement = path.resolveSibling("." + path.getFileName() + ".human"); + Files.write(replacement, contents); + Files.setPosixFilePermissions(replacement, PosixFilePermissions.fromString("rw-------")); + Files.move(replacement, path, StandardCopyOption.REPLACE_EXISTING); + } + + private boolean treeContains(byte[] contents) throws IOException { + try (var paths = Files.walk(home)) { + for (var path : paths.filter(Files::isRegularFile).toList()) { + if (java.util.Arrays.equals(contents, Files.readAllBytes(path))) { + return true; + } + } + } + return false; + } + private List tree() throws IOException { try (var paths = Files.walk(home)) { return paths.map(home::relativize).map(Path::toString).sorted().toList(); From 75f4cbacac2ad8a5e46d35e9175d11f3cffa6ad7 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 6 Sep 2026 04:09:43 -0500 Subject: [PATCH 61/65] Tools(refactor[swap]): Retire Python switcher why: Maintaining two config switchers would let safety and recovery contracts drift. what: - Remove the Python implementation and tests after native parity - Document native builds, scopes, preflight, and recovery --- scripts/README.md | 47 +- scripts/mcp_swap.py | 2305 -------------------------------------- scripts/test_mcp_swap.py | 1848 ------------------------------ tools/mcp-swap/README.md | 210 ++++ 4 files changed, 240 insertions(+), 4170 deletions(-) delete mode 100755 scripts/mcp_swap.py delete mode 100644 scripts/test_mcp_swap.py create mode 100644 tools/mcp-swap/README.md diff --git a/scripts/README.md b/scripts/README.md index aadf4eb..dd6f050 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -6,7 +6,7 @@ | --- | --- | | [`tmux-matrix.sh`](tmux-matrix.sh) | builds every supported tmux release into a tree the version matrix can use | | [`reap-stale-servers.sh`](reap-stale-servers.sh) | reports and optionally ends tmux servers this port abandoned | -| [`mcp_swap.py`](mcp_swap.py) | points every installed agent CLI at this build of `libtmux-mcp` | +| [`mcp-swap`](../tools/mcp-swap/README.md) | points every installed agent CLI at this build of `libtmux-mcp` | ## Build the tmux matrix @@ -41,20 +41,26 @@ killed. [`CONTRIBUTING.md`](../.github/CONTRIBUTING.md) explains why. ## Try the MCP server in a real agent +Build the repository's private Java utility: + +```console +$ ./gradlew :tools:mcp-swap:installDist +``` + See what each CLI points at now: ```console -$ uv run scripts/mcp_swap.py status +$ tools/mcp-swap/build/install/mcp-swap/bin/mcp-swap status ``` Point them all at this build, having first said what it would do: ```console -$ uv run scripts/mcp_swap.py use --dry-run +$ tools/mcp-swap/build/install/mcp-swap/bin/mcp-swap use --dry-run ``` ```console -$ uv run scripts/mcp_swap.py use \ +$ tools/mcp-swap/build/install/mcp-swap/bin/mcp-swap use \ --socket /tmp/libtmux-java-dev/demo/s ``` @@ -68,25 +74,32 @@ The retired `--safety` and `--watch` swapper arguments are rejected; the The default covers Claude, Codex, Cursor, Gemini, Grok, `agy`, OpenCode, and Pi. Repeat `--cli` to limit a command; `antigravity` is accepted as an alias -for the canonical `agy` name. OpenCode uses its global `opencode.jsonc` file. -Pi uses the `pi-mcp-adapter` config because Pi has no built-in MCP client; -`detect` and `doctor` report when that adapter is absent. +for the canonical `agy` name. Claude defaults to this repository's project +entry in `~/.claude.json`; `--scope user` selects its top-level fallback, and +unscoped status or revert covers both layers. OpenCode uses its global +`opencode.jsonc` file. Pi uses the `pi-mcp-adapter` config because Pi has no +built-in MCP client; `detect` and `doctor` report when that adapter is absent. Put them back: ```console -$ uv run scripts/mcp_swap.py revert +$ tools/mcp-swap/build/install/mcp-swap/bin/mcp-swap revert ``` -It rewrites **global** configs only, touches only the one server entry named by -`--name` (default `tmux`), and keeps everything else in the file — including -comments and trailing commas in JSONC, and comments in TOML. A backup and its -private recovery record are taken together once. Repeat swaps and reverts first -verify the exact config, path topology, backup, record, and server route; a -mismatch leaves the recovery pair intact. All selected files commit as one -transaction; failed commits reverse in order and retain recovery files if exact -rollback is not possible. `--dry-run` validates the complete plan without -building or writing. +It rewrites the global config file for each client, plus Claude's project entry +inside `~/.claude.json`; it does not walk workspace files. It touches only the +one server entry named by `--name` (default `tmux`) and keeps everything else in +the file — including comments and trailing commas in JSONC, and comments in +TOML. A backup and its private recovery record are taken together once. Repeat +swaps and reverts first verify the exact config, path topology, backup, record, +and server route; a mismatch leaves the recovery pair intact. All selected +files commit as one transaction; failed commits reverse in order and retain +recovery files if exact rollback is not possible. `use` also performs a bounded +MCP initialize preflight before locking or writing; `--dry-run` validates the +complete plan without building, starting the server, or writing. + +The [utility guide](../tools/mcp-swap/README.md) covers source modes, +capability environment overrides, client paths, and the recovery contract. To try it without changing anything at all, most CLIs take a config per invocation instead — `claude --mcp-config --strict-mcp-config`, or diff --git a/scripts/mcp_swap.py b/scripts/mcp_swap.py deleted file mode 100755 index 8079262..0000000 --- a/scripts/mcp_swap.py +++ /dev/null @@ -1,2305 +0,0 @@ -#!/usr/bin/env -S uv run --script -# /// script -# requires-python = ">=3.11" -# dependencies = ["tomlkit>=0.13"] -# /// -"""Point every installed agent CLI at this build of ``libtmux-mcp``. - -Use when you want to try the server you are editing in a real agent rather -than in a test. ``use`` rewrites each CLI's global config; ``revert`` -restores from the backup and recovery record the swap wrote. Swapping a -config that is already swapped keeps the first backup, after verifying -the owned recovery state, so ``revert`` lands on the pre-swap config. - -Sources -------- -``--source`` picks where the server comes from: - -- ``dist`` builds ``installDist`` and names the launcher script it - writes, so an agent spawns it directly with no Gradle in front of the - handshake. This is the default. -- ``gradle`` launches through ``./gradlew :libtmux-mcp:run``, which - rebuilds on every start. Current source with nothing to remember, at - the cost of a build check per launch — and a slow first launch after a - change can outlast a client's handshake timeout. -- ``path`` takes a launcher you name with ``--bin``, wherever it came - from. - -Examples --------- -```console -$ uv run scripts/mcp_swap.py detect -``` - -```console -$ uv run scripts/mcp_swap.py status -``` - -```console -$ uv run scripts/mcp_swap.py use --dry-run -``` - -```console -$ uv run scripts/mcp_swap.py use --socket /tmp/libtmux-java-dev/demo/s -``` - -```console -$ uv run scripts/mcp_swap.py revert -``` - -Scope ------ -Deliberately narrow, and transactional: - -- **Global configs only.** Project-local ``.mcp.json`` and - ``.cursor/mcp.json`` are left alone; a swap is a thing you do to your - own machine, not to a repository. -- **One server name.** Only the entry named by ``--name`` (default - ``tmux``) is touched. Everything else in the file is preserved, - including comments in TOML and JSONC. -- **A recovery pair per file, once.** The backup and its private, versioned - ``.state`` record are written beside the original. ``revert`` proceeds only - while the config, topology, backup, record, and server route still match. -- **One all-client transaction.** Every selected config, backup, and state - destination is checked and staged before replacement. A failure rolls back - in reverse; recovery files remain when exact rollback cannot be proven. -""" - -from __future__ import annotations - -import argparse -import contextlib -import fcntl -import hashlib -import json -import os -import pathlib -import stat -import subprocess -import sys -import tempfile -import typing as t - -import tomlkit - -REPO = pathlib.Path(__file__).resolve().parent.parent - -#: What the launcher is called once ``installDist`` has written it. -DIST_LAUNCHER = ( - REPO / "libtmux-mcp" / "build" / "install" / "libtmux-mcp" / "bin" / "libtmux-mcp" -) - -BACKUP_SUFFIX = ".mcp-swap-backup" -STATE_SUFFIX = ".state" -STATE_VERSION = 1 -STATE_MAX_BYTES = 16 * 1024 - - -def _xdg_state_home() -> pathlib.Path: - raw = os.environ.get("XDG_STATE_HOME") - if raw and pathlib.Path(raw).is_absolute(): - return pathlib.Path(raw) - return pathlib.Path.home() / ".local" / "state" - - -SWAP_LOCK_DIR = _xdg_state_home() / "libtmux-mcp-dev" / "swap" -SWAP_LOCK_FILE = SWAP_LOCK_DIR / "state.lock" - - -class Layer(t.NamedTuple): - """One CLI's global config, and how its MCP servers are spelled in it.""" - - cli: str - path: pathlib.Path - #: Where the servers live: a path of keys from the document root. - at: tuple[str, ...] - #: ``json``, ``jsonc``, or ``toml``. - format: str - - def exists(self) -> bool: - return self.path.is_file() - - -LAYERS = ( - Layer("claude", pathlib.Path.home() / ".claude.json", ("mcpServers",), "json"), - Layer( - "codex", - pathlib.Path.home() / ".codex" / "config.toml", - ("mcp_servers",), - "toml", - ), - Layer( - "cursor", pathlib.Path.home() / ".cursor" / "mcp.json", ("mcpServers",), "json" - ), - Layer( - "gemini", - pathlib.Path.home() / ".gemini" / "settings.json", - ("mcpServers",), - "json", - ), - Layer( - "grok", pathlib.Path.home() / ".grok" / "config.toml", ("mcp_servers",), "toml" - ), - Layer( - "agy", - pathlib.Path.home() / ".gemini" / "config" / "mcp_config.json", - ("mcpServers",), - "json", - ), - Layer( - "opencode", - pathlib.Path( - os.environ.get("XDG_CONFIG_HOME") or pathlib.Path.home() / ".config" - ) - / "opencode" - / "opencode.jsonc", - ("mcp",), - "jsonc", - ), - Layer( - "pi", - pathlib.Path.home() / ".pi" / "agent" / "mcp.json", - ("mcpServers",), - "jsonc", - ), -) - -CLI_ALIASES = {"antigravity": "agy"} -CLI_COLUMN = max(len(layer.cli) for layer in LAYERS) + 1 - -# Pi itself has no MCP client. This extension reads the config above. -PI_ADAPTER_DIR = ( - pathlib.Path.home() / ".pi" / "agent" / "npm" / "node_modules" / "pi-mcp-adapter" -) -PI_ADAPTER_HINT = "needs the pi-mcp-adapter package; pi has no built-in MCP client" - - -# ------------------------------------------------------------------ JSONC - - -_JSON_WS = " \t\n\r" - - -def _jsonc_blank_comments(text: str) -> str: - """Blank comments without moving offsets used by the edit scanner.""" - out = list(text) - i = 0 - in_string = False - while i < len(text): - char = text[i] - if in_string: - if char == "\\": - i += 2 - continue - if char == '"': - in_string = False - i += 1 - elif char == '"': - in_string = True - i += 1 - elif char == "/" and i + 1 < len(text) and text[i + 1] == "/": - while i < len(text) and text[i] != "\n": - out[i] = " " - i += 1 - elif char == "/" and i + 1 < len(text) and text[i + 1] == "*": - end = text.find("*/", i + 2) - end = len(text) if end == -1 else end + 2 - for index in range(i, end): - if out[index] != "\n": - out[index] = " " - i = end - else: - i += 1 - return "".join(out) - - -def _jsonc_blank_trailing_commas(text: str) -> str: - """Blank trailing commas so the standard JSON decoder can parse JSONC.""" - out = list(text) - i = 0 - in_string = False - last_comma = -1 - while i < len(text): - char = text[i] - if in_string: - if char == "\\": - i += 2 - continue - if char == '"': - in_string = False - i += 1 - continue - if char == '"': - in_string = True - last_comma = -1 - elif char == ",": - last_comma = i - elif char in "}]": - if last_comma != -1: - out[last_comma] = " " - last_comma = -1 - elif char not in _JSON_WS: - last_comma = -1 - i += 1 - return "".join(out) - - -def _jsonc_loads(text: str) -> t.Any: - if not text.strip(): - return {} - return json.loads(_jsonc_blank_trailing_commas(_jsonc_blank_comments(text))) - - -class _JsoncMember(t.NamedTuple): - key: str - start: int - end: int - value_start: int - value_end: int - - -class _JsoncScanner: - """Locate value spans in comment-blanked JSON text.""" - - def __init__(self, text: str) -> None: - self.text = text - self.pos = 0 - - def skip_ws(self) -> None: - while self.pos < len(self.text) and self.text[self.pos] in _JSON_WS: - self.pos += 1 - - def read_string(self) -> str: - start = self.pos - self.pos += 1 - while self.pos < len(self.text): - char = self.text[self.pos] - if char == "\\": - self.pos += 2 - continue - self.pos += 1 - if char == '"': - break - return self.text[start : self.pos] - - def read_value(self) -> tuple[int, int]: - self.skip_ws() - start = self.pos - char = self.text[self.pos] - if char == '"': - self.read_string() - elif char in "{[": - self._read_container() - else: - while ( - self.pos < len(self.text) - and self.text[self.pos] not in ",}]" - and self.text[self.pos] not in _JSON_WS - ): - self.pos += 1 - return start, self.pos - - def _read_container(self) -> None: - self.pos += 1 - depth = 1 - while self.pos < len(self.text) and depth: - char = self.text[self.pos] - if char == '"': - self.read_string() - continue - if char in "{[": - depth += 1 - elif char in "}]": - depth -= 1 - self.pos += 1 - - def read_members(self, start: int) -> list[_JsoncMember]: - self.pos = start + 1 - found: list[_JsoncMember] = [] - while True: - self.skip_ws() - if self.pos >= len(self.text) or self.text[self.pos] == "}": - return found - if self.text[self.pos] == ",": - self.pos += 1 - continue - member_start = self.pos - raw_key = self.read_string() - self.skip_ws() - self.pos += 1 - value_start, value_end = self.read_value() - found.append( - _JsoncMember( - json.loads(raw_key), - member_start, - value_end, - value_start, - value_end, - ) - ) - - -def _jsonc_object_span(text: str, path: tuple[str, ...]) -> tuple[int, int] | None: - scanner = _JsoncScanner(text) - scanner.skip_ws() - if scanner.pos >= len(text) or text[scanner.pos] != "{": - return None - cursor = scanner.pos - for key in path: - match = next( - ( - member - for member in _JsoncScanner(text).read_members(cursor) - if member.key == key - ), - None, - ) - if match is None or text[match.value_start] != "{": - return None - cursor = match.value_start - tail = _JsoncScanner(text) - tail.pos = cursor - return tail.read_value() - - -def _jsonc_render(value: t.Any, depth: int) -> str: - rendered = json.dumps(value, indent=2, ensure_ascii=False) - return rendered.replace("\n", "\n" + " " * depth) - - -def _jsonc_next_edit( - text: str, data: t.Mapping[str, t.Any], path: tuple[str, ...] -) -> tuple[int, int, str] | None: - blanked = _jsonc_blank_comments(text) - span = _jsonc_object_span(blanked, path) - if span is None: - return None - object_start, object_end = span - members = _JsoncScanner(blanked).read_members(object_start) - by_key = {member.key: member for member in members} - depth = len(path) + 1 - pad = " " * depth - - for key, value in data.items(): - member = by_key.get(key) - if member is None: - body = _jsonc_render(value, depth) - name = json.dumps(key, ensure_ascii=False) - if members: - tail = members[-1].end - return tail, tail, f",\n{pad}{name}: {body}" - if blanked[object_start + 1 : object_end - 1].strip(): - return None - interior = text[object_start + 1 : object_end - 1] - anchor = object_start + 1 + len(interior.rstrip()) - closing = " " * (depth - 1) - return anchor, object_end - 1, f"\n{pad}{name}: {body}\n{closing}" - current = json.loads( - _jsonc_blank_trailing_commas(blanked[member.value_start : member.value_end]) - ) - if isinstance(value, dict) and isinstance(current, dict): - nested = _jsonc_next_edit(text, value, (*path, key)) - if nested is not None: - return nested - elif current != value: - return member.value_start, member.value_end, _jsonc_render(value, depth) - - for index, member in enumerate(members): - if member.key in data: - continue - if index: - return members[index - 1].end, member.end, "" - trailing = blanked[member.end : object_end] - drop_to = member.end - if trailing.lstrip(_JSON_WS).startswith(","): - drop_to += trailing.index(",") + 1 - return object_start + 1, drop_to, "" - return None - - -def _jsonc_merge(text: str, data: t.Mapping[str, t.Any]) -> str: - """Reconcile data through text splices, preserving untouched JSONC bytes.""" - if not text.strip(): - return json.dumps(dict(data), indent=2, ensure_ascii=False) + "\n" - for _ in range(10_000): - edit = _jsonc_next_edit(text, data, ()) - if edit is None: - return text - start, end, replacement = edit - text = text[:start] + replacement + text[end:] - raise RuntimeError("JSONC merge did not converge") - - -# ------------------------------------------------------------------ reading and writing - - -def parse(layer: Layer, raw: bytes) -> t.Any: - text = raw.decode("utf-8") - if layer.format == "toml": - return tomlkit.parse(text) - if layer.format == "jsonc": - return _jsonc_loads(text) - return json.loads(text) - - -def load(layer: Layer) -> t.Any: - return parse(layer, layer.path.read_bytes()) - - -def render(layer: Layer, document: t.Any, original: bytes) -> bytes: - if layer.format == "toml": - return tomlkit.dumps(document).encode("utf-8") - if layer.format == "jsonc": - return _jsonc_merge(original.decode("utf-8"), document).encode("utf-8") - return (json.dumps(document, indent=2, ensure_ascii=False) + "\n").encode("utf-8") - - -def servers(layer: Layer, document: t.Any, *, create: bool = False) -> t.Any: - """The mapping of server name to launch spec, or None when there is none.""" - node = document - for key in layer.at: - if key not in node: - if not create: - return None - node[key] = {} - node = node[key] - return node - - -def backup_of(layer: Layer) -> pathlib.Path: - return layer.path.with_name(layer.path.name + BACKUP_SUFFIX) - - -def state_of(layer: Layer) -> pathlib.Path: - backup = backup_of(layer) - return backup.with_name(backup.name + STATE_SUFFIX) - - -def entry_for(layer: Layer, command: str, arguments: list[str]) -> dict[str, t.Any]: - if layer.cli == "opencode": - return {"type": "local", "command": [command, *arguments]} - return {"command": command, "args": arguments} - - -class FileState(t.NamedTuple): - device: int - inode: int - mode: int - size: int - modified_ns: int - data: bytes - - -OwnedFiles = dict[pathlib.Path, FileState] - - -class DirectoryState(t.NamedTuple): - logical: pathlib.Path - physical: pathlib.Path - symlink: bool - link_text: str | None - link_device: int - link_inode: int - link_mode: int - device: int - inode: int - mode: int - - -class LockState(t.NamedTuple): - logical: pathlib.Path - physical: pathlib.Path - parent: DirectoryState | None - device: int | None - inode: int | None - mode: int | None - links: int | None - descriptor: int | None - - -class ConfigState(t.NamedTuple): - layer: Layer - parent: DirectoryState - symlink: bool - link_text: str | None - link_device: int - link_inode: int - link_mode: int - target: pathlib.Path - file: FileState - - -class BackupState(t.NamedTuple): - path: pathlib.Path - parent: DirectoryState - physical: pathlib.Path - file: FileState | None - - -class StateFile(t.NamedTuple): - path: pathlib.Path - parent: DirectoryState - physical: pathlib.Path - file: FileState | None - record: dict[str, t.Any] | None - - -class PreparedUse(t.NamedTuple): - config: ConfigState - backup: BackupState - state: StateFile - output: bytes - entry: dict[str, t.Any] - server_name: str - command: str - arguments: tuple[str, ...] - - -class PreparedRevert(t.NamedTuple): - config: ConfigState - backup: BackupState - state: StateFile - - -class StagedUse(t.NamedTuple): - plan: PreparedUse - output: pathlib.Path - recovery: pathlib.Path - backup: pathlib.Path | None - state: pathlib.Path - state_recovery: pathlib.Path | None - - -class StagedRevert(t.NamedTuple): - plan: PreparedRevert - restored: pathlib.Path - recovery: pathlib.Path - backup_recovery: pathlib.Path - state_recovery: pathlib.Path - - -class ConfigWrite(t.NamedTuple): - config: ConfigState - committed: FileState - recovery: pathlib.Path - - -class ConfigRemoval(t.NamedTuple): - config: ConfigState - recovery: pathlib.Path - - -class BackupWrite(t.NamedTuple): - backup: BackupState - committed: FileState - cli: str - - -class StateWrite(t.NamedTuple): - state: StateFile - backup: BackupState - committed: FileState | None - recovery: pathlib.Path | None - cli: str - - -class BackupRemoval(t.NamedTuple): - backup: BackupState - recovery: pathlib.Path - cli: str - - -class StateRemoval(t.NamedTuple): - state: StateFile - recovery: pathlib.Path - cli: str - - -def _file_state(path: pathlib.Path) -> FileState: - before = path.stat() - if not stat.S_ISREG(before.st_mode): - raise ValueError(f"{path} is not a regular file") - data = path.read_bytes() - after = path.stat() - before_key = ( - before.st_dev, - before.st_ino, - before.st_mode, - before.st_size, - before.st_mtime_ns, - ) - after_key = ( - after.st_dev, - after.st_ino, - after.st_mode, - after.st_size, - after.st_mtime_ns, - ) - if before_key != after_key: - raise RuntimeError(f"{path} changed while it was read") - return FileState( - after.st_dev, - after.st_ino, - stat.S_IMODE(after.st_mode), - after.st_size, - after.st_mtime_ns, - data, - ) - - -def _regular_file_state(path: pathlib.Path) -> FileState: - details = path.lstat() - if stat.S_ISLNK(details.st_mode) or not stat.S_ISREG(details.st_mode): - raise ValueError(f"{path} is not a regular file") - file = _file_state(path) - if (details.st_dev, details.st_ino) != (file.device, file.inode): - raise RuntimeError(f"{path} changed while it was resolved") - return file - - -def _own(owned: OwnedFiles, path: pathlib.Path) -> None: - owned[path] = _regular_file_state(path) - - -def _release(owned: OwnedFiles, path: pathlib.Path) -> None: - owned.pop(path, None) - - -def _release_missing(owned: OwnedFiles, path: pathlib.Path) -> None: - if not os.path.lexists(path): - _release(owned, path) - - -def _directory_state(path: pathlib.Path) -> DirectoryState: - logical = path.lstat() - symlink = stat.S_ISLNK(logical.st_mode) - if not symlink and not stat.S_ISDIR(logical.st_mode): - raise ValueError(f"{path} is not a directory or directory symlink") - physical = path.resolve(strict=True) - details = physical.stat() - if not stat.S_ISDIR(details.st_mode): - raise ValueError(f"{path} is not a directory") - return DirectoryState( - path, - physical, - symlink, - os.readlink(path) if symlink else None, - logical.st_dev, - logical.st_ino, - logical.st_mode, - details.st_dev, - details.st_ino, - stat.S_IMODE(details.st_mode), - ) - - -def _inspect_lock(*, descriptor: int | None = None) -> LockState: - parent = None - if os.path.lexists(SWAP_LOCK_DIR): - parent = _directory_state(SWAP_LOCK_DIR) - if parent.symlink: - raise RuntimeError(f"swap lock directory is a symlink: {SWAP_LOCK_DIR}") - physical = parent.physical / SWAP_LOCK_FILE.name - else: - physical = SWAP_LOCK_FILE.resolve(strict=False) - if not os.path.lexists(SWAP_LOCK_FILE): - return LockState( - SWAP_LOCK_FILE, physical, parent, None, None, None, None, descriptor - ) - before = SWAP_LOCK_FILE.lstat() - if stat.S_ISLNK(before.st_mode) or not stat.S_ISREG(before.st_mode): - raise RuntimeError(f"swap lock is not a regular file: {SWAP_LOCK_FILE}") - resolved = SWAP_LOCK_FILE.resolve(strict=True) - after = SWAP_LOCK_FILE.lstat() - before_key = (before.st_dev, before.st_ino, before.st_mode, before.st_nlink) - after_key = (after.st_dev, after.st_ino, after.st_mode, after.st_nlink) - if before_key != after_key or resolved != physical: - raise RuntimeError( - f"swap lock changed while it was inspected: {SWAP_LOCK_FILE}" - ) - return LockState( - SWAP_LOCK_FILE, - physical, - parent, - after.st_dev, - after.st_ino, - stat.S_IMODE(after.st_mode), - after.st_nlink, - descriptor, - ) - - -def _validate_lock(lock: LockState) -> None: - if lock.device is None or lock.inode is None: - if lock.descriptor is not None: - raise RuntimeError(f"swap lock path disappeared: {lock.logical}") - return - if lock.mode != 0o600: - raise RuntimeError(f"swap lock mode is not 0600: {lock.logical}") - if lock.links != 1: - raise RuntimeError(f"swap lock has hard links: {lock.logical}") - if lock.descriptor is None: - return - current = _inspect_lock(descriptor=lock.descriptor) - expected = lock._replace(descriptor=lock.descriptor) - if current != expected: - raise RuntimeError(f"swap lock path changed: {lock.logical}") - opened = os.fstat(lock.descriptor) - if ( - not stat.S_ISREG(opened.st_mode) - or (opened.st_dev, opened.st_ino) != (lock.device, lock.inode) - or stat.S_IMODE(opened.st_mode) != lock.mode - or opened.st_nlink != lock.links - ): - raise RuntimeError(f"swap lock descriptor changed: {lock.logical}") - - -@contextlib.contextmanager -def _state_lock() -> t.Iterator[LockState]: - SWAP_LOCK_DIR.mkdir(parents=True, exist_ok=True) - directory_fd: int | None = None - lock_fd: int | None = None - try: - if not hasattr(os, "O_NOFOLLOW") or not hasattr(os, "O_DIRECTORY"): - raise RuntimeError( - "platform cannot open the swap lock without following links" - ) - directory_flags = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW - directory_flags |= getattr(os, "O_CLOEXEC", 0) - directory_fd = os.open(SWAP_LOCK_DIR, directory_flags) - parent = _directory_state(SWAP_LOCK_DIR) - opened_parent = os.fstat(directory_fd) - if parent.symlink or (opened_parent.st_dev, opened_parent.st_ino) != ( - parent.device, - parent.inode, - ): - raise RuntimeError(f"swap lock directory changed: {SWAP_LOCK_DIR}") - lock_flags = os.O_RDWR | os.O_NOFOLLOW | getattr(os, "O_CLOEXEC", 0) - try: - lock_fd = os.open( - SWAP_LOCK_FILE.name, - lock_flags | os.O_CREAT | os.O_EXCL, - 0o600, - dir_fd=directory_fd, - ) - os.fchmod(lock_fd, 0o600) - except FileExistsError: - lock_fd = os.open(SWAP_LOCK_FILE.name, lock_flags, dir_fd=directory_fd) - fcntl.flock(lock_fd, fcntl.LOCK_EX) - lock = _inspect_lock(descriptor=lock_fd) - _validate_lock(lock) - except Exception as error: - if lock_fd is not None: - os.close(lock_fd) - if directory_fd is not None: - os.close(directory_fd) - raise SystemExit(f"swap lock is unusable: {error}") from error - try: - yield lock - try: - _validate_lock(lock) - except Exception as error: - raise SystemExit(f"swap lock changed before release: {error}") from error - finally: - os.close(t.cast(int, lock_fd)) - os.close(t.cast(int, directory_fd)) - - -def _config_state(layer: Layer) -> ConfigState: - parent = _directory_state(layer.path.parent) - details = layer.path.lstat() - symlink = stat.S_ISLNK(details.st_mode) - if not symlink and not stat.S_ISREG(details.st_mode): - raise ValueError(f"{layer.path} is not a regular file or symlink") - link_text = os.readlink(layer.path) if symlink else None - target = layer.path.resolve(strict=True) - file = _file_state(target) - if not symlink and (details.st_dev, details.st_ino) != ( - file.device, - file.inode, - ): - raise RuntimeError(f"{layer.path} changed while it was resolved") - return ConfigState( - layer, - parent, - symlink, - link_text, - details.st_dev, - details.st_ino, - details.st_mode, - target, - file, - ) - - -def _backup_state(layer: Layer, *, required: bool = False) -> BackupState: - path = backup_of(layer) - parent = _directory_state(path.parent) - physical = parent.physical / path.name - if not os.path.lexists(path): - if required: - raise FileNotFoundError(path) - return BackupState(path, parent, physical, None) - details = path.lstat() - if stat.S_ISLNK(details.st_mode) or not stat.S_ISREG(details.st_mode): - raise ValueError(f"{path} is not a regular file") - if path.resolve(strict=True) != physical: - raise RuntimeError(f"{path} did not resolve in its preflight directory") - file = _file_state(physical) - if (details.st_dev, details.st_ino) != (file.device, file.inode): - raise RuntimeError(f"{path} changed while it was resolved") - return BackupState(path, parent, physical, file) - - -def _file_document(file: FileState) -> dict[str, t.Any]: - return { - "device": file.device, - "inode": file.inode, - "mode": file.mode, - "sha256": hashlib.sha256(file.data).hexdigest(), - "size": file.size, - } - - -def _directory_document(directory: DirectoryState) -> dict[str, t.Any]: - return { - "device": directory.device, - "inode": directory.inode, - "link_device": directory.link_device if directory.symlink else None, - "link_inode": directory.link_inode if directory.symlink else None, - "link_mode": directory.link_mode if directory.symlink else None, - "link_text": directory.link_text, - "logical": str(directory.logical), - "mode": directory.mode, - "physical": str(directory.physical), - "symlink": directory.symlink, - } - - -def _config_document(config: ConfigState, file: FileState) -> dict[str, t.Any]: - return { - "file": _file_document(file), - "link_device": config.link_device if config.symlink else None, - "link_inode": config.link_inode if config.symlink else None, - "link_mode": config.link_mode if config.symlink else None, - "link_text": config.link_text, - "logical": str(config.layer.path), - "parent": _directory_document(config.parent), - "symlink": config.symlink, - "target": str(config.target), - } - - -def _record_bytes(record: dict[str, t.Any]) -> bytes: - data = (json.dumps(record, sort_keys=True, separators=(",", ":")) + "\n").encode() - if len(data) > STATE_MAX_BYTES: - raise ValueError(f"recovery state exceeds {STATE_MAX_BYTES} bytes") - return data - - -def _object(value: t.Any, keys: set[str], label: str) -> dict[str, t.Any]: - if not isinstance(value, dict) or set(value) != keys: - raise ValueError(f"{label} has unknown or missing fields") - return value - - -def _decode_record(layer: Layer, data: bytes) -> dict[str, t.Any]: - if len(data) > STATE_MAX_BYTES: - raise ValueError(f"recovery state exceeds {STATE_MAX_BYTES} bytes") - try: - root = json.loads(data) - except (UnicodeDecodeError, json.JSONDecodeError) as error: - raise ValueError("recovery state is malformed") from error - document = _object( - root, {"backup", "cli", "config", "server", "version"}, "recovery state" - ) - if type(document["version"]) is not int or document["version"] != STATE_VERSION: - raise ValueError("recovery state version is unsupported") - if type(document["cli"]) is not str or document["cli"] != layer.cli: - raise ValueError("recovery state names another client") - server = _object( - document["server"], {"arguments", "command", "name"}, "recovery server" - ) - arguments = server["arguments"] - if ( - not isinstance(arguments, list) - or len(arguments) > 128 - or any(not isinstance(argument, str) for argument in arguments) - or type(server["command"]) is not str - or type(server["name"]) is not str - ): - raise ValueError("recovery server route is invalid") - _object(document["backup"], {"file", "parent", "path", "target"}, "recovery backup") - if not isinstance(document["config"], dict): - raise TypeError("recovery config is invalid") - return document - - -def _same_typed(left: t.Any, right: t.Any) -> bool: - if type(left) is not type(right): - return False - if isinstance(left, dict): - return set(left) == set(right) and all( - _same_typed(left[key], right[key]) for key in left - ) - if isinstance(left, list): - return len(left) == len(right) and all( - _same_typed(one, two) for one, two in zip(left, right, strict=True) - ) - return bool(left == right) - - -def _state_file(layer: Layer, *, required: bool = False) -> StateFile: - path = state_of(layer) - parent = _directory_state(path.parent) - physical = parent.physical / path.name - if not os.path.lexists(path): - if required: - raise FileNotFoundError(path) - return StateFile(path, parent, physical, None, None) - details = path.lstat() - if stat.S_ISLNK(details.st_mode) or not stat.S_ISREG(details.st_mode): - raise ValueError(f"{path} is not a regular file") - if details.st_size > STATE_MAX_BYTES: - raise ValueError(f"{path} exceeds {STATE_MAX_BYTES} bytes") - if path.resolve(strict=True) != physical: - raise RuntimeError(f"{path} did not resolve in its preflight directory") - file = _file_state(physical) - if (details.st_dev, details.st_ino) != (file.device, file.inode): - raise RuntimeError(f"{path} changed while it was resolved") - if file.mode != 0o600: - raise ValueError(f"{path} mode is not 0600") - return StateFile(path, parent, physical, file, _decode_record(layer, file.data)) - - -def _record_for( - plan: PreparedUse, target_file: FileState, backup_file: FileState -) -> dict[str, t.Any]: - return { - "backup": { - "file": _file_document(backup_file), - "parent": _directory_document(plan.backup.parent), - "path": str(plan.backup.path), - "target": str(plan.backup.physical), - }, - "cli": plan.config.layer.cli, - "config": _config_document(plan.config, target_file), - "server": { - "arguments": list(plan.arguments), - "command": plan.command, - "name": plan.server_name, - }, - "version": STATE_VERSION, - } - - -def _verify_owned_recovery( - config: ConfigState, backup: BackupState, state: StateFile -) -> None: - record = state.record - backup_file = backup.file - if record is None or backup_file is None: - raise RuntimeError("recovery backup and state are incomplete") - if not _same_typed(record["config"], _config_document(config, config.file)): - raise RuntimeError("config no longer matches the recovery state") - route = record["server"] - document = parse(config.layer, config.file.data) - actual = (servers(config.layer, document) or {}).get(route["name"]) - expected = entry_for(config.layer, route["command"], route["arguments"]) - if actual != expected: - raise RuntimeError("server route no longer matches the recovery state") - expected_backup = { - "file": _file_document(backup_file), - "parent": _directory_document(backup.parent), - "path": str(backup.path), - "target": str(backup.physical), - } - if not _same_typed(record["backup"], expected_backup): - raise RuntimeError("backup no longer matches the recovery state") - - -def _verify_directory(expected: DirectoryState) -> None: - current = _directory_state(expected.logical) - if current != expected: - raise RuntimeError(f"{expected.logical} changed") - - -def _verify_config(config: ConfigState, expected: FileState) -> None: - _verify_directory(config.parent) - details = config.layer.path.lstat() - if config.symlink: - if ( - not stat.S_ISLNK(details.st_mode) - or os.readlink(config.layer.path) != config.link_text - or (details.st_dev, details.st_ino, details.st_mode) - != (config.link_device, config.link_inode, config.link_mode) - ): - raise RuntimeError(f"{config.layer.path} symlink changed") - elif not stat.S_ISREG(details.st_mode): - raise RuntimeError(f"{config.layer.path} topology changed") - if config.layer.path.resolve(strict=True) != config.target: - raise RuntimeError(f"{config.layer.path} target changed") - current = _file_state(config.target) - if current != expected: - raise RuntimeError(f"{config.layer.path} identity, mode, or bytes changed") - if not config.symlink and (details.st_dev, details.st_ino) != ( - current.device, - current.inode, - ): - raise RuntimeError(f"{config.layer.path} logical identity changed") - - -def _verify_missing_config(config: ConfigState) -> None: - _verify_directory(config.parent) - if config.symlink: - details = config.layer.path.lstat() - if ( - not stat.S_ISLNK(details.st_mode) - or os.readlink(config.layer.path) != config.link_text - or (details.st_dev, details.st_ino, details.st_mode) - != (config.link_device, config.link_inode, config.link_mode) - ): - raise RuntimeError(f"{config.layer.path} symlink changed") - elif os.path.lexists(config.layer.path): - raise RuntimeError(f"{config.layer.path} appeared") - if os.path.lexists(config.target): - raise RuntimeError(f"{config.target} appeared") - - -def _verify_artifact( - artifact: BackupState | StateFile, expected: FileState | None -) -> None: - _verify_directory(artifact.parent) - if expected is None: - if os.path.lexists(artifact.path): - raise RuntimeError(f"{artifact.path} appeared") - return - if not os.path.lexists(artifact.path) or artifact.path.is_symlink(): - raise RuntimeError(f"{artifact.path} topology changed") - if artifact.path.resolve(strict=True) != artifact.physical: - raise RuntimeError(f"{artifact.path} target changed") - if _file_state(artifact.physical) != expected: - raise RuntimeError(f"{artifact.path} identity, mode, or bytes changed") - - -def _verify_restored_artifact(artifact: BackupState | StateFile) -> None: - expected = t.cast(FileState, artifact.file) - _verify_artifact(artifact, expected) - - -def _reject_duplicate_targets( - plans: t.Iterable[t.Any], lock: LockState | None = None -) -> None: - config_paths: dict[pathlib.Path, str] = {} - config_inodes: dict[tuple[int, int], str] = {} - all_paths: dict[pathlib.Path, str] = {} - all_inodes: dict[tuple[int, int], str] = {} - - def claim( - label: str, - logical: pathlib.Path, - physical: pathlib.Path, - inode: tuple[int, int] | None, - ) -> None: - owner = next( - ( - all_paths[path] - for path in dict.fromkeys((logical, physical)) - if path in all_paths and all_paths[path] != label - ), - None, - ) - if owner is None and inode is not None: - owner = all_inodes.get(inode) - if owner == label: - owner = None - if owner is not None: - raise SystemExit( - f"duplicate transaction destination for {owner} and {label}" - ) - all_paths[logical] = label - all_paths[physical] = label - if inode is not None: - all_inodes[inode] = label - - if lock is not None: - lock_inode = ( - None - if lock.device is None or lock.inode is None - else (lock.device, lock.inode) - ) - claim("swap lock", lock.logical, lock.physical, lock_inode) - for plan in plans: - config = plan.config - cli = config.layer.cli - by_path = config_paths.get(config.target) - by_inode = config_inodes.get((config.file.device, config.file.inode)) - if by_path is not None or by_inode is not None: - other = by_path or by_inode - raise SystemExit(f"duplicate physical config target for {other} and {cli}") - config_paths[config.target] = cli - config_inodes[(config.file.device, config.file.inode)] = cli - claim( - f"{cli} config", - config.layer.path, - config.target, - (config.file.device, config.file.inode), - ) - - backup = plan.backup - backup_inode = ( - None if backup.file is None else (backup.file.device, backup.file.inode) - ) - claim(f"{cli} backup", backup.path, backup.physical, backup_inode) - - state = plan.state - state_inode = ( - None if state.file is None else (state.file.device, state.file.inode) - ) - claim(f"{cli} state", state.path, state.physical, state_inode) - - -def _check_lock_plan(plans: t.Iterable[t.Any]) -> None: - try: - lock = _inspect_lock() - _reject_duplicate_targets(plans, lock) - _validate_lock(lock) - except Exception as error: - raise SystemExit(f"swap lock is unusable: {error}") from error - - -def _stage( - directory: pathlib.Path, - logical_name: str, - role: str, - data: bytes, - mode: int, -) -> pathlib.Path: - descriptor, temporary_name = tempfile.mkstemp( - prefix=f".{logical_name}.mcp-swap-{role}-", dir=str(directory) - ) - temporary = pathlib.Path(temporary_name) - try: - with os.fdopen(descriptor, "wb") as stream: - os.fchmod(stream.fileno(), mode) - stream.write(data) - stream.flush() - os.fsync(stream.fileno()) - return temporary - except Exception: - temporary.unlink(missing_ok=True) - raise - - -def _apply_replace( - staged: pathlib.Path, - destination: pathlib.Path, - *, - expected: FileState, - destination_expected: FileState, - lock: LockState, -) -> tuple[FileState, Exception | None]: - _validate_lock(lock) - if _regular_file_state(staged) != expected: - raise RuntimeError(f"{staged} changed before atomic take-aside") - if _regular_file_state(destination) != destination_expected: - raise RuntimeError(f"{destination} changed before atomic take-aside") - delayed = _apply_unlink( - destination, - expected=destination_expected, - lock=lock, - ) - if delayed is not None: - raise delayed - committed, delayed = _publish_absent( - staged, - destination, - expected=expected, - lock=lock, - ) - try: - removal_error = _apply_unlink(staged, expected=expected, lock=lock) - except Exception as error: # noqa: BLE001 - retain the committed recovery - removal_error = error - if delayed is None: - delayed = removal_error - return committed, delayed - - -def _publish_absent( - staged: pathlib.Path, - destination: pathlib.Path, - *, - expected: FileState, - lock: LockState, -) -> tuple[FileState, Exception | None]: - _validate_lock(lock) - if _regular_file_state(staged) != expected: - raise RuntimeError(f"{staged} changed before atomic publication") - if os.path.lexists(destination): - raise RuntimeError(f"{destination} appeared before atomic publication") - _validate_lock(lock) - delayed: Exception | None = None - try: - os.link(staged, destination, follow_symlinks=False) - except Exception as error: - try: - committed = _regular_file_state(destination) - except (OSError, RuntimeError, ValueError): - raise error - if committed != expected: - raise RuntimeError( - f"atomic publication of {destination} was not exact" - ) from error - delayed = error - else: - committed = _regular_file_state(destination) - if committed != expected: - raise RuntimeError(f"atomic publication of {destination} was not exact") - return committed, delayed - - -def _remove_exact(path: pathlib.Path, expected: FileState) -> Exception | None: - quarantine_dir = pathlib.Path( - tempfile.mkdtemp(prefix=f".{path.name}.mcp-swap-retained-", dir=path.parent) - ) - quarantine_dir.chmod(0o700) - quarantine = quarantine_dir / "artifact" - delayed: Exception | None = None - try: - path.rename(quarantine) - except Exception as error: # noqa: BLE001 - authenticate a possibly completed move - try: - current = _regular_file_state(quarantine) - except (OSError, RuntimeError, ValueError): - try: - quarantine_dir.rmdir() - except OSError: - pass - raise error - delayed = error - else: - current = _regular_file_state(quarantine) - if current != expected: - raise RuntimeError(f"{path} changed; retained at {quarantine_dir}") - if os.path.lexists(path): - delayed = delayed or RuntimeError(f"{path} appeared during removal") - try: - quarantine.unlink() - except Exception as error: - if os.path.lexists(quarantine): - raise RuntimeError( - f"{path} removal failed; retained at {quarantine_dir}: {error}" - ) from error - delayed = delayed or error - if os.path.lexists(quarantine): - raise RuntimeError(f"{quarantine} still exists after removal") - try: - quarantine_dir.rmdir() - except Exception as error: - if quarantine_dir.exists(): - raise - delayed = delayed or error - return delayed - - -def _apply_unlink( - path: pathlib.Path, - *, - expected: FileState, - lock: LockState, -) -> Exception | None: - _validate_lock(lock) - if _regular_file_state(path) != expected: - raise RuntimeError(f"{path} changed before removal") - _validate_lock(lock) - delayed = _remove_exact(path, expected) - try: - _validate_lock(lock) - except Exception as error: # noqa: BLE001 - preserve post-unlink failure - if delayed is None: - delayed = error - return delayed - - -def _cleanup_owned( - owned: OwnedFiles, - preserve: set[pathlib.Path] | None = None, - *, - lock: LockState, -) -> list[str]: - retained = preserve or set() - errors: list[str] = [] - for path in sorted( - (candidate for candidate in owned if candidate not in retained), key=str - ): - try: - if not os.path.lexists(path): - continue - _validate_lock(lock) - delayed = _remove_exact(path, owned[path]) - if delayed is not None: - raise delayed - _validate_lock(lock) - except (OSError, RuntimeError, ValueError) as error: - errors.append(f"could not remove task-owned stage {path}: {error}") - return errors - - -def _require_cleanup(action: str, owned: OwnedFiles, lock: LockState) -> None: - errors = _cleanup_owned(owned, lock=lock) - if not errors: - return - retained = {path for path in owned if os.path.lexists(path)} - detail = f"{action} committed but cleanup incomplete: " + "; ".join(errors) - if retained: - detail += "; recovery artifacts: " + ", ".join( - str(path) for path in sorted(retained, key=str) - ) - raise SystemExit(detail) - - -def _transaction_failure( - action: str, - error: Exception, - rollback_errors: list[str], - cleanup_errors: list[str], - preserved: set[pathlib.Path], -) -> t.NoReturn: - details = [f"{action} failed: {error}"] - if rollback_errors: - details.append("rollback incomplete: " + "; ".join(rollback_errors)) - if cleanup_errors: - details.append("cleanup incomplete: " + "; ".join(cleanup_errors)) - if preserved: - details.append( - "recovery artifacts: " - + ", ".join(str(path) for path in sorted(preserved, key=str)) - ) - raise SystemExit("; ".join(details)) from error - - -def _plan_use( - args: argparse.Namespace, command: str, arguments: list[str] -) -> list[PreparedUse]: - prepared: list[PreparedUse] = [] - for layer in chosen(args): - if not os.path.lexists(layer.path): - print(f"{layer.cli:<{CLI_COLUMN}} skipped, no config") - continue - try: - config = _config_state(layer) - except Exception as error: - raise SystemExit(f"{layer.cli} config is unreadable: {error}") from error - try: - backup = _backup_state(layer) - except Exception as error: - raise SystemExit(f"{layer.cli} backup is unusable: {error}") from error - try: - state = _state_file(layer) - if (backup.file is None) != (state.file is None): - raise RuntimeError("backup and state must exist together") - if state.file is not None: - _verify_owned_recovery(config, backup, state) - except Exception as error: - raise SystemExit( - f"{layer.cli} recovery state is unusable: {error}" - ) from error - try: - document = parse(layer, config.file.data) - entry = entry_for(layer, command, arguments) - into = servers(layer, document, create=True) - into[args.name] = entry - output = render(layer, document, config.file.data) - except Exception as error: - raise SystemExit(f"{layer.cli} config is unreadable: {error}") from error - plan = PreparedUse( - config, - backup, - state, - output, - entry, - args.name, - command, - tuple(arguments), - ) - largest_identity = (1 << 64) - 1 - preview_target = FileState( - largest_identity, - largest_identity, - config.file.mode, - len(output), - 0, - output, - ) - preview_backup = backup.file or FileState( - largest_identity, - largest_identity, - config.file.mode, - config.file.size, - 0, - config.file.data, - ) - try: - _record_bytes(_record_for(plan, preview_target, preview_backup)) - except Exception as error: - raise SystemExit( - f"{layer.cli} recovery state is unusable: {error}" - ) from error - prepared.append(plan) - _reject_duplicate_targets(prepared) - return prepared - - -def _plan_revert(args: argparse.Namespace) -> list[PreparedRevert]: - prepared: list[PreparedRevert] = [] - for layer in chosen(args): - backup_exists = os.path.lexists(backup_of(layer)) - state_exists = os.path.lexists(state_of(layer)) - if not backup_exists and not state_exists: - print(f"{layer.cli:<{CLI_COLUMN}} nothing to revert") - continue - if backup_exists != state_exists: - raise SystemExit(f"{layer.cli} recovery backup and state are incomplete") - try: - backup = _backup_state(layer, required=True) - except Exception as error: - raise SystemExit(f"{layer.cli} backup is unusable: {error}") from error - try: - state = _state_file(layer, required=True) - except Exception as error: - raise SystemExit( - f"{layer.cli} recovery state is unusable: {error}" - ) from error - try: - config = _config_state(layer) - except Exception as error: - raise SystemExit(f"{layer.cli} config is unreadable: {error}") from error - try: - _verify_owned_recovery(config, backup, state) - record = t.cast(dict[str, t.Any], state.record) - server_name = record["server"]["name"] - if server_name != args.name: - raise RuntimeError( - f"state belongs to server {server_name!r}, not {args.name!r}" - ) - except Exception as error: - raise SystemExit( - f"{layer.cli} recovery ownership changed: {error}" - ) from error - prepared.append(PreparedRevert(config, backup, state)) - _reject_duplicate_targets(prepared) - return prepared - - -def _changed_config(config: ConfigState, expected: FileState) -> None: - try: - _verify_config(config, expected) - except Exception as error: - raise RuntimeError( - f"{config.layer.cli} config changed during preflight" - ) from error - - -def _changed_backup(backup: BackupState, expected: FileState | None, cli: str) -> None: - try: - _verify_artifact(backup, expected) - except Exception as error: - raise RuntimeError(f"{cli} backup changed during preflight") from error - - -def _changed_state(state: StateFile, expected: FileState | None, cli: str) -> None: - try: - _verify_artifact(state, expected) - except Exception as error: - raise RuntimeError(f"{cli} recovery state changed during preflight") from error - - -def _stage_use( - plans: list[PreparedUse], owned: OwnedFiles, lock: LockState -) -> list[StagedUse]: - staged: list[StagedUse] = [] - try: - for plan in plans: - _validate_lock(lock) - config = plan.config - output = _stage( - config.target.parent, - config.layer.path.name, - "output", - plan.output, - config.file.mode, - ) - _own(owned, output) - recovery = _stage( - config.target.parent, - config.layer.path.name, - "recovery", - config.file.data, - config.file.mode, - ) - _own(owned, recovery) - backup = None - if plan.backup.file is None: - backup = _stage( - plan.backup.parent.physical, - plan.backup.path.name, - "new", - config.file.data, - config.file.mode, - ) - _own(owned, backup) - target_file = _file_state(output) - backup_file = ( - _file_state(backup) - if backup is not None - else t.cast(FileState, plan.backup.file) - ) - state = _stage( - plan.state.parent.physical, - plan.state.path.name, - "state", - _record_bytes(_record_for(plan, target_file, backup_file)), - 0o600, - ) - _own(owned, state) - state_recovery = None - if plan.state.file is not None: - state_recovery = _stage( - plan.state.parent.physical, - plan.state.path.name, - "recovery-state", - plan.state.file.data, - plan.state.file.mode, - ) - _own(owned, state_recovery) - staged.append( - StagedUse(plan, output, recovery, backup, state, state_recovery) - ) - _validate_lock(lock) - except Exception as error: - cleanup = _cleanup_owned(owned, lock=lock) - detail = f"swap staging failed: {error}" - if cleanup: - detail += "; " + "; ".join(cleanup) - raise SystemExit(detail) from error - return staged - - -def _stage_revert( - plans: list[PreparedRevert], owned: OwnedFiles, lock: LockState -) -> list[StagedRevert]: - staged: list[StagedRevert] = [] - try: - for plan in plans: - _validate_lock(lock) - config = plan.config - backup = t.cast(FileState, plan.backup.file) - restored = _stage( - config.target.parent, - config.layer.path.name, - "restore", - backup.data, - backup.mode, - ) - _own(owned, restored) - recovery = _stage( - config.target.parent, - config.layer.path.name, - "recovery", - config.file.data, - config.file.mode, - ) - _own(owned, recovery) - backup_recovery = _stage( - plan.backup.parent.physical, - plan.backup.path.name, - "recovery", - backup.data, - backup.mode, - ) - _own(owned, backup_recovery) - state = t.cast(FileState, plan.state.file) - state_recovery = _stage( - plan.state.parent.physical, - plan.state.path.name, - "recovery-state", - state.data, - state.mode, - ) - _own(owned, state_recovery) - staged.append( - StagedRevert( - plan, - restored, - recovery, - backup_recovery, - state_recovery, - ) - ) - _validate_lock(lock) - except Exception as error: - cleanup = _cleanup_owned(owned, lock=lock) - detail = f"revert staging failed: {error}" - if cleanup: - detail += "; " + "; ".join(cleanup) - raise SystemExit(detail) from error - return staged - - -def _restore_removed_config( - operation: ConfigWrite | ConfigRemoval, - owned: OwnedFiles, - lock: LockState, -) -> None: - if isinstance(operation, ConfigWrite): - _verify_config(operation.config, operation.committed) - delayed = _apply_unlink( - operation.config.target, - expected=operation.committed, - lock=lock, - ) - if delayed is not None: - raise delayed - else: - _verify_missing_config(operation.config) - _, delayed = _publish_absent( - operation.recovery, - operation.config.target, - expected=owned[operation.recovery], - lock=lock, - ) - _release_missing(owned, operation.recovery) - if delayed is not None: - raise delayed - _verify_config(operation.config, operation.config.file) - - -def _rollback_use( - operations: list[ConfigWrite | ConfigRemoval | BackupWrite | StateWrite], - owned: OwnedFiles, - lock: LockState, -) -> tuple[list[str], set[pathlib.Path]]: - errors: list[str] = [] - preserved: set[pathlib.Path] = set() - blocked: set[str] = set() - for operation in reversed(operations): - if isinstance(operation, (ConfigWrite, ConfigRemoval)): - cli = operation.config.layer.cli - try: - _restore_removed_config(operation, owned, lock) - except Exception as error: # noqa: BLE001 - continue reverse rollback - blocked.add(cli) - if operation.recovery.exists(): - preserved.add(operation.recovery) - errors.append(f"{cli} config: {error}") - continue - - if isinstance(operation, StateWrite): - cli = operation.cli - if cli in blocked: - preserved.update((operation.state.path, operation.backup.path)) - if operation.recovery is not None: - preserved.add(operation.recovery) - continue - try: - _verify_artifact(operation.state, operation.committed) - if operation.state.file is None: - delayed = _apply_unlink( - operation.state.physical, - expected=operation.committed, - lock=lock, - ) - if delayed is not None: - raise delayed - else: - recovery = t.cast(pathlib.Path, operation.recovery) - if operation.committed is not None: - delayed = _apply_unlink( - operation.state.physical, - expected=operation.committed, - lock=lock, - ) - if delayed is not None: - raise delayed - _, delayed = _publish_absent( - recovery, - operation.state.physical, - expected=owned[recovery], - lock=lock, - ) - _release_missing(owned, recovery) - if delayed is not None: - raise delayed - _verify_restored_artifact(operation.state) - except Exception as error: # noqa: BLE001 - continue reverse rollback - blocked.add(cli) - preserved.update((operation.state.path, operation.backup.path)) - if operation.recovery is not None and operation.recovery.exists(): - preserved.add(operation.recovery) - errors.append(f"{cli} recovery state: {error}") - continue - - cli = operation.cli - if cli in blocked: - preserved.add(operation.backup.path) - continue - try: - _verify_artifact(operation.backup, operation.committed) - delayed = _apply_unlink( - operation.backup.physical, - expected=operation.committed, - lock=lock, - ) - if delayed is not None: - raise delayed - except Exception as error: # noqa: BLE001 - continue reverse rollback - preserved.add(operation.backup.path) - errors.append(f"{cli} backup: {error}") - return errors, preserved - - -def _commit_use(staged: list[StagedUse], owned: OwnedFiles, lock: LockState) -> None: - operations: list[ConfigWrite | ConfigRemoval | BackupWrite | StateWrite] = [] - committed_backups: dict[str, FileState] = {} - committed_states: dict[str, FileState] = {} - try: - for item in staged: - _changed_config(item.plan.config, item.plan.config.file) - _changed_backup( - item.plan.backup, - item.plan.backup.file, - item.plan.config.layer.cli, - ) - _changed_state( - item.plan.state, - item.plan.state.file, - item.plan.config.layer.cli, - ) - - for item in staged: - if item.backup is None: - continue - plan = item.plan - cli = plan.config.layer.cli - _changed_config(plan.config, plan.config.file) - _changed_backup(plan.backup, None, cli) - committed, delayed = _publish_absent( - item.backup, - plan.backup.physical, - expected=owned[item.backup], - lock=lock, - ) - _release_missing(owned, item.backup) - operations.append(BackupWrite(plan.backup, committed, cli)) - committed_backups[cli] = committed - if delayed is not None: - raise delayed - _verify_artifact(plan.backup, committed) - - for item in staged: - plan = item.plan - cli = plan.config.layer.cli - _changed_config(plan.config, plan.config.file) - expected_backup = committed_backups.get(cli, plan.backup.file) - _changed_backup(plan.backup, expected_backup, cli) - _changed_state(plan.state, plan.state.file, cli) - if plan.state.file is not None: - recovery = t.cast(pathlib.Path, item.state_recovery) - removed, delayed = _apply_replace( - plan.state.physical, - recovery, - expected=plan.state.file, - destination_expected=owned[recovery], - lock=lock, - ) - if removed == plan.state.file: - owned[recovery] = removed - operations.append( - StateWrite(plan.state, plan.backup, None, recovery, cli) - ) - if removed != plan.state.file: - raise RuntimeError(f"{cli} recovery state identity changed") - if delayed is not None: - raise delayed - _verify_artifact(plan.state, None) - committed, delayed = _publish_absent( - item.state, - plan.state.physical, - expected=owned[item.state], - lock=lock, - ) - _release_missing(owned, item.state) - operation = StateWrite( - plan.state, - plan.backup, - committed, - item.state_recovery, - cli, - ) - if plan.state.file is None: - operations.append(operation) - else: - operations[-1] = operation - committed_states[cli] = committed - if delayed is not None: - raise delayed - _verify_artifact(plan.state, committed) - - for item in staged: - plan = item.plan - cli = plan.config.layer.cli - _changed_config(plan.config, plan.config.file) - expected_backup = committed_backups.get(cli, plan.backup.file) - _changed_backup(plan.backup, expected_backup, cli) - _changed_state(plan.state, committed_states[cli], cli) - removed, delayed = _apply_replace( - plan.config.target, - item.recovery, - expected=plan.config.file, - destination_expected=owned[item.recovery], - lock=lock, - ) - if removed == plan.config.file: - owned[item.recovery] = removed - operations.append(ConfigRemoval(plan.config, item.recovery)) - if removed != plan.config.file: - raise RuntimeError(f"{cli} config recovery identity changed") - if delayed is not None: - raise delayed - committed, delayed = _publish_absent( - item.output, - plan.config.target, - expected=owned[item.output], - lock=lock, - ) - _release_missing(owned, item.output) - operations[-1] = ConfigWrite(plan.config, committed, item.recovery) - _verify_config(plan.config, committed) - expected_record = _record_for( - plan, committed, t.cast(FileState, expected_backup) - ) - if ( - _decode_record(plan.config.layer, committed_states[cli].data) - != expected_record - ): - raise RuntimeError(f"{cli} recovery state does not own the new config") - if delayed is not None: - raise delayed - except Exception as error: # noqa: BLE001 - every commit failure rolls back - rollback_errors, preserved = _rollback_use(operations, owned, lock) - cleanup_errors = _cleanup_owned(owned, preserved, lock=lock) - _transaction_failure("swap", error, rollback_errors, cleanup_errors, preserved) - - _require_cleanup("swap", owned, lock) - - -def _rollback_revert( - operations: list[ConfigWrite | ConfigRemoval | BackupRemoval | StateRemoval], - owned: OwnedFiles, - lock: LockState, -) -> tuple[list[str], set[pathlib.Path]]: - errors: list[str] = [] - preserved: set[pathlib.Path] = set() - for operation in reversed(operations): - if isinstance(operation, StateRemoval): - cli = operation.cli - try: - _verify_directory(operation.state.parent) - if os.path.lexists(operation.state.path): - raise RuntimeError( - f"{operation.state.path} appeared before rollback" - ) - _, delayed = _publish_absent( - operation.recovery, - operation.state.physical, - expected=owned[operation.recovery], - lock=lock, - ) - _release_missing(owned, operation.recovery) - if delayed is not None: - raise delayed - _verify_restored_artifact(operation.state) - except Exception as error: # noqa: BLE001 - continue reverse rollback - if operation.recovery.exists(): - preserved.add(operation.recovery) - preserved.add(operation.state.path) - errors.append(f"{cli} recovery state: {error}") - continue - - if isinstance(operation, BackupRemoval): - cli = operation.cli - try: - _verify_directory(operation.backup.parent) - if os.path.lexists(operation.backup.path): - raise RuntimeError( - f"{operation.backup.path} appeared before rollback" - ) - _, delayed = _publish_absent( - operation.recovery, - operation.backup.physical, - expected=owned[operation.recovery], - lock=lock, - ) - _release_missing(owned, operation.recovery) - if delayed is not None: - raise delayed - _verify_restored_artifact(operation.backup) - except Exception as error: # noqa: BLE001 - continue reverse rollback - if operation.recovery.exists(): - preserved.add(operation.recovery) - errors.append(f"{cli} backup: {error}") - continue - - cli = operation.config.layer.cli - try: - _restore_removed_config(operation, owned, lock) - except Exception as error: # noqa: BLE001 - continue reverse rollback - if operation.recovery.exists(): - preserved.add(operation.recovery) - errors.append(f"{cli} config: {error}") - return errors, preserved - - -def _commit_revert( - staged: list[StagedRevert], owned: OwnedFiles, lock: LockState -) -> None: - operations: list[ConfigWrite | ConfigRemoval | BackupRemoval | StateRemoval] = [] - committed_configs: dict[str, FileState] = {} - try: - for item in staged: - plan = item.plan - _changed_config(plan.config, plan.config.file) - _changed_backup(plan.backup, plan.backup.file, plan.config.layer.cli) - _changed_state(plan.state, plan.state.file, plan.config.layer.cli) - - for item in staged: - plan = item.plan - cli = plan.config.layer.cli - _changed_config(plan.config, plan.config.file) - _changed_backup(plan.backup, plan.backup.file, cli) - _changed_state(plan.state, plan.state.file, cli) - removed, delayed = _apply_replace( - plan.config.target, - item.recovery, - expected=plan.config.file, - destination_expected=owned[item.recovery], - lock=lock, - ) - if removed == plan.config.file: - owned[item.recovery] = removed - operations.append(ConfigRemoval(plan.config, item.recovery)) - if removed != plan.config.file: - raise RuntimeError(f"{cli} config recovery identity changed") - if delayed is not None: - raise delayed - committed, delayed = _publish_absent( - item.restored, - plan.config.target, - expected=owned[item.restored], - lock=lock, - ) - _release_missing(owned, item.restored) - operations[-1] = ConfigWrite(plan.config, committed, item.recovery) - committed_configs[cli] = committed - if delayed is not None: - raise delayed - _verify_config(plan.config, committed) - - for item in staged: - plan = item.plan - cli = plan.config.layer.cli - _verify_config(plan.config, committed_configs[cli]) - _changed_backup(plan.backup, plan.backup.file, cli) - _changed_state(plan.state, plan.state.file, cli) - removed, delayed = _apply_replace( - plan.backup.physical, - item.backup_recovery, - expected=plan.backup.file, - destination_expected=owned[item.backup_recovery], - lock=lock, - ) - if removed == plan.backup.file: - owned[item.backup_recovery] = removed - operations.append(BackupRemoval(plan.backup, item.backup_recovery, cli)) - if removed != plan.backup.file: - raise RuntimeError(f"{cli} backup recovery identity changed") - if delayed is not None: - raise delayed - _verify_artifact(plan.backup, None) - - for item in staged: - plan = item.plan - cli = plan.config.layer.cli - _verify_config(plan.config, committed_configs[cli]) - _verify_directory(plan.backup.parent) - if os.path.lexists(plan.backup.path): - raise RuntimeError(f"{cli} backup still exists after removal") - _changed_state(plan.state, plan.state.file, cli) - removed, delayed = _apply_replace( - plan.state.physical, - item.state_recovery, - expected=plan.state.file, - destination_expected=owned[item.state_recovery], - lock=lock, - ) - if removed == plan.state.file: - owned[item.state_recovery] = removed - operations.append(StateRemoval(plan.state, item.state_recovery, cli)) - if removed != plan.state.file: - raise RuntimeError(f"{cli} recovery state identity changed") - if delayed is not None: - raise delayed - _verify_artifact(plan.state, None) - except Exception as error: # noqa: BLE001 - every commit failure rolls back - rollback_errors, preserved = _rollback_revert(operations, owned, lock) - cleanup_errors = _cleanup_owned(owned, preserved, lock=lock) - _transaction_failure( - "revert", error, rollback_errors, cleanup_errors, preserved - ) - - _require_cleanup("revert", owned, lock) - - -# ------------------------------------------------------------------ what to point at - - -def launcher(args: argparse.Namespace) -> tuple[str, list[str]]: - """The command and arguments an agent should spawn.""" - if args.source == "path": - if not args.bin: - raise SystemExit("--source path needs --bin") - command, prefix = args.bin, [] - elif args.source == "gradle": - command, prefix = ( - str(REPO / "gradlew"), - [ - "--quiet", - "--console=plain", - ":libtmux-mcp:run", - "--args", - ], - ) - else: - command, prefix = str(DIST_LAUNCHER), [] - - flags: list[str] = [] - if args.socket: - flags += ["--socket", args.socket] - if args.socket_name: - flags += ["--socket-name", args.socket_name] - if args.tmux: - flags += ["--tmux", args.tmux] - - # Gradle takes the server's own flags as one --args string. - if args.source == "gradle": - return command, prefix + [" ".join(flags)] - return command, prefix + flags - - -def build(args: argparse.Namespace) -> None: - """Make sure the launcher this is about to point at actually exists.""" - if args.source != "dist": - return - print("building :libtmux-mcp:installDist ...", file=sys.stderr) - subprocess.run( - [ - str(REPO / "gradlew"), - "--quiet", - "--console=plain", - ":libtmux-mcp:installDist", - ], - cwd=REPO, - check=True, - ) - if not DIST_LAUNCHER.is_file(): - raise SystemExit(f"installDist did not write {DIST_LAUNCHER}") - - -# ------------------------------------------------------------------ commands - - -def cmd_detect(args: argparse.Namespace) -> int: - for layer in LAYERS: - state = "present" if layer.exists() else "missing" - swapped = " (swapped)" if backup_of(layer).is_file() else "" - caveat = "" - if layer.cli == "pi" and not PI_ADAPTER_DIR.is_dir(): - caveat = f" -- {PI_ADAPTER_HINT}" - print(f"{layer.cli:<{CLI_COLUMN}} {state:<8} {layer.path}{swapped}{caveat}") - return 0 - - -def cmd_status(args: argparse.Namespace) -> int: - for layer in LAYERS: - if not layer.exists(): - continue - try: - entry = (servers(layer, load(layer)) or {}).get(args.name) - except (ValueError, tomlkit.exceptions.TOMLKitError) as error: - print(f"{layer.cli:<8} unreadable: {error}") - continue - if entry is None: - print(f"{layer.cli:<{CLI_COLUMN}} no '{args.name}' server") - continue - command = entry.get("command", "?") - if layer.cli == "opencode" and isinstance(command, list): - command, *arguments = command - else: - arguments = entry.get("args", []) - rest = " ".join(str(word) for word in arguments) - print(f"{layer.cli:<{CLI_COLUMN}} {command} {rest}".rstrip()) - return 0 - - -def cmd_use(args: argparse.Namespace) -> int: - command, arguments = launcher(args) - prepared = _plan_use(args, command, arguments) - _check_lock_plan(prepared) - print( - f"pointing '{args.name}' at: {command} {' '.join(arguments)}".rstrip(), - file=sys.stderr, - ) - if args.dry_run: - for item in prepared: - layer = item.config.layer - print( - f"{layer.cli:<{CLI_COLUMN}} would set {args.name} = {json.dumps(item.entry)}" - ) - return 0 - - build(args) - with _state_lock() as lock: - _reject_duplicate_targets(prepared, lock) - _validate_lock(lock) - owned: OwnedFiles = {} - staged = _stage_use(prepared, owned, lock) - _commit_use(staged, owned, lock) - for item in prepared: - layer = item.config.layer - print(f"{layer.cli:<{CLI_COLUMN}} set {args.name}") - return 0 - - -def cmd_revert(args: argparse.Namespace) -> int: - prepared = _plan_revert(args) - _check_lock_plan(prepared) - if args.dry_run: - for item in prepared: - layer = item.config.layer - print(f"{layer.cli:<{CLI_COLUMN}} would restore {item.backup.path}") - return 0 - - with _state_lock() as lock: - _reject_duplicate_targets(prepared, lock) - _validate_lock(lock) - owned: OwnedFiles = {} - staged = _stage_revert(prepared, owned, lock) - _commit_revert(staged, owned, lock) - for item in prepared: - layer = item.config.layer - print(f"{layer.cli:<{CLI_COLUMN}} restored") - return 0 - - -def cmd_doctor(args: argparse.Namespace) -> int: - ok = True - if not (REPO / "gradlew").is_file(): - print("no gradlew: is this the repository root?") - ok = False - if args.source == "dist" and not DIST_LAUNCHER.is_file(): - print( - f"no launcher at {DIST_LAUNCHER}; run './gradlew :libtmux-mcp:installDist'" - ) - ok = False - for layer in LAYERS: - if not layer.exists(): - continue - try: - load(layer) - except Exception as error: # noqa: BLE001 - a broken config is what this reports - print(f"{layer.cli:<{CLI_COLUMN}} will not parse: {error}") - ok = False - if ( - next(layer for layer in LAYERS if layer.cli == "pi").exists() - and not PI_ADAPTER_DIR.is_dir() - ): - print(f"pi{'':<{CLI_COLUMN - 2}} {PI_ADAPTER_HINT}") - ok = False - print("ready" if ok else "not ready") - return 0 if ok else 1 - - -def chosen(args: argparse.Namespace) -> tuple[Layer, ...]: - if not args.cli: - return LAYERS - wanted = {CLI_ALIASES.get(cli, cli) for cli in args.cli} - return tuple(layer for layer in LAYERS if layer.cli in wanted) - - -# ------------------------------------------------------------------ argument parsing - - -def build_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser( - prog="mcp_swap", description=__doc__.splitlines()[0] - ) - commands = parser.add_subparsers(dest="command", required=True) - - def shared(sub: argparse.ArgumentParser) -> None: - sub.add_argument( - "--name", - default="tmux", - help="the MCP server name to write (default: tmux)", - ) - sub.add_argument( - "--cli", - action="append", - choices=[*(layer.cli for layer in LAYERS), *CLI_ALIASES], - help="limit the swap; antigravity is an alias for agy", - ) - sub.add_argument( - "--dry-run", - action="store_true", - help="say what would change, change nothing", - ) - - detect = commands.add_parser("detect", help="which agent CLIs have a config here") - detect.set_defaults(run=cmd_detect) - - status = commands.add_parser("status", help="what each CLI currently points at") - status.add_argument("--name", default="tmux") - status.set_defaults(run=cmd_status) - - use = commands.add_parser("use", help="point every CLI at this build") - shared(use) - use.add_argument("--source", choices=("dist", "gradle", "path"), default="dist") - use.add_argument("--bin", help="the launcher to use with --source path") - use.add_argument("--socket", help="tmux socket path to serve") - use.add_argument("--socket-name", help="tmux socket name to serve") - use.add_argument("--tmux", help="which tmux binary the server should run") - use.set_defaults(run=cmd_use) - - revert = commands.add_parser("revert", help="restore each config from its backup") - shared(revert) - revert.set_defaults(run=cmd_revert) - - doctor = commands.add_parser("doctor", help="check this is ready to swap") - doctor.add_argument("--source", choices=("dist", "gradle", "path"), default="dist") - doctor.set_defaults(run=cmd_doctor) - - return parser - - -def main(argv: list[str] | None = None) -> int: - parser = build_parser() - arguments = sys.argv[1:] if argv is None else argv - if not arguments: - parser.print_help() - return 0 - args = parser.parse_args(arguments) - return int(args.run(args)) - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/scripts/test_mcp_swap.py b/scripts/test_mcp_swap.py deleted file mode 100644 index 557d85f..0000000 --- a/scripts/test_mcp_swap.py +++ /dev/null @@ -1,1848 +0,0 @@ -from __future__ import annotations - -import fcntl -import importlib.util -import json -import os -import pathlib -import re -import stat -import sys -import types - -import pytest -import tomllib - -SCRIPT = pathlib.Path(__file__).with_name("mcp_swap.py") -CANONICAL_CLIS = ( - "claude", - "codex", - "cursor", - "gemini", - "grok", - "agy", - "opencode", - "pi", -) -LAUNCHER = "/opt/libtmux-java/bin/libtmux-mcp" - - -@pytest.fixture -def swapper( - monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path -) -> types.ModuleType: - home = tmp_path / "home" - home.mkdir() - monkeypatch.setenv("HOME", str(home)) - monkeypatch.setenv("XDG_CONFIG_HOME", str(home / ".config")) - monkeypatch.setenv("XDG_STATE_HOME", str(home / ".local" / "state")) - name = f"mcp_swap_test_{tmp_path.name}" - spec = importlib.util.spec_from_file_location(name, SCRIPT) - assert spec is not None and spec.loader is not None - module = importlib.util.module_from_spec(spec) - sys.modules[name] = module - spec.loader.exec_module(module) - return module - - -def _jsonc_loads(text: str) -> object: - without_comments = re.sub(r"(?m)^[ \t]*//[^\n]*(?:\n|$)", "", text) - without_comments = re.sub(r"/\*.*?\*/", "", without_comments, flags=re.DOTALL) - without_trailing_commas = re.sub(r",(?=\s*[}\]])", "", without_comments) - return json.loads(without_trailing_commas) - - -def _seed_configs(swapper: types.ModuleType) -> dict[str, bytes]: - originals: dict[str, bytes] = {} - for layer in swapper.LAYERS: - layer.path.parent.mkdir(parents=True, exist_ok=True) - if layer.format == "toml": - raw = b'title = "keep"\n' - elif layer.cli == "opencode": - raw = ( - b"{\n" - b" // root comment stays\n" - b' "$schema": "https://opencode.ai/config.json",\n' - b' "unrelated": {"keep": true},\n' - b"}\n" - ) - elif layer.cli == "pi": - raw = ( - b"{\n" - b" // pi-mcp-adapter accepts JSONC\n" - b' "unrelated": {"keep": true},\n' - b' "mcpServers": {},\n' - b"}\n" - ) - else: - raw = b'{\n "unrelated": {"keep": true}\n}\n' - layer.path.write_bytes(raw) - layer.path.chmod(0o640) - originals[layer.cli] = raw - return originals - - -def _document(layer: object) -> dict[str, object]: - text = layer.path.read_text(encoding="utf-8") - if layer.format == "toml": - return tomllib.loads(text) - if layer.format == "jsonc": - parsed = _jsonc_loads(text) - assert isinstance(parsed, dict) - return parsed - parsed = json.loads(text) - assert isinstance(parsed, dict) - return parsed - - -def _assert_swapped(layer: object) -> None: - document = _document(layer) - entry = document[layer.at[0]]["tmux"] - if layer.cli == "opencode": - assert entry == { - "type": "local", - "command": [LAUNCHER, "--socket", "/tmp/libtmux-java-dev/test/s"], - } - else: - assert entry == { - "command": LAUNCHER, - "args": ["--socket", "/tmp/libtmux-java-dev/test/s"], - } - - -def _use_args(*extra: str) -> list[str]: - return [ - "use", - "--source", - "path", - "--bin", - LAUNCHER, - "--socket", - "/tmp/libtmux-java-dev/test/s", - *extra, - ] - - -@pytest.mark.parametrize("cli", CANONICAL_CLIS) -def test_each_client_swaps_and_restores_in_isolation( - swapper: types.ModuleType, cli: str -) -> None: - """Selecting one client must not touch any other client's config.""" - originals = _seed_configs(swapper) - - assert swapper.main(_use_args("--cli", cli)) == 0 - selected = next(layer for layer in swapper.LAYERS if layer.cli == cli) - _assert_swapped(selected) - assert stat.S_IMODE(selected.path.stat().st_mode) == 0o640 - assert swapper.backup_of(selected).read_bytes() == originals[cli] - assert _state_of(swapper, selected).is_file() - for layer in swapper.LAYERS: - if layer.cli != cli: - assert layer.path.read_bytes() == originals[layer.cli] - assert not swapper.backup_of(layer).exists() - - assert swapper.main(["revert", "--cli", cli]) == 0 - assert selected.path.read_bytes() == originals[cli] - assert stat.S_IMODE(selected.path.stat().st_mode) == 0o640 - assert not swapper.backup_of(selected).exists() - assert not _state_of(swapper, selected).exists() - - -def test_all_eight_clients_commit_only_after_full_preflight( - swapper: types.ModuleType, -) -> None: - """The default selection swaps and byte-restores all eight clients.""" - originals = _seed_configs(swapper) - - assert tuple(layer.cli for layer in swapper.LAYERS) == CANONICAL_CLIS - assert swapper.main(_use_args()) == 0 - for layer in swapper.LAYERS: - _assert_swapped(layer) - assert stat.S_IMODE(layer.path.stat().st_mode) == 0o640 - assert swapper.backup_of(layer).read_bytes() == originals[layer.cli] - assert _state_of(swapper, layer).is_file() - - assert swapper.main(["revert"]) == 0 - for layer in swapper.LAYERS: - assert layer.path.read_bytes() == originals[layer.cli] - assert stat.S_IMODE(layer.path.stat().st_mode) == 0o640 - assert not swapper.backup_of(layer).exists() - assert not _state_of(swapper, layer).exists() - - -@pytest.mark.parametrize("alias_kind", ["symlink", "hardlink"]) -@pytest.mark.parametrize("dry_run", [False, True], ids=["use", "dry-run"]) -def test_config_alias_to_swap_lock_is_rejected( - swapper: types.ModuleType, alias_kind: str, dry_run: bool -) -> None: - """A selected config cannot name the persistent transaction lock.""" - originals = _seed_configs(swapper) - claude = next(layer for layer in swapper.LAYERS if layer.cli == "claude") - swap_lock = _swap_lock(swapper) - swap_lock.parent.mkdir(parents=True) - swap_lock.write_bytes(originals[claude.cli]) - swap_lock.chmod(0o600) - lock_state = _path_identity(swap_lock) - claude.path.unlink() - if alias_kind == "symlink": - claude.path.symlink_to(swap_lock) - else: - os.link(swap_lock, claude.path) - config_state = _path_identity(claude.path) - args = _use_args("--cli", claude.cli) - if dry_run: - args.append("--dry-run") - - with pytest.raises(SystemExit, match="lock"): - swapper.main(args) - - assert _path_identity(swap_lock) == lock_state - assert _path_identity(claude.path) == config_state - assert not swapper.backup_of(claude).exists() - assert not _state_of(swapper, claude).exists() - - -@pytest.mark.parametrize("alias_kind", ["symlink", "hardlink"]) -@pytest.mark.parametrize("artifact", ["backup", "state"]) -@pytest.mark.parametrize( - "operation", ["use", "use-dry-run", "revert", "revert-dry-run"] -) -def test_recovery_alias_to_swap_lock_is_rejected( - swapper: types.ModuleType, - alias_kind: str, - artifact: str, - operation: str, -) -> None: - """Neither owned recovery file can become the transaction lock.""" - _seed_configs(swapper) - claude = next(layer for layer in swapper.LAYERS if layer.cli == "claude") - assert swapper.main(_use_args("--cli", claude.cli)) == 0 - swap_lock = _swap_lock(swapper) - recovery = ( - swapper.backup_of(claude) - if artifact == "backup" - else _state_of(swapper, claude) - ) - before = _owned_layer_state(swapper, claude) - swap_lock.unlink() - if alias_kind == "symlink": - swap_lock.symlink_to(recovery) - else: - os.link(recovery, swap_lock) - lock_state = _path_identity(swap_lock) - command = ( - ["revert", "--cli", claude.cli] - if operation.startswith("revert") - else _use_args("--cli", claude.cli, "--bin", "/opt/next/libtmux-mcp") - ) - if operation.endswith("dry-run"): - command.append("--dry-run") - - with pytest.raises(SystemExit, match="lock"): - swapper.main(command) - - assert _owned_layer_state(swapper, claude) == before - assert _path_identity(swap_lock) == lock_state - - -@pytest.mark.parametrize("artifact", ["backup", "state"]) -def test_prospective_lock_alias_is_rejected_before_build_or_creation( - swapper: types.ModuleType, - monkeypatch: pytest.MonkeyPatch, - artifact: str, -) -> None: - """An absent recovery path cannot become the lock before alias checks.""" - originals = _seed_configs(swapper) - claude = next(layer for layer in swapper.LAYERS if layer.cli == "claude") - swap_lock = _swap_lock(swapper) - swap_lock.parent.mkdir(parents=True) - built = False - - if artifact == "backup": - monkeypatch.setattr(swapper, "backup_of", lambda _layer: swap_lock) - else: - monkeypatch.setattr(swapper, "state_of", lambda _layer: swap_lock) - - def build(_args: object) -> None: - nonlocal built - built = True - - monkeypatch.setattr(swapper, "build", build) - with pytest.raises(SystemExit, match="lock"): - swapper.main(_use_args("--cli", claude.cli)) - - assert not built - assert claude.path.read_bytes() == originals[claude.cli] - assert not os.path.lexists(swap_lock) - assert swap_lock.parent.is_dir() - - -@pytest.mark.parametrize("dry_run", [False, True], ids=["use", "dry-run"]) -@pytest.mark.parametrize("defect", ["mode", "directory", "directory-symlink"]) -def test_unsafe_swap_lock_topology_is_rejected( - swapper: types.ModuleType, dry_run: bool, defect: str -) -> None: - """Lock inspection rejects unsafe type, mode, and directory topology.""" - originals = _seed_configs(swapper) - claude = next(layer for layer in swapper.LAYERS if layer.cli == "claude") - swap_lock = _swap_lock(swapper) - if defect == "directory-symlink": - target = claude.path.parent / "lock-target" - target.mkdir() - swap_lock.parent.parent.mkdir(parents=True) - swap_lock.parent.symlink_to(target, target_is_directory=True) - (target / swap_lock.name).write_bytes(b"") - (target / swap_lock.name).chmod(0o600) - else: - swap_lock.parent.mkdir(parents=True) - if defect == "directory": - swap_lock.mkdir() - else: - swap_lock.write_bytes(b"") - swap_lock.chmod(0o640) - args = _use_args("--cli", claude.cli) - if dry_run: - args.append("--dry-run") - - with pytest.raises(SystemExit, match="lock"): - swapper.main(args) - - assert claude.path.read_bytes() == originals[claude.cli] - assert not swapper.backup_of(claude).exists() - - -@pytest.mark.parametrize("operation", ["use", "revert"]) -def test_transaction_holds_and_revalidates_the_swap_lock( - swapper: types.ModuleType, - monkeypatch: pytest.MonkeyPatch, - operation: str, -) -> None: - """The lock stays exclusive and a same-path replacement stops the run.""" - _seed_configs(swapper) - claude = next(layer for layer in swapper.LAYERS if layer.cli == "claude") - if operation == "revert": - assert swapper.main(_use_args("--cli", claude.cli)) == 0 - swap_lock = _swap_lock(swapper) - real_link = os.link - real_replace = os.replace - replacement_inode: int | None = None - observed_locked = False - - def link( - source: object, - destination: object, - *args: object, - **kwargs: object, - ) -> None: - nonlocal observed_locked, replacement_inode - source_path = pathlib.Path(source) - destination_path = pathlib.Path(destination) - if replacement_inode is None and ".mcp-swap-" in destination_path.name: - competitor = os.open(swap_lock, os.O_RDWR) - try: - with pytest.raises(BlockingIOError): - fcntl.flock(competitor, fcntl.LOCK_EX | fcntl.LOCK_NB) - observed_locked = True - finally: - os.close(competitor) - human_lock = swap_lock.with_name("human-state.lock") - human_lock.write_bytes(b"human lock replacement\n") - human_lock.chmod(0o600) - real_replace(human_lock, swap_lock) - replacement_inode = swap_lock.stat().st_ino - real_link(source_path, destination_path, *args, **kwargs) - - monkeypatch.setattr(os, "link", link) - command = ( - ["revert", "--cli", claude.cli] - if operation == "revert" - else _use_args("--cli", claude.cli) - ) - - with pytest.raises(SystemExit, match="lock"): - swapper.main(command) - - assert observed_locked - assert replacement_inode is not None - assert swap_lock.stat().st_ino == replacement_inode - assert swap_lock.read_bytes() == b"human lock replacement\n" - - -def test_lock_directory_replacement_after_file_open_is_rejected( - swapper: types.ModuleType, monkeypatch: pytest.MonkeyPatch -) -> None: - """An open descriptor cannot authenticate a disappeared lock path.""" - originals = _seed_configs(swapper) - claude = next(layer for layer in swapper.LAYERS if layer.cli == "claude") - swap_lock = _swap_lock(swapper) - displaced = swap_lock.parent.with_name("displaced-swap-lock") - real_flock = fcntl.flock - real_rename = os.rename - injected = False - - def flock(descriptor: int, operation: int) -> None: - nonlocal injected - real_flock(descriptor, operation) - if injected or operation != fcntl.LOCK_EX: - return - real_rename(swap_lock.parent, displaced) - swap_lock.parent.mkdir() - injected = True - - monkeypatch.setattr(fcntl, "flock", flock) - with pytest.raises(SystemExit, match="lock"): - swapper.main(_use_args("--cli", claude.cli)) - - assert injected - assert claude.path.read_bytes() == originals[claude.cli] - assert not swapper.backup_of(claude).exists() - assert (displaced / swap_lock.name).is_file() - assert not os.path.lexists(swap_lock) - - -def test_use_and_revert_keep_one_private_persistent_lock( - swapper: types.ModuleType, -) -> None: - """Successful mutations reuse a private single-link lock inode.""" - _seed_configs(swapper) - claude = next(layer for layer in swapper.LAYERS if layer.cli == "claude") - swap_lock = _swap_lock(swapper) - - assert swapper.main(_use_args("--cli", claude.cli)) == 0 - first = _path_identity(swap_lock) - assert stat.S_IMODE(swap_lock.stat().st_mode) == 0o600 - assert swap_lock.stat().st_nlink == 1 - assert swapper.main(["revert", "--cli", claude.cli]) == 0 - assert _path_identity(swap_lock) == first - _assert_no_stages(swapper) - - -def test_dry_run_does_not_acquire_an_existing_lock( - swapper: types.ModuleType, monkeypatch: pytest.MonkeyPatch -) -> None: - """Dry-run inspects lock identity without creating a lock owner.""" - _seed_configs(swapper) - swap_lock = _swap_lock(swapper) - swap_lock.parent.mkdir(parents=True) - swap_lock.write_bytes(b"") - swap_lock.chmod(0o600) - before = _path_identity(swap_lock) - - def forbidden(*_args: object, **_kwargs: object) -> None: - raise AssertionError("dry-run acquired the swap lock") - - monkeypatch.setattr(fcntl, "flock", forbidden) - assert swapper.main(_use_args("--cli", "claude", "--dry-run")) == 0 - assert _path_identity(swap_lock) == before - - -@pytest.mark.parametrize("timing", ["before-read", "inside-rename"]) -@pytest.mark.parametrize( - ("operation", "boundary"), - [ - ("use", "backup-publish"), - ("use", "state-publish"), - ("use", "config-take-aside"), - ("use", "config-publish"), - ("repeat-use", "state-take-aside"), - ("revert", "config-take-aside"), - ("revert", "config-publish"), - ("revert", "backup-take-aside"), - ("revert", "state-take-aside"), - ], -) -def test_late_transition_source_replacement_survives( - swapper: types.ModuleType, - monkeypatch: pytest.MonkeyPatch, - operation: str, - boundary: str, - timing: str, -) -> None: - """Every commit boundary rejects and retains a late source inode.""" - _seed_configs(swapper) - claude = next(layer for layer in swapper.LAYERS if layer.cli == "claude") - if operation in {"repeat-use", "revert"}: - assert swapper.main(_use_args("--cli", claude.cli)) == 0 - backup = swapper.backup_of(claude) - state = _state_of(swapper, claude) - real_apply = swapper._apply_replace - real_publish = swapper._publish_absent - real_link = os.link - real_replace = os.replace - unexpected_inode: int | None = None - - def selected(source: pathlib.Path, destination: pathlib.Path) -> bool: - if boundary == "backup-publish": - return destination == backup and ".mcp-swap-new-" in source.name - if boundary == "state-publish": - return destination == state and ".mcp-swap-state-" in source.name - if boundary == "config-take-aside": - return source == claude.path and ".mcp-swap-recovery-" in destination.name - if boundary == "config-publish": - role = "restore" if operation == "revert" else "output" - return destination == claude.path and f".mcp-swap-{role}-" in source.name - if boundary == "state-take-aside": - return source == state and ".mcp-swap-recovery-state-" in destination.name - if boundary == "backup-take-aside": - return source == backup and ".mcp-swap-recovery-" in destination.name - raise AssertionError(f"unknown boundary {boundary}") - - def inject(source: pathlib.Path) -> None: - nonlocal unexpected_inode - human = source.with_name(f".{source.name}.human-{boundary}") - human.write_bytes(b"human boundary replacement\n") - human.chmod(0o600) - real_replace(human, source) - unexpected_inode = source.stat().st_ino - - if timing == "before-read": - - def apply( - source: pathlib.Path, - destination: pathlib.Path, - *args: object, - **kwargs: object, - ) -> tuple[object, object]: - source_path = pathlib.Path(source) - destination_path = pathlib.Path(destination) - if unexpected_inode is None and selected(source_path, destination_path): - inject(source_path) - return real_apply(source_path, destination_path, *args, **kwargs) - - monkeypatch.setattr(swapper, "_apply_replace", apply) - - def publish( - source: pathlib.Path, - destination: pathlib.Path, - *args: object, - **kwargs: object, - ) -> tuple[object, object]: - source_path = pathlib.Path(source) - destination_path = pathlib.Path(destination) - if unexpected_inode is None and selected(source_path, destination_path): - inject(source_path) - return real_publish(source_path, destination_path, *args, **kwargs) - - monkeypatch.setattr(swapper, "_publish_absent", publish) - else: - - def replace(source: object, destination: object) -> None: - source_path = pathlib.Path(source) - destination_path = pathlib.Path(destination) - if unexpected_inode is None and selected(source_path, destination_path): - inject(source_path) - real_replace(source_path, destination_path) - - monkeypatch.setattr(os, "replace", replace) - - def link( - source: object, - destination: object, - *args: object, - **kwargs: object, - ) -> None: - source_path = pathlib.Path(source) - destination_path = pathlib.Path(destination) - if unexpected_inode is None and selected(source_path, destination_path): - inject(source_path) - real_link(source_path, destination_path, *args, **kwargs) - - monkeypatch.setattr(os, "link", link) - - command = ( - ["revert", "--cli", claude.cli] - if operation == "revert" - else _use_args( - "--cli", - claude.cli, - *(["--bin", "/opt/next/libtmux-mcp"] if operation == "repeat-use" else []), - ) - ) - with pytest.raises(SystemExit): - swapper.main(command) - - assert unexpected_inode is not None - assert unexpected_inode in { - path.stat().st_ino for path in claude.path.parent.rglob("*") if path.is_file() - } - - -@pytest.mark.parametrize( - ("operation", "boundary"), - [ - ("use", "backup-publish"), - ("use", "state-publish"), - ("use", "config-take-aside"), - ("use", "config-publish"), - ("repeat-use", "state-take-aside"), - ("revert", "config-take-aside"), - ("revert", "config-publish"), - ("revert", "backup-take-aside"), - ("revert", "state-take-aside"), - ], -) -def test_transition_never_overwrites_a_late_destination( - swapper: types.ModuleType, - monkeypatch: pytest.MonkeyPatch, - operation: str, - boundary: str, -) -> None: - """A file arriving at a transition destination survives failure.""" - _seed_configs(swapper) - claude = next(layer for layer in swapper.LAYERS if layer.cli == "claude") - if operation in {"repeat-use", "revert"}: - assert swapper.main(_use_args("--cli", claude.cli)) == 0 - backup = swapper.backup_of(claude) - state = _state_of(swapper, claude) - real_link = os.link - real_replace = os.replace - unexpected_inode: int | None = None - - def selected(source: pathlib.Path, destination: pathlib.Path) -> bool: - if boundary == "backup-publish": - return destination == backup and ".mcp-swap-new-" in source.name - if boundary == "state-publish": - return destination == state and ".mcp-swap-state-" in source.name - if boundary == "config-take-aside": - return source == claude.path and ".mcp-swap-recovery-" in destination.name - if boundary == "state-take-aside": - return source == state and ".mcp-swap-recovery-state-" in destination.name - if boundary == "backup-take-aside": - return source == backup and ".mcp-swap-recovery-" in destination.name - role = "restore" if operation == "revert" else "output" - return destination == claude.path and f".mcp-swap-{role}-" in source.name - - def appear(destination: pathlib.Path) -> None: - nonlocal unexpected_inode - human = destination.with_name(f".{destination.name}.human-{boundary}") - human.write_bytes(b"human destination replacement\n") - human.chmod(0o600) - real_replace(human, destination) - unexpected_inode = destination.stat().st_ino - - def link( - source: object, destination: object, *args: object, **kwargs: object - ) -> None: - source_path = pathlib.Path(source) - destination_path = pathlib.Path(destination) - if unexpected_inode is None and selected(source_path, destination_path): - appear(destination_path) - real_link(source, destination, *args, **kwargs) - - def replace(source: object, destination: object) -> None: - source_path = pathlib.Path(source) - destination_path = pathlib.Path(destination) - if unexpected_inode is None and selected(source_path, destination_path): - appear(destination_path) - real_replace(source_path, destination_path) - - monkeypatch.setattr(os, "link", link) - monkeypatch.setattr(os, "replace", replace) - command = ( - ["revert", "--cli", claude.cli] - if operation == "revert" - else _use_args( - "--cli", - claude.cli, - *(["--bin", "/opt/next/libtmux-mcp"] if operation == "repeat-use" else []), - ) - ) - - with pytest.raises(SystemExit): - swapper.main(command) - - assert unexpected_inode is not None - assert unexpected_inode in { - path.stat().st_ino for path in claude.path.parent.rglob("*") if path.is_file() - } - - -def test_exact_removal_retains_a_late_replacement( - swapper: types.ModuleType, monkeypatch: pytest.MonkeyPatch -) -> None: - """Removing an owned public path never deletes a substituted inode.""" - _seed_configs(swapper) - claude = next(layer for layer in swapper.LAYERS if layer.cli == "claude") - expected = swapper._regular_file_state(claude.path) - real_rename = os.rename - real_replace = os.replace - real_unlink = os.unlink - unexpected_inode: int | None = None - - def inject(path: pathlib.Path) -> None: - nonlocal unexpected_inode - if unexpected_inode is not None or path != claude.path: - return - human = path.with_name("human-removal-replacement.json") - human.write_bytes(b"human removal replacement\n") - human.chmod(0o600) - real_replace(human, path) - unexpected_inode = path.stat().st_ino - - def rename( - source: object, - destination: object, - *args: object, - **kwargs: object, - ) -> None: - inject(pathlib.Path(source)) - real_rename(source, destination, *args, **kwargs) - - def unlink(path: object, *args: object, **kwargs: object) -> None: - inject(pathlib.Path(path)) - real_unlink(path, *args, **kwargs) - - monkeypatch.setattr(os, "rename", rename) - monkeypatch.setattr(os, "unlink", unlink) - with ( - swapper._state_lock() as lock, - pytest.raises(RuntimeError, match="changed|retained"), - ): - swapper._apply_unlink(claude.path, expected=expected, lock=lock) - - assert unexpected_inode is not None - assert unexpected_inode in { - path.stat().st_ino for path in claude.path.parent.rglob("*") if path.is_file() - } - - -@pytest.mark.parametrize( - ("operation", "role"), [("use", "output"), ("revert", "restore")] -) -def test_cleanup_retains_a_late_owned_path_replacement( - swapper: types.ModuleType, - monkeypatch: pytest.MonkeyPatch, - operation: str, - role: str, -) -> None: - """Use and revert cleanup retain a substituted task-owned inode.""" - _seed_configs(swapper) - claude = next(layer for layer in swapper.LAYERS if layer.cli == "claude") - if operation == "revert": - assert swapper.main(_use_args("--cli", claude.cli)) == 0 - real_rename = os.rename - real_replace = os.replace - real_unlink = os.unlink - unexpected_inode: int | None = None - - def inject(path: pathlib.Path) -> None: - nonlocal unexpected_inode - if unexpected_inode is not None or f".mcp-swap-{role}-" not in path.name: - return - human = path.with_name(f".{path.name}.human-cleanup") - human.write_bytes(b"human cleanup replacement\n") - human.chmod(0o600) - real_replace(human, path) - unexpected_inode = path.stat().st_ino - - def rename( - source: object, - destination: object, - *args: object, - **kwargs: object, - ) -> None: - inject(pathlib.Path(source)) - real_rename(source, destination, *args, **kwargs) - - def unlink(path: object, *args: object, **kwargs: object) -> None: - inject(pathlib.Path(path)) - real_unlink(path, *args, **kwargs) - - monkeypatch.setattr(os, "rename", rename) - monkeypatch.setattr(os, "unlink", unlink) - command = ( - ["revert", "--cli", claude.cli] - if operation == "revert" - else _use_args("--cli", claude.cli) - ) - - with pytest.raises(SystemExit, match="cleanup|retained|task-owned"): - swapper.main(command) - - assert unexpected_inode is not None - assert unexpected_inode in { - path.stat().st_ino for path in claude.path.parent.rglob("*") if path.is_file() - } - - -@pytest.mark.parametrize( - ("operation", "role"), [("use", "output"), ("revert", "restore")] -) -def test_config_symlink_retargeted_during_publish_is_preserved( - swapper: types.ModuleType, - monkeypatch: pytest.MonkeyPatch, - tmp_path: pathlib.Path, - operation: str, - role: str, -) -> None: - """A late symlink retarget stops the transaction without touching its target.""" - originals = _seed_configs(swapper) - claude = next(layer for layer in swapper.LAYERS if layer.cli == "claude") - first = tmp_path / "first.json" - second = tmp_path / "human.json" - first.write_bytes(originals[claude.cli]) - first.chmod(0o640) - second.write_bytes(b'{"human": "retargeted"}\n') - second.chmod(0o600) - human_identity = _path_identity(second) - claude.path.unlink() - claude.path.symlink_to(first) - if operation == "revert": - assert swapper.main(_use_args("--cli", claude.cli)) == 0 - real_link = os.link - retargeted = False - - def link( - source: object, - destination: object, - *args: object, - **kwargs: object, - ) -> None: - nonlocal retargeted - source_path = pathlib.Path(source) - destination_path = pathlib.Path(destination) - if ( - not retargeted - and destination_path == first - and f".mcp-swap-{role}-" in source_path.name - ): - claude.path.unlink() - claude.path.symlink_to(second) - retargeted = True - real_link(source, destination, *args, **kwargs) - - monkeypatch.setattr(os, "link", link) - command = ( - ["revert", "--cli", claude.cli] - if operation == "revert" - else _use_args("--cli", claude.cli) - ) - with pytest.raises(SystemExit, match="claude|symlink|target"): - swapper.main(command) - - assert retargeted - assert claude.path.is_symlink() and claude.path.resolve() == second - assert _path_identity(second) == human_identity - assert swapper.backup_of(claude).is_file() - assert _state_of(swapper, claude).is_file() - assert [ - path for path in first.parent.iterdir() if ".mcp-swap-recovery-" in path.name - ] - - -def test_failed_late_config_preflight_writes_nothing( - swapper: types.ModuleType, -) -> None: - """A malformed final config must not leave earlier clients half-swapped.""" - originals = _seed_configs(swapper) - pi = next(layer for layer in swapper.LAYERS if layer.cli == "pi") - originals["pi"] = b"{ malformed\n" - pi.path.write_bytes(originals["pi"]) - - with pytest.raises(SystemExit, match="pi.*unreadable"): - swapper.main(_use_args()) - - for layer in swapper.LAYERS: - assert layer.path.read_bytes() == originals[layer.cli] - assert not swapper.backup_of(layer).exists() - - -def test_config_changed_after_render_preflight_writes_nothing( - swapper: types.ModuleType, monkeypatch: pytest.MonkeyPatch -) -> None: - """A concurrent late-config edit must stop before earlier writes begin.""" - originals = _seed_configs(swapper) - pi = next(layer for layer in swapper.LAYERS if layer.cli == "pi") - external = b'{"external": true}\n' - - def change_late_config(_args: object) -> None: - pi.path.write_bytes(external) - - monkeypatch.setattr(swapper, "build", change_late_config) - with pytest.raises(SystemExit, match="pi.*changed during preflight"): - swapper.main(_use_args()) - - for layer in swapper.LAYERS: - expected = external if layer.cli == "pi" else originals[layer.cli] - assert layer.path.read_bytes() == expected - assert not swapper.backup_of(layer).exists() - - -def test_late_backup_destination_failure_writes_nothing( - swapper: types.ModuleType, -) -> None: - """Every backup destination must be feasible before the first write.""" - originals = _seed_configs(swapper) - pi = next(layer for layer in swapper.LAYERS if layer.cli == "pi") - blocked = swapper.backup_of(pi) - blocked.mkdir() - - with pytest.raises(SystemExit, match="pi.*backup"): - swapper.main(_use_args()) - - for layer in swapper.LAYERS: - assert layer.path.read_bytes() == originals[layer.cli] - if layer.cli != pi.cli: - assert not swapper.backup_of(layer).exists() - assert list(blocked.iterdir()) == [] - - -def test_use_commit_failure_rolls_back_every_client( - swapper: types.ModuleType, monkeypatch: pytest.MonkeyPatch -) -> None: - """A later config failure must reverse all earlier config and backup writes.""" - originals = _seed_configs(swapper) - pi = next(layer for layer in swapper.LAYERS if layer.cli == "pi") - destinations = _fail_first_replace_to(monkeypatch, pi.path) - - with pytest.raises((OSError, SystemExit), match="synthetic replace failure"): - swapper.main(_use_args()) - - _assert_original_state(swapper, originals) - configs = [layer.path for layer in swapper.LAYERS] - assert [path for path in destinations if path in configs] == [ - *configs, - *reversed(configs), - ] - _assert_no_stages(swapper) - - -def test_state_publication_failure_rolls_back_every_client( - swapper: types.ModuleType, monkeypatch: pytest.MonkeyPatch -) -> None: - """A later sidecar failure reverses earlier sidecars and backups.""" - originals = _seed_configs(swapper) - states = [_state_of(swapper, layer) for layer in swapper.LAYERS] - destinations = _fail_first_replace_to(monkeypatch, states[-1]) - real_rename = os.rename - removed: list[pathlib.Path] = [] - - def track_state_removal( - source: object, - destination: object, - *args: object, - **kwargs: object, - ) -> None: - target = pathlib.Path(source) - if target in states: - removed.append(target) - real_rename(source, destination, *args, **kwargs) - - monkeypatch.setattr(os, "rename", track_state_removal) - - with pytest.raises(SystemExit, match="synthetic replace failure"): - swapper.main(_use_args()) - - _assert_original_state(swapper, originals) - assert [path for path in destinations if path in states] == states - assert removed == list(reversed(states[:-1])) - _assert_no_stages(swapper) - - -@pytest.mark.parametrize("failure", ["state", "config"]) -def test_failed_repeat_use_restores_existing_recovery_identity( - swapper: types.ModuleType, - monkeypatch: pytest.MonkeyPatch, - failure: str, -) -> None: - """Repeat-use rollback restores the owned recovery pair itself.""" - _seed_configs(swapper) - assert swapper.main(_use_args()) == 0 - before = {layer.cli: _layer_state(swapper, layer) for layer in swapper.LAYERS} - state_inodes = { - layer.cli: ( - _state_of(swapper, layer).stat().st_dev, - _state_of(swapper, layer).stat().st_ino, - ) - for layer in swapper.LAYERS - } - pi = next(layer for layer in swapper.LAYERS if layer.cli == "pi") - destination = _state_of(swapper, pi) if failure == "state" else pi.path - _fail_first_replace_to(monkeypatch, destination) - - with pytest.raises( - (OSError, SystemExit), match="synthetic replace failure" - ) as stopped: - swapper.main(_use_args("--bin", "/opt/another/libtmux-mcp")) - - assert "rollback incomplete" not in str(stopped.value) - assert { - layer.cli: _layer_state(swapper, layer) for layer in swapper.LAYERS - } == before - assert { - layer.cli: ( - _state_of(swapper, layer).stat().st_dev, - _state_of(swapper, layer).stat().st_ino, - ) - for layer in swapper.LAYERS - } == state_inodes - _assert_no_stages(swapper) - - -def test_blocked_repeat_use_retains_prior_state_recovery( - swapper: types.ModuleType, monkeypatch: pytest.MonkeyPatch -) -> None: - """A human replacement must not discard the prior recovery state.""" - _seed_configs(swapper) - layer = next(layer for layer in swapper.LAYERS if layer.cli == "claude") - assert swapper.main(_use_args("--cli", layer.cli)) == 0 - state = _state_of(swapper, layer) - prior_config = layer.path.read_bytes() - prior_state = state.read_bytes() - prior_identity = (state.stat().st_dev, state.stat().st_ino) - original_backup = swapper.backup_of(layer).read_bytes() - human = b'{"human": true}\n' - human_identity: tuple[int, int] | None = None - real_link = os.link - real_replace = os.replace - - def publish_then_human_edit( - src: object, dst: object, *args: object, **kwargs: object - ) -> None: - nonlocal human_identity - source = pathlib.Path(src) - destination = pathlib.Path(dst) - real_link(source, destination, *args, **kwargs) - if destination == layer.path and "mcp-swap-output" in source.name: - replacement = destination.with_name(f".{destination.name}.human") - replacement.write_bytes(human) - replacement.chmod(0o640) - real_replace(replacement, destination) - human_identity = (destination.stat().st_dev, destination.stat().st_ino) - raise OSError("synthetic post-commit failure") - - monkeypatch.setattr(os, "link", publish_then_human_edit) - with pytest.raises(SystemExit, match="rollback incomplete"): - swapper.main( - _use_args( - "--cli", - layer.cli, - "--bin", - "/opt/another/libtmux-mcp", - ) - ) - - assert layer.path.read_bytes() == human - assert (layer.path.stat().st_dev, layer.path.stat().st_ino) == human_identity - assert swapper.backup_of(layer).read_bytes() == original_backup - config_recoveries = list( - layer.path.parent.glob(f".{layer.path.name}.mcp-swap-recovery-*") - ) - assert len(config_recoveries) == 1 - assert config_recoveries[0].read_bytes() == prior_config - recoveries = list(state.parent.glob(f".{state.name}.mcp-swap-recovery-state-*")) - assert len(recoveries) == 1 - assert recoveries[0].read_bytes() == prior_state - assert (recoveries[0].stat().st_dev, recoveries[0].stat().st_ino) == ( - prior_identity - ) - - -def test_repeat_use_refuses_an_unowned_config_edit( - swapper: types.ModuleType, -) -> None: - """A retained backup never authorizes overwriting a human edit.""" - _seed_configs(swapper) - layer = next(layer for layer in swapper.LAYERS if layer.cli == "claude") - assert swapper.main(_use_args("--cli", layer.cli)) == 0 - human = layer.path.read_bytes() + b"\n" - layer.path.write_bytes(human) - - with pytest.raises(SystemExit, match="claude"): - swapper.main(_use_args("--cli", layer.cli, "--bin", "/opt/next/mcp")) - - assert layer.path.read_bytes() == human - assert swapper.backup_of(layer).is_file() - assert _state_of(swapper, layer).is_file() - - -def test_repeat_use_updates_owned_state_but_keeps_the_first_backup( - swapper: types.ModuleType, -) -> None: - """An owned repeat swap advances its record without moving its baseline.""" - originals = _seed_configs(swapper) - layer = next(layer for layer in swapper.LAYERS if layer.cli == "claude") - assert swapper.main(_use_args("--cli", layer.cli)) == 0 - backup = swapper.backup_of(layer) - first_state = _state_of(swapper, layer).read_bytes() - - assert swapper.main(_use_args("--cli", layer.cli, "--bin", "/opt/next/mcp")) == 0 - - assert backup.read_bytes() == originals[layer.cli] - assert _state_of(swapper, layer).read_bytes() != first_state - assert _document(layer)["mcpServers"]["tmux"]["command"] == "/opt/next/mcp" - assert swapper.main(["revert", "--cli", layer.cli]) == 0 - assert layer.path.read_bytes() == originals[layer.cli] - - -def test_revert_commit_failure_restores_the_swapped_transaction( - swapper: types.ModuleType, monkeypatch: pytest.MonkeyPatch -) -> None: - """A later restore failure must put earlier clients and backups back.""" - _seed_configs(swapper) - assert swapper.main(_use_args()) == 0 - before = {layer.cli: _layer_state(swapper, layer) for layer in swapper.LAYERS} - pi = next(layer for layer in swapper.LAYERS if layer.cli == "pi") - destinations = _fail_first_replace_to(monkeypatch, pi.path) - - with pytest.raises((OSError, SystemExit), match="synthetic replace failure"): - swapper.main(["revert"]) - - assert { - layer.cli: _layer_state(swapper, layer) for layer in swapper.LAYERS - } == before - configs = [layer.path for layer in swapper.LAYERS] - assert [path for path in destinations if path in configs] == [ - *configs, - *reversed(configs), - ] - _assert_no_stages(swapper) - - -def test_state_removal_failure_restores_the_swapped_transaction( - swapper: types.ModuleType, monkeypatch: pytest.MonkeyPatch -) -> None: - """Sidecar cleanup participates in reverse all-client rollback.""" - _seed_configs(swapper) - assert swapper.main(_use_args()) == 0 - before = {layer.cli: _owned_layer_state(swapper, layer) for layer in swapper.LAYERS} - pi = next(layer for layer in swapper.LAYERS if layer.cli == "pi") - blocked = _state_of(swapper, pi) - real_replace = swapper._apply_replace - - def fail_state_removal( - source: pathlib.Path, - destination: pathlib.Path, - *args: object, - **kwargs: object, - ) -> tuple[object, object]: - if pathlib.Path(source) == blocked: - raise OSError("synthetic state removal failure") - return real_replace(source, destination, *args, **kwargs) - - monkeypatch.setattr(swapper, "_apply_replace", fail_state_removal) - with pytest.raises(SystemExit, match="synthetic state removal failure"): - swapper.main(["revert"]) - - assert { - layer.cli: _owned_layer_state(swapper, layer) for layer in swapper.LAYERS - } == before - assert swapper.main(["revert", "--dry-run"]) == 0 - _assert_no_stages(swapper) - - -def test_revert_refuses_a_human_edit_and_retains_recovery( - swapper: types.ModuleType, -) -> None: - """Revert owns only the exact config state written by use.""" - originals = _seed_configs(swapper) - layer = next(layer for layer in swapper.LAYERS if layer.cli == "claude") - assert swapper.main(_use_args("--cli", layer.cli)) == 0 - human = layer.path.read_bytes() + b"\n" - layer.path.write_bytes(human) - - with pytest.raises(SystemExit, match="claude"): - swapper.main(["revert", "--cli", layer.cli]) - - assert layer.path.read_bytes() == human - assert swapper.backup_of(layer).read_bytes() == originals[layer.cli] - assert _state_of(swapper, layer).is_file() - - -def test_revert_refuses_a_same_path_inode_replacement( - swapper: types.ModuleType, -) -> None: - """Identical bytes at a new physical config identity are not owned.""" - _seed_configs(swapper) - layer = next(layer for layer in swapper.LAYERS if layer.cli == "claude") - assert swapper.main(_use_args("--cli", layer.cli)) == 0 - swapped = layer.path.read_bytes() - prior_inode = layer.path.stat().st_ino - replacement = layer.path.with_name("replacement.json") - replacement.write_bytes(swapped) - replacement.chmod(0o640) - os.replace(replacement, layer.path) - assert layer.path.stat().st_ino != prior_inode - - with pytest.raises(SystemExit, match="claude"): - swapper.main(["revert", "--cli", layer.cli]) - - assert layer.path.read_bytes() == swapped - assert swapper.backup_of(layer).is_file() - assert _state_of(swapper, layer).is_file() - - -def test_symlink_config_survives_use_and_revert( - swapper: types.ModuleType, tmp_path: pathlib.Path -) -> None: - """Both directions write through a config symlink without replacing it.""" - originals = _seed_configs(swapper) - layer = next(layer for layer in swapper.LAYERS if layer.cli == "claude") - target = tmp_path / "real" / "claude.json" - target.parent.mkdir() - target.write_bytes(originals[layer.cli]) - target.chmod(0o640) - layer.path.unlink() - link_text = os.path.relpath(target, layer.path.parent) - layer.path.symlink_to(link_text) - - assert swapper.main(_use_args("--cli", layer.cli)) == 0 - assert layer.path.is_symlink() - assert os.readlink(layer.path) == link_text - _assert_swapped(layer) - - assert swapper.main(["revert", "--cli", layer.cli]) == 0 - assert layer.path.is_symlink() - assert os.readlink(layer.path) == link_text - assert target.read_bytes() == originals[layer.cli] - assert stat.S_IMODE(target.stat().st_mode) == 0o640 - assert not swapper.backup_of(layer).exists() - assert not any(".mcp-swap-" in path.name for path in tmp_path.rglob("*")) - - -def test_revert_refuses_a_config_symlink_retarget( - swapper: types.ModuleType, tmp_path: pathlib.Path -) -> None: - """A link moved after use cannot redirect restoration into another file.""" - originals = _seed_configs(swapper) - layer = next(layer for layer in swapper.LAYERS if layer.cli == "claude") - first = tmp_path / "first.json" - second = tmp_path / "second.json" - first.write_bytes(originals[layer.cli]) - first.chmod(0o640) - layer.path.unlink() - layer.path.symlink_to(first) - assert swapper.main(_use_args("--cli", layer.cli)) == 0 - swapped = first.read_bytes() - second.write_bytes(swapped) - second.chmod(0o640) - layer.path.unlink() - layer.path.symlink_to(second) - - with pytest.raises(SystemExit, match="claude"): - swapper.main(["revert", "--cli", layer.cli]) - - assert second.read_bytes() == swapped - assert first.read_bytes() == swapped - assert swapper.backup_of(layer).is_file() - assert _state_of(swapper, layer).is_file() - - -@pytest.mark.parametrize("artifact", ["backup", "state"]) -def test_revert_refuses_tampered_recovery_artifacts( - swapper: types.ModuleType, artifact: str -) -> None: - """Neither half of the recovery unit may change before restore.""" - _seed_configs(swapper) - layer = next(layer for layer in swapper.LAYERS if layer.cli == "claude") - assert swapper.main(_use_args("--cli", layer.cli)) == 0 - swapped = layer.path.read_bytes() - selected = ( - swapper.backup_of(layer) if artifact == "backup" else _state_of(swapper, layer) - ) - if artifact == "backup": - tampered = b"tampered recovery artifact\n" - else: - document = json.loads(selected.read_text(encoding="utf-8")) - document["server"]["command"] = "/tampered/mcp" - tampered = ( - json.dumps(document, sort_keys=True, separators=(",", ":")) + "\n" - ).encode() - selected.write_bytes(tampered) - - with pytest.raises(SystemExit, match="claude"): - swapper.main(["revert", "--cli", layer.cli]) - - assert layer.path.read_bytes() == swapped - assert selected.read_bytes() == tampered - assert swapper.backup_of(layer).exists() - assert _state_of(swapper, layer).exists() - - -def test_revert_refuses_a_replaced_backup_inode( - swapper: types.ModuleType, -) -> None: - """Byte-identical backup replacement still loses recovery ownership.""" - _seed_configs(swapper) - layer = next(layer for layer in swapper.LAYERS if layer.cli == "claude") - assert swapper.main(_use_args("--cli", layer.cli)) == 0 - swapped = layer.path.read_bytes() - backup = swapper.backup_of(layer) - replacement = backup.with_name("replacement.backup") - replacement.write_bytes(backup.read_bytes()) - replacement.chmod(stat.S_IMODE(backup.stat().st_mode)) - os.replace(replacement, backup) - - with pytest.raises(SystemExit, match="claude"): - swapper.main(["revert", "--cli", layer.cli]) - - assert layer.path.read_bytes() == swapped - assert backup.is_file() and _state_of(swapper, layer).is_file() - - -@pytest.mark.parametrize("dry_run", [True, False], ids=["dry-run", "commit"]) -def test_all_selected_revert_preflights_every_recovery_record( - swapper: types.ModuleType, dry_run: bool -) -> None: - """A bad final record blocks every selected restore, including dry-run.""" - _seed_configs(swapper) - assert swapper.main(_use_args()) == 0 - pi = next(layer for layer in swapper.LAYERS if layer.cli == "pi") - _state_of(swapper, pi).write_bytes(b"{ malformed\n") - before = {layer.cli: _owned_layer_state(swapper, layer) for layer in swapper.LAYERS} - command = ["revert", *(["--dry-run"] if dry_run else [])] - - with pytest.raises(SystemExit, match="pi"): - swapper.main(command) - - assert { - layer.cli: _owned_layer_state(swapper, layer) for layer in swapper.LAYERS - } == before - _assert_no_stages(swapper) - - -def test_recovery_record_is_bounded_private_and_route_specific( - swapper: types.ModuleType, -) -> None: - """The durable ownership record identifies the exact requested route.""" - _seed_configs(swapper) - layer = next(layer for layer in swapper.LAYERS if layer.cli == "claude") - args = _use_args("--cli", layer.cli, "--name", "private-tmux") - assert swapper.main(args) == 0 - state = _state_of(swapper, layer) - document = json.loads(state.read_text(encoding="utf-8")) - - assert stat.S_ISREG(state.lstat().st_mode) - assert stat.S_IMODE(state.stat().st_mode) == 0o600 - assert state.stat().st_size <= 16 * 1024 - assert document["version"] == 1 - assert document["cli"] == layer.cli - assert document["server"] == { - "name": "private-tmux", - "command": LAUNCHER, - "arguments": ["--socket", "/tmp/libtmux-java-dev/test/s"], - } - - with pytest.raises(SystemExit, match="claude"): - swapper.main(["revert", "--cli", layer.cli]) - assert swapper.main(["revert", "--cli", layer.cli, "--name", "private-tmux"]) == 0 - - -def test_duplicate_physical_config_targets_are_rejected( - swapper: types.ModuleType, -) -> None: - """Two logical client configs may not race to replace one physical file.""" - originals = _seed_configs(swapper) - claude = next(layer for layer in swapper.LAYERS if layer.cli == "claude") - cursor = next(layer for layer in swapper.LAYERS if layer.cli == "cursor") - cursor.path.unlink() - cursor.path.symlink_to(claude.path) - - with pytest.raises(SystemExit, match="duplicate physical config target"): - swapper.main(_use_args()) - - assert claude.path.read_bytes() == originals[claude.cli] - assert cursor.path.is_symlink() - assert all(not swapper.backup_of(layer).exists() for layer in swapper.LAYERS) - - -def test_duplicate_recovery_destinations_are_rejected( - swapper: types.ModuleType, monkeypatch: pytest.MonkeyPatch -) -> None: - """A sidecar cannot share another selected client's physical destination.""" - originals = _seed_configs(swapper) - claude = next(layer for layer in swapper.LAYERS if layer.cli == "claude") - cursor = next(layer for layer in swapper.LAYERS if layer.cli == "cursor") - real_state_of = swapper.state_of - shared = real_state_of(claude) - - def overlapping_state(layer: object) -> pathlib.Path: - return shared if layer.cli == cursor.cli else real_state_of(layer) - - monkeypatch.setattr(swapper, "state_of", overlapping_state) - with pytest.raises(SystemExit, match="duplicate transaction destination"): - swapper.main(_use_args()) - - _assert_original_state(swapper, originals) - - -def test_symlink_transition_after_planning_writes_nothing( - swapper: types.ModuleType, - monkeypatch: pytest.MonkeyPatch, - tmp_path: pathlib.Path, -) -> None: - """A link retargeted to identical bytes still invalidates the plan.""" - originals = _seed_configs(swapper) - pi = next(layer for layer in swapper.LAYERS if layer.cli == "pi") - first = tmp_path / "first.json" - second = tmp_path / "second.json" - for target in (first, second): - target.write_bytes(originals[pi.cli]) - target.chmod(0o640) - pi.path.unlink() - pi.path.symlink_to(first) - - def retarget(_args: object) -> None: - pi.path.unlink() - pi.path.symlink_to(second) - - monkeypatch.setattr(swapper, "build", retarget) - with pytest.raises(SystemExit, match="pi.*changed during preflight"): - swapper.main(_use_args()) - - assert pi.path.is_symlink() and pi.path.resolve() == second - assert second.read_bytes() == originals[pi.cli] - assert all( - layer.path.read_bytes() == originals[layer.cli] for layer in swapper.LAYERS - ) - assert all(not swapper.backup_of(layer).exists() for layer in swapper.LAYERS) - - -def test_mode_transition_after_planning_writes_nothing( - swapper: types.ModuleType, monkeypatch: pytest.MonkeyPatch -) -> None: - """Mode is part of the preflight identity even when bytes do not change.""" - originals = _seed_configs(swapper) - pi = next(layer for layer in swapper.LAYERS if layer.cli == "pi") - - def change_mode(_args: object) -> None: - pi.path.chmod(0o600) - - monkeypatch.setattr(swapper, "build", change_mode) - with pytest.raises(SystemExit, match="pi.*changed during preflight"): - swapper.main(_use_args()) - - assert pi.path.read_bytes() == originals[pi.cli] - assert stat.S_IMODE(pi.path.stat().st_mode) == 0o600 - assert all( - layer.path.read_bytes() == originals[layer.cli] for layer in swapper.LAYERS - ) - assert all(not swapper.backup_of(layer).exists() for layer in swapper.LAYERS) - - -def test_backup_appearance_after_planning_writes_nothing( - swapper: types.ModuleType, monkeypatch: pytest.MonkeyPatch -) -> None: - """A concurrent backup must be preserved and invalidate the whole plan.""" - originals = _seed_configs(swapper) - pi = next(layer for layer in swapper.LAYERS if layer.cli == "pi") - external = b"external backup\n" - - def create_backup(_args: object) -> None: - swapper.backup_of(pi).write_bytes(external) - - monkeypatch.setattr(swapper, "build", create_backup) - with pytest.raises(SystemExit, match="pi.*backup changed during preflight"): - swapper.main(_use_args()) - - _assert_original_state(swapper, originals, backups={pi.cli: external}) - - -def test_existing_backup_mode_change_invalidates_the_plan( - swapper: types.ModuleType, monkeypatch: pytest.MonkeyPatch -) -> None: - """A retained backup's identity and mode are frozen with the configs.""" - _seed_configs(swapper) - assert swapper.main(_use_args()) == 0 - before = {layer.cli: _layer_state(swapper, layer) for layer in swapper.LAYERS} - pi = next(layer for layer in swapper.LAYERS if layer.cli == "pi") - pi_backup = swapper.backup_of(pi) - - def change_backup_mode(_args: object) -> None: - pi_backup.chmod(0o600) - - monkeypatch.setattr(swapper, "build", change_backup_mode) - with pytest.raises(SystemExit, match="pi.*backup changed during preflight"): - swapper.main(_use_args("--bin", "/opt/another/libtmux-mcp")) - - for layer in swapper.LAYERS: - current = _layer_state(swapper, layer) - if layer.cli == pi.cli: - expected = list(before[layer.cli]) - expected[6] = 0o600 - assert current == tuple(expected) - else: - assert current == before[layer.cli] - _assert_no_stages(swapper) - - -def test_dry_run_plans_without_building_or_writing( - swapper: types.ModuleType, monkeypatch: pytest.MonkeyPatch -) -> None: - """Dry-run parses every config and backup destination but changes no files.""" - originals = _seed_configs(swapper) - - def forbidden_build(_args: object) -> None: - raise AssertionError("dry-run built the distribution") - - monkeypatch.setattr(swapper, "build", forbidden_build) - swap_lock = _swap_lock(swapper) - assert not os.path.lexists(swap_lock) - assert swapper.main(_use_args("--dry-run")) == 0 - _assert_original_state(swapper, originals) - _assert_no_stages(swapper) - assert not os.path.lexists(swap_lock) - assert not swap_lock.parent.exists() - - -def test_staging_failure_cleans_up_before_any_destination_write( - swapper: types.ModuleType, monkeypatch: pytest.MonkeyPatch -) -> None: - """A late stage failure leaves configs, backups, and earlier stages absent.""" - originals = _seed_configs(swapper) - pi = next(layer for layer in swapper.LAYERS if layer.cli == "pi") - real_stage = swapper._stage - - def fail_pi_stage( - directory: pathlib.Path, - logical_name: str, - role: str, - data: bytes, - mode: int, - ) -> pathlib.Path: - if logical_name == pi.path.name and role == "output": - raise OSError("synthetic stage failure") - return real_stage(directory, logical_name, role, data, mode) - - monkeypatch.setattr(swapper, "_stage", fail_pi_stage) - with pytest.raises(SystemExit, match="synthetic stage failure"): - swapper.main(_use_args()) - - _assert_original_state(swapper, originals) - _assert_no_stages(swapper) - - -def test_use_cleanup_failure_is_not_reported_as_success( - swapper: types.ModuleType, monkeypatch: pytest.MonkeyPatch -) -> None: - """A committed swap with retained private stages must return failure.""" - _seed_configs(swapper) - claude = next(layer for layer in swapper.LAYERS if layer.cli == "claude") - _fail_stage_cleanup(monkeypatch, "output") - - with pytest.raises(SystemExit, match="cleanup incomplete") as stopped: - swapper.main(_use_args("--cli", claude.cli)) - - retained = list(claude.path.parent.glob(".*mcp-swap-output-*mcp-swap-retained-*")) - assert len(retained) == 1 - assert str(retained[0]) in str(stopped.value) - _assert_swapped(claude) - - -def test_revert_cleanup_failure_is_not_reported_as_success( - swapper: types.ModuleType, monkeypatch: pytest.MonkeyPatch -) -> None: - """A committed revert with retained private stages must return failure.""" - originals = _seed_configs(swapper) - claude = next(layer for layer in swapper.LAYERS if layer.cli == "claude") - assert swapper.main(_use_args("--cli", claude.cli)) == 0 - _fail_stage_cleanup(monkeypatch, "restore") - - with pytest.raises(SystemExit, match="cleanup incomplete") as stopped: - swapper.main(["revert", "--cli", claude.cli]) - - retained = list(claude.path.parent.glob(".*mcp-swap-restore-*mcp-swap-retained-*")) - assert len(retained) == 1 - assert str(retained[0]) in str(stopped.value) - assert claude.path.read_bytes() == originals[claude.cli] - - -def test_failed_rollback_preserves_backup_and_recovery_stage( - swapper: types.ModuleType, monkeypatch: pytest.MonkeyPatch -) -> None: - """Unknown rollback state must retain both recoverable copies.""" - originals = _seed_configs(swapper) - claude = next(layer for layer in swapper.LAYERS if layer.cli == "claude") - cursor = next(layer for layer in swapper.LAYERS if layer.cli == "cursor") - real_link = os.link - claude_writes = 0 - - def fail_commit_and_rollback( - src: object, dst: object, *args: object, **kwargs: object - ) -> None: - nonlocal claude_writes - destination = pathlib.Path(dst) - if destination == cursor.path: - raise OSError("synthetic replace failure") - if destination == claude.path: - claude_writes += 1 - if claude_writes == 2: - raise OSError("synthetic rollback failure") - real_link(src, dst, *args, **kwargs) - - monkeypatch.setattr(os, "link", fail_commit_and_rollback) - with pytest.raises(SystemExit, match="synthetic rollback failure"): - swapper.main(_use_args()) - - assert swapper.backup_of(claude).read_bytes() == originals[claude.cli] - recovery = list(claude.path.parent.glob(f".{claude.path.name}.mcp-swap-recovery-*")) - assert len(recovery) == 1 - assert recovery[0].read_bytes() == originals[claude.cli] - assert stat.S_IMODE(recovery[0].stat().st_mode) == 0o640 - - -def test_failed_backup_rollback_preserves_its_recovery_copy( - swapper: types.ModuleType, monkeypatch: pytest.MonkeyPatch -) -> None: - """A removed backup is retained as a stage if recreation cannot be proven.""" - originals = _seed_configs(swapper) - assert swapper.main(_use_args()) == 0 - swapped = {layer.cli: layer.path.read_bytes() for layer in swapper.LAYERS} - claude = next(layer for layer in swapper.LAYERS if layer.cli == "claude") - codex = next(layer for layer in swapper.LAYERS if layer.cli == "codex") - claude_backup = swapper.backup_of(claude) - codex_backup = swapper.backup_of(codex) - real_apply = swapper._apply_replace - real_link = os.link - rollback_started = False - - def fail_backup_removal( - src: pathlib.Path, - dst: pathlib.Path, - *args: object, - **kwargs: object, - ) -> tuple[object, object]: - nonlocal rollback_started - if pathlib.Path(src) == codex_backup: - rollback_started = True - raise OSError("synthetic backup removal failure") - return real_apply(src, dst, *args, **kwargs) - - def fail_backup_restore( - src: object, dst: object, *args: object, **kwargs: object - ) -> None: - if rollback_started and pathlib.Path(dst) == claude_backup: - raise OSError("synthetic backup rollback failure") - real_link(src, dst, *args, **kwargs) - - monkeypatch.setattr(swapper, "_apply_replace", fail_backup_removal) - monkeypatch.setattr(os, "link", fail_backup_restore) - with pytest.raises(SystemExit, match="synthetic backup rollback failure"): - swapper.main(["revert"]) - - for layer in swapper.LAYERS: - assert layer.path.read_bytes() == swapped[layer.cli] - assert not claude_backup.exists() - recovery = list( - claude_backup.parent.glob(f".{claude_backup.name}.mcp-swap-recovery-*") - ) - assert len(recovery) == 1 - assert recovery[0].read_bytes() == originals[claude.cli] - assert stat.S_IMODE(recovery[0].stat().st_mode) == 0o640 - for layer in swapper.LAYERS[1:]: - assert swapper.backup_of(layer).read_bytes() == originals[layer.cli] - - -def test_opencode_missing_root_preserves_jsonc_bytes_and_mode( - swapper: types.ModuleType, -) -> None: - """Adding mcp leaves comments, trailing comma, and unrelated bytes alone.""" - _seed_configs(swapper) - layer = next(layer for layer in swapper.LAYERS if layer.cli == "opencode") - before = layer.path.read_text(encoding="utf-8") - - assert swapper.main(_use_args("--cli", "opencode")) == 0 - - after = layer.path.read_text(encoding="utf-8") - assert after.startswith(before[: before.index(",\n}")]) - assert after.endswith(",\n}\n") - assert "// root comment stays" in after - assert _document(layer)["unrelated"] == {"keep": True} - assert stat.S_IMODE(layer.path.stat().st_mode) == 0o640 - _assert_swapped(layer) - - -def test_opencode_replaces_entry_without_dropping_its_comment( - swapper: types.ModuleType, -) -> None: - """A rationale inside the replaced server entry survives the swap.""" - _seed_configs(swapper) - layer = next(layer for layer in swapper.LAYERS if layer.cli == "opencode") - layer.path.write_text( - "{\n" - ' "mcp": {\n' - ' "tmux": {\n' - ' "type": "local",\n' - " // pinned locally; keep this rationale\n" - ' "command": ["old", "server"],\n' - " },\n" - ' "other": {"type": "local", "command": ["echo", "keep"]},\n' - " },\n" - "}\n", - encoding="utf-8", - ) - - assert swapper.main(_use_args("--cli", "opencode")) == 0 - - after = layer.path.read_text(encoding="utf-8") - assert "// pinned locally; keep this rationale" in after - document = _document(layer) - assert document["mcp"]["other"]["command"] == ["echo", "keep"] - _assert_swapped(layer) - - -def test_pi_detect_explains_adapter_availability( - swapper: types.ModuleType, - tmp_path: pathlib.Path, - capsys: pytest.CaptureFixture[str], -) -> None: - """Pi is not reported usable when only its adapter config exists.""" - _seed_configs(swapper) - swapper.PI_ADAPTER_DIR = tmp_path / "missing-adapter" - - assert swapper.main(["detect"]) == 0 - assert swapper.PI_ADAPTER_HINT in capsys.readouterr().out - - swapper.PI_ADAPTER_DIR.mkdir() - assert swapper.main(["detect"]) == 0 - assert swapper.PI_ADAPTER_HINT not in capsys.readouterr().out - - -def test_antigravity_is_an_alias_for_canonical_agy( - swapper: types.ModuleType, -) -> None: - """The legacy name selects one agy layer and never appears as a ninth.""" - originals = _seed_configs(swapper) - - assert swapper.main(_use_args("--cli", "antigravity")) == 0 - - assert tuple(layer.cli for layer in swapper.LAYERS) == CANONICAL_CLIS - agy = next(layer for layer in swapper.LAYERS if layer.cli == "agy") - _assert_swapped(agy) - for layer in swapper.LAYERS: - if layer.cli != "agy": - assert layer.path.read_bytes() == originals[layer.cli] - - -def test_no_arguments_and_explicit_help_exit_zero( - swapper: types.ModuleType, capsys: pytest.CaptureFixture[str] -) -> None: - """Help is a successful query, including the convenient no-arg form.""" - assert swapper.main([]) == 0 - assert "usage: mcp_swap" in capsys.readouterr().out - - with pytest.raises(SystemExit) as stopped: - swapper.main(["--help"]) - assert stopped.value.code == 0 - assert "usage: mcp_swap" in capsys.readouterr().out - - -def _assert_original_state( - swapper: types.ModuleType, - originals: dict[str, bytes], - *, - backups: dict[str, bytes] | None = None, -) -> None: - expected_backups = backups or {} - for layer in swapper.LAYERS: - assert layer.path.read_bytes() == originals[layer.cli] - assert stat.S_IMODE(layer.path.stat().st_mode) == 0o640 - backup = swapper.backup_of(layer) - if layer.cli in expected_backups: - assert backup.read_bytes() == expected_backups[layer.cli] - else: - assert not backup.exists() - assert not _state_of(swapper, layer).exists() - - -def _layer_state(swapper: types.ModuleType, layer: object) -> tuple[object, ...]: - link = os.readlink(layer.path) if layer.path.is_symlink() else None - backup = swapper.backup_of(layer) - state = _state_of(swapper, layer) - return ( - layer.path.is_symlink(), - link, - layer.path.read_bytes(), - stat.S_IMODE(layer.path.stat().st_mode), - backup.is_symlink(), - backup.read_bytes() if backup.is_file() else None, - stat.S_IMODE(backup.stat().st_mode) if backup.is_file() else None, - state.is_symlink(), - state.read_bytes() if state.is_file() else None, - stat.S_IMODE(state.stat().st_mode) if state.is_file() else None, - ) - - -def _state_of(swapper: types.ModuleType, layer: object) -> pathlib.Path: - backup = swapper.backup_of(layer) - return backup.with_name(backup.name + ".state") - - -def _swap_lock(swapper: types.ModuleType) -> pathlib.Path: - home = next(layer for layer in swapper.LAYERS if layer.cli == "claude").path.parent - return home / ".local" / "state" / "libtmux-mcp-dev" / "swap" / "state.lock" - - -def _path_identity(path: pathlib.Path) -> tuple[object, ...]: - details = path.lstat() - return ( - stat.S_ISLNK(details.st_mode), - os.readlink(path) if stat.S_ISLNK(details.st_mode) else None, - path.read_bytes(), - stat.S_IMODE(path.stat().st_mode), - path.stat().st_dev, - path.stat().st_ino, - ) - - -def _owned_layer_state(swapper: types.ModuleType, layer: object) -> tuple[object, ...]: - return _layer_state(swapper, layer) - - -def _assert_no_stages(swapper: types.ModuleType) -> None: - home = next(layer for layer in swapper.LAYERS if layer.cli == "claude").path.parent - roles = re.compile(r"\.mcp-swap-(?:new|output|recovery|restore|state)-") - assert [path for path in home.rglob("*") if roles.search(path.name)] == [] - - -def _fail_stage_cleanup(monkeypatch: pytest.MonkeyPatch, role: str) -> None: - real_unlink = pathlib.Path.unlink - - def refuse(path: pathlib.Path, *args: object, **kwargs: object) -> None: - if f".mcp-swap-{role}-" in path.parent.name: - raise OSError("synthetic cleanup failure") - real_unlink(path, *args, **kwargs) - - monkeypatch.setattr(pathlib.Path, "unlink", refuse) - - -def _fail_first_replace_to( - monkeypatch: pytest.MonkeyPatch, destination: pathlib.Path -) -> list[pathlib.Path]: - real_link = os.link - real_replace = os.replace - real_rename = os.rename - failed = False - destinations: list[pathlib.Path] = [] - - def replace(src: object, dst: object, *args: object, **kwargs: object) -> None: - nonlocal failed - target = pathlib.Path(dst) - destinations.append(target) - if target == destination and not failed: - failed = True - raise OSError("synthetic replace failure") - real_replace(src, dst, *args, **kwargs) - - def rename(src: object, dst: object, *args: object, **kwargs: object) -> None: - nonlocal failed - target = pathlib.Path(dst) - destinations.append(target) - if target == destination and not failed: - failed = True - raise OSError("synthetic replace failure") - real_rename(src, dst, *args, **kwargs) - - def link(src: object, dst: object, *args: object, **kwargs: object) -> None: - nonlocal failed - target = pathlib.Path(dst) - destinations.append(target) - if target == destination and not failed: - failed = True - raise OSError("synthetic replace failure") - real_link(src, dst, *args, **kwargs) - - monkeypatch.setattr(os, "link", link) - monkeypatch.setattr(os, "replace", replace) - monkeypatch.setattr(os, "rename", rename) - return destinations diff --git a/tools/mcp-swap/README.md b/tools/mcp-swap/README.md new file mode 100644 index 0000000..2fd1b2b --- /dev/null +++ b/tools/mcp-swap/README.md @@ -0,0 +1,210 @@ +# mcp-swap + +`mcp-swap` points installed agent CLIs at the `libtmux-mcp` server built from +this checkout. It is a private Gradle application: the repository tests and +distributes it locally, but no published libtmux artifact contains it. + +Use it to exercise a branch in real clients, then restore every config byte to +its pre-swap state. + +## Build the utility + +From the repository root: + +```console +$ ./gradlew :tools:mcp-swap:installDist +``` + +The launcher is +`tools/mcp-swap/build/install/mcp-swap/bin/mcp-swap`. Rebuild it after changing +the utility. + +## Inspect clients + +Show which of the eight known clients have both their executable on `PATH` and +their config file present: + +```console +$ tools/mcp-swap/build/install/mcp-swap/bin/mcp-swap detect +``` + +Show the current `tmux` entry in each existing config: + +```console +$ tools/mcp-swap/build/install/mcp-swap/bin/mcp-swap status +``` + +For Claude, unscoped status checks both the top-level user entry and this +repository's project entry. `--scope user` or `--scope project` limits that +view. `--name` selects another server entry. Repeat `--cli` to limit a command; +comma-separated names also work. `antigravity` is an alias for `agy`. + +```console +$ tools/mcp-swap/build/install/mcp-swap/bin/mcp-swap status \ + --name libtmux \ + --cli claude,codex +``` + +## Select a server build + +The default `dist` source builds `:libtmux-mcp:installDist`, then records its +launcher directly. No Gradle process sits in front of the MCP handshake. + +Preview the complete transaction without building, writing, or creating state +directories: + +```console +$ tools/mcp-swap/build/install/mcp-swap/bin/mcp-swap use \ + --dry-run +``` + +Point every existing config at the built server and a tmux socket: + +```console +$ tools/mcp-swap/build/install/mcp-swap/bin/mcp-swap use \ + --socket /tmp/libtmux-java-dev/demo/s +``` + +Use `gradle` when every client launch should rebuild current sources. This is +convenient while editing, but a cold Gradle start can exceed a client's MCP +handshake deadline. + +```console +$ tools/mcp-swap/build/install/mcp-swap/bin/mcp-swap use \ + --source gradle \ + --socket-name demo +``` + +Use `path` for an executable built elsewhere: + +```console +$ ./gradlew :libtmux-mcp:installDist +``` + +```console +$ tools/mcp-swap/build/install/mcp-swap/bin/mcp-swap use \ + --source path \ + --bin libtmux-mcp/build/install/libtmux-mcp/bin/libtmux-mcp +``` + +`--socket` and `--socket-name` are mutually exclusive. Without either, the MCP +server uses its normal tmux endpoint resolution. `--tmux` can name a different +tmux executable. + +Before taking the transaction lock or changing a client config, `use` starts +each final client-specific command, sends MCP `initialize`, and requires a +complete JSON-RPC 2.0 result. It accepts a server that stays running after the +handshake, then terminates the process tree. Use `--no-preflight` only when the +server cannot be safely started outside the client. + +## Set the capability surface + +The swapper preserves an entry's existing environment and accepts repeatable +overrides. Use the capability model's toolsets and exact include/exclude lists: + +```console +$ tools/mcp-swap/build/install/mcp-swap/bin/mcp-swap use \ + --env LIBTMUX_TOOLSETS=inspect,execute \ + --env LIBTMUX_EXCLUDE_TOOLS=send_keys +``` + +`LIBTMUX_SAFETY` is retired. The swap preserves an inherited value in the +preflight command so the server can report the migration error instead of +silently widening authority. Supply `LIBTMUX_TOOLSETS` explicitly; the +successful transaction then removes the retired setting and keeps all +unrelated environment values. Passing `LIBTMUX_SAFETY` explicitly is rejected. + +## Restore configs + +Restore every selected config from its first pre-swap backup: + +```console +$ tools/mcp-swap/build/install/mcp-swap/bin/mcp-swap revert +``` + +Claude defaults to project scope, under +`projects[].mcpServers`. Use `--scope user` for its +top-level `mcpServers` fallback. The two scopes have independent recovery +records and can coexist; an unscoped revert restores both in strict LIFO order. +To unwind only the newest layer: + +```console +$ tools/mcp-swap/build/install/mcp-swap/bin/mcp-swap revert \ + --cli claude \ + --scope user +``` + +The scope flag is silently coerced to `user` for every non-Claude client. + +A repeated `use` keeps the first backup. It updates the owned config and +recovery record only after proving that the config still matches the previous +swap. `revert` likewise refuses a human edit, route change, replaced backup, or +incomplete recovery pair. + +Run read-only diagnostics before a swap: + +```console +$ tools/mcp-swap/build/install/mcp-swap/bin/mcp-swap doctor +``` + +## Client files + +| Client | Config | Server table | +| --- | --- | --- | +| Claude | `~/.claude.json` (user or current-project scope) | `mcpServers` | +| Codex | `~/.codex/config.toml` | `mcp_servers` | +| Cursor | `~/.cursor/mcp.json` | `mcpServers` | +| Gemini | `~/.gemini/settings.json` | `mcpServers` | +| Grok | `~/.grok/config.toml` | `mcp_servers` | +| agy / Antigravity | `~/.gemini/config/mcp_config.json` | `mcpServers` | +| OpenCode | `$XDG_CONFIG_HOME/opencode/opencode.jsonc` | `mcp` | +| Pi adapter | `~/.pi/agent/mcp.json` | `mcpServers` | + +Claude's project scope is the one project-aware exception. The utility does not +walk repository or workspace config files for other clients. OpenCode's +`.jsonc` file wins over its sibling global JSON files, so the swap owns that +layer while preserving comments and trailing commas. Pi has no built-in MCP +client; its entry takes effect only when the third-party `pi-mcp-adapter` is +installed. `detect` and `doctor` report that caveat. + +## Transaction contract + +One command covers all selected clients as a single transaction: + +- It serializes with every language port through + `$XDG_STATE_HOME/libtmux-mcp-dev/swap/state.lock`; Java backup and state + filenames carry a `mcp-swap-java` marker so another port cannot claim them. +- It parses every config and validates all config, backup, state, and lock + routes before the first replacement. +- It preflights the exact environment-adjusted command for every selected + client and aborts if any final command changes before the lock is acquired. +- It rejects aliases and hard links across selected and unselected clients. +- It writes private, checksummed recovery records and binds the first backup's + file identity, bytes, mode, and route. +- It gives Claude's user and project layers distinct Java recovery files and + refuses to remove an older layer before a newer layer on the same config. +- It rechecks every protected route at each publication boundary. +- It rolls a failed commit back in reverse order. If exact rollback becomes + uncertain, it preserves the human destination and the remaining recovery + artifacts instead of guessing. +- It keeps config symlinks themselves intact while authenticating their target + and refusing a retarget. + +JSON, JSONC, and TOML updates keep unrelated servers and values. JSONC and TOML +updates also keep comments; swap and revert restore the complete original byte +sequence. + +The test suite exercises all eight clients, every ordering of their selectors, +Claude scope layering and LIFO restoration, bounded MCP initialization and +process-tree cleanup, repeat swaps, byte-exact restoration, malformed recovery +data, path aliases, late destination changes, lock contention, and interrupted +transaction rollback. + +## Test the utility + +```console +$ ./gradlew :tools:mcp-swap:check +``` + +The module uses the repository's JDK 21 toolchain and is included in the root +build, but it is absent from Maven publication and the BOM. From 9cb9c065e7ac9651c4d106bb96843e3389c92e12 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 6 Sep 2026 06:09:40 -0500 Subject: [PATCH 62/65] Mcp(fix[transport]): Drain sends without the lock why: The pinned SDK resolves a stdio send on the subscribing thread once its readiness sinks are complete, so completion re-entered the drain: a caller's callback ran while the send lock was held, and the queue recursed one stack frame per message. what: - Promote queued sends on a drain flag instead of recursively - Subscribe and resolve caller sinks outside the send lock - Cover a synchronously completing delegate, which the paused test double could not reach --- .../mcp/SerializedTransportProvider.java | 78 +++++++++++-------- .../mcp/SerializedTransportProviderTest.java | 66 ++++++++++++++++ 2 files changed, 112 insertions(+), 32 deletions(-) diff --git a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/SerializedTransportProvider.java b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/SerializedTransportProvider.java index 239d8d9..3c4ea46 100644 --- a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/SerializedTransportProvider.java +++ b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/SerializedTransportProvider.java @@ -69,6 +69,8 @@ private static final class SerializedTransport implements McpServerTransport { private int admitted; private long admittedBytes; private boolean closed; + private boolean draining; + private boolean drainAgain; SerializedTransport(McpServerTransport delegate) { this.delegate = Objects.requireNonNull(delegate, "delegate"); @@ -92,7 +94,6 @@ public Mono sendMessage(McpSchema.JSONRPCMessage message) { private void enqueue(PendingSend added) { @Nullable Throwable refused = null; - @Nullable PendingSend next = null; synchronized (sends) { if (added.cancelled) { return; @@ -105,20 +106,16 @@ private void enqueue(PendingSend added) { admitted++; admittedBytes += added.bytes; pending.addLast(added); - if (active == null) { - next = takeNext(); - } } } if (refused != null) { added.sink.error(refused); - } else if (next != null) { - start(next); + } else { + drain(); } } private void cancel(PendingSend cancelled) { - @Nullable PendingSend next = null; synchronized (sends) { cancelled.cancelled = true; if (pending.remove(cancelled)) { @@ -126,57 +123,74 @@ private void cancel(PendingSend cancelled) { } else if (active == cancelled && !cancelled.started) { active = null; release(cancelled); - if (!pending.isEmpty()) { - next = takeNext(); - } + } else { + return; } } - if (next != null) { - start(next); - } + drain(); } - private PendingSend takeNext() { - PendingSend next = pending.removeFirst(); - active = next; - return next; - } - - private void start(PendingSend next) { - try { + /** + * Promotes queued sends one at a time. + * + *

The pinned SDK's stdio transport completes its send {@code Mono} on the subscribing thread once its + * readiness sinks are resolved, so {@link #finish} re-enters this method from inside {@link #start}. Draining + * on a flag rather than recursively keeps stack depth independent of queue depth, and keeps {@code sends} + * unheld while the delegate writes and while a caller's completion callback runs. + */ + private void drain() { + synchronized (sends) { + if (draining) { + drainAgain = true; + return; + } + draining = true; + } + while (true) { + @Nullable PendingSend next = null; + synchronized (sends) { + drainAgain = false; + if (!closed && active == null && !pending.isEmpty()) { + next = pending.removeFirst(); + active = next; + next.started = true; + } + } + if (next != null) { + start(next); + } synchronized (sends) { - if (closed || active != next) { + if (!drainAgain) { + draining = false; return; } - next.started = true; - delegate.sendMessage(next.message) - .subscribe(ignored -> {}, failure -> finish(next, failure), () -> finish(next, null)); } + } + } + + private void start(PendingSend next) { + try { + delegate.sendMessage(next.message) + .subscribe(ignored -> {}, failure -> finish(next, failure), () -> finish(next, null)); } catch (RuntimeException | Error failure) { finish(next, failure); } } private void finish(PendingSend completed, @Nullable Throwable failure) { - @Nullable PendingSend next = null; synchronized (sends) { if (active != completed) { return; } active = null; release(completed); - if (!pending.isEmpty()) { - next = takeNext(); - } } if (failure == null) { completed.sink.success(); } else { completed.sink.error(failure); } - if (next != null) { - start(next); - } + drain(); } private void release(PendingSend released) { diff --git a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/SerializedTransportProviderTest.java b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/SerializedTransportProviderTest.java index be7852d..e5a7e1a 100644 --- a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/SerializedTransportProviderTest.java +++ b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/SerializedTransportProviderTest.java @@ -2,13 +2,18 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; import io.modelcontextprotocol.json.TypeRef; import io.modelcontextprotocol.spec.McpSchema; import io.modelcontextprotocol.spec.McpServerTransport; import java.util.ArrayList; import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicReference; import org.junit.jupiter.api.Test; @@ -172,10 +177,71 @@ void closeFailsEveryAdmittedSendAndRefusesAnother() { refused.dispose(); } + @Test + void aSynchronousDelegateDoesNotHoldTheLockAcrossCompletion() throws InterruptedException { + ImmediateTransport delegate = new ImmediateTransport(); + McpServerTransport transport = SerializedTransportProvider.serialize(delegate); + CountDownLatch completing = new CountDownLatch(1); + CountDownLatch enqueued = new CountDownLatch(1); + AtomicReference failure = new AtomicReference<>(); + + Thread contender = Thread.ofPlatform().start(() -> { + try { + if (!completing.await(2, TimeUnit.SECONDS)) { + return; + } + transport.sendMessage(notification("contender")).subscribe(); + enqueued.countDown(); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + } catch (RuntimeException error) { + failure.set(error); + } + }); + + AtomicBoolean progressed = new AtomicBoolean(); + Disposable first = transport.sendMessage(notification("first")).subscribe(ignored -> {}, failure::set, () -> { + completing.countDown(); + try { + progressed.set(enqueued.await(2, TimeUnit.SECONDS)); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + } + }); + + contender.join(4000); + assertNull(failure.get()); + assertTrue(progressed.get(), "another thread could not enqueue while a completion callback was running"); + + first.dispose(); + } + private static McpSchema.JSONRPCNotification notification(String value) { return new McpSchema.JSONRPCNotification("test/notification", value); } + /** Completes on subscribe, the way the pinned SDK's stdio transport does once its sinks are ready. */ + private static final class ImmediateTransport implements McpServerTransport { + + @Override + public Mono sendMessage(McpSchema.JSONRPCMessage message) { + return Mono.empty(); + } + + @Override + public T unmarshalFrom(Object value, TypeRef type) { + throw new UnsupportedOperationException(); + } + + @Override + public Mono closeGracefully() { + return Mono.empty(); + } + + @Override + public void close() {} + } + private static final class PausingTransport implements McpServerTransport { private final List> completions = new ArrayList<>(); From c7a087625dabe746190f8de7ca193d5fc8f32771 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 6 Sep 2026 06:09:44 -0500 Subject: [PATCH 63/65] Docs(docs[changelog]): Name the native switcher why: The swap entry still opened with mcp_swap.py, the script this branch deletes, and was the only stale reference to that path left in the repository. what: - Name tools/mcp-swap and the task that builds it - Record the strict UTF-8 decoding the entry omitted --- CHANGELOG.md | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ef48159..d74008f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,14 +14,16 @@ production. ### Added -- **`mcp_swap.py` configures all eight supported agent clients.** OpenCode edits - preserve JSONC comments and trailing commas, Pi reports its adapter - prerequisite, and `antigravity` selects canonical `agy`. Multi-client use and - revert preflight and stage one transaction, preserve config symlinks, reverse - proven writes on failure, and keep `--dry-run` fully observational. - Persistent, versioned recovery records bind each backup to the exact swapped - config, path topology, and server route; drift fails closed without deleting - recovery. +- **`tools/mcp-swap` configures all eight supported agent clients.** It replaces + the retired `scripts/mcp_swap.py`, builds through + `./gradlew :tools:mcp-swap:installDist`, and decodes JSON, JSONC, and TOML + with strict UTF-8. OpenCode edits preserve JSONC comments and trailing commas, + Pi reports its adapter prerequisite, and `antigravity` selects canonical + `agy`. Multi-client use and revert preflight and stage one transaction, + preserve config symlinks, reverse proven writes on failure, and keep + `--dry-run` fully observational. Persistent, versioned recovery records bind + each backup to the exact swapped config, path topology, and server route; + drift fails closed without deleting recovery. - **`NamedServerFixture` safely owns explicitly named test servers.** It binds teardown to the reported process, socket path, and inode, then fails closed if any of that identity changes before cleanup. From f492e2092d6be2c9329a4a489b76a23dbda26f3b Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 6 Sep 2026 06:12:27 -0500 Subject: [PATCH 64/65] Build(fix[test]): Quarantine under the chosen root why: The tmux quarantine hardcoded the default socket root, so overriding libtmuxSocketRoot split a run's bare-client sockets from its named ones across two roots. what: - Derive the quarantine directory from the configured root - Check that root's length before building a path under it - Drop two comment stubs left by the move into doFirst --- .../main/kotlin/libtmux.java-library.gradle.kts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/build-logic/src/main/kotlin/libtmux.java-library.gradle.kts b/build-logic/src/main/kotlin/libtmux.java-library.gradle.kts index c782250..f73f1a2 100644 --- a/build-logic/src/main/kotlin/libtmux.java-library.gradle.kts +++ b/build-logic/src/main/kotlin/libtmux.java-library.gradle.kts @@ -100,7 +100,7 @@ tasks.withType().configureEach { // a real server and can kill it. Two environment values decide where a bare client lands: tmux // resolves its default socket under TMUX_TMPDIR when it execs, and $TMUX takes precedence over // that for a client started inside a pane — which the Gradle daemon may well have been. - // + // TMUX_TMPDIR is set per invocation in doFirst below, under the same root as the named sockets. environment.remove("TMUX") environment.remove("TMUX_PANE") @@ -115,7 +115,12 @@ tasks.withType().configureEach { val socketRoot = providers.gradleProperty("libtmuxSocketRoot").getOrElse("/tmp/libtmux-java-test") systemProperty("java.io.tmpdir", socketRoot) doFirst { - // Owner identity separates concurrent invocations; the 39-byte path leaves AF_UNIX room. + require(socketRoot.length <= 40) { + "libtmuxSocketRoot is $socketRoot, too long to leave room for a socket under it" + } + // The quarantine shares the configured root, so overriding libtmuxSocketRoot moves the + // bare-client sockets along with the named ones instead of splitting them across two roots. + // Owner identity separates concurrent invocations; 16 hex digits leave AF_UNIX room. val quarantineIdentity = listOf( rootProject.rootDir.canonicalPath, path, @@ -124,7 +129,7 @@ tasks.withType().configureEach { val quarantineDigest = MessageDigest.getInstance("SHA-256") .digest(quarantineIdentity.toByteArray(StandardCharsets.UTF_8)) val quarantineName = HexFormat.of().formatHex(quarantineDigest, 0, 8) - val tmuxTmpDir = Path.of("/tmp/libtmux-java-test", quarantineName) + val tmuxTmpDir = Path.of(socketRoot, quarantineName) if (Files.exists(tmuxTmpDir, LinkOption.NOFOLLOW_LINKS)) { require(Files.isDirectory(tmuxTmpDir, LinkOption.NOFOLLOW_LINKS)) { @@ -141,9 +146,6 @@ tasks.withType().configureEach { } Files.createDirectories(tmuxTmpDir) environment("TMUX_TMPDIR", tmuxTmpDir.toString()) - require(socketRoot.length <= 40) { - "libtmuxSocketRoot is $socketRoot, too long to leave room for a socket under it" - } File(socketRoot).mkdirs() } From 3dff75a75e35dde5bf16e39e567c68faf076f9f5 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 6 Sep 2026 06:30:21 -0500 Subject: [PATCH 65/65] Examples(feat[mcp]): Show the served tool surface why: Nothing in examples/ reached libtmux-mcp, so the one question an embedder asks first, what an agent will actually be offered, had no runnable answer. what: - Serve a tmux server over a caller-supplied transport and list tools - Record that teardown is withheld from a server the example did not start, which is why the count is 41 and not the catalog's 45 - Run it under ExamplesRunTest with the other four --- examples/build.gradle.kts | 6 ++ .../libtmux/examples/ServeTmuxOverMcp.java | 68 +++++++++++++++++++ .../libtmux/examples/ExamplesRunTest.java | 12 ++++ 3 files changed, 86 insertions(+) create mode 100644 examples/src/main/java/io/github/libtmux/examples/ServeTmuxOverMcp.java diff --git a/examples/build.gradle.kts b/examples/build.gradle.kts index 2b8249e..7d5def8 100644 --- a/examples/build.gradle.kts +++ b/examples/build.gradle.kts @@ -10,5 +10,11 @@ dependencies { implementation(project(":libtmux")) implementation(project(":libtmux-workspace")) + // Embedding libtmux-mcp means supplying the transport, which means supplying its JSON mapper. + implementation(project(":libtmux-mcp")) + implementation(libs.jackson.databind) + implementation(libs.mcp.core) + implementation(libs.mcp.json.jackson2) + testImplementation(project(":libtmux-junit5")) } diff --git a/examples/src/main/java/io/github/libtmux/examples/ServeTmuxOverMcp.java b/examples/src/main/java/io/github/libtmux/examples/ServeTmuxOverMcp.java new file mode 100644 index 0000000..18677cf --- /dev/null +++ b/examples/src/main/java/io/github/libtmux/examples/ServeTmuxOverMcp.java @@ -0,0 +1,68 @@ +package io.github.libtmux.examples; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.github.libtmux.Server; +import io.github.libtmux.ServerConfig; +import io.github.libtmux.ServerEndpoint; +import io.github.libtmux.mcp.TmuxMcpServer; +import io.modelcontextprotocol.json.jackson2.JacksonMcpJsonMapper; +import io.modelcontextprotocol.server.McpSyncServer; +import io.modelcontextprotocol.server.transport.StdioServerTransportProvider; +import io.modelcontextprotocol.spec.McpSchema; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.file.Path; +import java.util.List; + +/** + * Serves a tmux server over MCP and reports the tool surface an agent would see. + * + *

{@code
+ * java ServeTmuxOverMcp.java /tmp/libtmux-java-dev/demo/s
+ * }
+ * + *

The surface is fixed: one catalog decides which tools exist, and {@code LIBTMUX_TOOLSETS} and + * {@code LIBTMUX_TOOLS} decide which of them this process offers. Reading it back is how you check + * what a given configuration actually exposes, without attaching an agent to find out. + * + *

Expect fewer tools than the catalog holds when the socket already has a server on it: teardown + * is enabled by default only for a minimal daemon this process started, so attaching to somebody + * else's tmux does not hand an agent the tools that end it. + */ +public final class ServeTmuxOverMcp { + + private ServeTmuxOverMcp() {} + + public static void main(String[] args) { + Path socket = Path.of(args.length > 0 ? args[0] : "/tmp/libtmux-java-dev/demo/s"); + run(socket).forEach(System.out::println); + } + + /** Separated from {@code main} so the suite can run exactly what a reader runs. */ + public static List run(Path socket) { + ServerConfig config = ServerConfig.builder() + .endpoint(ServerEndpoint.socketPath(socket)) + .build(); + + try (Server server = Server.open(config)) { + // A real client speaks over this process's stdin and stdout, which TmuxMcpServer.overStdio + // wires up. Here the streams are empty and discarded: the point is the surface, not a + // conversation, and an example that wrote JSON-RPC to stdout could not also print. + StdioServerTransportProvider transport = new StdioServerTransportProvider( + new JacksonMcpJsonMapper(new ObjectMapper()), + InputStream.nullInputStream(), + OutputStream.nullOutputStream()); + + // Serving hands the transport over; closing the returned server closes it. + McpSyncServer mcp = TmuxMcpServer.serving(server, transport); + try { + return mcp.listTools().stream() + .map(McpSchema.Tool::name) + .sorted() + .toList(); + } finally { + mcp.close(); + } + } + } +} diff --git a/examples/src/test/java/io/github/libtmux/examples/ExamplesRunTest.java b/examples/src/test/java/io/github/libtmux/examples/ExamplesRunTest.java index 5602d54..f6a55b1 100644 --- a/examples/src/test/java/io/github/libtmux/examples/ExamplesRunTest.java +++ b/examples/src/test/java/io/github/libtmux/examples/ExamplesRunTest.java @@ -52,6 +52,18 @@ void watchingAPaneSeesWhatItPrints(TmuxSocketPath socket) { assertFalse(seen.isEmpty(), "attaching is what makes tmux push output, and none arrived"); } + @Test + void servingOverMcpReportsTheFixedToolSurface(TmuxSocketPath socket) { + List tools = ServeTmuxOverMcp.run(socket.path()); + + // The catalog holds 45. Teardown is enabled by default only for a daemon this process + // created, and the fixture's server already exists, so its four tools are not offered. + assertEquals(41, tools.size(), tools.toString()); + assertTrue(tools.contains("capture_pane"), tools.toString()); + assertFalse(tools.contains("kill_session"), "teardown reached a server the example did not start: " + tools); + assertEquals(tools.stream().sorted().toList(), tools, "the example reports a stable order"); + } + @Test void watchingAServerIsToldWhenAWindowAppears(TmuxSocketPath socket) { List seen = WatchWhatChanges.run(socket.path(), Duration.ofSeconds(30), event -> {});