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:
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user