From 93cfdb640f3ac7f4a0800cdefe83513daf090653 Mon Sep 17 00:00:00 2001 From: Dai Ha Date: Sun, 12 Jul 2026 20:00:02 +0200 Subject: [PATCH] Stage-1 walking skeleton: bridged Java daemon (herdr client + REST + guard) Maven/Java 25 module under bridged/. End-to-end verified against live herdr 0.7.0 (protocol 14): GET /healthz and GET /sessions serve real workspace data through the socket client. - herdr client (CB-101): Unix-socket JSON-RPC via UnixDomainSocketAddress. Two contract facts pinned by tests against the real daemon: ids MUST be strings, and herdr is one-shot per connection (connection-per-call, which also makes the client lock-free). - subscription guard: worker base_url must be on the off-subscription allowlist (gx00.gw, ollama.ltms.dev); primary env must carry no base_url. - config (CB-106): Jackson YAML + Logback; example grounded in ltms-local. - REST app (CB-104 start): injectable HerdrClient so acceptance tests run on an ephemeral port with a fake herdr, no daemon/Claude in the loop. - tests: 17 unit/acceptance (mvn test) + 3 contract (mvn test -Pcontract). --- bridged/.gitignore | 11 ++ bridged/bridged.example.yaml | 27 ++++ bridged/pom.xml | 122 ++++++++++++++++++ .../main/java/dev/ltms/bridged/Bridged.java | 45 +++++++ .../ltms/bridged/config/BridgedConfig.java | 83 ++++++++++++ .../ltms/bridged/guard/GuardException.java | 8 ++ .../ltms/bridged/guard/SubscriptionGuard.java | 64 +++++++++ .../dev/ltms/bridged/herdr/HerdrClient.java | 38 ++++++ .../dev/ltms/bridged/herdr/HerdrCodec.java | 70 ++++++++++ .../ltms/bridged/herdr/HerdrException.java | 30 +++++ .../bridged/herdr/UnixSocketHerdrClient.java | 122 ++++++++++++++++++ .../dev/ltms/bridged/rest/BridgedApp.java | 73 +++++++++++ bridged/src/main/resources/logback.xml | 15 +++ .../bridged/config/BridgedConfigTest.java | 55 ++++++++ .../bridged/guard/SubscriptionGuardTest.java | 52 ++++++++ .../ltms/bridged/herdr/HerdrCodecTest.java | 63 +++++++++ .../ltms/bridged/herdr/HerdrContractTest.java | 60 +++++++++ .../dev/ltms/bridged/rest/BridgedAppTest.java | 97 ++++++++++++++ 18 files changed, 1035 insertions(+) create mode 100644 bridged/.gitignore create mode 100644 bridged/bridged.example.yaml create mode 100644 bridged/pom.xml create mode 100644 bridged/src/main/java/dev/ltms/bridged/Bridged.java create mode 100644 bridged/src/main/java/dev/ltms/bridged/config/BridgedConfig.java create mode 100644 bridged/src/main/java/dev/ltms/bridged/guard/GuardException.java create mode 100644 bridged/src/main/java/dev/ltms/bridged/guard/SubscriptionGuard.java create mode 100644 bridged/src/main/java/dev/ltms/bridged/herdr/HerdrClient.java create mode 100644 bridged/src/main/java/dev/ltms/bridged/herdr/HerdrCodec.java create mode 100644 bridged/src/main/java/dev/ltms/bridged/herdr/HerdrException.java create mode 100644 bridged/src/main/java/dev/ltms/bridged/herdr/UnixSocketHerdrClient.java create mode 100644 bridged/src/main/java/dev/ltms/bridged/rest/BridgedApp.java create mode 100644 bridged/src/main/resources/logback.xml create mode 100644 bridged/src/test/java/dev/ltms/bridged/config/BridgedConfigTest.java create mode 100644 bridged/src/test/java/dev/ltms/bridged/guard/SubscriptionGuardTest.java create mode 100644 bridged/src/test/java/dev/ltms/bridged/herdr/HerdrCodecTest.java create mode 100644 bridged/src/test/java/dev/ltms/bridged/herdr/HerdrContractTest.java create mode 100644 bridged/src/test/java/dev/ltms/bridged/rest/BridgedAppTest.java diff --git a/bridged/.gitignore b/bridged/.gitignore new file mode 100644 index 0000000..34f02b9 --- /dev/null +++ b/bridged/.gitignore @@ -0,0 +1,11 @@ +# Build output +target/ +dependency-reduced-pom.xml + +# Local runtime config (copy from bridged.example.yaml) +bridged.yaml + +# Editor / OS +*.iml +.idea/ +.DS_Store diff --git a/bridged/bridged.example.yaml b/bridged/bridged.example.yaml new file mode 100644 index 0000000..187e084 --- /dev/null +++ b/bridged/bridged.example.yaml @@ -0,0 +1,27 @@ +# bridged configuration (example). Copy to bridged.yaml and adjust. +# +# bridged is the sole gateway between primary/worker Claude sessions and herdr. +# It is NOT a Claude process and must never carry ANTHROPIC_BASE_URL. + +# REST + MCP listen address. Keep it on loopback — bridged is same-host in Stage-1. +bind: + host: 127.0.0.1 + port: 8080 + +# herdr Unix socket. Omit to use the client default +# (${HERDR_SOCKET_PATH:-~/.config/herdr/herdr.sock}). +herdrSocket: ~/.config/herdr/herdr.sock + +# How a worker session is spawned. Stage-1 uses the existing ccs `ltms-local` +# profile, whose .claude.json routes to the gx00 vLLM below. +worker: + profile: ltms-local + baseUrl: http://gx00.gw:8000 # the gx00 vLLM (models: coder / deepseek-v4-flash) + model: coder + +# Subscription boundary. A worker's base_url host MUST be one of these; the primary +# must carry none. Grounded in ltms-local's real endpoints. +guard: + offSubscriptionHosts: + - gx00.gw + - ollama.ltms.dev diff --git a/bridged/pom.xml b/bridged/pom.xml new file mode 100644 index 0000000..7ac7529 --- /dev/null +++ b/bridged/pom.xml @@ -0,0 +1,122 @@ + + + 4.0.0 + + dev.ltms + bridged + 0.1.0-SNAPSHOT + jar + + bridged + claude-bridge message server: sole gateway between primary/worker Claude sessions and herdr + + + 25 + UTF-8 + dev.ltms.bridged.Bridged + + 2.18.2 + 6.3.0 + 2.0.16 + 1.5.15 + 5.11.4 + + + + + + com.fasterxml.jackson.core + jackson-databind + ${jackson.version} + + + com.fasterxml.jackson.dataformat + jackson-dataformat-yaml + ${jackson.version} + + + + + io.javalin + javalin + ${javalin.version} + + + + + org.slf4j + slf4j-api + ${slf4j.version} + + + ch.qos.logback + logback-classic + ${logback.version} + + + + + org.junit.jupiter + junit-jupiter + ${junit.version} + test + + + + + bridged + + + org.apache.maven.plugins + maven-compiler-plugin + 3.14.0 + + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.5.2 + + ${excludedGroups} + + + + + + org.apache.maven.plugins + maven-shade-plugin + 3.6.0 + + + package + shade + + + + ${mainClass} + + + + + + + + + + + + + + default-excludes + true + contract + + + contract + + + + diff --git a/bridged/src/main/java/dev/ltms/bridged/Bridged.java b/bridged/src/main/java/dev/ltms/bridged/Bridged.java new file mode 100644 index 0000000..75ca1a1 --- /dev/null +++ b/bridged/src/main/java/dev/ltms/bridged/Bridged.java @@ -0,0 +1,45 @@ +package dev.ltms.bridged; + +import dev.ltms.bridged.config.BridgedConfig; +import dev.ltms.bridged.guard.SubscriptionGuard; +import dev.ltms.bridged.herdr.UnixSocketHerdrClient; +import dev.ltms.bridged.rest.BridgedApp; +import io.javalin.Javalin; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.nio.file.Path; + +/** + * {@code bridged} entry point. Wires the real herdr socket client to the REST app and + * starts listening. Before anything else it asserts its own environment is clean — + * {@code bridged} is not a Claude process and must never carry a base_url. + */ +public final class Bridged { + + private static final Logger log = LoggerFactory.getLogger(Bridged.class); + + public static void main(String[] args) { + Path configPath = Path.of(args.length > 0 ? args[0] : "bridged.yaml"); + BridgedConfig cfg = BridgedConfig.load(configPath); + + // The primary/host env that launched bridged must not be tainted. + SubscriptionGuard guard = new SubscriptionGuard(cfg.guard().hostSet()); + guard.assertPrimaryClean(System.getenv()); + + Path socket = cfg.herdrSocket() != null && !cfg.herdrSocket().isBlank() + ? Path.of(cfg.herdrSocket()) + : UnixSocketHerdrClient.defaultSocketPath(); + + UnixSocketHerdrClient herdr = UnixSocketHerdrClient.connect(socket, new com.fasterxml.jackson.databind.ObjectMapper()); + Runtime.getRuntime().addShutdownHook(new Thread(herdr::close)); + + Javalin app = new BridgedApp(herdr).build(); + app.start(cfg.bind().host(), cfg.bind().port()); + log.info("bridged listening on {}:{}, herdr socket {}", + cfg.bind().host(), cfg.bind().port(), socket); + } + + private Bridged() { + } +} diff --git a/bridged/src/main/java/dev/ltms/bridged/config/BridgedConfig.java b/bridged/src/main/java/dev/ltms/bridged/config/BridgedConfig.java new file mode 100644 index 0000000..20eea15 --- /dev/null +++ b/bridged/src/main/java/dev/ltms/bridged/config/BridgedConfig.java @@ -0,0 +1,83 @@ +package dev.ltms.bridged.config; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Set; + +/** + * {@code bridged} configuration, loaded from a YAML file (see + * {@code bridged.example.yaml}). Unknown keys are ignored so config can grow ahead + * of the code. + * + * @param bind REST/MCP listen host:port + * @param herdrSocket path to herdr's Unix socket ({@code null} → client default) + * @param worker worker-spawn settings + * @param guard subscription-boundary allowlist + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public record BridgedConfig( + Bind bind, + String herdrSocket, + Worker worker, + Guard guard) { + + @JsonIgnoreProperties(ignoreUnknown = true) + public record Bind(String host, int port) { + public Bind { + if (host == null || host.isBlank()) host = "127.0.0.1"; + if (port <= 0) port = 8080; + } + } + + /** + * @param profile ccs profile a worker is spawned under (Stage-1: {@code ltms-local}) + * @param baseUrl the off-subscription endpoint the worker's launch line sets + * @param model model alias to request from that endpoint + */ + @JsonIgnoreProperties(ignoreUnknown = true) + public record Worker(String profile, String baseUrl, String model) { + } + + /** + * Subscription boundary. Only these hosts may back a worker's + * {@code ANTHROPIC_BASE_URL}; the primary must carry none. + * + * @param offSubscriptionHosts hostnames allowed for worker base_urls + */ + @JsonIgnoreProperties(ignoreUnknown = true) + public record Guard(List offSubscriptionHosts) { + public Guard { + offSubscriptionHosts = offSubscriptionHosts == null ? List.of() : List.copyOf(offSubscriptionHosts); + } + + public Set hostSet() { + return Set.copyOf(offSubscriptionHosts); + } + } + + private static final ObjectMapper YAML = new ObjectMapper(new YAMLFactory()); + + /** Load and validate config from {@code path}. */ + public static BridgedConfig load(Path path) { + try { + BridgedConfig cfg = YAML.readValue(Files.readString(path), BridgedConfig.class); + return cfg.withDefaults(); + } catch (IOException e) { + throw new UncheckedIOException("cannot read bridged config at " + path, e); + } + } + + /** Fill in nested defaults so callers never see nulls for structural fields. */ + public BridgedConfig withDefaults() { + Bind b = bind != null ? bind : new Bind(null, 0); + Guard g = guard != null ? guard : new Guard(List.of()); + return new BridgedConfig(b, herdrSocket, worker, g); + } +} diff --git a/bridged/src/main/java/dev/ltms/bridged/guard/GuardException.java b/bridged/src/main/java/dev/ltms/bridged/guard/GuardException.java new file mode 100644 index 0000000..b3cbd57 --- /dev/null +++ b/bridged/src/main/java/dev/ltms/bridged/guard/GuardException.java @@ -0,0 +1,8 @@ +package dev.ltms.bridged.guard; + +/** Thrown when the subscription boundary would be violated. Never swallow this. */ +public class GuardException extends RuntimeException { + public GuardException(String message) { + super(message); + } +} diff --git a/bridged/src/main/java/dev/ltms/bridged/guard/SubscriptionGuard.java b/bridged/src/main/java/dev/ltms/bridged/guard/SubscriptionGuard.java new file mode 100644 index 0000000..64fd4a7 --- /dev/null +++ b/bridged/src/main/java/dev/ltms/bridged/guard/SubscriptionGuard.java @@ -0,0 +1,64 @@ +package dev.ltms.bridged.guard; + +import java.net.URI; +import java.net.URISyntaxException; +import java.util.Map; +import java.util.Set; + +/** + * Enforces the non-negotiable subscription boundary: + * + *
    + *
  • A worker must egress to an off-subscription host — its + * {@code ANTHROPIC_BASE_URL} host must be on the allowlist + * (Stage-1: {@code gx00.gw}, {@code ollama.ltms.dev}).
  • + *
  • The primary must never carry {@code ANTHROPIC_BASE_URL}; a set value + * means its traffic would leave the subscription. That is a hard stop.
  • + *
