CB-102: native agent.* worker spawn (env-injected, guard-checked)

Spike decided the worker south side in favour of herdr's native agent.*
namespace over pane+send_text. Proven against live herdr 0.7.0:
agent.start takes a first-class env map that reaches the process
environment (ANTHROPIC_BASE_URL confirmed via agent.read), and herdr
tracks each worker's Claude session UUID itself.

- AgentControl: start/send/read/get/status/list + pane.close over agent.*.
- Agent/AgentStatus: projection of herdr agent records (session UUID,
  injectable status gate).
- WorkerService: build worker env (base_url/token/model/config dir),
  assertWorker BEFORE any herdr call, then agent.start.
- REST: GET /agents (discovery by session UUID), POST /workers (201, or
  403 subscription_boundary), DELETE /workers/{paneId}.
- Full stack smoke-tested live: POST->guard->agent.start->new pane,
  GET /agents lists it, DELETE closes it.
- Tests: 21 unit/acceptance + 4 contract (incl. a live end-to-end probe
  spawn that proves env injection and cleans up its pane).

No agent.stop in herdr (use pane.close); herdr id must be a string;
one request per connection — all pinned by contract tests.
This commit is contained in:
Dai Ha
2026-07-12 20:18:18 +02:00
parent 1d558a3692
commit 2a815ee64e
10 changed files with 512 additions and 57 deletions
@@ -0,0 +1,63 @@
package dev.ltms.bridged.herdr;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import java.nio.file.Files;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
import static org.junit.jupiter.api.Assumptions.assumeTrue;
/**
* Contract test for the {@code agent.*} south side against a REAL herdr, locking in
* the CB-102 spike findings. It spawns a HARMLESS probe command (never {@code claude},
* so no subscription/token involvement), proves the {@code env} map reaches the process
* environment, exercises status/read, and always tears the pane down.
*
* <p>Tagged {@code contract}; run with {@code mvn test -Pcontract}.
*/
@Tag("contract")
class AgentControlContractTest {
private boolean noSocket() {
return !Files.exists(UnixSocketHerdrClient.defaultSocketPath());
}
@Test
void startInjectsEnvThenReadAndClose() throws Exception {
assumeTrue(!noSocket(), "no herdr socket — skipping");
try (UnixSocketHerdrClient herdr = UnixSocketHerdrClient.connect()) {
AgentControl agents = new AgentControl(herdr);
Agent probe = agents.start(
"__contract__",
List.of("bash", "-c", "printf 'PROBE_BASE=[%s]\\n' \"$ANTHROPIC_BASE_URL\"; sleep 20"),
Map.of("ANTHROPIC_BASE_URL", "http://gx00.gw:8000"));
assertNotNull(probe.terminalId());
assertNotNull(probe.paneId());
try {
// Give the shell a moment to print, then confirm env reached the process.
Thread.sleep(800);
String visible = agents.read(probe.terminalId(), "visible");
assertTrue(visible.contains("PROBE_BASE=[http://gx00.gw:8000]"),
"env map must reach the process; saw: " + visible);
// Status is queryable; the probe appears in the agent list.
assertNotNull(agents.status(probe.terminalId()));
assertTrue(agents.list().stream()
.anyMatch(a -> probe.terminalId().equals(a.terminalId())),
"spawned probe should appear in agent.list");
} finally {
agents.close(probe.paneId());
}
// After close the pane is gone.
assertFalse(agents.list().stream()
.anyMatch(a -> probe.terminalId().equals(a.terminalId())),
"closed probe should no longer be listed");
}
}
}
@@ -0,0 +1,69 @@
package dev.ltms.bridged.herdr;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.util.ArrayList;
import java.util.List;
/**
* Recording fake {@link HerdrClient} for unit/acceptance tests. Returns canned frames
* captured from the real herdr 0.7.0 daemon and records every call so tests can assert
* both behaviour and that guard-blocked paths never reached herdr.
*/
public final class FakeHerdr implements HerdrClient {
public record Call(String method, Object params) {
}
private final ObjectMapper mapper = new ObjectMapper();
public final List<Call> calls = new ArrayList<>();
private boolean healthy = true;
public FakeHerdr healthy(boolean h) {
this.healthy = h;
return this;
}
public boolean called(String method) {
return calls.stream().anyMatch(c -> c.method().equals(method));
}
public Call lastCall(String method) {
return calls.stream().filter(c -> c.method().equals(method))
.reduce((a, b) -> b).orElseThrow();
}
@Override
public JsonNode call(String method, Object params) {
calls.add(new Call(method, 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"}]}""");
case "agent.list" -> mapper.readTree("""
{"type":"agent_list","agents":[
{"terminal_id":"term_a","agent":"claude","agent_status":"idle",
"agent_session":{"kind":"id","value":"sess-1111"},
"workspace_id":"w2","tab_id":"w2:t7","pane_id":"w2:p7"}]}""");
case "agent.start" -> mapper.readTree("""
{"type":"agent_started","agent":{
"terminal_id":"term_new","name":"claude","agent_status":"unknown",
"workspace_id":"w2","tab_id":"w2:t9","pane_id":"w2:pZ"}}""");
case "pane.close" -> mapper.readTree("{\"type\":\"ok\"}");
default -> throw new HerdrException("fake has no canned response for " + method);
};
} catch (Exception e) {
throw new HerdrException("fake decode failed for " + method, e);
}
}
@Override
public void close() {
}
}
@@ -2,8 +2,11 @@ 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 dev.ltms.bridged.config.BridgedConfig;
import dev.ltms.bridged.guard.SubscriptionGuard;
import dev.ltms.bridged.herdr.AgentControl;
import dev.ltms.bridged.herdr.FakeHerdr;
import dev.ltms.bridged.worker.WorkerService;
import io.javalin.Javalin;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
@@ -12,13 +15,16 @@ import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.List;
import java.util.Map;
import java.util.Set;
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.
* REST acceptance tests — the feature contract over plain HTTP with a fake herdr, no
* live daemon and no Claude in the loop. This is the surface later MCP tools match by
* parity, and where the subscription boundary is proven at the API edge.
*/
class BridgedAppTest {
@@ -31,45 +37,34 @@ class BridgedAppTest {
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);
private int start(FakeHerdr herdr, String workerBaseUrl, Set<String> allow) {
BridgedConfig.Worker wcfg = new BridgedConfig.Worker(
"ltms-local", workerBaseUrl, "coder", null, "BRIDGED_WORKER_TOKEN", null);
WorkerService workers = new WorkerService(
new AgentControl(herdr), new SubscriptionGuard(allow), wcfg,
k -> "BRIDGED_WORKER_TOKEN".equals(k) ? "tok-abc" : null);
app = new BridgedApp(herdr, workers).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());
private int startHealthy() {
return start(new FakeHerdr(), "http://gx00.gw:8000", Set.of("gx00.gw"));
}
private HttpResponse<String> req(int port, String method, String path) throws Exception {
HttpRequest.Builder b = HttpRequest.newBuilder(URI.create("http://127.0.0.1:" + port + path));
b = switch (method) {
case "POST" -> b.POST(HttpRequest.BodyPublishers.noBody());
case "DELETE" -> b.DELETE();
default -> b.GET();
};
return http.send(b.build(), HttpResponse.BodyHandlers.ofString());
}
@Test
void healthzOkWhenHerdrAnswers() throws Exception {
int port = startWith(fakeHerdr(true));
HttpResponse<String> res = get(port, "/healthz");
int port = startHealthy();
HttpResponse<String> res = req(port, "GET", "/healthz");
assertEquals(200, res.statusCode());
JsonNode body = mapper.readTree(res.body());
assertEquals("ok", body.get("status").asText());
@@ -78,20 +73,66 @@ class BridgedAppTest {
@Test
void healthzDegradedWhenHerdrDown() throws Exception {
int port = startWith(fakeHerdr(false));
HttpResponse<String> res = get(port, "/healthz");
int port = start(new FakeHerdr().healthy(false), "http://gx00.gw:8000", Set.of("gx00.gw"));
HttpResponse<String> res = req(port, "GET", "/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");
int port = startHealthy();
JsonNode sessions = mapper.readTree(req(port, "GET", "/sessions").body()).get("sessions");
assertEquals(2, sessions.size());
assertEquals("w1", sessions.get(0).get("id").asText());
assertEquals("done", sessions.get(1).get("agentStatus").asText());
}
@Test
void agentsExposesSessionUuid() throws Exception {
int port = startHealthy();
JsonNode agents = mapper.readTree(req(port, "GET", "/agents").body()).get("agents");
assertEquals(1, agents.size());
assertEquals("sess-1111", agents.get(0).get("sessionId").asText());
assertEquals("idle", agents.get(0).get("status").asText());
}
@Test
void spawnWorkerInjectsBaseUrlAndReturns201() throws Exception {
FakeHerdr herdr = new FakeHerdr();
int port = start(herdr, "http://gx00.gw:8000", Set.of("gx00.gw"));
HttpResponse<String> res = req(port, "POST", "/workers");
assertEquals(201, res.statusCode());
assertEquals("w2:pZ", mapper.readTree(res.body()).get("paneId").asText());
// The proof: agent.start carried ANTHROPIC_BASE_URL in its env map.
@SuppressWarnings("unchecked")
Map<String, Object> params = (Map<String, Object>) herdr.lastCall("agent.start").params();
@SuppressWarnings("unchecked")
Map<String, String> env = (Map<String, String>) params.get("env");
assertEquals("http://gx00.gw:8000", env.get("ANTHROPIC_BASE_URL"));
assertEquals("tok-abc", env.get("ANTHROPIC_AUTH_TOKEN"));
assertEquals(List.of("claude"), params.get("argv"));
}
@Test
void spawnWorkerRejectsOffAllowlistBaseUrlAndNeverTouchesHerdr() throws Exception {
FakeHerdr herdr = new FakeHerdr();
// base_url points at the subscription — guard must block before any herdr call.
int port = start(herdr, "https://api.anthropic.com", Set.of("gx00.gw"));
HttpResponse<String> res = req(port, "POST", "/workers");
assertEquals(403, res.statusCode());
assertEquals("subscription_boundary", mapper.readTree(res.body()).get("error").asText());
assertFalse(herdr.called("agent.start"), "guard must stop the spawn before herdr");
}
@Test
void stopWorkerClosesPane() throws Exception {
FakeHerdr herdr = new FakeHerdr();
int port = start(herdr, "http://gx00.gw:8000", Set.of("gx00.gw"));
assertEquals(204, req(port, "DELETE", "/workers/w2:pZ").statusCode());
assertTrue(herdr.called("pane.close"));
}
}