From 2a815ee64e60190ca01befb072113f52ceaa3828 Mon Sep 17 00:00:00 2001 From: Dai Ha Date: Sun, 12 Jul 2026 20:18:18 +0200 Subject: [PATCH] CB-102: native agent.* worker spawn (env-injected, guard-checked) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../main/java/dev/ltms/bridged/Bridged.java | 7 +- .../ltms/bridged/config/BridgedConfig.java | 18 ++- .../java/dev/ltms/bridged/herdr/Agent.java | 42 ++++++ .../dev/ltms/bridged/herdr/AgentControl.java | 81 +++++++++++ .../dev/ltms/bridged/herdr/AgentStatus.java | 29 ++++ .../dev/ltms/bridged/rest/BridgedApp.java | 56 ++++++-- .../ltms/bridged/worker/WorkerService.java | 77 +++++++++++ .../herdr/AgentControlContractTest.java | 63 +++++++++ .../dev/ltms/bridged/herdr/FakeHerdr.java | 69 ++++++++++ .../dev/ltms/bridged/rest/BridgedAppTest.java | 127 ++++++++++++------ 10 files changed, 512 insertions(+), 57 deletions(-) create mode 100644 bridged/src/main/java/dev/ltms/bridged/herdr/Agent.java create mode 100644 bridged/src/main/java/dev/ltms/bridged/herdr/AgentControl.java create mode 100644 bridged/src/main/java/dev/ltms/bridged/herdr/AgentStatus.java create mode 100644 bridged/src/main/java/dev/ltms/bridged/worker/WorkerService.java create mode 100644 bridged/src/test/java/dev/ltms/bridged/herdr/AgentControlContractTest.java create mode 100644 bridged/src/test/java/dev/ltms/bridged/herdr/FakeHerdr.java diff --git a/bridged/src/main/java/dev/ltms/bridged/Bridged.java b/bridged/src/main/java/dev/ltms/bridged/Bridged.java index 75ca1a1..5276070 100644 --- a/bridged/src/main/java/dev/ltms/bridged/Bridged.java +++ b/bridged/src/main/java/dev/ltms/bridged/Bridged.java @@ -2,8 +2,10 @@ package dev.ltms.bridged; import dev.ltms.bridged.config.BridgedConfig; import dev.ltms.bridged.guard.SubscriptionGuard; +import dev.ltms.bridged.herdr.AgentControl; import dev.ltms.bridged.herdr.UnixSocketHerdrClient; import dev.ltms.bridged.rest.BridgedApp; +import dev.ltms.bridged.worker.WorkerService; import io.javalin.Javalin; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -34,7 +36,10 @@ public final class Bridged { UnixSocketHerdrClient herdr = UnixSocketHerdrClient.connect(socket, new com.fasterxml.jackson.databind.ObjectMapper()); Runtime.getRuntime().addShutdownHook(new Thread(herdr::close)); - Javalin app = new BridgedApp(herdr).build(); + AgentControl agents = new AgentControl(herdr); + WorkerService workers = new WorkerService(agents, guard, cfg.worker(), System::getenv); + + Javalin app = new BridgedApp(herdr, workers).build(); app.start(cfg.bind().host(), cfg.bind().port()); log.info("bridged listening on {}:{}, herdr socket {}", cfg.bind().host(), cfg.bind().port(), socket); diff --git a/bridged/src/main/java/dev/ltms/bridged/config/BridgedConfig.java b/bridged/src/main/java/dev/ltms/bridged/config/BridgedConfig.java index 20eea15..e63906d 100644 --- a/bridged/src/main/java/dev/ltms/bridged/config/BridgedConfig.java +++ b/bridged/src/main/java/dev/ltms/bridged/config/BridgedConfig.java @@ -37,12 +37,22 @@ public record BridgedConfig( } /** - * @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 + * @param profile ccs profile a worker is spawned under (Stage-1: {@code ltms-local}) + * @param baseUrl the off-subscription endpoint injected as {@code ANTHROPIC_BASE_URL} + * @param model model alias, injected as {@code ANTHROPIC_MODEL} (may be {@code null}) + * @param configDir {@code CLAUDE_CONFIG_DIR} so the worker inherits the profile's + * skills/MCP/hooks (may be {@code null}) + * @param tokenEnv name of the host env var holding the worker's auth token; its value + * is injected as {@code ANTHROPIC_AUTH_TOKEN} (never stored in config) + * @param argv launch command; defaults to {@code ["claude"]} */ @JsonIgnoreProperties(ignoreUnknown = true) - public record Worker(String profile, String baseUrl, String model) { + public record Worker(String profile, String baseUrl, String model, + String configDir, String tokenEnv, List argv) { + public Worker { + argv = (argv == null || argv.isEmpty()) ? List.of("claude") : List.copyOf(argv); + tokenEnv = (tokenEnv == null || tokenEnv.isBlank()) ? "BRIDGED_WORKER_TOKEN" : tokenEnv; + } } /** diff --git a/bridged/src/main/java/dev/ltms/bridged/herdr/Agent.java b/bridged/src/main/java/dev/ltms/bridged/herdr/Agent.java new file mode 100644 index 0000000..9b26b0f --- /dev/null +++ b/bridged/src/main/java/dev/ltms/bridged/herdr/Agent.java @@ -0,0 +1,42 @@ +package dev.ltms.bridged.herdr; + +import com.fasterxml.jackson.databind.JsonNode; + +/** + * A herdr-tracked agent (a Claude session, or any spawned command). Projected from the + * {@code agent} node returned by {@code agent.start}/{@code agent.get}/{@code agent.list}. + * + * @param terminalId herdr's stable handle — the {@code target} for send/read/get + * @param paneId pane handle — the argument to {@code pane.close} + * @param workspaceId owning workspace + * @param sessionId the agent's own session id (Claude's session UUID), or {@code null} + * before it has registered one (e.g. immediately after start) + * @param agentType agent kind, e.g. {@code "claude"} (the launch label for spawned probes) + * @param status current lifecycle state + */ +public record Agent( + String terminalId, + String paneId, + String workspaceId, + String sessionId, + String agentType, + AgentStatus status) { + + /** Project a herdr {@code agent} node. Tolerates the start-time shape (no session yet). */ + public static Agent from(JsonNode a) { + JsonNode session = a.get("agent_session"); + String sessionId = session != null && session.hasNonNull("value") + ? session.get("value").asText() + : null; + // start returns "name" (the launch label); list/get return "agent" (the kind). + String type = a.hasNonNull("agent") ? a.get("agent").asText() + : a.path("name").asText(null); + return new Agent( + a.path("terminal_id").asText(null), + a.path("pane_id").asText(null), + a.path("workspace_id").asText(null), + sessionId, + type, + AgentStatus.fromWire(a.path("agent_status").asText(null))); + } +} diff --git a/bridged/src/main/java/dev/ltms/bridged/herdr/AgentControl.java b/bridged/src/main/java/dev/ltms/bridged/herdr/AgentControl.java new file mode 100644 index 0000000..36bfeca --- /dev/null +++ b/bridged/src/main/java/dev/ltms/bridged/herdr/AgentControl.java @@ -0,0 +1,81 @@ +package dev.ltms.bridged.herdr; + +import com.fasterxml.jackson.databind.JsonNode; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** + * Domain layer over herdr's native {@code agent.*} namespace — the worker south side. + * Chosen in the CB-102 spike over the pane + {@code send_text} fallback because + * {@code agent.start} takes a first-class {@code env} map (clean, guard-checked + * subscription injection) and herdr tracks each worker's Claude session UUID itself. + * + *

Every method is one herdr call through the injected {@link HerdrClient}, so this + * layer is unit-testable with a fake and contract-tested against a live daemon. + */ +public final class AgentControl { + + private final HerdrClient herdr; + + public AgentControl(HerdrClient herdr) { + this.herdr = herdr; + } + + /** + * Spawn an agent. {@code env} is applied to the process environment verbatim — this + * is where a worker's {@code ANTHROPIC_BASE_URL} lives, and the ONLY place it should. + * + * @param name label/kind for herdr status detection (e.g. {@code "claude"}) + * @param argv launch command, e.g. {@code ["claude"]} + * @param env process environment additions ({@code ANTHROPIC_BASE_URL}, token, …) + */ + public Agent start(String name, List argv, Map env) { + JsonNode result = herdr.call("agent.start", Map.of( + "name", name, + "argv", argv, + "env", env)); + return Agent.from(result.get("agent")); + } + + /** Deliver {@code text} to an agent (its next prompt input). */ + public void send(String target, String text) { + herdr.call("agent.send", Map.of("target", target, "text", text)); + } + + /** + * Read an agent's terminal. + * + * @param source one of {@code visible|recent|recent_unwrapped|detection} + */ + public String read(String target, String source) { + JsonNode result = herdr.call("agent.read", Map.of("target", target, "source", source)); + return result.path("read").path("text").asText(""); + } + + /** Current agent record (status, session UUID, pane). */ + public Agent get(String target) { + return Agent.from(herdr.call("agent.get", Map.of("target", target)).get("agent")); + } + + /** Just the lifecycle status — what the status-gated injector checks before send. */ + public AgentStatus status(String target) { + return get(target).status(); + } + + /** All agents herdr tracks — the discovery surface ("what workers exist"). */ + public List list() { + JsonNode result = herdr.call("agent.list"); + List out = new ArrayList<>(); + for (JsonNode a : result.path("agents")) { + out.add(Agent.from(a)); + } + return out; + } + + /** Tear a worker down (there is no agent.stop — close its pane). */ + public void close(String paneId) { + herdr.call("pane.close", Map.of("pane_id", paneId)); + } +} diff --git a/bridged/src/main/java/dev/ltms/bridged/herdr/AgentStatus.java b/bridged/src/main/java/dev/ltms/bridged/herdr/AgentStatus.java new file mode 100644 index 0000000..36e712a --- /dev/null +++ b/bridged/src/main/java/dev/ltms/bridged/herdr/AgentStatus.java @@ -0,0 +1,29 @@ +package dev.ltms.bridged.herdr; + +/** + * A herdr agent's lifecycle state, as reported by {@code agent_status}. Drives the + * status-gated injector: a worker is safe to inject into only when {@link #IDLE} or + * {@link #BLOCKED}, never mid-turn ({@link #WORKING}). + */ +public enum AgentStatus { + IDLE, + WORKING, + BLOCKED, + UNKNOWN; + + /** Map herdr's wire string ({@code idle|working|blocked|unknown}) to the enum. */ + public static AgentStatus fromWire(String s) { + if (s == null) return UNKNOWN; + return switch (s.toLowerCase()) { + case "idle" -> IDLE; + case "working" -> WORKING; + case "blocked" -> BLOCKED; + default -> UNKNOWN; + }; + } + + /** Whether {@code bridged} may inject a message now without stepping on a live turn. */ + public boolean injectable() { + return this == IDLE || this == BLOCKED; + } +} diff --git a/bridged/src/main/java/dev/ltms/bridged/rest/BridgedApp.java b/bridged/src/main/java/dev/ltms/bridged/rest/BridgedApp.java index c5dac7c..a6f240b 100644 --- a/bridged/src/main/java/dev/ltms/bridged/rest/BridgedApp.java +++ b/bridged/src/main/java/dev/ltms/bridged/rest/BridgedApp.java @@ -1,30 +1,36 @@ package dev.ltms.bridged.rest; import com.fasterxml.jackson.databind.JsonNode; +import dev.ltms.bridged.guard.GuardException; +import dev.ltms.bridged.herdr.Agent; import dev.ltms.bridged.herdr.HerdrClient; import dev.ltms.bridged.herdr.HerdrException; +import dev.ltms.bridged.worker.WorkerService; import io.javalin.Javalin; import io.javalin.http.Context; import java.util.ArrayList; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; /** - * The REST surface — {@code bridged}'s contract, and its testability seam. Every + * 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. + *

Built from injected collaborators so tests supply fakes and run on an ephemeral + * port; {@code main} supplies the real Unix-socket client and worker service. */ public final class BridgedApp { private final HerdrClient herdr; + private final WorkerService workers; - public BridgedApp(HerdrClient herdr) { + public BridgedApp(HerdrClient herdr, WorkerService workers) { this.herdr = herdr; + this.workers = workers; } /** Wire routes onto a fresh, unstarted Javalin instance. Caller starts it. */ @@ -32,6 +38,9 @@ public final class BridgedApp { Javalin app = Javalin.create(cfg -> cfg.showJavalinBanner = false); app.get("/healthz", this::healthz); app.get("/sessions", this::sessions); + app.get("/agents", this::agents); + app.post("/workers", this::spawnWorker); + app.delete("/workers/{paneId}", this::stopWorker); return app; } @@ -52,11 +61,7 @@ public final class BridgedApp { } } - /** - * 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. - */ + /** Sessions view derived from herdr {@code workspace.list} (one workspace → one row). */ private void sessions(Context ctx) { JsonNode result = herdr.call("workspace.list"); List> out = new ArrayList<>(); @@ -70,4 +75,37 @@ public final class BridgedApp { } ctx.status(200).json(Map.of("sessions", out)); } + + /** Discovery: every agent herdr tracks, keyed by its Claude session UUID. */ + private void agents(Context ctx) { + ctx.status(200).json(Map.of("agents", workers.list().stream().map(BridgedApp::view).toList())); + } + + /** Spawn a guard-checked worker. 403 if the base_url would breach the subscription boundary. */ + private void spawnWorker(Context ctx) { + try { + Agent worker = workers.spawn(); + ctx.status(201).json(view(worker)); + } catch (GuardException e) { + ctx.status(403).json(Map.of("error", "subscription_boundary", "detail", e.getMessage())); + } + } + + /** Tear a worker down by pane id. */ + private void stopWorker(Context ctx) { + workers.stop(ctx.pathParam("paneId")); + ctx.status(204); + } + + /** Stable JSON projection of an agent (null-safe for the start-time shape). */ + private static Map view(Agent a) { + Map m = new LinkedHashMap<>(); + m.put("terminalId", a.terminalId()); + m.put("paneId", a.paneId()); + m.put("workspaceId", a.workspaceId()); + m.put("sessionId", a.sessionId()); + m.put("agentType", a.agentType()); + m.put("status", a.status().name().toLowerCase()); + return m; + } } diff --git a/bridged/src/main/java/dev/ltms/bridged/worker/WorkerService.java b/bridged/src/main/java/dev/ltms/bridged/worker/WorkerService.java new file mode 100644 index 0000000..43d53c6 --- /dev/null +++ b/bridged/src/main/java/dev/ltms/bridged/worker/WorkerService.java @@ -0,0 +1,77 @@ +package dev.ltms.bridged.worker; + +import dev.ltms.bridged.config.BridgedConfig; +import dev.ltms.bridged.guard.SubscriptionGuard; +import dev.ltms.bridged.herdr.Agent; +import dev.ltms.bridged.herdr.AgentControl; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Function; + +/** + * Spawns and lists worker sessions — the safe path from a delegation request to a + * running off-subscription Claude. + * + *

The spawn sequence encodes the subscription boundary: build the worker env with + * {@code ANTHROPIC_BASE_URL}, assert that host is on the allowlist before + * touching herdr, and only then {@code agent.start}. A worker's base_url lives in the + * env map handed to herdr and nowhere else; {@code bridged}'s own environment is never + * mutated. + */ +public final class WorkerService { + + private static final Logger log = LoggerFactory.getLogger(WorkerService.class); + + private final AgentControl agents; + private final SubscriptionGuard guard; + private final BridgedConfig.Worker cfg; + private final Function env; // host env lookup (injectable for tests) + + public WorkerService(AgentControl agents, SubscriptionGuard guard, + BridgedConfig.Worker cfg, Function env) { + this.agents = agents; + this.guard = guard; + this.cfg = cfg; + this.env = env; + } + + /** Spawn a worker for the configured profile. Guard runs before any herdr call. */ + public Agent spawn() { + String baseUrl = cfg.baseUrl(); + guard.assertWorker(baseUrl); // hard stop before we spawn anything + + Map workerEnv = new LinkedHashMap<>(); + workerEnv.put("ANTHROPIC_BASE_URL", baseUrl); + putIfPresent(workerEnv, "ANTHROPIC_MODEL", cfg.model()); + putIfPresent(workerEnv, "CLAUDE_CONFIG_DIR", cfg.configDir()); + String token = env.apply(cfg.tokenEnv()); + putIfPresent(workerEnv, "ANTHROPIC_AUTH_TOKEN", token); + + String name = "claude"; + List argv = cfg.argv(); + log.info("spawning worker profile={} base_url={} argv={}", cfg.profile(), baseUrl, argv); + Agent worker = agents.start(name, argv, workerEnv); + log.info("worker started pane={} terminal={}", worker.paneId(), worker.terminalId()); + return worker; + } + + /** All herdr-tracked agents — discovery for "what workers exist". */ + public List list() { + return agents.list(); + } + + /** Tear a worker down by pane id. */ + public void stop(String paneId) { + agents.close(paneId); + } + + private static void putIfPresent(Map m, String k, String v) { + if (v != null && !v.isBlank()) { + m.put(k, v); + } + } +} diff --git a/bridged/src/test/java/dev/ltms/bridged/herdr/AgentControlContractTest.java b/bridged/src/test/java/dev/ltms/bridged/herdr/AgentControlContractTest.java new file mode 100644 index 0000000..1e34c8d --- /dev/null +++ b/bridged/src/test/java/dev/ltms/bridged/herdr/AgentControlContractTest.java @@ -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. + * + *

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"); + } + } +} diff --git a/bridged/src/test/java/dev/ltms/bridged/herdr/FakeHerdr.java b/bridged/src/test/java/dev/ltms/bridged/herdr/FakeHerdr.java new file mode 100644 index 0000000..b0ed532 --- /dev/null +++ b/bridged/src/test/java/dev/ltms/bridged/herdr/FakeHerdr.java @@ -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 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() { + } +} diff --git a/bridged/src/test/java/dev/ltms/bridged/rest/BridgedAppTest.java b/bridged/src/test/java/dev/ltms/bridged/rest/BridgedAppTest.java index a9cc32c..25221ab 100644 --- a/bridged/src/test/java/dev/ltms/bridged/rest/BridgedAppTest.java +++ b/bridged/src/test/java/dev/ltms/bridged/rest/BridgedAppTest.java @@ -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 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 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 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 res = get(port, "/healthz"); + int port = startHealthy(); + HttpResponse 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 res = get(port, "/healthz"); + int port = start(new FakeHerdr().healthy(false), "http://gx00.gw:8000", Set.of("gx00.gw")); + HttpResponse 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 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 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 params = (Map) herdr.lastCall("agent.start").params(); + @SuppressWarnings("unchecked") + Map env = (Map) 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 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")); + } }