+ * + * Both checks throw {@link GuardException} on violation. {@code bridged} calls + * {@link #assertWorker} before spawning a worker and {@link #assertPrimaryClean} + * against its own environment at startup. + */ +public final class SubscriptionGuard { + + private final Set offSubscriptionHosts; + + public SubscriptionGuard(Set offSubscriptionHosts) { + this.offSubscriptionHosts = Set.copyOf(offSubscriptionHosts); + } + + /** The worker's base_url must resolve to an allowlisted off-subscription host. */ + public void assertWorker(String baseUrl) { + if (baseUrl == null || baseUrl.isBlank()) { + throw new GuardException("worker has no ANTHROPIC_BASE_URL — refusing to spawn " + + "a session that would bill the subscription"); + } + String host = hostOf(baseUrl); + if (host == null) { + throw new GuardException("worker ANTHROPIC_BASE_URL is not a valid URL: " + baseUrl); + } + if (!offSubscriptionHosts.contains(host)) { + throw new GuardException("worker base_url host '" + host + "' is not on the " + + "off-subscription allowlist " + offSubscriptionHosts + + " — refusing to spawn"); + } + } + + /** The primary's environment must not contain {@code ANTHROPIC_BASE_URL}. */ + public void assertPrimaryClean(Map env) { + String v = env.get("ANTHROPIC_BASE_URL"); + if (v != null && !v.isBlank()) { + throw new GuardException("primary environment is tainted: ANTHROPIC_BASE_URL=" + + v + " — the primary must run on the subscription, never a base_url"); + } + } + + private static String hostOf(String baseUrl) { + try { + return new URI(baseUrl).getHost(); + } catch (URISyntaxException e) { + return null; + } + } +} diff --git a/bridged/src/main/java/dev/ltms/bridged/herdr/HerdrClient.java b/bridged/src/main/java/dev/ltms/bridged/herdr/HerdrClient.java new file mode 100644 index 0000000..fb215da --- /dev/null +++ b/bridged/src/main/java/dev/ltms/bridged/herdr/HerdrClient.java @@ -0,0 +1,38 @@ +package dev.ltms.bridged.herdr; + +import com.fasterxml.jackson.databind.JsonNode; + +/** + * Client face onto the herdr daemon (protocol 14, herdr 0.7.0). + * + *

This is the ONLY thing in {@code bridged} that speaks to herdr. Every method + * maps to a herdr JSON-RPC call over its Unix domain socket. Requests are + * newline-delimited JSON with a string id; responses carry either a + * {@code result} object (whose {@code type} field discriminates the payload) or an + * {@code error} object. + * + *

Higher layers ({@code bridged}'s policy brain, REST endpoints, MCP adapters) + * depend on this interface, not on the socket. Tests substitute a fake; the + * {@code contract}-tagged suite exercises the real implementation against a running + * herdr to catch protocol drift. + */ +public interface HerdrClient extends AutoCloseable { + + /** + * Invoke a herdr method and return its {@code result} node. + * + * @param method herdr method name, e.g. {@code "ping"}, {@code "workspace.list"} + * @param params params object (may be {@code null} → sent as {@code {}}); serialized by Jackson + * @return the {@code result} node of the response + * @throws HerdrException on transport failure or an {@code error} envelope + */ + JsonNode call(String method, Object params) throws HerdrException; + + /** Convenience for parameterless calls. */ + default JsonNode call(String method) throws HerdrException { + return call(method, null); + } + + @Override + void close(); +} diff --git a/bridged/src/main/java/dev/ltms/bridged/herdr/HerdrCodec.java b/bridged/src/main/java/dev/ltms/bridged/herdr/HerdrCodec.java new file mode 100644 index 0000000..f44c816 --- /dev/null +++ b/bridged/src/main/java/dev/ltms/bridged/herdr/HerdrCodec.java @@ -0,0 +1,70 @@ +package dev.ltms.bridged.herdr; + +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.nio.charset.StandardCharsets; + +/** + * Wire codec for herdr's newline-delimited JSON-RPC (protocol 14). + * + *

Split out from the socket so the framing rules — the ones that actually bit us + * during the spike (id MUST be a string; response carries {@code result} or + * {@code error}, never {@code jsonrpc}) — are unit-testable without a live daemon. + */ +final class HerdrCodec { + + private final ObjectMapper mapper; + + HerdrCodec(ObjectMapper mapper) { + this.mapper = mapper; + } + + /** Build one request frame: a single JSON object terminated by {@code '\n'}. */ + byte[] encode(String id, String method, Object params) { + ObjectNode req = mapper.createObjectNode(); + req.put("jsonrpc", "2.0"); + req.put("id", id); // string id — herdr rejects integer ids with invalid_request + req.put("method", method); + JsonNode p = params == null ? mapper.createObjectNode() : mapper.valueToTree(params); + req.set("params", p); + try { + String line = mapper.writeValueAsString(req) + "\n"; + return line.getBytes(StandardCharsets.UTF_8); + } catch (JsonProcessingException e) { + throw new HerdrException("failed to encode herdr request for method " + method, e); + } + } + + /** + * Parse one response frame and return its {@code result} node. + * + * @throws HerdrException if the frame is an {@code error} envelope or is malformed + */ + JsonNode decodeResult(String line) { + JsonNode root; + try { + root = mapper.readTree(line); + } catch (JsonProcessingException e) { + throw new HerdrException("malformed herdr response: " + trim(line), e); + } + JsonNode error = root.get("error"); + if (error != null && !error.isNull()) { + String code = error.path("code").asText(null); + String message = error.path("message").asText("unknown herdr error"); + throw new HerdrException("herdr error [" + code + "]: " + message, code, null); + } + JsonNode result = root.get("result"); + if (result == null || result.isNull()) { + throw new HerdrException("herdr response has neither result nor error: " + trim(line)); + } + return result; + } + + private static String trim(String s) { + String t = s.strip(); + return t.length() > 200 ? t.substring(0, 200) + "…" : t; + } +} diff --git a/bridged/src/main/java/dev/ltms/bridged/herdr/HerdrException.java b/bridged/src/main/java/dev/ltms/bridged/herdr/HerdrException.java new file mode 100644 index 0000000..25540a1 --- /dev/null +++ b/bridged/src/main/java/dev/ltms/bridged/herdr/HerdrException.java @@ -0,0 +1,30 @@ +package dev.ltms.bridged.herdr; + +/** + * Raised when a herdr call fails: transport error, or an {@code error} envelope + * returned by the daemon. {@link #code()} carries herdr's error code + * (e.g. {@code invalid_request}) when the failure came back as a protocol error, + * or {@code null} for transport-level failures. + */ +public class HerdrException extends RuntimeException { + + private final String code; + + public HerdrException(String message) { + this(message, null, null); + } + + public HerdrException(String message, Throwable cause) { + this(message, null, cause); + } + + public HerdrException(String message, String code, Throwable cause) { + super(message, cause); + this.code = code; + } + + /** herdr protocol error code, or {@code null} if this was a transport failure. */ + public String code() { + return code; + } +} diff --git a/bridged/src/main/java/dev/ltms/bridged/herdr/UnixSocketHerdrClient.java b/bridged/src/main/java/dev/ltms/bridged/herdr/UnixSocketHerdrClient.java new file mode 100644 index 0000000..b2045d0 --- /dev/null +++ b/bridged/src/main/java/dev/ltms/bridged/herdr/UnixSocketHerdrClient.java @@ -0,0 +1,122 @@ +package dev.ltms.bridged.herdr; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.net.StandardProtocolFamily; +import java.net.UnixDomainSocketAddress; +import java.nio.ByteBuffer; +import java.nio.channels.SocketChannel; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.util.concurrent.atomic.AtomicLong; + +/** + * {@link HerdrClient} over herdr's Unix domain socket using the JDK's + * {@link UnixDomainSocketAddress} + {@link SocketChannel} (no native/JNI dependency). + * + *

Connection-per-call. The contract test against herdr 0.7.0 established + * that herdr serves one request/response per connection and then closes it — + * a second write on the same socket gets a broken pipe. So every {@link #call} opens a + * fresh connection, writes one frame, reads one line, and closes. A pleasant + * consequence: with no shared socket there is no shared read state, so the client is + * safe to call concurrently from many virtual threads with no locking. + * + *

Streaming methods ({@code events.subscribe}) keep their own long-lived connection + * and land in a later ticket; they do not reuse this request/response path. + */ +public final class UnixSocketHerdrClient implements HerdrClient { + + private static final Logger log = LoggerFactory.getLogger(UnixSocketHerdrClient.class); + + /** herdr's default socket, overridable by {@code HERDR_SOCKET_PATH}. */ + public static Path defaultSocketPath() { + String override = System.getenv("HERDR_SOCKET_PATH"); + if (override != null && !override.isBlank()) { + return Path.of(override); + } + return Path.of(System.getProperty("user.home"), ".config", "herdr", "herdr.sock"); + } + + private final Path socketPath; + private final HerdrCodec codec; + private final AtomicLong ids = new AtomicLong(1); + + private UnixSocketHerdrClient(Path socketPath, ObjectMapper mapper) { + this.socketPath = socketPath; + this.codec = new HerdrCodec(mapper); + } + + /** Client for the default socket with a fresh {@link ObjectMapper}. */ + public static UnixSocketHerdrClient connect() { + return connect(defaultSocketPath(), new ObjectMapper()); + } + + /** + * Client for {@code socketPath}. Does not hold a connection open (herdr is + * one-shot per connection); connectivity surfaces on the first {@link #call}. + */ + public static UnixSocketHerdrClient connect(Path socketPath, ObjectMapper mapper) { + log.debug("herdr client bound to socket {}", socketPath); + return new UnixSocketHerdrClient(socketPath, mapper); + } + + @Override + public JsonNode call(String method, Object params) { + String id = Long.toString(ids.getAndIncrement()); + byte[] frame = codec.encode(id, method, params); + try (SocketChannel ch = SocketChannel.open(StandardProtocolFamily.UNIX)) { + ch.connect(UnixDomainSocketAddress.of(socketPath)); + writeFully(ch, ByteBuffer.wrap(frame)); + return codec.decodeResult(readLine(ch)); + } catch (IOException e) { + throw new HerdrException("herdr call '" + method + "' failed at transport (socket " + + socketPath + ", is herdr running?)", e); + } + } + + private static void writeFully(SocketChannel ch, ByteBuffer buf) throws IOException { + while (buf.hasRemaining()) { + ch.write(buf); + } + } + + /** Read up to and including the first {@code '\n'}, returning the line without it. */ + private static String readLine(SocketChannel ch) throws IOException { + ByteBuffer readBuf = ByteBuffer.allocate(64 * 1024); + StringBuilder sb = new StringBuilder(); + while (true) { + int nl = indexOfNewline(sb); + if (nl >= 0) { + return sb.substring(0, nl); + } + readBuf.clear(); + int n = ch.read(readBuf); + if (n == -1) { + if (sb.length() > 0) { + return sb.toString(); // herdr closed after a complete, unterminated frame + } + throw new IOException("herdr closed the connection with no response"); + } + readBuf.flip(); + sb.append(StandardCharsets.UTF_8.decode(readBuf)); + } + } + + private static int indexOfNewline(CharSequence s) { + for (int i = 0; i < s.length(); i++) { + if (s.charAt(i) == '\n') { + return i; + } + } + return -1; + } + + /** No persistent resources to release; present for the {@link AutoCloseable} contract. */ + @Override + public void close() { + } +} diff --git a/bridged/src/main/java/dev/ltms/bridged/rest/BridgedApp.java b/bridged/src/main/java/dev/ltms/bridged/rest/BridgedApp.java new file mode 100644 index 0000000..c5dac7c --- /dev/null +++ b/bridged/src/main/java/dev/ltms/bridged/rest/BridgedApp.java @@ -0,0 +1,73 @@ +package dev.ltms.bridged.rest; + +import com.fasterxml.jackson.databind.JsonNode; +import dev.ltms.bridged.herdr.HerdrClient; +import dev.ltms.bridged.herdr.HerdrException; +import io.javalin.Javalin; +import io.javalin.http.Context; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** + * The REST surface — {@code bridged}'s contract, and its testability seam. Every + * feature is reachable here without Claude or MCP in the loop, so each is an + * acceptance test against plain HTTP. MCP tools (later) are thin adapters over these + * same endpoints and are validated by parity, not by re-implementing behaviour. + * + *

Built from an injected {@link HerdrClient} so tests can supply a fake and run on + * an ephemeral port; {@code main} supplies the real Unix-socket client. + */ +public final class BridgedApp { + + private final HerdrClient herdr; + + public BridgedApp(HerdrClient herdr) { + this.herdr = herdr; + } + + /** Wire routes onto a fresh, unstarted Javalin instance. Caller starts it. */ + public Javalin build() { + Javalin app = Javalin.create(cfg -> cfg.showJavalinBanner = false); + app.get("/healthz", this::healthz); + app.get("/sessions", this::sessions); + return app; + } + + /** Liveness + herdr reachability. 200 when herdr answers ping, 503 otherwise. */ + private void healthz(Context ctx) { + try { + JsonNode pong = herdr.call("ping"); + ctx.status(200).json(Map.of( + "status", "ok", + "herdr", Map.of( + "version", pong.path("version").asText(""), + "protocol", pong.path("protocol").asInt()))); + } catch (HerdrException e) { + ctx.status(503).json(Map.of( + "status", "degraded", + "herdr", "unreachable", + "detail", e.getMessage())); + } + } + + /** + * Sessions view, derived from herdr {@code workspace.list}. Stage-1 maps one + * workspace → one session summary; later tickets enrich this with the primary/ + * worker role and the subscription-guard verdict per pane. + */ + private void sessions(Context ctx) { + JsonNode result = herdr.call("workspace.list"); + List> out = new ArrayList<>(); + for (JsonNode w : result.path("workspaces")) { + out.add(Map.of( + "id", w.path("workspace_id").asText(""), + "label", w.path("label").asText(""), + "focused", w.path("focused").asBoolean(false), + "paneCount", w.path("pane_count").asInt(), + "agentStatus", w.path("agent_status").asText("unknown"))); + } + ctx.status(200).json(Map.of("sessions", out)); + } +} diff --git a/bridged/src/main/resources/logback.xml b/bridged/src/main/resources/logback.xml new file mode 100644 index 0000000..bd2ae5d --- /dev/null +++ b/bridged/src/main/resources/logback.xml @@ -0,0 +1,15 @@ + + + + %d{HH:mm:ss.SSS} %-5level [%thread] %logger{28} - %msg%n + + + + + + + + + + + diff --git a/bridged/src/test/java/dev/ltms/bridged/config/BridgedConfigTest.java b/bridged/src/test/java/dev/ltms/bridged/config/BridgedConfigTest.java new file mode 100644 index 0000000..ce1644e --- /dev/null +++ b/bridged/src/test/java/dev/ltms/bridged/config/BridgedConfigTest.java @@ -0,0 +1,55 @@ +package dev.ltms.bridged.config; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.*; + +class BridgedConfigTest { + + @Test + void loadsFullConfig(@TempDir Path dir) throws Exception { + Path f = dir.resolve("bridged.yaml"); + Files.writeString(f, """ + bind: + host: 127.0.0.1 + port: 8080 + herdrSocket: ~/.config/herdr/herdr.sock + worker: + profile: ltms-local + baseUrl: http://gx00.gw:8000 + model: coder + guard: + offSubscriptionHosts: + - gx00.gw + - ollama.ltms.dev + """); + + BridgedConfig cfg = BridgedConfig.load(f); + assertEquals(8080, cfg.bind().port()); + assertEquals("ltms-local", cfg.worker().profile()); + assertTrue(cfg.guard().hostSet().contains("gx00.gw")); + assertTrue(cfg.guard().hostSet().contains("ollama.ltms.dev")); + } + + @Test + void appliesDefaultsForMissingSections(@TempDir Path dir) throws Exception { + Path f = dir.resolve("minimal.yaml"); + Files.writeString(f, "bind:\n host: 0.0.0.0\n port: 9000\n"); + + BridgedConfig cfg = BridgedConfig.load(f); + assertEquals(9000, cfg.bind().port()); + assertNotNull(cfg.guard(), "guard must default to empty, never null"); + assertTrue(cfg.guard().offSubscriptionHosts().isEmpty()); + } + + @Test + void ignoresUnknownKeys(@TempDir Path dir) throws Exception { + Path f = dir.resolve("future.yaml"); + Files.writeString(f, "bind:\n port: 8080\nfutureFeature:\n enabled: true\n"); + assertDoesNotThrow(() -> BridgedConfig.load(f)); + } +} diff --git a/bridged/src/test/java/dev/ltms/bridged/guard/SubscriptionGuardTest.java b/bridged/src/test/java/dev/ltms/bridged/guard/SubscriptionGuardTest.java new file mode 100644 index 0000000..ca58f52 --- /dev/null +++ b/bridged/src/test/java/dev/ltms/bridged/guard/SubscriptionGuardTest.java @@ -0,0 +1,52 @@ +package dev.ltms.bridged.guard; + +import org.junit.jupiter.api.Test; + +import java.util.Map; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; + +/** The subscription boundary is the system's core invariant — test it hard. */ +class SubscriptionGuardTest { + + private final SubscriptionGuard guard = + new SubscriptionGuard(Set.of("gx00.gw", "ollama.ltms.dev")); + + @Test + void acceptsAllowlistedWorkerHosts() { + assertDoesNotThrow(() -> guard.assertWorker("http://gx00.gw:8000")); + assertDoesNotThrow(() -> guard.assertWorker("https://ollama.ltms.dev")); + } + + @Test + void rejectsHostNotOnAllowlist() { + GuardException ex = assertThrows(GuardException.class, + () -> guard.assertWorker("https://api.anthropic.com")); + assertTrue(ex.getMessage().contains("api.anthropic.com")); + } + + @Test + void rejectsMissingWorkerBaseUrl() { + assertThrows(GuardException.class, () -> guard.assertWorker(null)); + assertThrows(GuardException.class, () -> guard.assertWorker(" ")); + } + + @Test + void rejectsMalformedWorkerBaseUrl() { + assertThrows(GuardException.class, () -> guard.assertWorker("not a url")); + } + + @Test + void primaryWithBaseUrlIsTainted() { + GuardException ex = assertThrows(GuardException.class, + () -> guard.assertPrimaryClean(Map.of("ANTHROPIC_BASE_URL", "http://gx00.gw:8000"))); + assertTrue(ex.getMessage().contains("tainted")); + } + + @Test + void cleanPrimaryPasses() { + assertDoesNotThrow(() -> guard.assertPrimaryClean(Map.of("PATH", "/usr/bin"))); + assertDoesNotThrow(() -> guard.assertPrimaryClean(Map.of("ANTHROPIC_BASE_URL", ""))); + } +} diff --git a/bridged/src/test/java/dev/ltms/bridged/herdr/HerdrCodecTest.java b/bridged/src/test/java/dev/ltms/bridged/herdr/HerdrCodecTest.java new file mode 100644 index 0000000..b680c20 --- /dev/null +++ b/bridged/src/test/java/dev/ltms/bridged/herdr/HerdrCodecTest.java @@ -0,0 +1,63 @@ +package dev.ltms.bridged.herdr; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +/** Unit tests for the herdr wire codec — the framing rules the spike pinned down. */ +class HerdrCodecTest { + + private final ObjectMapper mapper = new ObjectMapper(); + private final HerdrCodec codec = new HerdrCodec(mapper); + + @Test + void encodesStringIdAndTrailingNewline() throws Exception { + byte[] frame = codec.encode("7", "ping", null); + String s = new String(frame, StandardCharsets.UTF_8); + assertTrue(s.endsWith("\n"), "frame must be newline-terminated"); + + JsonNode node = mapper.readTree(s); + // id MUST be a JSON string — herdr rejects integer ids with invalid_request. + assertTrue(node.get("id").isTextual()); + assertEquals("7", node.get("id").asText()); + assertEquals("ping", node.get("method").asText()); + assertTrue(node.get("params").isObject(), "null params serialize to {}"); + } + + @Test + void encodesParamsObject() throws Exception { + byte[] frame = codec.encode("1", "pane.list", Map.of("workspace_id", "w2")); + JsonNode node = mapper.readTree(new String(frame, StandardCharsets.UTF_8)); + assertEquals("w2", node.get("params").get("workspace_id").asText()); + } + + @Test + void decodesResultNode() { + String line = "{\"id\":\"1\",\"result\":{\"type\":\"pong\",\"protocol\":14}}"; + JsonNode result = codec.decodeResult(line); + assertEquals(14, result.get("protocol").asInt()); + assertEquals("pong", result.get("type").asText()); + } + + @Test + void throwsOnErrorEnvelope() { + // Exactly the shape herdr returned for an integer id during the spike. + String line = "{\"id\":\"\",\"error\":{\"code\":\"invalid_request\"," + + "\"message\":\"invalid request: invalid type: integer\"}}"; + HerdrException ex = assertThrows(HerdrException.class, () -> codec.decodeResult(line)); + assertEquals("invalid_request", ex.code()); + assertTrue(ex.getMessage().contains("invalid_request")); + } + + @Test + void throwsOnFrameWithNeitherResultNorError() { + HerdrException ex = assertThrows(HerdrException.class, + () -> codec.decodeResult("{\"id\":\"1\"}")); + assertTrue(ex.getMessage().contains("neither result nor error")); + } +} diff --git a/bridged/src/test/java/dev/ltms/bridged/herdr/HerdrContractTest.java b/bridged/src/test/java/dev/ltms/bridged/herdr/HerdrContractTest.java new file mode 100644 index 0000000..fcc7141 --- /dev/null +++ b/bridged/src/test/java/dev/ltms/bridged/herdr/HerdrContractTest.java @@ -0,0 +1,60 @@ +package dev.ltms.bridged.herdr; + +import com.fasterxml.jackson.databind.JsonNode; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +/** + * Contract test against a REAL running herdr. Tagged {@code contract} so it is + * excluded from {@code mvn test}; run it with {@code mvn test -Pcontract}. It fails + * loudly if herdr drifts from the protocol {@code bridged} was built against + * (0.7.0, protocol 14) — catching breakage that unit tests with canned frames cannot. + */ +@Tag("contract") +class HerdrContractTest { + + private Path socket() { + return UnixSocketHerdrClient.defaultSocketPath(); + } + + @Test + void pingReturnsProtocol14() { + assumeTrue(Files.exists(socket()), "no herdr socket at " + socket() + " — skipping"); + try (UnixSocketHerdrClient herdr = UnixSocketHerdrClient.connect()) { + JsonNode pong = herdr.call("ping"); + assertEquals("pong", pong.get("type").asText()); + assertEquals(14, pong.get("protocol").asInt(), + "bridged is built against herdr protocol 14"); + assertFalse(pong.get("version").asText().isBlank()); + } + } + + @Test + void workspaceListReturnsWorkspaces() { + assumeTrue(Files.exists(socket()), "no herdr socket — skipping"); + try (UnixSocketHerdrClient herdr = UnixSocketHerdrClient.connect()) { + JsonNode result = herdr.call("workspace.list"); + assertEquals("workspace_list", result.get("type").asText()); + assertTrue(result.get("workspaces").isArray()); + } + } + + @Test + void multipleSequentialCallsSucceed() { + // herdr is one-shot per connection: a persistent socket breaks on the 2nd call. + // The connection-per-call client must let many sequential calls through — this + // is the exact scenario that failed the first end-to-end smoke test. + assumeTrue(Files.exists(socket()), "no herdr socket — skipping"); + try (UnixSocketHerdrClient herdr = UnixSocketHerdrClient.connect()) { + assertEquals("pong", herdr.call("ping").get("type").asText()); + assertEquals("workspace_list", herdr.call("workspace.list").get("type").asText()); + assertEquals("pong", herdr.call("ping").get("type").asText()); + } + } +} diff --git a/bridged/src/test/java/dev/ltms/bridged/rest/BridgedAppTest.java b/bridged/src/test/java/dev/ltms/bridged/rest/BridgedAppTest.java new file mode 100644 index 0000000..a9cc32c --- /dev/null +++ b/bridged/src/test/java/dev/ltms/bridged/rest/BridgedAppTest.java @@ -0,0 +1,97 @@ +package dev.ltms.bridged.rest; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import dev.ltms.bridged.herdr.HerdrClient; +import dev.ltms.bridged.herdr.HerdrException; +import io.javalin.Javalin; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * REST acceptance tests — the feature contract exercised over plain HTTP with a fake + * herdr, no live daemon and no Claude in the loop. This is the surface later MCP + * tools must match by parity. + */ +class BridgedAppTest { + + private final ObjectMapper mapper = new ObjectMapper(); + private final HttpClient http = HttpClient.newHttpClient(); + private Javalin app; + + @AfterEach + void stop() { + if (app != null) app.stop(); + } + + /** Fake herdr returning canned frames captured from the real 0.7.0 daemon. */ + private HerdrClient fakeHerdr(boolean healthy) { + return new HerdrClient() { + @Override + public JsonNode call(String method, Object params) { + if (!healthy) throw new HerdrException("herdr unreachable (fake)"); + try { + return switch (method) { + case "ping" -> mapper.readTree( + "{\"type\":\"pong\",\"version\":\"0.7.0\",\"protocol\":14}"); + case "workspace.list" -> mapper.readTree(""" + {"type":"workspace_list","workspaces":[ + {"workspace_id":"w1","label":"dev-mgnl","focused":true,"pane_count":7,"agent_status":"unknown"}, + {"workspace_id":"w2","label":"ltms","focused":false,"pane_count":5,"agent_status":"done"}]}"""); + default -> throw new HerdrException("unexpected method " + method); + }; + } catch (Exception e) { + throw new HerdrException("fake decode failed", e); + } + } + @Override public void close() { } + }; + } + + private int startWith(HerdrClient herdr) { + app = new BridgedApp(herdr).build().start("127.0.0.1", 0); + return app.port(); + } + + private HttpResponse get(int port, String path) throws Exception { + return http.send( + HttpRequest.newBuilder(URI.create("http://127.0.0.1:" + port + path)).GET().build(), + HttpResponse.BodyHandlers.ofString()); + } + + @Test + void healthzOkWhenHerdrAnswers() throws Exception { + int port = startWith(fakeHerdr(true)); + HttpResponse res = get(port, "/healthz"); + assertEquals(200, res.statusCode()); + JsonNode body = mapper.readTree(res.body()); + assertEquals("ok", body.get("status").asText()); + assertEquals(14, body.get("herdr").get("protocol").asInt()); + } + + @Test + void healthzDegradedWhenHerdrDown() throws Exception { + int port = startWith(fakeHerdr(false)); + HttpResponse res = get(port, "/healthz"); + assertEquals(503, res.statusCode()); + assertEquals("degraded", mapper.readTree(res.body()).get("status").asText()); + } + + @Test + void sessionsMapsWorkspaceList() throws Exception { + int port = startWith(fakeHerdr(true)); + HttpResponse res = get(port, "/sessions"); + assertEquals(200, res.statusCode()); + JsonNode sessions = mapper.readTree(res.body()).get("sessions"); + assertEquals(2, sessions.size()); + assertEquals("w1", sessions.get(0).get("id").asText()); + assertEquals("done", sessions.get(1).get("agentStatus").asText()); + } +}