Compare commits

...

1 Commits

Author SHA1 Message Date
Dai Ha 7e97f5bff5 CB-617 Unit A: role charter via file, not argv
CI / build (pull_request) Successful in 1m16s
CI / contract (pull_request) Successful in 1m28s
herdr refused to shell-encode a multi-line inline --append-system-prompt
argument (invalid_agent_argument), which broke any Claude Code profile with
a configured multi-line fleet.charters.<role>. Split delivery: the role
charter now writes to a temp file and mounts via
--append-system-prompt-file; the one-line REPLY_CHARTER keeps its inline
--append-system-prompt delivery, since it must reach a member with no repo
checkout. Also pass --agent <role> when a role's agent-definition file
exists under the worker's cwd (.claude/agents/<role>.md for claude-code,
.opencode/agent/<role>.md for opencode); absent either file, nothing extra
is added and the member still spawns.

The base's LaunchSpec now carries roleCharter/replyCharter/cwd alongside
the existing composed charter field, so CharterReceipt keeps fingerprinting
the same composed text it always did -- unchanged, since the digest covers
the full logical charter content regardless of how it is delivered.
2026-08-22 12:04:38 +02:00
5 changed files with 213 additions and 26 deletions
@@ -9,6 +9,10 @@ import dev.ltms.bridged.peer.Capability;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.EnumSet;
import java.util.List;
import java.util.Map;
@@ -230,7 +234,7 @@ public final class ClaudeCodeLauncher extends HerdrPeerLauncher {
// model flag so --model keeps outranking the operator's own argv.
// mutableArgv: argvWithBridge may hand back the profile's own (immutable) List.of when it
// has neither MCP nor a charter — session flags must be added into a list we own.
List<String> argv = mutableArgv(argvWithBridge(cfg, spec.charter()));
List<String> argv = mutableArgv(argvWithBridge(cfg, spec));
String agentSessionId = applySessionIdentity(argv, spec.sessionName(), spec.resumeSessionId());
return new Launch(workerEnv, argvWithModel(argv, cfg), agentSessionId);
}
@@ -265,13 +269,25 @@ public final class ClaudeCodeLauncher extends HerdrPeerLauncher {
}
/**
* The launch argv, plus an inline {@code --mcp-config} when {@code worker.mcpUrl} is set and
* {@code --append-system-prompt} when the base composed a charter. Neither touches the profile's
* config; both are pure command-line flags. This inline-flag mount is Claude Code specific —
* other adapters mount MCP and instructions their own way.
* The launch argv, plus an inline {@code --mcp-config} when {@code worker.mcpUrl} is set, the
* CB-617 charter flags, and {@code --agent <role>} when the role has an agent-definition file
* under the worker's cwd. Neither touches the profile's config; all are pure command-line flags.
* This inline-flag mount is Claude Code specific — other adapters mount MCP and instructions
* their own way.
*
* <p>CB-617: the role charter is operator-authored and often multi-line, so it can never be a
* single inline argv element — herdr refuses to shell-encode a multi-line argument
* ({@code invalid_agent_argument}). It is written to a temp file instead and mounted with
* {@code --append-system-prompt-file}, which this host confirms Claude Code accepts for a
* multi-line file. The reply charter is always exactly one line and demonstrably encodes, and it
* must reach a member with no repo checkout (nowhere to put a file), so it keeps its inline
* {@code --append-system-prompt} delivery.
*/
private List<String> argvWithBridge(BridgedConfig.Profile cfg, String charter) {
if (!cfg.hasMcp() && charter == null) {
private List<String> argvWithBridge(BridgedConfig.Profile cfg, LaunchSpec spec) {
String roleCharter = nonBlank(spec.roleCharter());
String replyCharter = nonBlank(spec.replyCharter());
Path agentFile = agentDefinitionFile(spec.cwd(), spec.role(), ".claude", "agents");
if (!cfg.hasMcp() && roleCharter == null && replyCharter == null && agentFile == null) {
return cfg.argv();
}
List<String> argv = mutableArgv(cfg.argv());
@@ -281,13 +297,45 @@ public final class ClaudeCodeLauncher extends HerdrPeerLauncher {
argv.add("--mcp-config");
argv.add(mcpJson);
}
if (charter != null) {
if (roleCharter != null) {
argv.add("--append-system-prompt-file");
argv.add(writeRoleCharterFile(roleCharter).toString());
}
if (replyCharter != null) {
argv.add("--append-system-prompt");
argv.add(charter);
argv.add(replyCharter);
}
if (agentFile != null) {
argv.add("--agent");
argv.add(spec.role().wireName());
}
return argv;
}
/** {@code s}, or {@code null} when {@code s} is null/blank — the charter-presence test used above. */
private static String nonBlank(String s) {
return (s == null || s.isBlank()) ? null : s;
}
/**
* Write the role charter to a fresh temp file so it can be mounted with
* {@code --append-system-prompt-file} instead of riding inline in argv (CB-617). Best-effort
* cleaned via {@code deleteOnExit} — the same disposable-worker-config cleanup
* {@link OpenCodeLauncher#writeConfig} already uses for its charter file, since the process that
* reads this file (the spawned peer) outlives this JVM call and there is no spawn-scoped teardown
* hook to delete it synchronously.
*/
private static Path writeRoleCharterFile(String roleCharter) {
try {
Path file = Files.createTempFile("bridged-role-charter-", ".md");
Files.writeString(file, roleCharter);
file.toFile().deleteOnExit();
return file;
} catch (IOException e) {
throw new UncheckedIOException("cannot write role charter temp file", e);
}
}
/**
* Pin the model on the command line as well as in {@code ANTHROPIC_MODEL} (CB-533).
*
@@ -17,6 +17,8 @@ import dev.ltms.bridged.peer.SpawnRequest;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.SecureRandom;
import java.util.ArrayList;
import java.util.Collection;
@@ -279,8 +281,35 @@ public abstract class HerdrPeerLauncher implements PeerLauncher {
}
}
/** All per-spawn values adapters may need, including the base-composed effective charter. */
protected record LaunchSpec(String sessionName, String resumeSessionId, MemberRole role, String charter) {
/**
* All per-spawn values adapters may need.
*
* <p>{@code charter} is the base-composed effective charter (role charter, then the reply
* charter, joined by a blank line) — kept for an adapter that mounts both as one blob (opencode
* writes it to a single file) and as the exact input {@link CharterReceipt#compose} fingerprints.
* {@code roleCharter} and {@code replyCharter} are the same text split back into its two parts
* (CB-617), for an adapter that must deliver them differently: the role charter is
* operator-authored and often multi-line, so it cannot travel as an inline argv element (herdr
* refuses to shell-encode a multi-line argument); the reply charter is always one line and is
* demonstrated to encode, so it may still go inline. {@code cwd} is the spawn's resolved working
* directory (CB-112), needed to look up a role's agent-definition file before the peer starts.
*/
protected record LaunchSpec(String sessionName, String resumeSessionId, MemberRole role, String charter,
String roleCharter, String replyCharter, String cwd) {
}
/**
* The role's agent-definition file under {@code <cwd>/<dir1>/<dir2>/<role>.md}, or {@code null}
* when absent or inapplicable (no role, no cwd, or the file does not exist) — CB-617. A member
* whose role has no such file must still spawn, so this is a lookup, never a requirement: the
* caller passes {@code --agent <role>} only when the return value is non-null.
*/
protected static Path agentDefinitionFile(String cwd, MemberRole role, String dir1, String dir2) {
if (role == null || cwd == null || cwd.isBlank()) {
return null;
}
Path candidate = Path.of(cwd, dir1, dir2, role.wireName() + ".md");
return Files.isRegularFile(candidate) ? candidate : null;
}
// --- profile surface -----------------------------------------------------------------------
@@ -387,9 +416,10 @@ public abstract class HerdrPeerLauncher implements PeerLauncher {
// 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);
String cwd = resolveCwd(requestedCwd, cfg, callerCwd);
try {
Launch launch = buildLaunch(cfg, new LaunchSpec(sessionName, resumeSessionId, role, charter));
String cwd = resolveCwd(requestedCwd, cfg, callerCwd);
Launch launch = buildLaunch(cfg, new LaunchSpec(sessionName, resumeSessionId, role, charter,
roleCharter, replyCharter, cwd));
Agent agent = cfg.tabPlacement()
? spawnInTab(cfg, launch.env(), launch.argv(), cwd, role, liveFleet)
: spawnAsPane(cfg, launch.env(), launch.argv(), cwd, charter);
@@ -208,8 +208,24 @@ public final class OpenCodeLauncher extends HerdrPeerLauncher {
workerEnv.put("OPENCODE_CONFIG", writeConfig(cfg, spec.charter()).toString());
}
applyGitToken(workerEnv, cfg);
return new Launch(workerEnv,
argvWithResume(argvWithModel(argvWithAuto(cfg), cfg), spec.resumeSessionId()));
List<String> argv = argvWithResume(argvWithModel(argvWithAuto(cfg), cfg), spec.resumeSessionId());
return new Launch(workerEnv, argvWithAgent(argv, spec));
}
/**
* The launch argv plus, when the role has an agent-definition file under the worker's cwd,
* opencode's {@code --agent <role>} flag (CB-617). A role with no such file gets nothing added —
* the member must still spawn.
*/
private List<String> argvWithAgent(List<String> argv, LaunchSpec spec) {
Path agentFile = agentDefinitionFile(spec.cwd(), spec.role(), ".opencode", "agent");
if (agentFile == null) {
return argv;
}
List<String> withAgent = mutableArgv(argv);
withAgent.add("--agent");
withAgent.add(spec.role().wireName());
return withAgent;
}
/**
@@ -15,8 +15,11 @@ import dev.ltms.bridged.peer.PeerHandle;
import dev.ltms.bridged.peer.PeerUnreachableException;
import dev.ltms.bridged.peer.SpawnRequest;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.slf4j.LoggerFactory;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Map;
import java.util.Set;
@@ -106,10 +109,19 @@ class ClaudeCodeLauncherTest {
"the operator's own args are preserved, in order, ahead of the model flag");
}
// --- CB-617: the role charter travels by file, never inline; the reply charter stays inline ---
/**
* CB-617: herdr refuses to shell-encode a multi-line inline argv argument
* ({@code invalid_agent_argument}) — the exact failure this reproduced on profile {@code opus}.
* The role charter is operator-authored and often multi-line, so it must never appear as an argv
* element; the one-line reply charter still may. This drives the real launcher entry point
* ({@code spawn}), the same path a live spawn takes — not the argv builder in isolation.
*/
@Test
void appendsTheBaseComposedRoleAndReplyCharter() {
void roleCharterTravelsByFileAndReplyCharterStaysInline() {
FakeHerdr herdr = new FakeHerdr();
String roleCharter = "You review changes.";
String roleCharter = "You review changes.\nLine two.\nLine three.";
BridgedConfig.Profile cfg = new BridgedConfig.Profile(
"sonnet", "http://gx00.gw:8000", null, null, "BRIDGED_WORKER_TOKEN",
List.of("claude"), "tab", "bridged-workers", "w #{n}",
@@ -121,11 +133,20 @@ class ClaudeCodeLauncherTest {
svc.spawn(new SpawnRequest("sonnet", null, null, null, null, MemberRole.REVIEWER));
List<String> args = spawnedArgs(herdr);
int flag = args.indexOf("--append-system-prompt");
assertEquals(1, args.stream().filter("--append-system-prompt"::equals).count(),
"the composed charter is passed once");
assertEquals(roleCharter + "\n\n" + HerdrPeerLauncher.REPLY_CHARTER, args.get(flag + 1),
"the role charter comes first and the reply rule comes last");
assertTrue(args.stream().noneMatch(a -> a.contains("\n")),
"no argv element may be multi-line — herdr cannot shell-encode one: " + args);
int fileFlag = args.indexOf("--append-system-prompt-file");
assertTrue(fileFlag >= 0, "the role charter is mounted via --append-system-prompt-file: " + args);
assertDoesNotThrow(() -> {
String written = Files.readString(Path.of(args.get(fileFlag + 1)));
assertEquals(roleCharter, written, "the file holds exactly the role charter text");
}, "the --append-system-prompt-file path must be a readable file");
int inlineFlag = args.indexOf("--append-system-prompt");
assertTrue(inlineFlag >= 0, "the reply charter still travels inline: " + args);
assertEquals(HerdrPeerLauncher.REPLY_CHARTER, args.get(inlineFlag + 1),
"only the reply charter (one line) rides inline — the role charter never does");
}
@Test
@@ -135,11 +156,13 @@ class ClaudeCodeLauncherTest {
svc.spawn(new SpawnRequest("sonnet", null, null, null, null, MemberRole.DEV));
assertFalse(spawnedArgs(herdr).contains("--append-system-prompt"));
List<String> args = spawnedArgs(herdr);
assertFalse(args.contains("--append-system-prompt"));
assertFalse(args.contains("--append-system-prompt-file"));
}
@Test
void profileWithoutMcpStillGetsItsRoleCharter() {
void profileWithoutMcpStillGetsItsRoleCharterAsAFileNotInline() {
FakeHerdr herdr = new FakeHerdr();
String roleCharter = "You design changes.";
ClaudeCodeLauncher svc = labelService(herdr, () -> fleet(Map.of("architect", roleCharter), null));
@@ -147,12 +170,52 @@ class ClaudeCodeLauncherTest {
svc.spawn(new SpawnRequest("sonnet", null, null, null, null, MemberRole.ARCHITECT));
List<String> args = spawnedArgs(herdr);
int flag = args.indexOf("--append-system-prompt");
int flag = args.indexOf("--append-system-prompt-file");
assertTrue(flag >= 0, "a role charter does not need an MCP mount");
assertEquals(roleCharter, args.get(flag + 1));
assertDoesNotThrow(() -> assertEquals(roleCharter,
Files.readString(Path.of(args.get(flag + 1)))),
"the file holds the role charter");
assertFalse(args.contains("--append-system-prompt"), "no reply charter without an MCP mount");
assertFalse(args.contains("--mcp-config"));
}
// --- CB-617: --agent <role> when the role has an agent-definition file --------------------
@Test
void agentFlagIsPassedWhenTheRoleAgentDefinitionFileExists(@TempDir Path cwd) throws Exception {
FakeHerdr herdr = new FakeHerdr();
Path agentsDir = Files.createDirectories(cwd.resolve(".claude/agents"));
Files.writeString(agentsDir.resolve("reviewer.md"), "---\nname: reviewer\n---\nBe a reviewer.");
BridgedConfig.Profile cfg = new BridgedConfig.Profile(
"sonnet", "http://gx00.gw:8000", null, null, "BRIDGED_WORKER_TOKEN",
List.of("claude"), "tab", "bridged-workers", "w #{n}", null, null, null);
ClaudeCodeLauncher svc = new ClaudeCodeLauncher(new AgentControl(herdr), new WorkspaceControl(herdr),
new SubscriptionGuard(Set.of("gx00.gw")), Map.of(cfg.profile(), cfg), cfg.profile(), _ -> null);
svc.spawn(new SpawnRequest("sonnet", cwd.toString(), null, null, null, MemberRole.REVIEWER));
List<String> args = spawnedArgs(herdr);
int flag = args.indexOf("--agent");
assertTrue(flag >= 0, "--agent is passed when the role's agent file exists: " + args);
assertEquals("reviewer", args.get(flag + 1));
}
@Test
void noAgentFlagWhenTheRoleAgentDefinitionFileIsAbsent(@TempDir Path cwd) {
FakeHerdr herdr = new FakeHerdr();
BridgedConfig.Profile cfg = new BridgedConfig.Profile(
"sonnet", "http://gx00.gw:8000", null, null, "BRIDGED_WORKER_TOKEN",
List.of("claude"), "tab", "bridged-workers", "w #{n}", null, null, null);
ClaudeCodeLauncher svc = new ClaudeCodeLauncher(new AgentControl(herdr), new WorkspaceControl(herdr),
new SubscriptionGuard(Set.of("gx00.gw")), Map.of(cfg.profile(), cfg), cfg.profile(), _ -> null);
PeerHandle handle = svc.spawn(new SpawnRequest("sonnet", cwd.toString(), null, null, null, MemberRole.REVIEWER));
assertNotNull(handle, "the member still spawns with no agent-definition file");
assertFalse(spawnedArgs(herdr).contains("--agent"),
"no --agent flag when the role has no agent-definition file");
}
private ClaudeCodeLauncher multiProfile(FakeHerdr herdr) {
BridgedConfig.Profile gx10 = new BridgedConfig.Profile("gx10", "http://gx10.gw:8000", "coder",
null, "BRIDGED_WORKER_TOKEN", List.of("claude"), "tab", "bridged-workers", "w #{n}", null, null, null);
@@ -8,6 +8,7 @@ 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.PeerUnreachableException;
import dev.ltms.bridged.peer.SpawnRequest;
@@ -208,6 +209,35 @@ class OpenCodeLauncherTest {
"--auto is unconditional: a model-less worker still must never block on approval");
}
// --- CB-617: --agent <role> when the role has an agent-definition file --------------------
@Test
void agentFlagIsPassedWhenTheRoleAgentDefinitionFileExists(@TempDir Path root) throws Exception {
FakeHerdr herdr = new FakeHerdr();
Path agentsDir = Files.createDirectories(root.resolve(".opencode/agent"));
Files.writeString(agentsDir.resolve("dev.md"), "You are a dev.");
OpenCodeLauncher svc = service(herdr, root, opencodeCfg(null, null, null));
svc.spawn(new SpawnRequest(null, root.toString(), null, null, null, MemberRole.DEV));
List<String> args = startArgs(herdr);
int flag = args.indexOf("--agent");
assertTrue(flag >= 0, "--agent is passed when the role's agent file exists: " + args);
assertEquals("dev", args.get(flag + 1));
}
@Test
void noAgentFlagWhenTheRoleAgentDefinitionFileIsAbsent(@TempDir Path root) {
FakeHerdr herdr = new FakeHerdr();
OpenCodeLauncher svc = service(herdr, root, opencodeCfg(null, null, null));
PeerHandle handle = svc.spawn(new SpawnRequest(null, root.toString(), null, null, null, MemberRole.DEV));
assertNotNull(handle, "the member still spawns with no agent-definition file");
assertFalse(startArgs(herdr).contains("--agent"),
"no --agent flag when the role has no agent-definition file");
}
@Test
void injectsForgeTokenWhenProfileGrantsIt(@TempDir Path root) {
FakeHerdr herdr = new FakeHerdr();