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