CB-5xx: Stage 5 hardening — auth, authz+audit, metrics, CI, supervision

Closes out single-host before the cross-host work. Sequenced BEFORE CB-308
deliberately: federation's own gating concern is the trust model, and it
inherits whatever identity shape lands here.

The finding this stage is built around: bridged had exactly ONE security
control, the loopback bind. ConnectionIdentity resolves a worker from its
connection (unforgeable), but every caller that was not a recognised worker
pane fell through to being treated as the PRIMARY -- the most privileged role
on the bus. Latent today; load-bearing the moment a bind widens.

CB-501 auth:
- Role/Principal/CallerResolver: connection identity first, bearer token
  second, ANONYMOUS third. Inverts the old default so absence of identity
  means nothing, not everything.
- Worker identity is never token-gated, so enabling auth cannot lock the
  fleet out of bridge_reply.
- Constant-time token compare (MessageDigest.isEqual).
- validateAuthExposure(): a non-loopback bind under loopback-trust now
  REFUSES TO START. Makes the dangerous config unrepresentable rather than
  merely documented.
- TLS terminates at a reverse proxy by design (D3), not in the JVM.

CB-505 authz + audit, enforced on BOTH entry paths:
- The docs describe MCP as "a thin adapter over the REST core"; at code level
  it is not. BridgeMcp calls MessageService directly, and /mcp is a raw
  servlet on Jetty's context handler that never traverses Javalin's before
  filter. Enforcing only at REST would have left /mcp open.
- Load-bearing rule is own-session-only: a worker may reply/ask only as
  itself. Structurally true over MCP already; over REST the session id in the
  URL path had simply been trusted.
- Audit: JSON lines to a dedicated appender, additivity=false. Never records
  message content -- this bus carries source and prompts.

CB-502 metrics: zero new dependencies. A ~150-line Prometheus text renderer
instead of the specced Micrometer, because this pom already hand-pins
jackson-annotations to reconcile Jackson 2/3, imports a Jetty BOM against
skew, and carries four accepted-CVE advisories -- and CLAUDE.md's mandated
dependency CVE gate could not be run (no JetBrains MCP server connected).
Instrumented at MessageService, the single funnel both surfaces share.

CB-503 CI: .gitea/workflows/ci.yml against the already-running Gitea runner.
Needs no contract-exclusion flag -- the pom's default-excludes profile
already sets excludedGroups=contract, so plain `mvn clean install` IS the
mock-socket surface. Provisions JDK 25 explicitly (runner default-jdk is older).

CB-504 supervision: launchd agent (the real target -- this host is macOS,
there is no systemd) plus a systemd unit for the Linux gateways CB-308 adds.
Ordering directives are advisory, so the actual fix is that startup now waits
up to 30s for the herdr socket and then serves degraded, instead of crashing
into a restart loop on a boot-order race.

Also fixes drift found while surveying:
- bridged.example.yaml documented spawn_ready_timeout_ms in snake_case; config
  binds via plain Jackson with ignoreUnknown, so uncommenting it would have
  been silently dropped and the default kept. Now camelCase, with a test that
  loads the shipped example and one that pins every documented knob's
  spelling -- no test had ever loaded that file.
- Added the 6 shipped-but-undocumented knobs (worktreeRoot, parityOverlay,
  gitTokenEnv, gitHostEnv, configDir, primary:).
- README "Next" listed bridge_ask and session lifecycle as upcoming; both
  shipped long ago.
- docs/CB-301-ext and docs/CB-402 status headers said "design"/"pre-
  implementation" for work already merged.

