From 1966c6999407276befc6d43e98c1c9c0e40cb18b Mon Sep 17 00:00:00 2001 From: Dai Ha Date: Sat, 15 Aug 2026 08:10:56 +0200 Subject: [PATCH 1/3] CB-575: charter receipt on spawn, in the roster and in the logs Record a CharterReceipt (role, source, sha-256 digest, byte count) for every launch, store it on the MemberSession, expose it in bridge_list and GET /members, and log it at spawn as digest+role only. Redact the charter argv argument in the legacy pane-placement spawn log so the charter text never reaches the daemon log. The charter prose itself is never recorded. --- .../bridged/member/HerdrPeerLauncher.java | 93 ++++++++++++++++--- .../dev/ltms/bridged/peer/CharterReceipt.java | 84 +++++++++++++++++ .../dev/ltms/bridged/peer/PeerHandle.java | 12 +++ .../ltms/bridged/session/MemberSession.java | 25 ++++- .../ltms/bridged/session/SessionManager.java | 16 +++- .../member/HerdrPeerLauncherCharterTest.java | 75 ++++++++++++++- .../ltms/bridged/peer/CharterReceiptTest.java | 57 ++++++++++++ .../bridged/session/SessionManagerTest.java | 20 ++++ 8 files changed, 356 insertions(+), 26 deletions(-) create mode 100644 bridged/src/main/java/dev/ltms/bridged/peer/CharterReceipt.java create mode 100644 bridged/src/test/java/dev/ltms/bridged/peer/CharterReceiptTest.java 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..324de7e 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-575) 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-575: 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-575: 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-575) 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/peer/CharterReceipt.java b/bridged/src/main/java/dev/ltms/bridged/peer/CharterReceipt.java new file mode 100644 index 0000000..3387408 --- /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-575: 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..13fac9c 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,16 @@ public interface PeerHandle { default String agentSessionId() { return null; } + + /** + * The charter receipt (CB-575) 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. + * + * @return the fingerprint, or {@code null} when the launcher carries none + */ + default CharterReceipt charterReceipt() { + return null; + } } 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..0b9f072 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-575) 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-575). 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 fdb5766..11d7b02 100644 --- a/bridged/src/main/java/dev/ltms/bridged/session/SessionManager.java +++ b/bridged/src/main/java/dev/ltms/bridged/session/SessionManager.java @@ -162,7 +162,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={}", @@ -344,7 +345,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={}", @@ -410,6 +412,16 @@ public final class SessionManager implements TurnListener { if (session.ownerTerminal() != null) { m.put("owner", session.ownerTerminal()); } + // CB-575: 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/HerdrPeerLauncherCharterTest.java b/bridged/src/test/java/dev/ltms/bridged/member/HerdrPeerLauncherCharterTest.java index 0da9740..de2580f 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-575), 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/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 febf37a..4d8a3a3 100644 --- a/bridged/src/test/java/dev/ltms/bridged/session/SessionManagerTest.java +++ b/bridged/src/test/java/dev/ltms/bridged/session/SessionManagerTest.java @@ -10,6 +10,8 @@ import dev.ltms.bridged.herdr.AgentControl; import dev.ltms.bridged.herdr.FakeHerdr; import dev.ltms.bridged.herdr.WorkspaceControl; import dev.ltms.bridged.member.ClaudeCodeLauncher; +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; @@ -90,6 +92,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-575). + 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 From 9ca9c43dfab1f8c1473347176f960d0baf670751 Mon Sep 17 00:00:00 2001 From: Dai Ha Date: Sat, 15 Aug 2026 08:48:57 +0200 Subject: [PATCH 2/3] CB-571: retag charter-receipt references from the taken CB-575 CB-575 already names the merged MCP-cancellation-filter change, so the charter-receipt comments used the wrong number. Retag to CB-571, the number this work was authored against. --- .../java/dev/ltms/bridged/member/HerdrPeerLauncher.java | 8 ++++---- .../main/java/dev/ltms/bridged/peer/CharterReceipt.java | 2 +- .../src/main/java/dev/ltms/bridged/peer/PeerHandle.java | 2 +- .../main/java/dev/ltms/bridged/session/MemberSession.java | 4 ++-- .../java/dev/ltms/bridged/session/SessionManager.java | 2 +- .../ltms/bridged/member/HerdrPeerLauncherCharterTest.java | 2 +- .../java/dev/ltms/bridged/session/SessionManagerTest.java | 2 +- 7 files changed, 11 insertions(+), 11 deletions(-) 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 324de7e..320509d 100644 --- a/bridged/src/main/java/dev/ltms/bridged/member/HerdrPeerLauncher.java +++ b/bridged/src/main/java/dev/ltms/bridged/member/HerdrPeerLauncher.java @@ -272,7 +272,7 @@ public abstract class HerdrPeerLauncher implements PeerLauncher { /** * A started peer plus the launch's agent-session id (the resume handle, or null) and the - * charter receipt (CB-575) the base composed for it. + * charter receipt (CB-571) the base composed for it. */ private record Spawned(Agent agent, String agentSessionId, CharterReceipt receipt) { } @@ -309,7 +309,7 @@ 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; - // CB-575: fingerprint the exact composed charter bytes once, here in the base, before the + // 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. @@ -469,7 +469,7 @@ public abstract class HerdrPeerLauncher implements PeerLauncher { /** * Legacy placement: split the currently-focused tab; the peer still starts in {@code cwd}. * - *

CB-575: this is the one legacy log that printed the full argv, and the charter travels + *

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 @@ -709,7 +709,7 @@ public abstract class HerdrPeerLauncher implements PeerLauncher { * A concrete {@link PeerHandle} wrapping herdr agent coordinates, the profile that spawned it, * 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-575) the base computed for this launch. + * (CB-571) the base computed for this launch. */ private record WorkerHandle(String id, String terminalId, String profile, String sessionName, String agentSessionId, diff --git a/bridged/src/main/java/dev/ltms/bridged/peer/CharterReceipt.java b/bridged/src/main/java/dev/ltms/bridged/peer/CharterReceipt.java index 3387408..79bf78b 100644 --- a/bridged/src/main/java/dev/ltms/bridged/peer/CharterReceipt.java +++ b/bridged/src/main/java/dev/ltms/bridged/peer/CharterReceipt.java @@ -6,7 +6,7 @@ import java.security.NoSuchAlgorithmException; import java.util.HexFormat; /** - * CB-575: a fingerprint of the exact charter bytes handed to a spawned member. + * 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 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 13fac9c..9c80c5e 100644 --- a/bridged/src/main/java/dev/ltms/bridged/peer/PeerHandle.java +++ b/bridged/src/main/java/dev/ltms/bridged/peer/PeerHandle.java @@ -69,7 +69,7 @@ public interface PeerHandle { } /** - * The charter receipt (CB-575) for this peer's launch — the fingerprint of the exact charter + * 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. 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 0b9f072..9726e06 100644 --- a/bridged/src/main/java/dev/ltms/bridged/session/MemberSession.java +++ b/bridged/src/main/java/dev/ltms/bridged/session/MemberSession.java @@ -24,7 +24,7 @@ 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-575) of the charter bytes this member was started + * @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( @@ -54,7 +54,7 @@ public record MemberSession( /** * Backward-compatible shape: a session with no charter receipt (a test or a launcher before - * CB-575). A separate constructor rather than a new parameter on the canonical one, so existing + * 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, 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 11d7b02..08399bd 100644 --- a/bridged/src/main/java/dev/ltms/bridged/session/SessionManager.java +++ b/bridged/src/main/java/dev/ltms/bridged/session/SessionManager.java @@ -412,7 +412,7 @@ public final class SessionManager implements TurnListener { if (session.ownerTerminal() != null) { m.put("owner", session.ownerTerminal()); } - // CB-575: which charter this member was started with — never the charter text itself. The + // 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"). 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 de2580f..c657744 100644 --- a/bridged/src/test/java/dev/ltms/bridged/member/HerdrPeerLauncherCharterTest.java +++ b/bridged/src/test/java/dev/ltms/bridged/member/HerdrPeerLauncherCharterTest.java @@ -48,7 +48,7 @@ class HerdrPeerLauncherCharterTest { @Test void panePlacementSpawnLogNeverContainsTheCharterText() { - // A pane-placement spawn used to log the whole argv (CB-575), and the charter travels + // 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 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 4d8a3a3..190cf8a 100644 --- a/bridged/src/test/java/dev/ltms/bridged/session/SessionManagerTest.java +++ b/bridged/src/test/java/dev/ltms/bridged/session/SessionManagerTest.java @@ -95,7 +95,7 @@ class SessionManagerTest { @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-575). + // 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")); From 35ade146300eb54e12f3e92c1ca8f8c20815684c Mon Sep 17 00:00:00 2001 From: Dai Ha Date: Sat, 15 Aug 2026 09:55:52 +0200 Subject: [PATCH 3/3] CB-571: make PeerHandle.charterReceipt() abstract, fix OpenCode adapter's silent null SessionAwareHandle wrapped the base's WorkerHandle but never overrode charterReceipt(), so it silently inherited the interface default (null) while the real receipt sat on its delegate. sol/terra never got a charterSource/charterSha256 roster row. Deletes the default so every PeerHandle must answer explicitly; the compiler now catches this class of gap instead of a roster field quietly going missing. --- .../ltms/bridged/member/OpenCodeLauncher.java | 6 ++++++ .../dev/ltms/bridged/peer/PeerHandle.java | 8 ++++--- .../member/CompositePeerLauncherTest.java | 2 ++ .../bridged/member/OpenCodeLauncherTest.java | 21 +++++++++++++++++++ 4 files changed, 34 insertions(+), 3 deletions(-) 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/PeerHandle.java b/bridged/src/main/java/dev/ltms/bridged/peer/PeerHandle.java index 9c80c5e..8d4827c 100644 --- a/bridged/src/main/java/dev/ltms/bridged/peer/PeerHandle.java +++ b/bridged/src/main/java/dev/ltms/bridged/peer/PeerHandle.java @@ -74,9 +74,11 @@ public interface PeerHandle { * 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 */ - default CharterReceipt charterReceipt() { - return null; - } + CharterReceipt charterReceipt(); } 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/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. */