diff --git a/bridged/src/main/java/dev/ltms/bridged/member/HerdrPeerLauncher.java b/bridged/src/main/java/dev/ltms/bridged/member/HerdrPeerLauncher.java index 82dd8a8..320509d 100644 --- a/bridged/src/main/java/dev/ltms/bridged/member/HerdrPeerLauncher.java +++ b/bridged/src/main/java/dev/ltms/bridged/member/HerdrPeerLauncher.java @@ -7,6 +7,7 @@ import dev.ltms.bridged.herdr.HerdrException; import dev.ltms.bridged.herdr.Tab; import dev.ltms.bridged.herdr.Workspace; import dev.ltms.bridged.herdr.WorkspaceControl; +import dev.ltms.bridged.peer.CharterReceipt; import dev.ltms.bridged.peer.MemberRole; import dev.ltms.bridged.peer.PeerHandle; import dev.ltms.bridged.peer.PeerLauncher; @@ -269,8 +270,11 @@ public abstract class HerdrPeerLauncher implements PeerLauncher { // --- spawn --------------------------------------------------------------------------------- - /** A started peer plus the launch's agent-session id (the resume handle, or null). */ - private record Spawned(Agent agent, String agentSessionId) { + /** + * A started peer plus the launch's agent-session id (the resume handle, or null) and the + * charter receipt (CB-571) the base composed for it. + */ + private record Spawned(Agent agent, String agentSessionId, CharterReceipt receipt) { } /** @@ -305,12 +309,41 @@ public abstract class HerdrPeerLauncher implements PeerLauncher { String replyCharter = cfg.hasMcp() ? REPLY_CHARTER : null; String charter = roleCharter == null ? replyCharter : replyCharter == null ? roleCharter : roleCharter + "\n\n" + replyCharter; - Launch launch = buildLaunch(cfg, new LaunchSpec(sessionName, resumeSessionId, role, charter)); - String cwd = resolveCwd(requestedCwd, cfg, callerCwd); - Agent agent = cfg.tabPlacement() - ? spawnInTab(cfg, launch.env(), launch.argv(), cwd, role, liveFleet) - : spawnAsPane(cfg, launch.env(), launch.argv(), cwd); - return new Spawned(agent, launch.agentSessionId()); + // CB-571: fingerprint the exact composed charter bytes once, here in the base, before the + // string leaves for an adapter — so Claude and OpenCode derive the same digest. A failed + // start has no bridge_spawn result and no roster row, so the failure log below is the only + // surface the byte count can appear on. The charter text itself is never logged. + CharterReceipt receipt = CharterReceipt.compose(role, cfg.profile(), roleCharter, charter); + try { + Launch launch = buildLaunch(cfg, new LaunchSpec(sessionName, resumeSessionId, role, charter)); + String cwd = resolveCwd(requestedCwd, cfg, callerCwd); + Agent agent = cfg.tabPlacement() + ? spawnInTab(cfg, launch.env(), launch.argv(), cwd, role, liveFleet) + : spawnAsPane(cfg, launch.env(), launch.argv(), cwd, charter); + logCharterReceipt(receipt, true); + return new Spawned(agent, launch.agentSessionId(), receipt); + } catch (RuntimeException e) { + logCharterReceipt(receipt, false); + throw e; + } + } + + /** + * The one place the charter's size and digest appear in the logs. {@code success} true after a + * start, false from the failure path of {@link #spawnInternal} where no handle or roster row + * exists to carry the receipt. Always metadata only — never the charter text. + */ + private static void logCharterReceipt(CharterReceipt receipt, boolean success) { + String role = receipt.role() == null ? "" : receipt.role().wireName(); + if (success) { + log.info("spawned role={} profile={} charterSource={} charterSha256={} charterBytes={}", + role, receipt.profile(), receipt.charterSource(), + receipt.charterSha256(), receipt.charterBytes()); + } else { + log.warn("spawn failed; charter role={} profile={} charterSource={} charterSha256={} charterBytes={}", + role, receipt.profile(), receipt.charterSource(), + receipt.charterSha256(), receipt.charterBytes()); + } } /** @@ -349,7 +382,7 @@ public abstract class HerdrPeerLauncher implements PeerLauncher { String id = UUID.randomUUID().toString(); paneByAgentId.put(id, paneId); return new WorkerHandle(id, agent.terminalId(), requireProfile(req.profileName()).profile(), - req.sessionName(), spawned.agentSessionId()); + req.sessionName(), spawned.agentSessionId(), spawned.receipt()); } @Override @@ -433,11 +466,19 @@ public abstract class HerdrPeerLauncher implements PeerLauncher { } } - /** Legacy placement: split the currently-focused tab; the peer still starts in {@code cwd}. */ + /** + * Legacy placement: split the currently-focused tab; the peer still starts in {@code cwd}. + * + *

CB-571: this is the one legacy log that printed the full argv, and the charter travels + * inside argv — so the charter text went to the daemon log on every pane-placement spawn. The + * {@code spawnInTab} path never logs argv, so only this site is fixed. {@code charter} is the + * composed charter, if any; its argv element is replaced by its digest so the log still shows + * which args were passed without exposing the charter prose. + */ private Agent spawnAsPane(BridgedConfig.Profile cfg, Map workerEnv, - List argv, String cwd) { + List argv, String cwd, String charter) { log.info("spawning {} (pane placement) profile={} cwd={} argv={}", - namePrefix, cfg.profile(), cwd, argv); + namePrefix, cfg.profile(), cwd, redactCharter(argv, charter)); String paneId = spaces.splitPane(cwd, workerEnv); if (paneId == null) { throw new IllegalStateException("pane.split returned no pane — cannot start a peer"); @@ -447,6 +488,21 @@ public abstract class HerdrPeerLauncher implements PeerLauncher { return peer; } + /** + * A copy of {@code argv} with an element equal to {@code charter} replaced by its digest, so + * the pane log never prints the charter prose. The charter is handed to an adapter as one argv + * element, so exact-equality is the right match; every other argument passes through unchanged. + */ + private static List redactCharter(List argv, String charter) { + if (charter == null || charter.isBlank() || argv == null || argv.isEmpty()) { + return argv; + } + String digest = CharterReceipt.digestOf(charter); + return argv.stream() + .map(a -> a.equals(charter) ? "" : a) + .toList(); + } + /** A started peer together with the sequence its unique name/label used. */ private record Started(Agent agent, long seq) { } @@ -651,11 +707,18 @@ public abstract class HerdrPeerLauncher implements PeerLauncher { /** * A concrete {@link PeerHandle} wrapping herdr agent coordinates, the profile that spawned it, - * and the session identity the launch resolved (CB-547a): the bridge's logical name and the - * peer's own session id, both null when the spawn carried no identity. + * the session identity the launch resolved (CB-547a): the bridge's logical name and the peer's + * own session id, both null when the spawn carried no identity — and the charter receipt + * (CB-571) the base computed for this launch. */ private record WorkerHandle(String id, String terminalId, String profile, - String sessionName, String agentSessionId) implements PeerHandle { + String sessionName, String agentSessionId, + CharterReceipt receipt) implements PeerHandle { + + @Override + public CharterReceipt charterReceipt() { + return receipt; + } } // --- shared helpers ------------------------------------------------------------------------ diff --git a/bridged/src/main/java/dev/ltms/bridged/member/OpenCodeLauncher.java b/bridged/src/main/java/dev/ltms/bridged/member/OpenCodeLauncher.java index b830672..8eb300e 100644 --- a/bridged/src/main/java/dev/ltms/bridged/member/OpenCodeLauncher.java +++ b/bridged/src/main/java/dev/ltms/bridged/member/OpenCodeLauncher.java @@ -7,6 +7,7 @@ import dev.ltms.bridged.herdr.Agent; import dev.ltms.bridged.herdr.AgentControl; import dev.ltms.bridged.herdr.WorkspaceControl; import dev.ltms.bridged.peer.Capability; +import dev.ltms.bridged.peer.CharterReceipt; import dev.ltms.bridged.peer.PeerHandle; import dev.ltms.bridged.peer.SpawnRequest; @@ -406,6 +407,11 @@ public final class OpenCodeLauncher extends HerdrPeerLauncher { // appeared). return discovery.sessionIdForDirectory(cwd); } + + @Override + public CharterReceipt charterReceipt() { + return delegate.charterReceipt(); + } } // --- Agent-returning convenience spawns (used by callers/tests that want the herdr Agent) --- diff --git a/bridged/src/main/java/dev/ltms/bridged/peer/CharterReceipt.java b/bridged/src/main/java/dev/ltms/bridged/peer/CharterReceipt.java new file mode 100644 index 0000000..79bf78b --- /dev/null +++ b/bridged/src/main/java/dev/ltms/bridged/peer/CharterReceipt.java @@ -0,0 +1,84 @@ +package dev.ltms.bridged.peer; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; + +/** + * CB-571: a fingerprint of the exact charter bytes handed to a spawned member. + * + *

Lets an operator prove which charter a member actually got, without ever logging the + * charter text. The digest covers the exact composed UTF-8 string {@code HerdrPeerLauncher} passes + * to its adapter as {@code LaunchSpec.charter()}, so every adapter that receives the same string — + * Claude inlining it, OpenCode writing it to a file — produces the same digest for the same config. + * Two spawns of the same role from the same config agree; editing the charter changes the digest. + * + *

Deliberately places no charter prose. A charter is operator-authored text that may name + * internal projects or unreleased plans, and logs get tailed, shipped, and pasted into tickets. + * The {@code charterSource} key is what the operator wants to confirm, and it carries no content. + */ +public record CharterReceipt( + MemberRole role, + String profile, + String charterSource, + String charterSha256, + int charterBytes) { + + /** Source reported when the role has no configured charter, so the field is never omitted. */ + public static final String NO_SOURCE = "none"; + + /** + * The config key that supplied the role's charter text, e.g. {@code fleet.charters.architect}. + */ + public static String sourceKey(MemberRole role) { + return "fleet.charters." + (role == null ? "?" : role.wireName()); + } + + /** + * Fingerprint the composed charter for {@code role} on {@code profile}. {@code configured} is + * the role's charter text as read from config ({@code null} when none is configured); + * {@code composed} is the exact string the launcher will pass to the adapter — the reply + * charter may be appended to {@code configured}, or stand alone when no role charter exists. + * + *

No composed charter at all is reported as an explicit absence — a {@code null} digest and + * a zero byte count — never a digest of the empty string, which would hide the fact that no + * text was supplied. {@code configured} being {@code null} while {@code composed} is the reply + * charter alone is a normal case, and the source says so. + */ + public static CharterReceipt compose(MemberRole role, String profile, + String configured, String composed) { + String source = (configured == null || configured.isBlank()) + ? NO_SOURCE : sourceKey(role); + if (composed == null) { + return new CharterReceipt(role, profile, source, null, 0); + } + byte[] bytes = composed.getBytes(StandardCharsets.UTF_8); + return new CharterReceipt(role, profile, source, digestOf(composed), bytes.length); + } + + /** Whether the composed charter was absent (no text was given to the member). */ + public boolean absent() { + return charterSha256 == null; + } + + /** + * The stable SHA-256 hex digest of {@code text}, or {@code null} for null/blank text. Used both + * for the receipt's fingerprint and to redact a charter argument in a spawn log. + */ + public static String digestOf(String text) { + if (text == null || text.isBlank()) { + return null; + } + return sha256Hex(text.getBytes(StandardCharsets.UTF_8)); + } + + private static String sha256Hex(byte[] bytes) { + try { + MessageDigest md = MessageDigest.getInstance("SHA-256"); + return HexFormat.of().formatHex(md.digest(bytes)); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 is unavailable", e); + } + } +} diff --git a/bridged/src/main/java/dev/ltms/bridged/peer/PeerHandle.java b/bridged/src/main/java/dev/ltms/bridged/peer/PeerHandle.java index b5ed481..8d4827c 100644 --- a/bridged/src/main/java/dev/ltms/bridged/peer/PeerHandle.java +++ b/bridged/src/main/java/dev/ltms/bridged/peer/PeerHandle.java @@ -67,4 +67,18 @@ public interface PeerHandle { default String agentSessionId() { return null; } + + /** + * The charter receipt (CB-571) for this peer's launch — the fingerprint of the exact charter + * bytes it was started with. {@code null} when the launcher records none (a non-instrumented + * adapter, or a launcher before this field); the session registry stores it so the spawn result + * and the roster row can show an operator which charter a member actually got. + * + *

Deliberately not a {@code default}: a decorator that forgets to override this silently + * answers {@code null} for a question it has no basis to answer, and the gap surfaces only as + * a missing roster field, not a compile error. Every implementation must answer explicitly. + * + * @return the fingerprint, or {@code null} when the launcher carries none + */ + CharterReceipt charterReceipt(); } diff --git a/bridged/src/main/java/dev/ltms/bridged/session/MemberSession.java b/bridged/src/main/java/dev/ltms/bridged/session/MemberSession.java index 8c80bdb..9726e06 100644 --- a/bridged/src/main/java/dev/ltms/bridged/session/MemberSession.java +++ b/bridged/src/main/java/dev/ltms/bridged/session/MemberSession.java @@ -1,5 +1,6 @@ package dev.ltms.bridged.session; +import dev.ltms.bridged.peer.CharterReceipt; import dev.ltms.bridged.peer.MemberRole; /** @@ -23,6 +24,8 @@ import dev.ltms.bridged.peer.MemberRole; * @param lastActivityAtNanos {@link System#nanoTime()} of the most recent lifecycle event * @param turnCount number of delegated turns that have been delivered to this session * @param state current lifecycle state in the one-shot FSM + * @param charterReceipt the fingerprint (CB-571) of the charter bytes this member was started + * with; {@code null} for a session whose launcher recorded none */ public record MemberSession( String paneId, @@ -36,7 +39,8 @@ public record MemberSession( int turnCount, State state, String worktree, - String branch) { + String branch, + CharterReceipt charterReceipt) { /** One-shot worker lifecycle states. */ public enum State { @@ -48,21 +52,34 @@ public record MemberSession( RELEASED } + /** + * Backward-compatible shape: a session with no charter receipt (a test or a launcher before + * CB-571). A separate constructor rather than a new parameter on the canonical one, so existing + * call sites that have nothing to record keep compiling unchanged. + */ + public MemberSession(String paneId, String terminalId, String profile, MemberRole role, + String cwd, String ownerTerminal, long spawnedAtNanos, + long lastActivityAtNanos, int turnCount, State state, + String worktree, String branch) { + this(paneId, terminalId, profile, role, cwd, ownerTerminal, spawnedAtNanos, + lastActivityAtNanos, turnCount, state, worktree, branch, null); + } + /** Return a copy of this session in {@code state}. */ public MemberSession withState(State state) { return new MemberSession(paneId, terminalId, profile, role, cwd, ownerTerminal, spawnedAtNanos, - lastActivityAtNanos, turnCount, state, worktree, branch); + lastActivityAtNanos, turnCount, state, worktree, branch, charterReceipt); } /** Return a copy with {@code lastActivityAtNanos} updated to {@code nowNanos}. */ public MemberSession withActivity(long nowNanos) { return new MemberSession(paneId, terminalId, profile, role, cwd, ownerTerminal, spawnedAtNanos, - nowNanos, turnCount, state, worktree, branch); + nowNanos, turnCount, state, worktree, branch, charterReceipt); } /** Return a copy with the turn count incremented and activity timestamped at {@code nowNanos}. */ public MemberSession bumpTurn(long nowNanos) { return new MemberSession(paneId, terminalId, profile, role, cwd, ownerTerminal, spawnedAtNanos, - nowNanos, turnCount + 1, state, worktree, branch); + nowNanos, turnCount + 1, state, worktree, branch, charterReceipt); } } diff --git a/bridged/src/main/java/dev/ltms/bridged/session/SessionManager.java b/bridged/src/main/java/dev/ltms/bridged/session/SessionManager.java index 4f5bc72..0a7563c 100644 --- a/bridged/src/main/java/dev/ltms/bridged/session/SessionManager.java +++ b/bridged/src/main/java/dev/ltms/bridged/session/SessionManager.java @@ -163,7 +163,8 @@ public final class SessionManager implements TurnListener { 0, MemberSession.State.SPAWNING, null, - null); + null, + handle.charterReceipt()); registry.put(handle.id(), session); memberLifecycle.acquired(session.role(), session.profile(), session.terminalId()); log.debug("acquired session id={} terminal={} profile={} owner={}", @@ -355,7 +356,8 @@ public final class SessionManager implements TurnListener { 0, MemberSession.State.SPAWNING, path, - branch); + branch, + handle.charterReceipt()); registry.put(handle.id(), session); memberLifecycle.acquired(session.role(), session.profile(), session.terminalId()); log.debug("acquired worktree session id={} terminal={} profile={} branch={} path={}", @@ -421,6 +423,16 @@ public final class SessionManager implements TurnListener { if (session.ownerTerminal() != null) { m.put("owner", session.ownerTerminal()); } + // CB-571: which charter this member was started with — never the charter text itself. The + // digest lets a lead tell at a glance whether all members got the same charter; the source + // records whether a role charter was configured ("fleet.charters.") or only the reply + // charter was composed ("none"). + if (session.charterReceipt() != null) { + m.put("charterSource", session.charterReceipt().charterSource()); + if (session.charterReceipt().charterSha256() != null) { + m.put("charterSha256", session.charterReceipt().charterSha256()); + } + } m.put("liveStatus", live == null ? "unknown" : live.status().name().toLowerCase()); return m; } diff --git a/bridged/src/test/java/dev/ltms/bridged/member/CompositePeerLauncherTest.java b/bridged/src/test/java/dev/ltms/bridged/member/CompositePeerLauncherTest.java index f73ccf8..b1f1994 100644 --- a/bridged/src/test/java/dev/ltms/bridged/member/CompositePeerLauncherTest.java +++ b/bridged/src/test/java/dev/ltms/bridged/member/CompositePeerLauncherTest.java @@ -10,6 +10,7 @@ import dev.ltms.bridged.herdr.AgentControl; import dev.ltms.bridged.herdr.FakeHerdr; import dev.ltms.bridged.herdr.WorkspaceControl; import dev.ltms.bridged.peer.Capability; +import dev.ltms.bridged.peer.CharterReceipt; import dev.ltms.bridged.peer.MemberRole; import dev.ltms.bridged.peer.PeerHandle; import dev.ltms.bridged.peer.PeerLauncher; @@ -93,6 +94,7 @@ class CompositePeerLauncherTest { @Override public String id() { return "pane-" + p; } @Override public String terminalId() { return "term-" + p; } @Override public String profile() { return p; } + @Override public CharterReceipt charterReceipt() { return null; } }; } diff --git a/bridged/src/test/java/dev/ltms/bridged/member/HerdrPeerLauncherCharterTest.java b/bridged/src/test/java/dev/ltms/bridged/member/HerdrPeerLauncherCharterTest.java index 0da9740..c657744 100644 --- a/bridged/src/test/java/dev/ltms/bridged/member/HerdrPeerLauncherCharterTest.java +++ b/bridged/src/test/java/dev/ltms/bridged/member/HerdrPeerLauncherCharterTest.java @@ -1,13 +1,19 @@ package dev.ltms.bridged.member; +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; import dev.ltms.bridged.config.BridgedConfig; import dev.ltms.bridged.herdr.AgentControl; import dev.ltms.bridged.herdr.FakeHerdr; import dev.ltms.bridged.herdr.WorkspaceControl; import dev.ltms.bridged.peer.Capability; +import dev.ltms.bridged.peer.CharterReceipt; import dev.ltms.bridged.peer.MemberRole; import dev.ltms.bridged.peer.SpawnRequest; import org.junit.jupiter.api.Test; +import org.slf4j.LoggerFactory; import java.util.ArrayList; import java.util.List; @@ -17,6 +23,8 @@ import java.util.concurrent.atomic.AtomicReference; import java.util.function.Supplier; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; class HerdrPeerLauncherCharterTest { @@ -38,10 +46,72 @@ class HerdrPeerLauncherCharterTest { "a role charter does not depend on an MCP mount"); } + @Test + void panePlacementSpawnLogNeverContainsTheCharterText() { + // A pane-placement spawn used to log the whole argv (CB-571), and the charter travels + // inside argv — so the charter text leaked to the daemon log. Prove the legacy pane path + // now redacts it to its digest. + String secret = "TOP SECRET charter marker 99x"; // distinctive, so a leak is unambiguous + AtomicReference fleet = new AtomicReference<>(fleet(Map.of("dev", secret))); + CharterArgLauncher launcher = new CharterArgLauncher(fleet::get); + + Logger logger = (Logger) LoggerFactory.getLogger(HerdrPeerLauncher.class); + Level previous = logger.getLevel(); + ListAppender appender = new ListAppender<>(); + appender.start(); + logger.addAppender(appender); + logger.setLevel(Level.INFO); // the test logback sets dev.ltms.bridged to WARN; a leak lives at INFO + try { + launcher.spawn(new SpawnRequest("mcp", null, null, null, null, MemberRole.DEV)); + + String all = String.join("\n", appender.list.stream().map(ILoggingEvent::getFormattedMessage).toList()); + assertFalse(all.contains(secret), + "the pane-placement spawn log must not contain the charter text; got:\n" + all); + // The "mcp" profile composes role + reply charter; the digest must match that composed + // string (the exact bytes the adapter receives), proving the redaction hashes and + // removes the real, full charter — not some placeholder. + String composed = secret + "\n\n" + HerdrPeerLauncher.REPLY_CHARTER; + assertTrue(all.contains(""), + "the charter argv argument should be replaced by its digest; got:\n" + all); + } finally { + logger.setLevel(previous); + logger.detachAppender(appender); + } + } + private static BridgedConfig.Fleet fleet(Map charters) { return new BridgedConfig.Fleet(Map.of(), Map.of(), Map.of(), Map.of(), charters, null); } + private static BridgedConfig.Profile profile(String name, String mcpUrl) { + return new BridgedConfig.Profile(name, "http://gx00.gw:8000", null, null, + "BRIDGED_WORKER_TOKEN", List.of("test"), "pane", null, null, mcpUrl, null, null); + } + + /** + * A launcher whose {@code buildLaunch} hands the composed charter to herdr as one argv element + * (what the claude-cod adapter does), so a pane-placement spawn log would print it unless the + * base redacts it. + */ + private static final class CharterArgLauncher extends HerdrPeerLauncher { + + CharterArgLauncher(Supplier fleet) { + super("test", new AgentControl(new FakeHerdr()), new WorkspaceControl(new FakeHerdr()), + Map.of("mcp", profile("mcp", "http://bridge")), + "mcp", _ -> null, 0, () -> 0L, () -> { }, fleet); + } + + @Override + protected Launch buildLaunch(BridgedConfig.Profile cfg, LaunchSpec spec) { + return new Launch(Map.of(), List.of("test", spec.charter() == null ? "none" : spec.charter())); + } + + @Override + public Set capabilities() { + return Set.of(); + } + } + private static final class CapturingLauncher extends HerdrPeerLauncher { private final List specs = new ArrayList<>(); @@ -62,10 +132,5 @@ class HerdrPeerLauncherCharterTest { public Set capabilities() { return Set.of(); } - - private static BridgedConfig.Profile profile(String name, String mcpUrl) { - return new BridgedConfig.Profile(name, "http://gx00.gw:8000", null, null, - "BRIDGED_WORKER_TOKEN", List.of("test"), "pane", null, null, mcpUrl, null, null); - } } } diff --git a/bridged/src/test/java/dev/ltms/bridged/member/OpenCodeLauncherTest.java b/bridged/src/test/java/dev/ltms/bridged/member/OpenCodeLauncherTest.java index b4dee40..5c1600e 100644 --- a/bridged/src/test/java/dev/ltms/bridged/member/OpenCodeLauncherTest.java +++ b/bridged/src/test/java/dev/ltms/bridged/member/OpenCodeLauncherTest.java @@ -7,6 +7,7 @@ import dev.ltms.bridged.herdr.AgentControl; import dev.ltms.bridged.herdr.FakeHerdr; import dev.ltms.bridged.herdr.WorkspaceControl; import dev.ltms.bridged.peer.Capability; +import dev.ltms.bridged.peer.CharterReceipt; import dev.ltms.bridged.peer.PeerHandle; import dev.ltms.bridged.peer.PeerUnreachableException; import dev.ltms.bridged.peer.SpawnRequest; @@ -322,6 +323,26 @@ class OpenCodeLauncherTest { assertFalse(herdr.called("agent.get"), "no polling when the gate is disabled"); } + @Test + void handleCarriesTheRealCharterReceiptNotTheInterfaceDefault(@TempDir Path root) { + // The base's WorkerHandle computes a real CharterReceipt (CB-571), but the opencode adapter + // wraps it in SessionAwareHandle for lazy session discovery. Before this fix that decorator + // did not override charterReceipt(), so it silently inherited PeerHandle's `null` default + // and the real receipt sitting on its delegate was lost. + FakeHerdr herdr = new FakeHerdr(); + BridgedConfig.Fleet fleet = new BridgedConfig.Fleet(Map.of(), Map.of(), Map.of(), Map.of(), + Map.of("dev", "role rule"), null); + PeerHandle handle = service(herdr, root, + opencodeCfg("google/gemini-2.5-pro", "http://127.0.0.1:8765/mcp", null), () -> fleet) + .spawn(new SpawnRequest(null, null, null)); + + assertNotNull(handle.charterReceipt(), + "an opencode spawn's charterReceipt() must not silently be null"); + String composed = "role rule\n\n" + HerdrPeerLauncher.REPLY_CHARTER; + assertEquals(CharterReceipt.digestOf(composed), handle.charterReceipt().charterSha256(), + "the receipt on the wrapped handle must match the exact composed charter bytes"); + } + // --- CB-508: pinned OpenAI-compatible endpoint (e.g. a local vLLM) --------------------------- /** A profile with a baseUrl but no model provider prefix cannot be resolved — fail loudly. */ diff --git a/bridged/src/test/java/dev/ltms/bridged/peer/CharterReceiptTest.java b/bridged/src/test/java/dev/ltms/bridged/peer/CharterReceiptTest.java new file mode 100644 index 0000000..5d7e976 --- /dev/null +++ b/bridged/src/test/java/dev/ltms/bridged/peer/CharterReceiptTest.java @@ -0,0 +1,57 @@ +package dev.ltms.bridged.peer; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class CharterReceiptTest { + + @Test + void recordsNoCharterConfiguredDistinctFromCharterDelivered() { + // No role charter configured — only the reply charter is composed. Source is "none", but + // text was still delivered, so absent() is false and the digest is present. + CharterReceipt viaReply = CharterReceipt.compose(MemberRole.DEV, "s", null, "reply charter"); + // A role charter was configured AND delivered. + CharterReceipt delivered = CharterReceipt.compose(MemberRole.DEV, "s", "role charter", + "role charter\n\nreply charter"); + + // The two cases must not collapse: the no-role-charter case reports "none", the delivered + // case reports the config key, and their digests differ. + assertEquals(CharterReceipt.NO_SOURCE, viaReply.charterSource()); + assertEquals("fleet.charters.dev", delivered.charterSource()); + assertNotEquals(viaReply.charterSource(), delivered.charterSource()); + assertNotEquals(viaReply.charterSha256(), delivered.charterSha256()); + + // Both actually delivered text — the distinction is the source and digest, not absence. + assertFalse(viaReply.absent()); + assertFalse(delivered.absent()); + } + + @Test + void recordsExplicitAbsenceWhenNoCharterIsComposed() { + CharterReceipt none = CharterReceipt.compose(MemberRole.REVIEWER, "s", null, null); + assertTrue(none.absent()); + assertNull(none.charterSha256()); + assertEquals(0, none.charterBytes()); + assertEquals(CharterReceipt.NO_SOURCE, none.charterSource(), + "no configured charter and nothing composed still reports a source, never a gap"); + } + + @Test + void digestIsStableForSameTextAndDiffersForDifferentText() { + assertEquals(CharterReceipt.digestOf("charter-aaa"), CharterReceipt.digestOf("charter-aaa"), + "the same text must always produce the same digest"); + assertNotEquals(CharterReceipt.digestOf("charter-aaa"), CharterReceipt.digestOf("charter-bbb"), + "different text must produce a different digest"); + assertNull(CharterReceipt.digestOf(""), "blank text carries no digest"); + + // The record's fingerprint matches the standalone digest for the same composed string. + CharterReceipt r = CharterReceipt.compose(MemberRole.DEV, "s", "role", "the composed text"); + assertEquals(CharterReceipt.digestOf("the composed text"), r.charterSha256()); + assertFalse(r.absent()); + } +} diff --git a/bridged/src/test/java/dev/ltms/bridged/session/SessionManagerTest.java b/bridged/src/test/java/dev/ltms/bridged/session/SessionManagerTest.java index 422831a..d8d9009 100644 --- a/bridged/src/test/java/dev/ltms/bridged/session/SessionManagerTest.java +++ b/bridged/src/test/java/dev/ltms/bridged/session/SessionManagerTest.java @@ -11,6 +11,8 @@ import dev.ltms.bridged.herdr.FakeHerdr; import dev.ltms.bridged.herdr.WorkspaceControl; import dev.ltms.bridged.member.ClaudeCodeLauncher; import dev.ltms.bridged.msg.TestTurnTokens; +import dev.ltms.bridged.peer.CharterReceipt; +import dev.ltms.bridged.peer.MemberRole; import dev.ltms.bridged.peer.PeerUnreachableException; import org.junit.jupiter.api.Test; import org.slf4j.LoggerFactory; @@ -91,6 +93,24 @@ class SessionManagerTest { assertEquals(2, sessions.roster().size(), "both sessions are registered"); } + @Test + void rosterViewExposesTheCharterReceiptButNeverTheCharterText() { + // The roster (bridge_list and GET /members both render through rosterView) must let a lead + // see which charter a member got, without ever carrying the charter prose itself (CB-571). + MemberSession s = new MemberSession("p1", "term1", "prof", MemberRole.DEV, "/cwd", null, + 0, 0, 0, MemberSession.State.READY, null, null, + CharterReceipt.compose(MemberRole.DEV, "prof", "role charter", "role charter\n\nreply")); + + Map view = SessionManager.rosterView(s, null); + + assertEquals("fleet.charters.dev", view.get("charterSource"), + "the config key that supplied the role charter is reported"); + assertEquals(CharterReceipt.digestOf("role charter\n\nreply"), view.get("charterSha256"), + "the digest of the exact composed charter bytes is reported"); + assertFalse(view.values().toString().contains("role charter"), + "the roster row must not embed the charter text itself"); + } + @Test void aNullTerminalFromThePrimaryIsANoOpEvenWithSessionsRegistered() { // The primary resolves to a Principal with no terminal, and BridgeMcp's context extractor