307 unit/acceptance tests green (was 266), mvn clean install BUILD SUCCESS.
Note: CLAUDE.md's per-file ide_diagnostics gate and the pom Mend.io CVE check
could not be run -- no JetBrains/intellij-index MCP server is connected this
session. mvn clean install is the only gate that ran.
This commit is contained in:
2026-07-29 22:29:26 +07:00
parent c9f0ca9359
commit 9daf1ec5ba
28 changed files with 2298 additions and 37 deletions
@@ -0,0 +1,80 @@
package dev.ltms.bridged.auth;
import org.junit.jupiter.api.Test;
import static dev.ltms.bridged.auth.Authz.Action.*;
import static org.junit.jupiter.api.Assertions.*;
/** CB-505 — the authorization table, pinned so it cannot drift silently. */
class AuthzTest {
private static final Principal PRIMARY = Principal.primary(100);
private static final Principal WORKER_A = Principal.worker("term_a", 200);
private static final Principal WORKER_B = Principal.worker("term_b", 300);
private static final Principal ANON = Principal.anonymous();
@Test
void anonymousIsAuthorizedForNothing() {
for (Authz.Action a : Authz.Action.values()) {
assertFalse(Authz.permits(ANON, a, "term_a"),
a + " must be refused to an unauthenticated caller");
}
}
@Test
void aNullCallerIsTreatedAsAnonymous() {
assertFalse(Authz.permits(null, READ, null));
assertTrue(Authz.isUnauthenticated(null));
}
@Test
void orchestrationBelongsToThePrimaryAlone() {
for (Authz.Action a : new Authz.Action[]{SPAWN, STOP, SEND, DRAIN}) {
assertTrue(Authz.permits(PRIMARY, a, "term_a"), "the primary orchestrates: " + a);
assertFalse(Authz.permits(WORKER_A, a, "term_a"),
"a worker performing " + a + " would be escalating into the orchestrator role");
}
}
@Test
void aWorkerMayReplyAndAskOnlyAsItself() {
assertTrue(Authz.permits(WORKER_A, REPLY, "term_a"));
assertTrue(Authz.permits(WORKER_A, ASK, "term_a"));
assertFalse(Authz.permits(WORKER_A, REPLY, "term_b"),
"worker A must not be able to reply on worker B's session");
assertFalse(Authz.permits(WORKER_B, ASK, "term_a"),
"worker B must not be able to ask as worker A");
}
@Test
void thePrimaryMayNotForgeAWorkersReply() {
// Not a hypothetical nicety: a forged reply would resolve the rendezvous the primary is
// itself blocked on, corrupting the correlation between a turn and its answer.
assertFalse(Authz.permits(PRIMARY, REPLY, "term_a"));
assertFalse(Authz.permits(PRIMARY, ASK, "term_a"));
}
@Test
void aWorkerWithNoTargetCannotReply() {
assertFalse(Authz.permits(WORKER_A, REPLY, null),
"an absent session id must not satisfy the own-session rule");
}
@Test
void observationIsOpenToBothAuthenticatedRoles() {
assertTrue(Authz.permits(PRIMARY, READ, null));
assertTrue(Authz.permits(WORKER_A, READ, null));
assertTrue(Authz.permits(PRIMARY, METRICS, null));
assertTrue(Authz.permits(WORKER_A, METRICS, null));
}
@Test
void unauthenticatedIsDistinguishedFromMerelyForbidden() {
// Drives the 401-vs-403 split: a missing credential is fixable by the caller, a wrong role
// is not.
assertTrue(Authz.isUnauthenticated(ANON));
assertFalse(Authz.isUnauthenticated(WORKER_A));
assertFalse(Authz.isUnauthenticated(PRIMARY));
}
}
@@ -0,0 +1,106 @@
package dev.ltms.bridged.auth;
import dev.ltms.bridged.herdr.FakeHerdr;
import dev.ltms.bridged.herdr.PaneLocator;
import dev.ltms.bridged.mcp.ConnectionIdentity;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
/**
* CB-501. The behaviour under test is the inversion of the pre-CB-501 default: failing every
* identity check must yield {@link Role#ANONYMOUS}, not {@code PRIMARY}.
*/
class CallerResolverTest {
private final FakeHerdr herdr = new FakeHerdr();
/** Identity resolving the canned worker pane, keyed off a faked peer-PID lookup. */
private ConnectionIdentity identity(long pid) {
return new ConnectionIdentity(new PaneLocator(herdr), _ -> pid);
}
/** A PID that owns a worker pane in the fake. */
private ConnectionIdentity workerIdentity() {
return identity(FakeHerdr.WORKER_PID);
}
/** A PID that owns no pane — i.e. the primary, or any other local process. */
private ConnectionIdentity nonWorkerIdentity() {
return identity(999_999);
}
@Test
void aLoopbackWorkerPaneResolvesToWorkerRegardlessOfAuthMode() {
Principal underTrust = new CallerResolver(workerIdentity()).resolve("127.0.0.1", 42, null);
Principal underToken = new CallerResolver(workerIdentity(), true, "s3cret")
.resolve("127.0.0.1", 42, null);
assertEquals(Role.WORKER, underTrust.role());
assertEquals("term_a", underTrust.terminal());
assertEquals(Role.WORKER, underToken.role(),
"worker identity is unforgeable and must never be token-gated — otherwise enabling "
+ "auth would lock the whole fleet out of bridge_reply");
assertEquals("term_a", underToken.terminal());
}
@Test
void loopbackTrustTreatsANonWorkerLoopbackCallerAsThePrimary() {
Principal p = new CallerResolver(nonWorkerIdentity()).resolve("127.0.0.1", 99, null);
assertEquals(Role.PRIMARY, p.role(), "the historical behaviour, now an explicit choice");
}
@Test
void tokenModeRefusesANonWorkerCallerThatPresentsNoToken() {
Principal p = new CallerResolver(nonWorkerIdentity(), true, "s3cret")
.resolve("127.0.0.1", 99, null);
assertEquals(Role.ANONYMOUS, p.role(),
"no credential must mean NOTHING, not the most privileged role on the bus");
}
@Test
void tokenModeAcceptsAValidBearerTokenAsThePrimary() {
Principal p = new CallerResolver(nonWorkerIdentity(), true, "s3cret")
.resolve("127.0.0.1", 99, "Bearer s3cret");
assertEquals(Role.PRIMARY, p.role());
}
@Test
void tokenModeRejectsAWrongOrMalformedCredential() {
CallerResolver r = new CallerResolver(nonWorkerIdentity(), true, "s3cret");
assertEquals(Role.ANONYMOUS, r.resolve("127.0.0.1", 99, "Bearer wrong").role());
assertEquals(Role.ANONYMOUS, r.resolve("127.0.0.1", 99, "s3cret").role(), "scheme required");
assertEquals(Role.ANONYMOUS, r.resolve("127.0.0.1", 99, "Bearer ").role(), "empty credential");
assertEquals(Role.ANONYMOUS, r.resolve("127.0.0.1", 99, "Basic s3cret").role(), "wrong scheme");
}
@Test
void theBearerSchemeIsCaseInsensitivePerRfc7235() {
CallerResolver r = new CallerResolver(nonWorkerIdentity(), true, "s3cret");
assertEquals(Role.PRIMARY, r.resolve("127.0.0.1", 99, "bearer s3cret").role());
assertEquals(Role.PRIMARY, r.resolve("127.0.0.1", 99, "BEARER s3cret").role());
}
@Test
void aNonLoopbackCallerIsNeverThePrimaryUnderLoopbackTrust() {
// Defence in depth: startup already refuses this pairing (validateAuthExposure), but if a
// proxy ever forwards a remote peer onto the loopback listener, the resolver must not
// hand it the primary role.
Principal p = new CallerResolver(nonWorkerIdentity()).resolve("10.0.0.7", 99, null);
assertEquals(Role.ANONYMOUS, p.role());
}
@Test
void tokenModeRequiresANonEmptyConfiguredToken() {
ConnectionIdentity id = nonWorkerIdentity();
assertThrows(IllegalArgumentException.class, () -> new CallerResolver(id, true, null));
assertThrows(IllegalArgumentException.class, () -> new CallerResolver(id, true, " "));
}
}
@@ -233,4 +233,136 @@ class BridgedConfigTest {
assertEquals(java.util.List.of("opencode"), cfg.workerProfiles().get("gemini").argv(),
"an opencode worker with no argv defaults to the opencode binary, never claude");
}
@Test
void authDefaultsToLoopbackTrustSoExistingConfigsBehaveAsBefore(@TempDir Path dir) throws Exception {
Path f = dir.resolve("no-auth-block.yaml");
Files.writeString(f, "bind:\n host: 127.0.0.1\n port: 8765\n");
BridgedConfig cfg = BridgedConfig.load(f);
assertNotNull(cfg.auth(), "auth must default rather than be null");
assertFalse(cfg.auth().tokenMode());
assertEquals("BRIDGED_API_TOKEN", cfg.auth().tokenEnv(), "documented default env var");
assertDoesNotThrow(cfg::validateAuthExposure, "loopback + loopback-trust is the safe pairing");
}
/**
* CB-501's highest-value check. Under loopback-trust, "not a known worker" means "the primary" —
* sound only while the OS refuses remote connections. Widening the bind without token mode
* would silently promote every reachable client to the most privileged role on the bus.
*/
@Test
void aNonLoopbackBindWithoutTokenModeIsRefusedAtStartup(@TempDir Path dir) throws Exception {
Path f = dir.resolve("exposed.yaml");
Files.writeString(f, "bind:\n host: 0.0.0.0\n port: 8765\n");
BridgedConfig cfg = BridgedConfig.load(f);
IllegalStateException e = assertThrows(IllegalStateException.class, cfg::validateAuthExposure);
assertTrue(e.getMessage().contains("auth.mode: token"),
"the error must say how to fix it, not just that it refused");
}
@Test
void aNonLoopbackBindIsAllowedOnceTokenModeIsOn(@TempDir Path dir) throws Exception {
Path f = dir.resolve("exposed-with-token.yaml");
Files.writeString(f, """
bind:
host: 0.0.0.0
port: 8765
auth:
mode: token
tokenEnv: MY_TOKEN
""");
BridgedConfig cfg = BridgedConfig.load(f);
assertTrue(cfg.auth().tokenMode());
assertEquals("MY_TOKEN", cfg.auth().tokenEnv());
assertDoesNotThrow(cfg::validateAuthExposure);
}
@Test
void loopbackFormsAreAllRecognised(@TempDir Path dir) throws Exception {
for (String host : new String[]{"127.0.0.1", "localhost", "::1", "127.0.0.53"}) {
Path f = dir.resolve("lb-" + host.replace(':', '_') + ".yaml");
Files.writeString(f, "bind:\n host: \"" + host + "\"\n port: 8765\n");
assertDoesNotThrow(() -> BridgedConfig.load(f).validateAuthExposure(),
host + " is loopback and must not trip the exposure guard");
}
}
/**
* The shipped {@code bridged.example.yaml} must actually parse. Config binds through a plain
* Jackson mapper with {@code ignoreUnknown = true}, so a misspelled key in the example is
* silently dropped and the operator gets a default they did not ask for — exactly how a
* {@code spawn_ready_timeout_ms} typo survived in the example until the CB-5xx wrap-up.
*/
@Test
void shippedExampleConfigParses() {
Path example = Path.of("bridged.example.yaml");
assertTrue(Files.exists(example), "bridged.example.yaml must ship next to the pom");
BridgedConfig cfg = BridgedConfig.load(example);
assertEquals(8765, cfg.bind().port(), "example binds the documented default port");
assertTrue(cfg.workerProfiles().containsKey("gx10"), "example documents the gx10 profile");
assertEquals("gx10", cfg.defaultProfile(), "example's defaultWorker resolves");
assertTrue(cfg.guard().hostSet().contains("gx01.gw"),
"every example profile's base_url host must be in the example allowlist");
}
/**
* Every optional knob the example documents must bind under the exact spelling used there.
* Keep this list in step with {@code bridged.example.yaml}: a rename that updates the record
* but not the example (or vice versa) fails here instead of silently no-op'ing in production.
*/
@Test
void everyOptionalKnobDocumentedInTheExampleBinds(@TempDir Path dir) throws Exception {
Path f = dir.resolve("all-knobs.yaml");
Files.writeString(f, """
bind:
host: 127.0.0.1
port: 8765
spawnReadyTimeoutMs: 25000
spawnReadyPollMs: 400
worktreeRoot: /tmp/bridged-worktrees
workers:
gx10:
kind: claude-code
baseUrl: http://gx01.gw:8000
configDir: /tmp/ccs/gx10
cwd: /tmp/repo
parityOverlay: [".mcp.json", ".env"]
gitTokenEnv: GITEA_TOKEN
gitHostEnv: GITEA_HOST
lifecycle:
idleTtlSeconds: 300
contextCap: 10
drainTimeoutSeconds: 5
broker:
uri: amqp://guest:guest@127.0.0.1:5672
primary:
terminal: term_abc123
pushReminders: 5
pushBackoffMs: 15000
""");
BridgedConfig cfg = BridgedConfig.load(f);
assertEquals(25000, cfg.spawnReadyTimeoutMs(), "spawnReadyTimeoutMs is camelCase, not snake_case");
assertEquals(400, cfg.spawnReadyPollMs(), "spawnReadyPollMs is camelCase, not snake_case");
assertEquals("/tmp/bridged-worktrees", cfg.worktreeRoot());
BridgedConfig.Worker w = cfg.workerProfiles().get("gx10");
assertEquals("/tmp/ccs/gx10", w.configDir());
assertEquals("/tmp/repo", w.cwd());
assertEquals(java.util.List.of(".mcp.json", ".env"), w.parityOverlay());
assertTrue(w.hasGitToken(), "gitTokenEnv binds and enables the CB-302 PR grant");
assertEquals("GITEA_HOST", w.gitHostEnv());
assertEquals(300, cfg.lifecycle().idleTtlSeconds());
assertEquals(10, cfg.lifecycle().contextCap());
assertEquals(5, cfg.lifecycle().drainTimeoutSeconds());
assertEquals("amqp://guest:guest@127.0.0.1:5672", cfg.broker().uri());
assertEquals("term_abc123", cfg.primary().terminal());
assertEquals(5, cfg.primary().remindersOrDefault());
assertEquals(15000L, cfg.primary().backoffMsOrDefault());
}
}
@@ -0,0 +1,127 @@
package dev.ltms.bridged.metrics;
import org.junit.jupiter.api.Test;
import java.util.LinkedHashMap;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
/** CB-502 — the zero-dependency Prometheus text renderer. */
class MetricsTest {
@Test
void countersAccumulatePerLabelSet() {
Metrics m = new Metrics();
m.inc("bridged_sends_total", "outcome", "replied");
m.inc("bridged_sends_total", "outcome", "replied");
m.inc("bridged_sends_total", "outcome", "timeout");
assertEquals(2, m.count("bridged_sends_total", "outcome", "replied"));
assertEquals(1, m.count("bridged_sends_total", "outcome", "timeout"));
assertEquals(0, m.count("bridged_sends_total", "outcome", "failed"),
"an untouched series reads as zero, not an error");
}
@Test
void rendersHelpAndTypeOncePerFamily() {
Metrics m = new Metrics();
m.describe("bridged_sends_total", "counter", "Delegated sends by outcome.");
m.inc("bridged_sends_total", "outcome", "replied");
m.inc("bridged_sends_total", "outcome", "timeout");
String out = m.render();
assertEquals(1, countOccurrences(out, "# HELP bridged_sends_total"),
"HELP is per family, not per series");
assertEquals(1, countOccurrences(out, "# TYPE bridged_sends_total counter"));
assertTrue(out.contains("bridged_sends_total{outcome=\"replied\"} 1"));
assertTrue(out.contains("bridged_sends_total{outcome=\"timeout\"} 1"));
}
@Test
void labelsAreSortedSoScrapesAreByteStable() {
Metrics a = new Metrics();
a.inc("m", "b", "2", "a", "1");
Metrics b = new Metrics();
b.inc("m", "a", "1", "b", "2");
assertEquals(a.render(), b.render(), "label order in the call must not change the output");
assertTrue(a.render().contains("m{a=\"1\",b=\"2\"}"));
}
@Test
void gaugesAreEvaluatedAtScrapeTimeNotRegistrationTime() {
Metrics m = new Metrics();
int[] live = {1};
m.gauge("bridged_sessions", () -> live[0], "state", "ready");
assertTrue(m.render().contains("bridged_sessions{state=\"ready\"} 1"));
live[0] = 5;
assertTrue(m.render().contains("bridged_sessions{state=\"ready\"} 5"),
"the gauge must read current state on every scrape");
}
@Test
void aThrowingGaugeDoesNotBreakTheWholeScrape() {
Metrics m = new Metrics();
m.inc("good_total");
m.gauge("bad_gauge", () -> {
throw new IllegalStateException("herdr is down");
});
String out = assertDoesNotThrow(m::render);
assertTrue(out.contains("good_total 1"), "healthy series must still be exported");
assertFalse(out.contains("bad_gauge"), "the broken series is simply absent");
}
@Test
void collectorsDiscoverTheirLabelSetPerScrape() {
Metrics m = new Metrics();
Map<String, Number> depths = new LinkedHashMap<>();
m.collector("bridged_inbox_depth", "target", () -> depths);
assertFalse(m.render().contains("bridged_inbox_depth"), "no targets yet ⇒ no series");
depths.put("term_a", 2);
depths.put("term_b", 0);
String out = m.render();
assertTrue(out.contains("bridged_inbox_depth{target=\"term_a\"} 2"));
assertTrue(out.contains("bridged_inbox_depth{target=\"term_b\"} 0"));
}
@Test
void labelValuesAreEscaped() {
Metrics m = new Metrics();
m.inc("m", "detail", "he said \"hi\"\nand \\left");
String out = m.render();
assertTrue(out.contains("\\\""), "quotes escaped");
assertTrue(out.contains("\\n"), "newlines escaped — a raw one would corrupt the exposition");
assertTrue(out.contains("\\\\"), "backslashes escaped");
}
@Test
void wholeNumberGaugesRenderWithoutADecimalPoint() {
Metrics m = new Metrics();
m.gauge("whole", () -> 3.0);
m.gauge("fractional", () -> 1.5);
String out = m.render();
assertTrue(out.contains("whole 3"), "3.0 should not render as 3.0");
assertTrue(out.contains("fractional 1.5"));
}
@Test
void oddLabelCountIsRejected() {
Metrics m = new Metrics();
assertThrows(IllegalArgumentException.class, () -> m.inc("m", "dangling"));
}
private static int countOccurrences(String haystack, String needle) {
int n = 0;
for (int i = haystack.indexOf(needle); i >= 0; i = haystack.indexOf(needle, i + 1)) {
n++;
}
return n;
}
}
@@ -0,0 +1,225 @@
package dev.ltms.bridged.rest;
import dev.ltms.bridged.auth.CallerResolver;
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.herdr.PaneLocator;
import dev.ltms.bridged.herdr.WorkspaceControl;
import dev.ltms.bridged.inject.Injector;
import dev.ltms.bridged.mcp.ConnectionIdentity;
import dev.ltms.bridged.metrics.BridgedMetrics;
import dev.ltms.bridged.metrics.Metrics;
import dev.ltms.bridged.msg.MessageService;
import dev.ltms.bridged.msg.Rendezvous;
import dev.ltms.bridged.session.FakeWorktrees;
import dev.ltms.bridged.session.SessionManager;
import dev.ltms.bridged.worker.ClaudeCodeLauncher;
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 java.util.Map;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.*;
/**
* CB-501/505 enforcement over real HTTP. The unit tests pin the policy; these pin that the policy
* is actually reached from a request — a rule enforced nowhere is not a control.
*/
class BridgedAppAuthTest {
private final HttpClient http = HttpClient.newHttpClient();
private Javalin app;
private Metrics metrics;
@AfterEach
void stop() {
if (app != null) app.stop();
}
/**
* Start the app with the given identity/auth wiring.
*
* @param pid the PID every connection resolves to — {@link FakeHerdr#WORKER_PID} makes the
* caller worker {@code term_a}, anything else makes it a non-worker
*/
private int start(long pid, boolean tokenMode, String token) {
FakeHerdr herdr = new FakeHerdr();
BridgedConfig.Worker wcfg = new BridgedConfig.Worker(
"ltms-local", "http://gx00.gw:8000", "coder", null, "BRIDGED_WORKER_TOKEN", null,
"tab", "bridged-workers", "worker: {profile} #{n}", null, null, null);
AgentControl agents = new AgentControl(herdr);
ClaudeCodeLauncher workers = new ClaudeCodeLauncher(
agents, new WorkspaceControl(herdr), new SubscriptionGuard(Set.of("gx00.gw")),
Map.of(wcfg.profile(), wcfg), wcfg.profile(),
k -> "BRIDGED_WORKER_TOKEN".equals(k) ? "tok-abc" : null);
SessionManager sessions = new SessionManager(workers, new FakeWorktrees());
Injector injector = new Injector(agents);
MessageService messages = new MessageService(agents, injector, new Rendezvous());
ConnectionIdentity identity = new ConnectionIdentity(new PaneLocator(herdr), _ -> pid);
CallerResolver callers = tokenMode
? new CallerResolver(identity, true, token)
: new CallerResolver(identity);
metrics = BridgedMetrics.create(sessions, new dev.ltms.bridged.msg.InMemoryReplyInbox());
app = new BridgedApp(herdr, workers, sessions, messages, sessions.asPresence(), null,
callers, metrics).build().start("127.0.0.1", 0);
return app.port();
}
private HttpResponse<String> send(int port, String method, String path, String body, String auth)
throws Exception {
HttpRequest.Builder b = HttpRequest.newBuilder(URI.create("http://127.0.0.1:" + port + path))
.header("Content-Type", "application/json");
if (auth != null) {
b.header("Authorization", auth);
}
b = switch (method) {
case "POST" -> b.POST(body == null
? HttpRequest.BodyPublishers.noBody()
: HttpRequest.BodyPublishers.ofString(body));
case "DELETE" -> b.DELETE();
default -> b.GET();
};
return http.send(b.build(), HttpResponse.BodyHandlers.ofString());
}
// --- loopback-trust: the caller is the primary -------------------------------------------
@Test
void thePrimaryMayOrchestrateButMayNotForgeAWorkerReply() throws Exception {
int port = start(999_999, false, null); // no pane ⇒ primary
HttpResponse<String> read = send(port, "GET", "/profiles", null, null);
assertEquals(200, read.statusCode(), "the primary may observe");
HttpResponse<String> reply = send(port, "POST", "/sessions/term_a/reply",
"{\"content\":\"forged\"}", null);
assertEquals(403, reply.statusCode(),
"a forged reply would resolve the rendezvous the primary is itself waiting on");
assertTrue(reply.body().contains("forbidden"));
}
// --- loopback-trust: the caller is a worker ------------------------------------------------
@Test
void aWorkerMayReplyAsItselfButNotAsAnother() throws Exception {
int port = start(FakeHerdr.WORKER_PID, false, null); // resolves to term_a
HttpResponse<String> own = send(port, "POST", "/sessions/term_a/reply",
"{\"content\":\"done\"}", null);
assertEquals(200, own.statusCode(), "a worker replies on its own session");
HttpResponse<String> other = send(port, "POST", "/sessions/term_b/reply",
"{\"content\":\"not mine\"}", null);
assertEquals(403, other.statusCode(),
"REST trusted the path id before CB-505; this is the hole being closed");
}
@Test
void aWorkerMayNotOrchestrate() throws Exception {
int port = start(FakeHerdr.WORKER_PID, false, null);
assertEquals(403, send(port, "POST", "/workers", null, null).statusCode(),
"a worker spawning workers would be escalating into the orchestrator role");
assertEquals(403, send(port, "DELETE", "/workers/w2:p7", null, null).statusCode());
assertEquals(403, send(port, "POST", "/sessions/term_b/message",
"{\"content\":\"hi\"}", null).statusCode());
assertEquals(403, send(port, "GET", "/sessions/term_a/replies", null, null).statusCode(),
"draining an inbox is the primary's collection step");
}
// --- token mode ---------------------------------------------------------------------------
@Test
void tokenModeRejectsAnUncredentialedNonWorkerWith401() throws Exception {
int port = start(999_999, true, "s3cret");
HttpResponse<String> res = send(port, "GET", "/profiles", null, null);
assertEquals(401, res.statusCode(), "no credential ⇒ authenticated as nothing");
assertTrue(res.body().contains("unauthenticated"));
}
@Test
void tokenModeAcceptsAValidBearerToken() throws Exception {
int port = start(999_999, true, "s3cret");
assertEquals(200, send(port, "GET", "/profiles", null, "Bearer s3cret").statusCode());
}
@Test
void tokenModeStillHonoursConnectionDerivedWorkerIdentity() throws Exception {
// The fleet must keep working when auth is switched on: a worker presents no token, and
// must still be able to reply.
int port = start(FakeHerdr.WORKER_PID, true, "s3cret");
assertEquals(200, send(port, "POST", "/sessions/term_a/reply",
"{\"content\":\"done\"}", null).statusCode());
}
// --- health, metrics ----------------------------------------------------------------------
@Test
void healthzStaysOpenWithoutCredentials() throws Exception {
int port = start(999_999, true, "s3cret");
assertEquals(200, send(port, "GET", "/healthz", null, null).statusCode(),
"a supervisor must be able to probe liveness before any credential is configured");
}
@Test
void metricsRequireAuthenticationAndRenderPrometheusText() throws Exception {
int port = start(999_999, true, "s3cret");
assertEquals(401, send(port, "GET", "/metrics", null, null).statusCode());
HttpResponse<String> ok = send(port, "GET", "/metrics", null, "Bearer s3cret");
assertEquals(200, ok.statusCode());
assertTrue(ok.headers().firstValue("Content-Type").orElse("").startsWith("text/plain"));
assertTrue(ok.body().contains("bridged_sessions{state=\"ready\"}"),
"the session census gauge is exported even when empty");
}
@Test
void refusalsAreCounted() throws Exception {
int port = start(999_999, true, "s3cret");
send(port, "GET", "/profiles", null, null); // 401
send(port, "POST", "/sessions/term_a/reply", "{}", "Bearer s3cret"); // 403
assertEquals(1, metrics.count(BridgedMetrics.AUTH_FAILURES, "reason", "unauthenticated"));
assertEquals(1, metrics.count(BridgedMetrics.AUTH_FAILURES, "reason", "forbidden"));
}
// --- legacy constructor -------------------------------------------------------------------
@Test
void theLegacyConstructorLeavesAuthorizationOff() throws Exception {
// The 29 pre-existing acceptance tests rely on this: no auth fixture, no enforcement.
FakeHerdr herdr = new FakeHerdr();
AgentControl agents = new AgentControl(herdr);
BridgedConfig.Worker wcfg = new BridgedConfig.Worker(
"ltms-local", "http://gx00.gw:8000", "coder", null, "BRIDGED_WORKER_TOKEN", null,
"tab", "bridged-workers", "worker: {profile} #{n}", null, null, null);
ClaudeCodeLauncher workers = new ClaudeCodeLauncher(
agents, new WorkspaceControl(herdr), new SubscriptionGuard(Set.of("gx00.gw")),
Map.of(wcfg.profile(), wcfg), wcfg.profile(), _ -> "tok");
SessionManager sessions = new SessionManager(workers, new FakeWorktrees());
MessageService messages = new MessageService(agents, new Injector(agents), new Rendezvous());
app = new BridgedApp(herdr, workers, sessions, messages, sessions.asPresence(), null)
.build().start("127.0.0.1", 0);
assertEquals(200, send(app.port(), "POST", "/sessions/term_a/reply",
"{\"content\":\"x\"}", null).statusCode());
assertEquals(404, send(app.port(), "GET", "/metrics", null, null).statusCode(),
"no registry supplied ⇒ the endpoint is not mounted at all");
}
}