From f37b613c92b7b26b58e52d9f80a53f8c7bf5347e Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Mon, 24 Aug 2026 21:34:47 -0500 Subject: [PATCH] Examples(feat[arena]): Accept an owned server why: Let the workspace example run against an arena-owned tmux endpoint without changing ordinary command-line use. what: - Validate the opt-in arena contract and pin its binary and socket - Emit verified JSON evidence after the existing workspace setup - Cover rejection, endpoint identity, escaping, and daemon ownership --- examples/build.gradle.kts | 1 + .../libtmux/examples/BuildAWorkspace.java | 129 +++++++++++++++-- .../libtmux/examples/ExamplesRunTest.java | 134 ++++++++++++++++++ 3 files changed, 252 insertions(+), 12 deletions(-) diff --git a/examples/build.gradle.kts b/examples/build.gradle.kts index 2b8249e..953fd2c 100644 --- a/examples/build.gradle.kts +++ b/examples/build.gradle.kts @@ -11,4 +11,5 @@ dependencies { implementation(project(":libtmux-workspace")) testImplementation(project(":libtmux-junit5")) + testImplementation(libs.jackson.databind) } diff --git a/examples/src/main/java/io/github/libtmux/examples/BuildAWorkspace.java b/examples/src/main/java/io/github/libtmux/examples/BuildAWorkspace.java index be9bb86..4fac9b6 100644 --- a/examples/src/main/java/io/github/libtmux/examples/BuildAWorkspace.java +++ b/examples/src/main/java/io/github/libtmux/examples/BuildAWorkspace.java @@ -8,6 +8,8 @@ import io.github.libtmux.Session; import io.github.libtmux.Window; import java.nio.file.Path; +import java.util.Map; +import java.util.Optional; /** * Lays out a session the way you would set one up by hand before starting work. @@ -18,12 +20,61 @@ */ public final class BuildAWorkspace { + private static final String ARENA_ARTIFACT = "java-build-a-workspace"; + private BuildAWorkspace() {} public static void main(String[] args) { + Optional arena = arenaConfig(System.getenv()); + if (arena.isPresent()) { + System.out.println("LIBTMUX_ARENA_EVIDENCE=" + runArena(arena.orElseThrow())); + return; + } run(Path.of(args.length > 0 ? args[0] : "/tmp/libtmux-java-dev/demo/s")); } + static Optional arenaConfig(Map environment) { + String descriptor = environment.get("LIBTMUX_ARENA_DESCRIPTOR"); + if (descriptor == null || descriptor.isEmpty()) { + return Optional.empty(); + } + + String artifact = required(environment, "LIBTMUX_ARENA_ARTIFACT"); + if (!ARENA_ARTIFACT.equals(artifact)) { + throw new IllegalArgumentException("LIBTMUX_ARENA_ARTIFACT does not select this example"); + } + return Optional.of(ServerConfig.builder() + .binary(required(environment, "LIBTMUX_TMUX_BIN")) + .endpoint(ServerEndpoint.socketPath(Path.of(required(environment, "LIBTMUX_SOCKET_PATH")))) + .build()); + } + + static String runArena(Map environment) { + return runArena(arenaConfig(environment) + .orElseThrow(() -> new IllegalStateException("LIBTMUX_ARENA_DESCRIPTOR is not set"))); + } + + private static String runArena(ServerConfig config) { + if (!(config.endpoint() instanceof ServerEndpoint.SocketPath socket)) { + throw new IllegalStateException("arena requires a socket path"); + } + try (Server server = Server.open(config)) { + run(server); + String challenge = server.globalOptions() + .get("@libtmux_arena_challenge") + .filter(value -> !value.isEmpty()) + .orElseThrow(() -> new IllegalStateException("arena challenge is missing")); + long serverPid = Long.parseLong(server.expand("#{pid}")); + String actualSocket = server.expand("#{socket_path}"); + if (!socket.path().toString().equals(actualSocket)) { + throw new IllegalStateException("arena server socket does not match the requested socket"); + } + return "{\"artifact\":\"" + ARENA_ARTIFACT + "\",\"challenge\":" + jsonString(challenge) + + ",\"schema\":1,\"server_pid\":" + serverPid + ",\"socket_path\":" + + jsonString(actualSocket) + "}"; + } + } + /** Separated from {@code main} so the suite can run exactly what a reader runs. */ public static String run(Path socket) { ServerConfig config = ServerConfig.builder() @@ -33,21 +84,75 @@ public static String run(Path socket) { // Closing a server closes this client. The tmux server, and the session, outlive the program // — which is the whole point of tmux and the reason nothing here kills it. try (Server server = Server.open(config)) { - Session session = server.hasSession("work") - ? server.sessions().stream() - .filter(candidate -> candidate.name().equals("work")) - .findFirst() - .orElseThrow() - : server.newSession("work"); + return run(server); + } + } + + static String run(Server server) { + Session session = server.hasSession("work") + ? server.sessions().stream() + .filter(candidate -> candidate.name().equals("work")) + .findFirst() + .orElseThrow() + : server.newSession("work"); + + Window editor = session.newWindow(window -> window.named("editor").detached()); + Pane shell = editor.split(split -> split.toRight()); + shell.sendLine("git status --short"); - Window editor = session.newWindow(window -> window.named("editor").detached()); - Pane shell = editor.split(split -> split.toRight()); - shell.sendLine("git status --short"); + editor.selectLayout(Layout.MAIN_VERTICAL); - editor.selectLayout(Layout.MAIN_VERTICAL); + return "session " + session.name() + " has " + + session.refresh().windows().size() + " windows"; + } + + private static String required(Map environment, String name) { + String value = environment.get(name); + if (value == null || value.isEmpty()) { + throw new IllegalArgumentException(name + " is required for arena mode"); + } + return value; + } - return "session " + session.name() + " has " - + session.refresh().windows().size() + " windows"; + static String jsonString(String value) { + StringBuilder json = new StringBuilder(value.length() + 2).append('"'); + for (int index = 0; index < value.length(); index++) { + char character = value.charAt(index); + if (Character.isHighSurrogate(character)) { + if (index + 1 == value.length() || !Character.isLowSurrogate(value.charAt(index + 1))) { + throw new IllegalArgumentException("JSON evidence cannot contain an unpaired surrogate"); + } + json.appendCodePoint(value.codePointAt(index)); + index++; + continue; + } + if (Character.isLowSurrogate(character)) { + throw new IllegalArgumentException("JSON evidence cannot contain an unpaired surrogate"); + } + switch (character) { + case '"' -> json.append("\\\""); + case '\\' -> json.append("\\\\"); + case '\b' -> json.append("\\b"); + case '\f' -> json.append("\\f"); + case '\n' -> json.append("\\n"); + case '\r' -> json.append("\\r"); + case '\t' -> json.append("\\t"); + default -> { + if (character < ' ') { + appendUnicodeEscape(json, character); + } else { + json.append(character); + } + } + } } + return json.append('"').toString(); + } + + private static void appendUnicodeEscape(StringBuilder json, char character) { + String hex = Integer.toHexString(character); + json.append("\\u"); + json.append("0000", 0, 4 - hex.length()); + json.append(hex); } } 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..e74baa4 100644 --- a/examples/src/test/java/io/github/libtmux/examples/ExamplesRunTest.java +++ b/examples/src/test/java/io/github/libtmux/examples/ExamplesRunTest.java @@ -2,16 +2,24 @@ 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 com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; import io.github.libtmux.Pane; import io.github.libtmux.Server; +import io.github.libtmux.ServerConfig; import io.github.libtmux.control.ControlEvent; import io.github.libtmux.control.PaneOutput; import io.github.libtmux.junit5.TmuxExtension; import io.github.libtmux.junit5.TmuxSocketPath; +import java.nio.file.Files; +import java.nio.file.Path; import java.time.Duration; import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -25,6 +33,9 @@ @ExtendWith(TmuxExtension.class) final class ExamplesRunTest { + private static final String ARENA_EVIDENCE_PREFIX = "LIBTMUX_ARENA_EVIDENCE="; + private static final ObjectMapper JSON = new ObjectMapper(); + @Test void buildingAWorkspaceLeavesOneBehind(Server server, TmuxSocketPath socket) { String reported = BuildAWorkspace.run(socket.path()); @@ -33,6 +44,102 @@ void buildingAWorkspaceLeavesOneBehind(Server server, TmuxSocketPath socket) { assertTrue(server.hasSession("work"), "the example is supposed to leave a session running"); } + @Test + void arenaAliasesAndAnEmptyDescriptorKeepTheSocketArgumentPath(Server server, TmuxSocketPath socket) { + Map aliases = Map.of( + "LIBTMUX_SOCKET_PATH", socket.path().toString(), + "LIBTMUX_TMUX_BIN", server.config().binary()); + + assertTrue(BuildAWorkspace.arenaConfig(aliases).isEmpty()); + assertTrue(BuildAWorkspace.arenaConfig(with(aliases, "LIBTMUX_ARENA_DESCRIPTOR", "")) + .isEmpty()); + assertTrue(BuildAWorkspace.run(socket.path()).startsWith("session work has ")); + } + + @Test + void arenaRejectsActivatedIncompleteAndMismatchedContracts() { + assertThrows( + IllegalArgumentException.class, + () -> BuildAWorkspace.arenaConfig(Map.of("LIBTMUX_ARENA_DESCRIPTOR", "arena"))); + assertThrows( + IllegalArgumentException.class, + () -> BuildAWorkspace.arenaConfig(Map.of( + "LIBTMUX_ARENA_DESCRIPTOR", "arena", + "LIBTMUX_ARENA_ARTIFACT", "java-build-a-workspace", + "LIBTMUX_SOCKET_PATH", "", + "LIBTMUX_TMUX_BIN", "not-a-tmux-binary"))); + assertThrows( + IllegalArgumentException.class, + () -> BuildAWorkspace.arenaConfig(Map.of( + "LIBTMUX_ARENA_DESCRIPTOR", "arena", + "LIBTMUX_ARENA_ARTIFACT", "other-artifact", + "LIBTMUX_SOCKET_PATH", "/tmp/no-server", + "LIBTMUX_TMUX_BIN", "not-a-tmux-binary"))); + } + + @Test + void arenaConfigPinsTheRequestedBinaryAndSocket(TmuxSocketPath socket) { + ServerConfig config = BuildAWorkspace.arenaConfig(Map.of( + "LIBTMUX_ARENA_DESCRIPTOR", "arena", + "LIBTMUX_ARENA_ARTIFACT", "java-build-a-workspace", + "LIBTMUX_SOCKET_PATH", socket.path().toString(), + "LIBTMUX_TMUX_BIN", "requested-tmux")) + .orElseThrow(); + + assertEquals("requested-tmux", config.binary()); + assertEquals( + List.of("-S", socket.path().toAbsolutePath().normalize().toString()), + config.endpoint().flags()); + } + + @Test + void arenaMainEmitsOneValidatedEvidenceRecordWithoutStoppingExternalServer(Server server, TmuxSocketPath socket) + throws Exception { + String challenge = "quote\" slash\\"; + server.globalOptions().set("@libtmux_arena_challenge", challenge); + Path output = socket.path().resolveSibling("arena-main-output"); + ProcessBuilder builder = new ProcessBuilder( + Path.of(System.getProperty("java.home"), "bin", "java").toString(), + "-classpath", + System.getProperty("java.class.path"), + BuildAWorkspace.class.getName()); + builder.redirectErrorStream(true); + builder.redirectOutput(output.toFile()); + builder.environment().remove("TMUX"); + builder.environment().remove("TMUX_PANE"); + builder.environment().putAll(arenaEnvironment(server, socket)); + + Process process = builder.start(); + boolean finished = process.waitFor(30, TimeUnit.SECONDS); + if (!finished) { + process.destroyForcibly(); + process.waitFor(30, TimeUnit.SECONDS); + } + List lines = Files.readAllLines(output); + + assertTrue(finished, () -> "the arena main did not finish; it said " + lines); + assertEquals(0, process.exitValue(), () -> "the arena main failed; it said " + lines); + List records = lines.stream() + .filter(line -> line.startsWith(ARENA_EVIDENCE_PREFIX)) + .toList(); + assertEquals(1, records.size(), () -> "expected one arena evidence record; main said " + lines); + JsonNode evidence = JSON.readTree(records.getFirst().substring(ARENA_EVIDENCE_PREFIX.length())); + + assertEquals(arenaEvidence(challenge, server, socket), evidence); + assertTrue(server.hasSession("work"), "closing the arena client must not stop its daemon"); + } + + @Test + void arenaJsonRejectsUnpairedSurrogatesAndPreservesUnicode() throws Exception { + String controls = "\u0000\b\f\n\r\t\u001f\"\\"; + String supplementary = "\ud83d\ude03"; + + assertEquals(controls, JSON.readValue(BuildAWorkspace.jsonString(controls), String.class)); + assertEquals(supplementary, JSON.readValue(BuildAWorkspace.jsonString(supplementary), String.class)); + assertThrows(IllegalArgumentException.class, () -> BuildAWorkspace.jsonString("\ud800")); + assertThrows(IllegalArgumentException.class, () -> BuildAWorkspace.jsonString("\udc00")); + } + @Test void findingPanesSelectsOnWhatIsRunning(Server server, TmuxSocketPath socket) { // The fixture's pane runs a shell, so the shell's own name is the one thing certain to match. @@ -60,4 +167,31 @@ void watchingAServerIsToldWhenAWindowAppears(TmuxSocketPath socket) { WatchWhatChanges.sawTheNewWindow(seen), "tmux compares a watched format itself and reports the difference: " + seen); } + + private static Map arenaEnvironment(Server server, TmuxSocketPath socket) { + return Map.of( + "LIBTMUX_ARENA_DESCRIPTOR", + "arena", + "LIBTMUX_ARENA_ARTIFACT", + "java-build-a-workspace", + "LIBTMUX_SOCKET_PATH", + socket.path().toString(), + "LIBTMUX_TMUX_BIN", + server.config().binary()); + } + + private static Map with(Map values, String name, String value) { + var copy = new java.util.HashMap<>(values); + copy.put(name, value); + return copy; + } + + private static JsonNode arenaEvidence(String challenge, Server server, TmuxSocketPath socket) { + return JSON.createObjectNode() + .put("artifact", "java-build-a-workspace") + .put("challenge", challenge) + .put("schema", 1) + .put("server_pid", Integer.parseInt(server.expand("#{pid}"))) + .put("socket_path", socket.path().toAbsolutePath().normalize().toString()); + } }