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).
This commit is contained in:
Dai Ha
2026-07-12 20:00:02 +02:00
parent b415003245
commit 93cfdb640f
18 changed files with 1035 additions and 0 deletions
+11
View File
@@ -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
+27
View File
@@ -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
+122
View File
@@ -0,0 +1,122 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>dev.ltms</groupId>
<artifactId>bridged</artifactId>
<version>0.1.0-SNAPSHOT</version>
<packaging>jar</packaging>
<name>bridged</name>
<description>claude-bridge message server: sole gateway between primary/worker Claude sessions and herdr</description>
<properties>
<maven.compiler.release>25</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<mainClass>dev.ltms.bridged.Bridged</mainClass>
<jackson.version>2.18.2</jackson.version>
<javalin.version>6.3.0</javalin.version>
<slf4j.version>2.0.16</slf4j.version>
<logback.version>1.5.15</logback.version>
<junit.version>5.11.4</junit.version>
</properties>
<dependencies>
<!-- JSON + YAML (config, herdr wire format, REST bodies) -->
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>${jackson.version}</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.dataformat</groupId>
<artifactId>jackson-dataformat-yaml</artifactId>
<version>${jackson.version}</version>
</dependency>
<!-- REST: the testability surface. MCP tools are thin adapters over these endpoints. -->
<dependency>
<groupId>io.javalin</groupId>
<artifactId>javalin</artifactId>
<version>${javalin.version}</version>
</dependency>
<!-- Logging -->
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
<version>${slf4j.version}</version>
</dependency>
<dependency>
<groupId>ch.qos.logback</groupId>
<artifactId>logback-classic</artifactId>
<version>${logback.version}</version>
</dependency>
<!-- Tests -->
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>${junit.version}</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<finalName>bridged</finalName>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.14.0</version>
</plugin>
<!-- Unit tests run by default; contract tests (live herdr) are tag-excluded. -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.5.2</version>
<configuration>
<excludedGroups>${excludedGroups}</excludedGroups>
</configuration>
</plugin>
<!-- Runnable fat jar: java -jar target/bridged.jar -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<version>3.6.0</version>
<executions>
<execution>
<phase>package</phase>
<goals><goal>shade</goal></goals>
<configuration>
<transformers>
<transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
<mainClass>${mainClass}</mainClass>
</transformer>
<transformer implementation="org.apache.maven.plugins.shade.resource.ServicesResourceTransformer"/>
</transformers>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
<profiles>
<!-- `mvn test` excludes contract tests. `mvn test -Pcontract` runs them against a live herdr. -->
<profile>
<id>default-excludes</id>
<activation><activeByDefault>true</activeByDefault></activation>
<properties><excludedGroups>contract</excludedGroups></properties>
</profile>
<profile>
<id>contract</id>
<properties><excludedGroups></excludedGroups></properties>
</profile>
</profiles>
</project>
@@ -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() {
}
}
@@ -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<String> offSubscriptionHosts) {
public Guard {
offSubscriptionHosts = offSubscriptionHosts == null ? List.of() : List.copyOf(offSubscriptionHosts);
}
public Set<String> 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);
}
}
@@ -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);
}
}
@@ -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:
*
* <ul>
* <li>A <b>worker</b> 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}).</li>
* <li>The <b>primary</b> must never carry {@code ANTHROPIC_BASE_URL}; a set value
* means its traffic would leave the subscription. That is a hard stop.</li>
* </ul>
*
* 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<String> offSubscriptionHosts;
public SubscriptionGuard(Set<String> 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<String, String> 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;
}
}
}
@@ -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).
*
* <p>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 <em>string</em> id; responses carry either a
* {@code result} object (whose {@code type} field discriminates the payload) or an
* {@code error} object.
*
* <p>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();
}
@@ -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).
*
* <p>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;
}
}
@@ -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;
}
}
@@ -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).
*
* <p><b>Connection-per-call.</b> The contract test against herdr 0.7.0 established
* that herdr serves <em>one</em> 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.
*
* <p>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() {
}
}
@@ -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.
*
* <p>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<Map<String, Object>> 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));
}
}
+15
View File
@@ -0,0 +1,15 @@
<configuration>
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{HH:mm:ss.SSS} %-5level [%thread] %logger{28} - %msg%n</pattern>
</encoder>
</appender>
<logger name="dev.ltms.bridged" level="DEBUG"/>
<logger name="io.javalin" level="INFO"/>
<logger name="org.eclipse.jetty" level="WARN"/>
<root level="INFO">
<appender-ref ref="STDOUT"/>
</root>
</configuration>
@@ -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));
}
}
@@ -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", "")));
}
}
@@ -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"));
}
}
@@ -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());
}
}
}
@@ -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<String> 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<String> 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<String> 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<String> 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());
}
}