Merge CB-571: charter receipt on the roster, with no silent null for OpenCode
Verified by the lead: own build of this branch merged onto main — 710 tests, BUILD SUCCESS, exit 0. Supersedes PR #54. A reviewer found that OpenCodeLauncher.SessionAwareHandle wrapped the base WorkerHandle but never overrode charterReceipt(), so it inherited the interface default of null while the real receipt sat on its delegate — sol and terra would have shown no charterSource/charterSha256 on the roster while Claude Code members showed both. The fix is the root one, not the one-line override: PeerHandle.charterReceipt() is no longer a default, so the compiler forces every implementation to answer. This repo had shipped that same class of defect — a defaulted dependency that compiles, passes tests, and quietly turns a feature off — eight times before this one.
This commit is contained in:
@@ -7,6 +7,7 @@ import dev.ltms.bridged.herdr.HerdrException;
|
|||||||
import dev.ltms.bridged.herdr.Tab;
|
import dev.ltms.bridged.herdr.Tab;
|
||||||
import dev.ltms.bridged.herdr.Workspace;
|
import dev.ltms.bridged.herdr.Workspace;
|
||||||
import dev.ltms.bridged.herdr.WorkspaceControl;
|
import dev.ltms.bridged.herdr.WorkspaceControl;
|
||||||
|
import dev.ltms.bridged.peer.CharterReceipt;
|
||||||
import dev.ltms.bridged.peer.MemberRole;
|
import dev.ltms.bridged.peer.MemberRole;
|
||||||
import dev.ltms.bridged.peer.PeerHandle;
|
import dev.ltms.bridged.peer.PeerHandle;
|
||||||
import dev.ltms.bridged.peer.PeerLauncher;
|
import dev.ltms.bridged.peer.PeerLauncher;
|
||||||
@@ -269,8 +270,11 @@ public abstract class HerdrPeerLauncher implements PeerLauncher {
|
|||||||
|
|
||||||
// --- spawn ---------------------------------------------------------------------------------
|
// --- 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 replyCharter = cfg.hasMcp() ? REPLY_CHARTER : null;
|
||||||
String charter = roleCharter == null ? replyCharter
|
String charter = roleCharter == null ? replyCharter
|
||||||
: replyCharter == null ? roleCharter : roleCharter + "\n\n" + replyCharter;
|
: replyCharter == null ? roleCharter : roleCharter + "\n\n" + replyCharter;
|
||||||
|
// 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));
|
Launch launch = buildLaunch(cfg, new LaunchSpec(sessionName, resumeSessionId, role, charter));
|
||||||
String cwd = resolveCwd(requestedCwd, cfg, callerCwd);
|
String cwd = resolveCwd(requestedCwd, cfg, callerCwd);
|
||||||
Agent agent = cfg.tabPlacement()
|
Agent agent = cfg.tabPlacement()
|
||||||
? spawnInTab(cfg, launch.env(), launch.argv(), cwd, role, liveFleet)
|
? spawnInTab(cfg, launch.env(), launch.argv(), cwd, role, liveFleet)
|
||||||
: spawnAsPane(cfg, launch.env(), launch.argv(), cwd);
|
: spawnAsPane(cfg, launch.env(), launch.argv(), cwd, charter);
|
||||||
return new Spawned(agent, launch.agentSessionId());
|
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();
|
String id = UUID.randomUUID().toString();
|
||||||
paneByAgentId.put(id, paneId);
|
paneByAgentId.put(id, paneId);
|
||||||
return new WorkerHandle(id, agent.terminalId(), requireProfile(req.profileName()).profile(),
|
return new WorkerHandle(id, agent.terminalId(), requireProfile(req.profileName()).profile(),
|
||||||
req.sessionName(), spawned.agentSessionId());
|
req.sessionName(), spawned.agentSessionId(), spawned.receipt());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@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}.
|
||||||
|
*
|
||||||
|
* <p>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<String, String> workerEnv,
|
private Agent spawnAsPane(BridgedConfig.Profile cfg, Map<String, String> workerEnv,
|
||||||
List<String> argv, String cwd) {
|
List<String> argv, String cwd, String charter) {
|
||||||
log.info("spawning {} (pane placement) profile={} cwd={} argv={}",
|
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);
|
String paneId = spaces.splitPane(cwd, workerEnv);
|
||||||
if (paneId == null) {
|
if (paneId == null) {
|
||||||
throw new IllegalStateException("pane.split returned no pane — cannot start a peer");
|
throw new IllegalStateException("pane.split returned no pane — cannot start a peer");
|
||||||
@@ -447,6 +488,21 @@ public abstract class HerdrPeerLauncher implements PeerLauncher {
|
|||||||
return peer;
|
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<String> redactCharter(List<String> 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) ? "<charter sha256=" + digest + ">" : a)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
/** A started peer together with the sequence its unique name/label used. */
|
/** A started peer together with the sequence its unique name/label used. */
|
||||||
private record Started(Agent agent, long seq) {
|
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,
|
* 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
|
* the session identity the launch resolved (CB-547a): the bridge's logical name and the peer's
|
||||||
* peer's own session id, both null when the spawn carried no identity.
|
* 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,
|
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 ------------------------------------------------------------------------
|
// --- shared helpers ------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import dev.ltms.bridged.herdr.Agent;
|
|||||||
import dev.ltms.bridged.herdr.AgentControl;
|
import dev.ltms.bridged.herdr.AgentControl;
|
||||||
import dev.ltms.bridged.herdr.WorkspaceControl;
|
import dev.ltms.bridged.herdr.WorkspaceControl;
|
||||||
import dev.ltms.bridged.peer.Capability;
|
import dev.ltms.bridged.peer.Capability;
|
||||||
|
import dev.ltms.bridged.peer.CharterReceipt;
|
||||||
import dev.ltms.bridged.peer.PeerHandle;
|
import dev.ltms.bridged.peer.PeerHandle;
|
||||||
import dev.ltms.bridged.peer.SpawnRequest;
|
import dev.ltms.bridged.peer.SpawnRequest;
|
||||||
|
|
||||||
@@ -406,6 +407,11 @@ public final class OpenCodeLauncher extends HerdrPeerLauncher {
|
|||||||
// appeared).
|
// appeared).
|
||||||
return discovery.sessionIdForDirectory(cwd);
|
return discovery.sessionIdForDirectory(cwd);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public CharterReceipt charterReceipt() {
|
||||||
|
return delegate.charterReceipt();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Agent-returning convenience spawns (used by callers/tests that want the herdr Agent) ---
|
// --- Agent-returning convenience spawns (used by callers/tests that want the herdr Agent) ---
|
||||||
|
|||||||
@@ -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.
|
||||||
|
*
|
||||||
|
* <p>Lets an operator prove <em>which</em> 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.
|
||||||
|
*
|
||||||
|
* <p>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.
|
||||||
|
*
|
||||||
|
* <p>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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -67,4 +67,18 @@ public interface PeerHandle {
|
|||||||
default String agentSessionId() {
|
default String agentSessionId() {
|
||||||
return null;
|
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.
|
||||||
|
*
|
||||||
|
* <p>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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package dev.ltms.bridged.session;
|
package dev.ltms.bridged.session;
|
||||||
|
|
||||||
|
import dev.ltms.bridged.peer.CharterReceipt;
|
||||||
import dev.ltms.bridged.peer.MemberRole;
|
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 lastActivityAtNanos {@link System#nanoTime()} of the most recent lifecycle event
|
||||||
* @param turnCount number of delegated turns that have been delivered to this session
|
* @param turnCount number of delegated turns that have been delivered to this session
|
||||||
* @param state current lifecycle state in the one-shot FSM
|
* @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(
|
public record MemberSession(
|
||||||
String paneId,
|
String paneId,
|
||||||
@@ -36,7 +39,8 @@ public record MemberSession(
|
|||||||
int turnCount,
|
int turnCount,
|
||||||
State state,
|
State state,
|
||||||
String worktree,
|
String worktree,
|
||||||
String branch) {
|
String branch,
|
||||||
|
CharterReceipt charterReceipt) {
|
||||||
|
|
||||||
/** One-shot worker lifecycle states. */
|
/** One-shot worker lifecycle states. */
|
||||||
public enum State {
|
public enum State {
|
||||||
@@ -48,21 +52,34 @@ public record MemberSession(
|
|||||||
RELEASED
|
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}. */
|
/** Return a copy of this session in {@code state}. */
|
||||||
public MemberSession withState(State state) {
|
public MemberSession withState(State state) {
|
||||||
return new MemberSession(paneId, terminalId, profile, role, cwd, ownerTerminal, spawnedAtNanos,
|
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}. */
|
/** Return a copy with {@code lastActivityAtNanos} updated to {@code nowNanos}. */
|
||||||
public MemberSession withActivity(long nowNanos) {
|
public MemberSession withActivity(long nowNanos) {
|
||||||
return new MemberSession(paneId, terminalId, profile, role, cwd, ownerTerminal, spawnedAtNanos,
|
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}. */
|
/** Return a copy with the turn count incremented and activity timestamped at {@code nowNanos}. */
|
||||||
public MemberSession bumpTurn(long nowNanos) {
|
public MemberSession bumpTurn(long nowNanos) {
|
||||||
return new MemberSession(paneId, terminalId, profile, role, cwd, ownerTerminal, spawnedAtNanos,
|
return new MemberSession(paneId, terminalId, profile, role, cwd, ownerTerminal, spawnedAtNanos,
|
||||||
nowNanos, turnCount + 1, state, worktree, branch);
|
nowNanos, turnCount + 1, state, worktree, branch, charterReceipt);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -163,7 +163,8 @@ public final class SessionManager implements TurnListener {
|
|||||||
0,
|
0,
|
||||||
MemberSession.State.SPAWNING,
|
MemberSession.State.SPAWNING,
|
||||||
null,
|
null,
|
||||||
null);
|
null,
|
||||||
|
handle.charterReceipt());
|
||||||
registry.put(handle.id(), session);
|
registry.put(handle.id(), session);
|
||||||
memberLifecycle.acquired(session.role(), session.profile(), session.terminalId());
|
memberLifecycle.acquired(session.role(), session.profile(), session.terminalId());
|
||||||
log.debug("acquired session id={} terminal={} profile={} owner={}",
|
log.debug("acquired session id={} terminal={} profile={} owner={}",
|
||||||
@@ -355,7 +356,8 @@ public final class SessionManager implements TurnListener {
|
|||||||
0,
|
0,
|
||||||
MemberSession.State.SPAWNING,
|
MemberSession.State.SPAWNING,
|
||||||
path,
|
path,
|
||||||
branch);
|
branch,
|
||||||
|
handle.charterReceipt());
|
||||||
registry.put(handle.id(), session);
|
registry.put(handle.id(), session);
|
||||||
memberLifecycle.acquired(session.role(), session.profile(), session.terminalId());
|
memberLifecycle.acquired(session.role(), session.profile(), session.terminalId());
|
||||||
log.debug("acquired worktree session id={} terminal={} profile={} branch={} path={}",
|
log.debug("acquired worktree session id={} terminal={} profile={} branch={} path={}",
|
||||||
@@ -421,6 +423,16 @@ public final class SessionManager implements TurnListener {
|
|||||||
if (session.ownerTerminal() != null) {
|
if (session.ownerTerminal() != null) {
|
||||||
m.put("owner", session.ownerTerminal());
|
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.<role>") 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());
|
m.put("liveStatus", live == null ? "unknown" : live.status().name().toLowerCase());
|
||||||
return m;
|
return m;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import dev.ltms.bridged.herdr.AgentControl;
|
|||||||
import dev.ltms.bridged.herdr.FakeHerdr;
|
import dev.ltms.bridged.herdr.FakeHerdr;
|
||||||
import dev.ltms.bridged.herdr.WorkspaceControl;
|
import dev.ltms.bridged.herdr.WorkspaceControl;
|
||||||
import dev.ltms.bridged.peer.Capability;
|
import dev.ltms.bridged.peer.Capability;
|
||||||
|
import dev.ltms.bridged.peer.CharterReceipt;
|
||||||
import dev.ltms.bridged.peer.MemberRole;
|
import dev.ltms.bridged.peer.MemberRole;
|
||||||
import dev.ltms.bridged.peer.PeerHandle;
|
import dev.ltms.bridged.peer.PeerHandle;
|
||||||
import dev.ltms.bridged.peer.PeerLauncher;
|
import dev.ltms.bridged.peer.PeerLauncher;
|
||||||
@@ -93,6 +94,7 @@ class CompositePeerLauncherTest {
|
|||||||
@Override public String id() { return "pane-" + p; }
|
@Override public String id() { return "pane-" + p; }
|
||||||
@Override public String terminalId() { return "term-" + p; }
|
@Override public String terminalId() { return "term-" + p; }
|
||||||
@Override public String profile() { return p; }
|
@Override public String profile() { return p; }
|
||||||
|
@Override public CharterReceipt charterReceipt() { return null; }
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,19 @@
|
|||||||
package dev.ltms.bridged.member;
|
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.config.BridgedConfig;
|
||||||
import dev.ltms.bridged.herdr.AgentControl;
|
import dev.ltms.bridged.herdr.AgentControl;
|
||||||
import dev.ltms.bridged.herdr.FakeHerdr;
|
import dev.ltms.bridged.herdr.FakeHerdr;
|
||||||
import dev.ltms.bridged.herdr.WorkspaceControl;
|
import dev.ltms.bridged.herdr.WorkspaceControl;
|
||||||
import dev.ltms.bridged.peer.Capability;
|
import dev.ltms.bridged.peer.Capability;
|
||||||
|
import dev.ltms.bridged.peer.CharterReceipt;
|
||||||
import dev.ltms.bridged.peer.MemberRole;
|
import dev.ltms.bridged.peer.MemberRole;
|
||||||
import dev.ltms.bridged.peer.SpawnRequest;
|
import dev.ltms.bridged.peer.SpawnRequest;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -17,6 +23,8 @@ import java.util.concurrent.atomic.AtomicReference;
|
|||||||
import java.util.function.Supplier;
|
import java.util.function.Supplier;
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
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 {
|
class HerdrPeerLauncherCharterTest {
|
||||||
|
|
||||||
@@ -38,10 +46,72 @@ class HerdrPeerLauncherCharterTest {
|
|||||||
"a role charter does not depend on an MCP mount");
|
"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<BridgedConfig.Fleet> 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<ILoggingEvent> 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("<charter sha256=" + CharterReceipt.digestOf(composed) + ">"),
|
||||||
|
"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<String, String> charters) {
|
private static BridgedConfig.Fleet fleet(Map<String, String> charters) {
|
||||||
return new BridgedConfig.Fleet(Map.of(), Map.of(), Map.of(), Map.of(), charters, null);
|
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<BridgedConfig.Fleet> 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<Capability> capabilities() {
|
||||||
|
return Set.of();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static final class CapturingLauncher extends HerdrPeerLauncher {
|
private static final class CapturingLauncher extends HerdrPeerLauncher {
|
||||||
private final List<LaunchSpec> specs = new ArrayList<>();
|
private final List<LaunchSpec> specs = new ArrayList<>();
|
||||||
|
|
||||||
@@ -62,10 +132,5 @@ class HerdrPeerLauncherCharterTest {
|
|||||||
public Set<Capability> capabilities() {
|
public Set<Capability> capabilities() {
|
||||||
return Set.of();
|
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import dev.ltms.bridged.herdr.AgentControl;
|
|||||||
import dev.ltms.bridged.herdr.FakeHerdr;
|
import dev.ltms.bridged.herdr.FakeHerdr;
|
||||||
import dev.ltms.bridged.herdr.WorkspaceControl;
|
import dev.ltms.bridged.herdr.WorkspaceControl;
|
||||||
import dev.ltms.bridged.peer.Capability;
|
import dev.ltms.bridged.peer.Capability;
|
||||||
|
import dev.ltms.bridged.peer.CharterReceipt;
|
||||||
import dev.ltms.bridged.peer.PeerHandle;
|
import dev.ltms.bridged.peer.PeerHandle;
|
||||||
import dev.ltms.bridged.peer.PeerUnreachableException;
|
import dev.ltms.bridged.peer.PeerUnreachableException;
|
||||||
import dev.ltms.bridged.peer.SpawnRequest;
|
import dev.ltms.bridged.peer.SpawnRequest;
|
||||||
@@ -322,6 +323,26 @@ class OpenCodeLauncherTest {
|
|||||||
assertFalse(herdr.called("agent.get"), "no polling when the gate is disabled");
|
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) ---------------------------
|
// --- 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. */
|
/** A profile with a baseUrl but no model provider prefix cannot be resolved — fail loudly. */
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,8 @@ import dev.ltms.bridged.herdr.FakeHerdr;
|
|||||||
import dev.ltms.bridged.herdr.WorkspaceControl;
|
import dev.ltms.bridged.herdr.WorkspaceControl;
|
||||||
import dev.ltms.bridged.member.ClaudeCodeLauncher;
|
import dev.ltms.bridged.member.ClaudeCodeLauncher;
|
||||||
import dev.ltms.bridged.msg.TestTurnTokens;
|
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 dev.ltms.bridged.peer.PeerUnreachableException;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
@@ -91,6 +93,24 @@ class SessionManagerTest {
|
|||||||
assertEquals(2, sessions.roster().size(), "both sessions are registered");
|
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<String, Object> 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
|
@Test
|
||||||
void aNullTerminalFromThePrimaryIsANoOpEvenWithSessionsRegistered() {
|
void aNullTerminalFromThePrimaryIsANoOpEvenWithSessionsRegistered() {
|
||||||
// The primary resolves to a Principal with no terminal, and BridgeMcp's context extractor
|
// The primary resolves to a Principal with no terminal, and BridgeMcp's context extractor
|
||||||
|
|||||||
Reference in New Issue
Block a user