Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6eaa7aced3 | |||
| ef49835c4f | |||
| ef1e014b41 | |||
| e3c8393d1b | |||
| 379e03f9d0 | |||
| e13921aa8a |
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "claude-bridge",
|
||||
"description": "Tooling for orchestrating a fleet of delegated coding agents through the bridged MCP gateway.",
|
||||
"owner": {
|
||||
"name": "LTMS"
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
"name": "claude-bridge",
|
||||
"source": "./plugin",
|
||||
"description": "Make a project bridge-ready: mount the bridged MCP gateway and apply standard Claude Code settings so a session can orchestrate delegated workers. Ships no credentials.",
|
||||
"version": "0.1.0",
|
||||
"author": {
|
||||
"name": "LTMS"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -116,15 +116,21 @@ workers:
|
||||
# configDir: /Users/me/.ccs/instances/gx10 # CLAUDE_CONFIG_DIR — inherit that profile's skills/MCP
|
||||
# cwd: /Users/me/src/myrepo # pin the working dir; omit to inherit the primary's
|
||||
# parityOverlay: [".claude/settings.local.json", ".env", ".envrc"] # never add .mcp.json — see above
|
||||
ollama:
|
||||
baseUrl: http://ollama.ltms.dev # local/self-hosted; usually no token
|
||||
gx11: # a second backend, so `placement: weighted` has a choice
|
||||
baseUrl: http://gx01.gw:8000 # self-hosted; ccs handles the model + token
|
||||
placement: tab
|
||||
workspace: bridged-workers
|
||||
tabLabel: "worker: {profile} #{n}"
|
||||
mcpUrl: http://127.0.0.1:8765/mcp
|
||||
argv: ["ccs", "ollama"]
|
||||
argv: ["ccs", "gx11"]
|
||||
weight: 0.5
|
||||
maxLoad: 2
|
||||
# Pin an auto-compact window BELOW the served model's context ceiling. The global
|
||||
# ~/.claude/settings.json value is shared by every ccs instance and the primary, so the
|
||||
# per-profile override belongs here. Equal to the ceiling means auto-compact never fires
|
||||
# before the server rejects the prompt, which kills a worker mid-turn (CB-523).
|
||||
env:
|
||||
CLAUDE_CODE_AUTO_COMPACT_WINDOW: "280000"
|
||||
# CB-402: a second coding-agent kind, proving the PeerLauncher SPI is provider-neutral.
|
||||
# opencode is provider-agnostic and uses NONE of Claude's private seams: no ANTHROPIC_BASE_URL /
|
||||
# SubscriptionGuard (so it needs no `guard` host entry), no --mcp-config / --append-system-prompt.
|
||||
@@ -179,7 +185,6 @@ guard:
|
||||
offSubscriptionHosts:
|
||||
- gx00.gw
|
||||
- gx01.gw
|
||||
- ollama.ltms.dev
|
||||
|
||||
# Spawn-readiness gate (CB-306). The launcher blocks until the worker's herdr status is
|
||||
# injectable (IDLE/BLOCKED/DONE) or the timeout elapses. 0 disables the gate.
|
||||
|
||||
@@ -126,6 +126,12 @@ public record BridgedConfig(
|
||||
public static final String KIND_CLAUDE_CODE = "claude-code";
|
||||
/** Peer kind spawned by the opencode adapter (CB-402). */
|
||||
public static final String KIND_OPENCODE = "opencode";
|
||||
/**
|
||||
* Peer kind spawned by the Codex adapter (CB-528). Like {@link #KIND_OPENCODE} it carries
|
||||
* its own argv and never inherits the Claude binary, and it sits outside the
|
||||
* {@code ANTHROPIC_BASE_URL} subscription guard because Codex has no such seam.
|
||||
*/
|
||||
public static final String KIND_CODEX = "codex";
|
||||
|
||||
public Worker {
|
||||
// A claude-code worker defaults its launch command to `claude`; other kinds carry their own
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package dev.ltms.bridged.worker;
|
||||
|
||||
import dev.ltms.bridged.config.BridgedConfig;
|
||||
|
||||
import java.nio.file.Path;
|
||||
|
||||
/**
|
||||
* Provisions an isolated {@code CODEX_HOME} for one Codex peer (CB-528).
|
||||
*
|
||||
* <p>Codex reads <em>everything</em> from {@code CODEX_HOME} — its config, credentials, sessions,
|
||||
* skills, plugins, and state. Pointing a peer at the operator's own {@code ~/.codex} would give it
|
||||
* the operator's tool surface and let it write into the operator's session history, which is the
|
||||
* same class of failure CB-525 exists to prevent on the Claude side. So every peer gets its own
|
||||
* directory, and this is the seam that builds it.
|
||||
*
|
||||
* <p>It is an interface rather than a method on the launcher for two reasons: provisioning is
|
||||
* filesystem work with its own failure modes (a missing credential is the most common, and it
|
||||
* surfaces as an opaque {@code 401} from Codex rather than a spawn error), and keeping it separate
|
||||
* lets the launcher be tested without touching a real home directory.
|
||||
*
|
||||
* <p>Three things the implementation must put in the home, because Codex has no launch flag for
|
||||
* any of them:
|
||||
* <ul>
|
||||
* <li>the bridge MCP server, as {@code [mcp_servers.*]} in {@code config.toml};</li>
|
||||
* <li>the reply charter, as {@code AGENTS.md} — Codex has no {@code --append-system-prompt},
|
||||
* so the standing instruction has to reach it as a file;</li>
|
||||
* <li>credentials, since a freshly created home has none and Codex fails closed.</li>
|
||||
* </ul>
|
||||
*/
|
||||
public interface CodexHome {
|
||||
|
||||
/**
|
||||
* Build a fresh, isolated home for a peer launching under {@code cfg} and return its path,
|
||||
* suitable for the {@code CODEX_HOME} environment variable.
|
||||
*
|
||||
* @param cfg the profile being launched; supplies the MCP URL and any bearer-token variable
|
||||
* @return the provisioned directory
|
||||
* @throws RuntimeException if the home cannot be provisioned — including when no credential is
|
||||
* available, which must fail loudly here rather than as a 401 later
|
||||
*/
|
||||
Path provision(BridgedConfig.Worker cfg);
|
||||
|
||||
/**
|
||||
* Remove a home previously returned by {@link #provision}. Idempotent: releasing an already
|
||||
* released or never provisioned path is not an error, because teardown races teardown.
|
||||
*/
|
||||
void release(Path home);
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
package dev.ltms.bridged.worker;
|
||||
|
||||
import dev.ltms.bridged.config.BridgedConfig;
|
||||
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 java.util.EnumSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.function.Function;
|
||||
import java.util.function.LongSupplier;
|
||||
|
||||
/**
|
||||
* The {@link HerdrPeerLauncher} adapter for <strong>OpenAI's {@code codex}</strong> CLI — the third
|
||||
* peer kind behind the bridge (after Claude Code and opencode). Like {@link OpenCodeLauncher} it
|
||||
* extends {@link HerdrPeerLauncher} and reuses every line of shared transport (tab/pane placement,
|
||||
* the CB-306 readiness gate, unique naming + CB-117 reap, teardown, listing, cwd), overriding only
|
||||
* the launch seams.
|
||||
*
|
||||
* <p>The divergences, all confined to {@link #buildLaunch}:
|
||||
* <ul>
|
||||
* <li><strong>No subscription boundary.</strong> Codex authenticates through its own
|
||||
* {@code CODEX_HOME} credentials and has no {@code ANTHROPIC_BASE_URL} seam, so there is no
|
||||
* {@link dev.ltms.bridged.guard.SubscriptionGuard} — the guard is a Claude-private concern,
|
||||
* not part of the SPI. This asymmetry is deliberate and safe for the same reason opencode's is:
|
||||
* the guard exists to stop a worker borrowing the primary's Anthropic subscription, and a
|
||||
* codex process has no Anthropic credential path at all, so nothing can leak the subscription.
|
||||
* The bridge injects no {@code ANTHROPIC_*}/{@code CLAUDE_*} variable and reads none.</li>
|
||||
* <li><strong>Isolated home.</strong> Codex reads everything from {@code CODEX_HOME}. The
|
||||
* {@link CodexHome} seam provisions a fresh, isolated directory (config, reply charter,
|
||||
* credentials — none of which Codex can take as a launch flag) and the worker is pointed at it
|
||||
* with {@code CODEX_HOME}, so a peer never inherits the operator's own {@code ~/.codex}.</li>
|
||||
* <li><strong>{@code --approve-for-me}</strong> is mandatory: without it, every MCP tool call from
|
||||
* the peer returns "user cancelled MCP tool call" and the peer can never call
|
||||
* {@code bridge_reply}. It routes approvals through automatic review while keeping the sandbox
|
||||
* on — deliberately not {@code --dangerously-bypass-approvals-and-sandbox}.</li>
|
||||
* <li><strong>Flags, not env/files.</strong> the model ({@code -m}), the bridge MCP override
|
||||
* ({@code -c mcp_servers.bridged.url=...}), and the bridge bearer-token variable
|
||||
* ({@code --bearer-token-env-var}) are all command-line.</li>
|
||||
* <li><strong>{@code codex} name prefix</strong> so reap matches {@code codex-*} panes and never
|
||||
* another adapter's.</li>
|
||||
* </ul>
|
||||
*/
|
||||
public final class CodexLauncher extends HerdrPeerLauncher {
|
||||
|
||||
/** Label prefix for this adapter's herdr agent names (drives naming + orphan reap). */
|
||||
private static final String NAME_PREFIX = "codex";
|
||||
|
||||
/** Provisions the isolated {@code CODEX_HOME} each peer runs against (CB-528). */
|
||||
private final CodexHome codexHome;
|
||||
|
||||
/**
|
||||
* Production constructor — disables the spawn-ready gate ({@code spawnReadyTimeoutMs == 0}) so it
|
||||
* matches the legacy non-blocking spawn semantics.
|
||||
*/
|
||||
public CodexLauncher(AgentControl agents, WorkspaceControl spaces, CodexHome codexHome,
|
||||
Map<String, BridgedConfig.Worker> profiles, String defaultProfile,
|
||||
Function<String, String> env) {
|
||||
this(agents, spaces, codexHome, profiles, defaultProfile, env, 0,
|
||||
System::currentTimeMillis, () -> sleepUninterruptibly(300));
|
||||
}
|
||||
|
||||
/**
|
||||
* Production constructor with the spawn-ready gate enabled. Polls {@code agents.status()} until
|
||||
* the pane reports an injectable state or {@code spawnReadyTimeoutMs} elapses.
|
||||
*/
|
||||
public CodexLauncher(AgentControl agents, WorkspaceControl spaces, CodexHome codexHome,
|
||||
Map<String, BridgedConfig.Worker> profiles, String defaultProfile,
|
||||
Function<String, String> env,
|
||||
long spawnReadyTimeoutMs, long spawnReadyPollMs) {
|
||||
this(agents, spaces, codexHome, profiles, defaultProfile, env,
|
||||
spawnReadyTimeoutMs, System::currentTimeMillis,
|
||||
() -> sleepUninterruptibly(spawnReadyPollMs));
|
||||
}
|
||||
|
||||
/**
|
||||
* Full testability constructor. Every injectable collaborator is explicit so unit tests supply a
|
||||
* fake clock ({@code nowMillis}), poll-loop wait ({@code sleeper}), and a stub {@link CodexHome}
|
||||
* whose returned path they inspect as {@code CODEX_HOME}.
|
||||
*
|
||||
* @param agents herdr agent control (start, status, close)
|
||||
* @param spaces workspace / tab control (ensure, create, close)
|
||||
* @param codexHome seam that provisions the isolated {@code CODEX_HOME} (CB-528)
|
||||
* @param profiles configured worker profiles
|
||||
* @param defaultProfile profile a no-argument spawn uses (nullable)
|
||||
* @param env host env lookup (injectable for tests)
|
||||
* @param spawnReadyTimeoutMs max ms to wait for injectable state (0 disables the gate)
|
||||
* @param nowMillis monotonic clock source (e.g. {@code System::currentTimeMillis})
|
||||
* @param sleeper sleep/wait hook (never called when the gate is disabled)
|
||||
*/
|
||||
public CodexLauncher(AgentControl agents, WorkspaceControl spaces, CodexHome codexHome,
|
||||
Map<String, BridgedConfig.Worker> profiles, String defaultProfile,
|
||||
Function<String, String> env,
|
||||
long spawnReadyTimeoutMs,
|
||||
LongSupplier nowMillis, Runnable sleeper) {
|
||||
super(NAME_PREFIX, agents, spaces, profiles, defaultProfile, env,
|
||||
spawnReadyTimeoutMs, nowMillis, sleeper);
|
||||
this.codexHome = codexHome;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* <p>Builds the codex launch: no {@code ANTHROPIC_*} and no guard (codex reads its own
|
||||
* {@code CODEX_HOME} credentials); provision an isolated home via {@link #codexHome} and point
|
||||
* the worker at it with {@code CODEX_HOME}; carry the parity-neutral git-forge grant; and pass
|
||||
* the model, bridge MCP override, and bearer-token variable as flags — with mandatory
|
||||
* {@code --approve-for-me}.
|
||||
*/
|
||||
@Override
|
||||
protected Launch buildLaunch(BridgedConfig.Worker cfg) {
|
||||
Map<String, String> workerEnv = baseEnv(cfg);
|
||||
workerEnv.put("CODEX_HOME", codexHome.provision(cfg).toString());
|
||||
applyGitToken(workerEnv, cfg);
|
||||
return new Launch(workerEnv, argvFor(cfg));
|
||||
}
|
||||
|
||||
/**
|
||||
* The launch argv: {@code cfg.argv()} as the base command (the profile may override the
|
||||
* executable), then mandatory {@code --approve-for-me}, then the optional model / bridge-MCP /
|
||||
* bearer-token flags.
|
||||
*
|
||||
* <p>{@code --approve-for-me} is not optional polish — without it Codex auto-rejects every MCP
|
||||
* tool call ("user cancelled MCP tool call") and the peer can never answer via
|
||||
* {@code bridge_reply}. It routes approvals through automatic review while keeping the sandbox
|
||||
* on; it is deliberately not the escape-hatch bypass flag.
|
||||
*/
|
||||
private List<String> argvFor(BridgedConfig.Worker cfg) {
|
||||
List<String> argv = mutableArgv(cfg.argv());
|
||||
argv.add("--approve-for-me");
|
||||
if (cfg.model() != null && !cfg.model().isBlank()) {
|
||||
argv.add("-m");
|
||||
argv.add(cfg.model());
|
||||
}
|
||||
if (cfg.hasMcp()) {
|
||||
argv.add("-c");
|
||||
argv.add("mcp_servers.bridged.url=\"" + cfg.mcpUrl() + "\"");
|
||||
}
|
||||
if (cfg.tokenEnv() != null && !cfg.tokenEnv().isBlank()) {
|
||||
argv.add("--bearer-token-env-var");
|
||||
argv.add(cfg.tokenEnv());
|
||||
}
|
||||
return argv;
|
||||
}
|
||||
|
||||
// --- Agent-returning convenience spawns (used by callers/tests that want the herdr Agent) ---
|
||||
|
||||
/** Spawn a worker for the default profile in the resolved default cwd. */
|
||||
public Agent spawn() {
|
||||
return spawnInternal(null, null, null);
|
||||
}
|
||||
|
||||
/** Spawn a worker for a named profile (null → default) in the resolved default cwd. */
|
||||
public Agent spawn(String profileName) {
|
||||
return spawnInternal(profileName, null, null);
|
||||
}
|
||||
|
||||
/** Spawn a worker for a named profile with an explicit requested/caller cwd (CB-112). */
|
||||
public Agent spawn(String profileName, String requestedCwd, String callerCwd) {
|
||||
return spawnInternal(profileName, requestedCwd, callerCwd);
|
||||
}
|
||||
|
||||
// --- capabilities --------------------------------------------------------------------------
|
||||
|
||||
@Override
|
||||
public Set<Capability> capabilities() {
|
||||
Set<Capability> caps = EnumSet.of(Capability.MID_TURN_ASK, Capability.WORKTREE, Capability.ORPHAN_REAP);
|
||||
if (hasGitTokenProfile()) {
|
||||
caps.add(Capability.SELF_PR);
|
||||
}
|
||||
return Set.copyOf(caps);
|
||||
}
|
||||
|
||||
/** Whether any configured profile opts into a git-forge token (required for {@link Capability#SELF_PR}). */
|
||||
private boolean hasGitTokenProfile() {
|
||||
return profileConfigs().stream().anyMatch(BridgedConfig.Worker::hasGitToken);
|
||||
}
|
||||
|
||||
// --- CB-117 reap predicate (codex prefix), kept for direct unit testing --------------------
|
||||
|
||||
/**
|
||||
* Whether {@code name} is a codex bridge worker started by a <em>different</em> process than
|
||||
* {@code currentNonce}. A thin {@code codex}-prefix binding of
|
||||
* {@link HerdrPeerLauncher#isForeignWorker(String, String, String)}.
|
||||
*/
|
||||
static boolean isForeignWorker(String name, String currentNonce) {
|
||||
return HerdrPeerLauncher.isForeignWorker(NAME_PREFIX, name, currentNonce);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
package dev.ltms.bridged.worker;
|
||||
|
||||
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 org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* The Codex adapter's launch build (CB-528): an isolated {@code CODEX_HOME} provisioned through the
|
||||
* {@link CodexHome} seam, mandatory {@code --approve-for-me}, the {@code -m}/{@code -c}/
|
||||
* {@code --bearer-token-env-var} flags, and — like opencode — no {@code ANTHROPIC_*} and no
|
||||
* subscription guard (Codex authenticates via its own home credentials). Plus the shared base
|
||||
* transport (herdr kind, capabilities, reap).
|
||||
*/
|
||||
class CodexLauncherTest {
|
||||
|
||||
/** A codex profile. {@code null} argv → the kind default {@code ["codex"]}. */
|
||||
private static BridgedConfig.Worker codexCfg(String model, String mcpUrl,
|
||||
String tokenEnv, String gitTokenEnv) {
|
||||
return new BridgedConfig.Worker("codex-peer", null, model, null, tokenEnv,
|
||||
null, "tab", "bridged-workers", "codex: {model} #{n}", mcpUrl,
|
||||
null, null, gitTokenEnv, null, BridgedConfig.Worker.KIND_CODEX);
|
||||
}
|
||||
|
||||
/** A stub {@link CodexHome} returning {@code path} from {@code provision} (records calls). */
|
||||
private static final class FakeCodexHome implements CodexHome {
|
||||
private final Path path;
|
||||
int provisions;
|
||||
FakeCodexHome(Path path) {
|
||||
this.path = path;
|
||||
}
|
||||
@Override
|
||||
public Path provision(BridgedConfig.Worker cfg) {
|
||||
provisions++;
|
||||
return path;
|
||||
}
|
||||
@Override
|
||||
public void release(Path home) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Gate-disabled launcher with a stub home and an env that resolves the forge token. */
|
||||
private CodexLauncher service(FakeHerdr herdr, FakeCodexHome home, BridgedConfig.Worker cfg) {
|
||||
return new CodexLauncher(new AgentControl(herdr), new WorkspaceControl(herdr), home,
|
||||
Map.of(cfg.profile(), cfg), cfg.profile(),
|
||||
k -> "GITEA_ACCESS_TOKEN".equals(k) ? "tok" : null,
|
||||
0, System::currentTimeMillis, () -> { });
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static Map<String, Object> lastStart(FakeHerdr herdr) {
|
||||
return (Map<String, Object>) herdr.lastCall("agent.start").params();
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static Map<String, String> startEnv(FakeHerdr herdr) {
|
||||
Map<String, String> env =
|
||||
(Map<String, String>) ((Map<String, Object>) herdr.lastCall("tab.create").params()).get("env");
|
||||
return env == null ? Map.of() : env;
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static List<String> startArgs(FakeHerdr herdr) {
|
||||
return (List<String>) lastStart(herdr).get("args");
|
||||
}
|
||||
|
||||
@Test
|
||||
void approveForMeIsAlwaysPresent(@TempDir Path root) {
|
||||
FakeHerdr herdr = new FakeHerdr();
|
||||
FakeCodexHome home = new FakeCodexHome(root.resolve("codex-home"));
|
||||
service(herdr, home, codexCfg(null, null, null, null)).spawn();
|
||||
|
||||
List<String> args = startArgs(herdr);
|
||||
assertTrue(args.contains("--approve-for-me"),
|
||||
"--approve-for-me is mandatory — without it MCP tool calls are user-cancelled");
|
||||
assertEquals("--approve-for-me", args.getFirst(),
|
||||
"--approve-for-me is the first launch flag, right after the executable");
|
||||
}
|
||||
|
||||
@Test
|
||||
void argvHasAllFlagsWhenModelMcpAndTokenSet(@TempDir Path root) {
|
||||
FakeHerdr herdr = new FakeHerdr();
|
||||
service(herdr, new FakeCodexHome(root.resolve("h")),
|
||||
codexCfg("gpt-5.2", "http://127.0.0.1:8765/mcp", "CODEX_TOKEN", null)).spawn();
|
||||
|
||||
assertEquals(List.of(
|
||||
"--approve-for-me",
|
||||
"-m", "gpt-5.2",
|
||||
"-c", "mcp_servers.bridged.url=\"http://127.0.0.1:8765/mcp\"",
|
||||
"--bearer-token-env-var", "CODEX_TOKEN"),
|
||||
startArgs(herdr),
|
||||
"all three optional flags follow --approve-for-me when configured");
|
||||
}
|
||||
|
||||
@Test
|
||||
void argvOmitsModelAndMcpWhenUnsetButKeepsApproveForMe(@TempDir Path root) {
|
||||
FakeHerdr herdr = new FakeHerdr();
|
||||
// tokenEnv defaults to BRIDGED_WORKER_TOKEN (never null/blank after record normalization).
|
||||
service(herdr, new FakeCodexHome(root.resolve("h")),
|
||||
codexCfg(null, null, null, null)).spawn();
|
||||
|
||||
List<String> args = startArgs(herdr);
|
||||
assertFalse(args.contains("-m"), "no model → no -m flag");
|
||||
assertFalse(args.contains("-c"), "no mcp url → no -c override");
|
||||
assertTrue(args.contains("--approve-for-me"), "--approve-for-me is never dropped");
|
||||
assertEquals(List.of("--approve-for-me", "--bearer-token-env-var", "BRIDGED_WORKER_TOKEN"), args,
|
||||
"only the mandatory flag and the default bearer-token var remain");
|
||||
}
|
||||
|
||||
@Test
|
||||
void codexHomeIsSetToExactlyWhatTheSeamReturned(@TempDir Path root) {
|
||||
FakeHerdr herdr = new FakeHerdr();
|
||||
FakeCodexHome home = new FakeCodexHome(root.resolve("codex-home"));
|
||||
service(herdr, home, codexCfg(null, null, null, null)).spawn();
|
||||
|
||||
assertEquals(root.resolve("codex-home").toString(), startEnv(herdr).get("CODEX_HOME"),
|
||||
"CODEX_HOME is the provisioned home, verbatim from the CodexHome seam");
|
||||
assertEquals(1, home.provisions, "provision is called exactly once per spawn");
|
||||
}
|
||||
|
||||
@Test
|
||||
void codexHomeIsSetEvenWithoutMcp(@TempDir Path root) {
|
||||
// Codex reads everything from CODEX_HOME, so a peer must never inherit ~/.codex even when no
|
||||
// bridge MCP is mounted — this asserts provision is unconditional, not gated on hasMcp().
|
||||
FakeHerdr herdr = new FakeHerdr();
|
||||
FakeCodexHome home = new FakeCodexHome(root.resolve("home"));
|
||||
service(herdr, home, codexCfg(null, null, null, null)).spawn();
|
||||
assertEquals(root.resolve("home").toString(), startEnv(herdr).get("CODEX_HOME"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void noAnthropicOrClaudeVarsInWorkerEnv(@TempDir Path root) {
|
||||
FakeHerdr herdr = new FakeHerdr();
|
||||
service(herdr, new FakeCodexHome(root.resolve("h")),
|
||||
codexCfg(null, null, null, null)).spawn();
|
||||
|
||||
// Assert on the whole env map (a prefix match, not named keys) so the next variable someone
|
||||
// adds to this boundary is caught too.
|
||||
startEnv(herdr).keySet().forEach(k -> {
|
||||
String up = k.toUpperCase();
|
||||
assertFalse(up.startsWith("ANTHROPIC_"), "worker env must not carry " + k
|
||||
+ " — Codex has no Anthropic seam and must not borrow the subscription");
|
||||
assertFalse(up.startsWith("CLAUDE_"), "worker env must not carry " + k
|
||||
+ " — the Claude config dir is a Claude-private concern");
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void constructionNeedsNoSubscriptionGuard(@TempDir Path root) {
|
||||
// Codex authenticates through its own CODEX_HOME credentials, so the launcher takes a
|
||||
// CodexHome, not a SubscriptionGuard, and spawns without consulting one.
|
||||
FakeHerdr herdr = new FakeHerdr();
|
||||
FakeCodexHome home = new FakeCodexHome(root.resolve("h"));
|
||||
CodexLauncher launcher = new CodexLauncher(new AgentControl(herdr), new WorkspaceControl(herdr),
|
||||
home, Map.of("codex-peer", codexCfg(null, null, null, null)), "codex-peer", _ -> null);
|
||||
|
||||
launcher.spawn();
|
||||
assertTrue(noAnthropicOrClaude(startEnv(herdr)), "the production constructor sets no ANTHROPIC_*");
|
||||
|
||||
// The gate-enabled constructor builds the same way (sanity access to profiles).
|
||||
CodexLauncher gated = new CodexLauncher(new AgentControl(herdr), new WorkspaceControl(herdr),
|
||||
home, Map.of("codex-peer", codexCfg(null, null, null, null)), "codex-peer",
|
||||
_ -> null, 5000, 100);
|
||||
assertEquals("codex-peer", gated.defaultProfile());
|
||||
}
|
||||
|
||||
private static boolean noAnthropicOrClaude(Map<String, String> env) {
|
||||
return env.keySet().stream()
|
||||
.noneMatch(k -> k.toUpperCase().startsWith("ANTHROPIC_")
|
||||
|| k.toUpperCase().startsWith("CLAUDE_"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void herdrAgentKindIsCodex(@TempDir Path root) {
|
||||
FakeHerdr herdr = new FakeHerdr();
|
||||
service(herdr, new FakeCodexHome(root.resolve("h")),
|
||||
codexCfg(null, null, null, null)).spawn();
|
||||
|
||||
assertEquals("codex", lastStart(herdr).get("kind"),
|
||||
"herdr detects and status-tracks the pane natively under the codex kind");
|
||||
}
|
||||
|
||||
@Test
|
||||
void capabilitiesDeclareOrphanReapAndMcpAskAndConditionalSelfPr(@TempDir Path root) {
|
||||
FakeHerdr herdr = new FakeHerdr();
|
||||
FakeCodexHome home = new FakeCodexHome(root.resolve("h"));
|
||||
assertEquals(Set.of(Capability.MID_TURN_ASK, Capability.WORKTREE, Capability.ORPHAN_REAP),
|
||||
service(herdr, home, codexCfg(null, null, null, null)).capabilities(),
|
||||
"no git token → no SELF_PR");
|
||||
assertTrue(service(herdr, home, codexCfg(null, null, null, "GITEA_ACCESS_TOKEN"))
|
||||
.capabilities().contains(Capability.SELF_PR),
|
||||
"a git-token profile adds SELF_PR");
|
||||
}
|
||||
|
||||
@Test
|
||||
void injectsForgeTokenWhenProfileGrantsIt(@TempDir Path root) {
|
||||
FakeHerdr herdr = new FakeHerdr();
|
||||
service(herdr, new FakeCodexHome(root.resolve("h")),
|
||||
codexCfg(null, null, null, "GITEA_ACCESS_TOKEN")).spawn();
|
||||
assertEquals("tok", startEnv(herdr).get("GITEA_TOKEN"),
|
||||
"a git-token profile gets the peer-neutral GITEA_TOKEN grant, same as Claude/opencode");
|
||||
}
|
||||
|
||||
@Test
|
||||
void foreignWorkerMatchesCodexPrefixButNotClaude() {
|
||||
String nonce = "abc123";
|
||||
assertTrue(CodexLauncher.isForeignWorker("codex-codex-peer-def456-1", nonce),
|
||||
"a codex pane from another process is foreign");
|
||||
assertFalse(CodexLauncher.isForeignWorker("codex-codex-peer-" + nonce + "-1", nonce),
|
||||
"our own codex pane (same nonce) is not foreign");
|
||||
assertFalse(CodexLauncher.isForeignWorker("claude-ltms-local-def456-1", nonce),
|
||||
"a claude pane is never reaped by the codex adapter");
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
# CB-308 — Multi-Host Federation (Stage 5)
|
||||
|
||||
**Status:** design note (proposal)
|
||||
**Status:** design note (proposal) — core design decisions resolved 2026-08-10 (§7)
|
||||
**Depends on:** CB-307 (broker-based reliable delivery) — CB-308 is the multi-host layer built *on*
|
||||
CB-307's broker fabric.
|
||||
**Relates to:** CB-401 (`PeerHandle` opaque id), CB-304 (`rosterView`), CB-306 (spawn-readiness),
|
||||
@@ -143,6 +143,8 @@ extending the same broker from "worker→primary reliability" to "gateway↔gate
|
||||
5. **Trust** — the broker connection is now the security boundary. A gateway injects env/tokens at
|
||||
daemon privilege (the CB-401 Stage-C concern), so a **remote-triggered spawn/send** needs
|
||||
authn/authz: who may act on which host, and which control channels a gateway will honour.
|
||||
*Authenticity* is resolved — signed messages, §7.1; *authorization* (who may do what) remains
|
||||
open — §8.
|
||||
|
||||
## 5. The one thing the broker does NOT dissolve
|
||||
|
||||
@@ -191,15 +193,111 @@ useful), and build CB-308's items on top once the broker fabric exists. Choose C
|
||||
channel naming **multi-host-ready** now (per-agent routing keys, a `roster.*` topic namespace) so
|
||||
CB-308 doesn't have to repaint the topology.
|
||||
|
||||
## 7. Open questions
|
||||
## 7. Resolved design decisions (2026-08-10)
|
||||
|
||||
Settled in a design review of this note + wiki chapter 10. The broker-level operational rules
|
||||
(inbox caps, TLS + private broker, schema versioning, trace id, exclusive consumers, U8 broadcast)
|
||||
are recorded in wiki 10 §10; the CB-308-side decisions are below. Entries 1–6 are the first-pass
|
||||
decisions; 7–10 came out of the adversarial second-pass review (same day) and supersede 1–6 where
|
||||
they overlap (notably: the envelope is no longer optional, and dedup is split by path).
|
||||
|
||||
1. **Sender authenticity — sign every message.** Each gateway holds its own signing key and signs
|
||||
what it publishes (sender gid, `msgId`, timestamp). The receiving gateway verifies the
|
||||
signature **and** checks against the roster that the claimed sender lives on the signing
|
||||
gateway's host. This extends the single-host invariant — *identity comes from the connection,
|
||||
never an argument* — across the broker: cross-host, identity comes from the key. Complements
|
||||
(not replaces) per-gateway broker logins over TLS.
|
||||
2. **Profiles are owned by the worker's host.** `bridge_spawn(profile, host)` resolves the name in
|
||||
the *target* gateway's `bridged.yaml`. Gateways advertise their profile names in presence
|
||||
heartbeats, so a leader sees what each host offers before spawning; an unknown name is a clear
|
||||
error from the target. Secrets (base URLs, tokens) never leave the host that uses them.
|
||||
3. **Repo provisioning — clone from the forge, pinned.** A cross-host spawn names the repo URL and
|
||||
the exact commit. The target gateway clones from the forge into a local cache (first spawn
|
||||
only), then cuts a per-worker worktree — the CB-301-ext flow with a clone step in front,
|
||||
covered by the same repo-scoped forge token (CB-302). Git stays the only channel code moves
|
||||
through.
|
||||
4. **Asks are live-only, with expiry.** `ASK`/`ANSWER` (U2) traverse the broker as short-lived
|
||||
(TTL'd) messages carrying the `turn_id`, and are never held durably — the single-host rule
|
||||
kept. An answer arriving after its turn ended is **not** injected; it is dropped and the leader
|
||||
gets a `TOO_LATE` notice, so the one failure case is loud rather than weird. Only terminal
|
||||
replies are durable. Walkthrough: wiki 10 §7.4.
|
||||
5. **Spawn dedup — a spawn id, remembered on the target.** The control queue redelivers like any
|
||||
queue; a replayed `SpawnRequest` must not double-spawn. Requests carry a unique spawn id; the
|
||||
target gateway keeps a short memory of handled ids and answers a redelivery with the existing
|
||||
`PeerHandle`. CB-117's orphan reap stays as the backstop.
|
||||
6. **Broker down — local unaffected, remote fails fast.** The routing fork (§3.2) means same-host
|
||||
traffic never touches the broker; that is now a written promise. A send to a remote agent while
|
||||
the broker is unreachable **fails immediately** with a clear error — the gateway never buffers
|
||||
on the broker's behalf (it stays soft-state, so a crash cannot lose messages it claimed to
|
||||
deliver). Gateways auto-reconnect; remote hosts read as unknown in the roster meanwhile. Broker
|
||||
HA is a later ops choice, not a design requirement.
|
||||
|
||||
7. **Turn state — split by where the signals are.** The *worker's* gateway owns the turn record
|
||||
(turnId minting, ask coalescing, STALE_TURN, the completion/failure fallbacks, CB-516 abandon):
|
||||
every input to those decisions — pane status, injection, teardown — is local to it. The
|
||||
*sender's* gateway owns only the waiter. The two are stitched by terminal-outcome envelope
|
||||
kinds (`REPLY` / `FAILED` / `ABANDONED`) published to the sender's inbox: a worker dying on B
|
||||
fails A's waiter fast because gateway B sees the death synchronously and says so.
|
||||
**`ABANDONED` is belt-and-braces over the waiter's own timeout and roster expiry, never a
|
||||
replacement** — the case where the waiter hangs longest is gateway B itself dying, which is
|
||||
exactly when B can publish nothing.
|
||||
8. **Dual ack model + spawn idempotence by construction.** Forward path (a brief into a worker):
|
||||
ack **before** the inject — at-most-once, duplicates structurally impossible; the loss window
|
||||
is closed by an `INJECTED` confirmation published after the inject lands (no `INJECTED` within
|
||||
a bound = loud fast failure at the sender, not a silent send-timeout). Reply/pull path keeps
|
||||
ack-after-drain — a duplicate reply is benign, deduped by `msgId`. Spawn: the requester mints
|
||||
**spawn id = the new worker's gid**; the target checks it against the **live pane registry**,
|
||||
and the gid is **stored in the herdr pane itself** (label/env, readable back), so a restarted
|
||||
gateway rebuilds gid↔pane from herdr and the check survives restarts with *no persisted
|
||||
ledger* — this storage point is the load-bearing detail of the no-ledger position. An
|
||||
**in-flight reservation set**, entered before the launcher call, absorbs a redelivery arriving
|
||||
while the first spawn is still inside CB-306's readiness gate; a crash mid-spawn leaves a
|
||||
half-built pane, which is exactly what CB-117 reaps.
|
||||
9. **Publish is enforced, not fire-and-forget.** Publisher confirms + the `mandatory` flag + a
|
||||
return listener, on a **publish channel separate from the consume/ack channel** — synchronous
|
||||
confirms on the single shared channel would hold its lock across a broker round trip and
|
||||
serialize acks fleet-wide. Ordering caveat: a *return* (unroutable) arrives **before** the
|
||||
confirm, so "confirmed" ≠ "routed"; the sender checks the returned-set at confirm time.
|
||||
`mandatory` is false only for `BROADCAST`, where an empty group is legal silence.
|
||||
10. **Queue lifecycle is session lifecycle.** `bridge_stop`/reap deletes the worker's inbox queue
|
||||
(its `broadcast.*` bindings die with it — no broadcasts to the dead); `x-expires` collects
|
||||
queues orphaned by a crashed gateway (long for main/orchestrator inboxes, short for workers).
|
||||
Queue names carry a version suffix (`.v2`): AMQP refuses to redeclare an existing durable
|
||||
queue with new arguments (`PRECONDITION_FAILED` — a crash loop on an in-place upgrade from
|
||||
v1.0.0), and the suffix keeps old sender-keyed and new recipient-keyed queues apart during
|
||||
the keying migration (wiki 10 §3 footnote).
|
||||
|
||||
## 8. Still open
|
||||
|
||||
- **Directory ground-truth:** pure soft-state presence (heartbeats) vs. also treating broker queue
|
||||
existence as authoritative. Lean soft-state to preserve the persistence boundary; revisit if
|
||||
split-brain roster views cause mis-routing.
|
||||
- **Global id scheme:** `<host>/<paneId>` (human-legible, leaks host) vs. opaque UUID (clean, needs
|
||||
the directory to resolve host). Probably UUID in the protocol, host as directory metadata.
|
||||
- **Gateway discovery:** how gateways find the broker and each other (static config vs. discovery).
|
||||
- **Trust model shape:** per-host shared secret vs. mTLS on the broker vs. a capability token per
|
||||
control action — ties into CB-401 Stage-C.
|
||||
- **Failure semantics:** a host/gateway dies mid-turn — how the federated roster reaps it (missed
|
||||
heartbeat) and whether in-flight primary-bound messages survive (broker durability = yes).
|
||||
- **Control authorization — THE GATE ON U4.** Signing (§7.1) settles *who sent it*; authorization
|
||||
is *who may do what*. **Cross-host spawn must not land before the minimal version exists**: a
|
||||
per-host allowlist in `bridged.yaml` — beside the peer public keys — of gateway ids permitted to
|
||||
publish control to this host, checked against the verified signature. A few lines of config and
|
||||
check; without them, any principal holding broker credentials can start processes on every host
|
||||
in the fleet.
|
||||
- **Key distribution & rotation:** static config (host → public key in each `bridged.yaml`) is
|
||||
fine at the current 2–3 host scale; rotation is manual. A refinement, not a blocker.
|
||||
- **Gateway death mid-turn:** the roster reaps it by missed heartbeat, and in-flight primary-bound
|
||||
messages survive by broker durability; still open is reconciling *worker* state when the dead
|
||||
gateway's host comes back (orphaned panes vs. still-valid sessions).
|
||||
|
||||
*(Resolved and moved up: the global id scheme — an opaque UUID minted by the spawn requester as
|
||||
the spawn id, host carried as roster metadata; §7.8.)*
|
||||
|
||||
## 9. Implementation order (each step verifiable single-host)
|
||||
|
||||
1. **As-built fixes, independent of CB-308** (v1.0.x tickets): `basicQos` prefetch on the AMQP
|
||||
consumer (today the queue drains into gateway heap, so any cap would guard an empty queue);
|
||||
publisher confirms + `mandatory` (§7.9); the `drainReplies` javadoc that claims "the ack is
|
||||
local" — false for the AMQP adapter.
|
||||
2. Envelope + signing (wiki 10 §2.1) — testable against the single-host broker.
|
||||
3. Recipient-keyed queue migration (`.v2` names, drain-by-`from`, `ReplyPushLoop` rekeyed).
|
||||
4. Global id + queue lifecycle (§7.8, §7.10).
|
||||
5. Roster: host-level heartbeat + signed presence; then the routing fork (§3.2).
|
||||
6. U2 cross-host with the terminal-outcome kinds (§7.4, §7.7).
|
||||
7. U4 cross-host spawn — **gated on the control allowlist (§8)**.
|
||||
8. U8 broadcast **last** — it is the feature that punishes an unfinished queue lifecycle.
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "claude-bridge",
|
||||
"description": "Make a project bridge-ready: mount the bridged MCP gateway and set up standard Claude Code settings so this session can orchestrate a fleet of delegated workers. Ships no credentials.",
|
||||
"version": "0.1.0",
|
||||
"author": {
|
||||
"name": "LTMS"
|
||||
},
|
||||
"homepage": "https://git.ltms.dev/lms/claude-bridge",
|
||||
"repository": "https://git.ltms.dev/lms/claude-bridge",
|
||||
"license": "MIT",
|
||||
"keywords": [
|
||||
"mcp",
|
||||
"orchestration",
|
||||
"multi-agent",
|
||||
"delegation",
|
||||
"codex"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"bridged": {
|
||||
"type": "http",
|
||||
"url": "http://127.0.0.1:8765/mcp"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
# claude-bridge (Claude Code plugin)
|
||||
|
||||
Makes a project **bridge-ready**: mounts the `bridged` MCP gateway and applies standard Claude Code
|
||||
settings, so the session can orchestrate a fleet of delegated workers.
|
||||
|
||||
**This plugin ships no credentials.** Every secret is referenced by environment-variable *name*;
|
||||
the values stay with the user. Nothing the plugin writes is unsafe to commit.
|
||||
|
||||
## What it is not
|
||||
|
||||
The plugin is the **client-side setup**, not the bridge. `bridged` is a separate daemon and `herdr`
|
||||
is a separate PTY multiplexer, each with its own lifecycle and install. The plugin mounts an
|
||||
already-running daemon and tells you what is missing when one isn't there — it deliberately does
|
||||
not try to install system services on your behalf.
|
||||
|
||||
## Install
|
||||
|
||||
```shell
|
||||
/plugin marketplace add ltms/claude-bridge
|
||||
/plugin install claude-bridge@claude-bridge
|
||||
```
|
||||
|
||||
Then, in the project you want to onboard:
|
||||
|
||||
```shell
|
||||
/claude-bridge:setup
|
||||
```
|
||||
|
||||
## What you get
|
||||
|
||||
| Component | Effect |
|
||||
|---|---|
|
||||
| `.mcp.json` | mounts `bridged` at `http://127.0.0.1:8765/mcp` for any session with the plugin enabled |
|
||||
| `skills/setup` | `/claude-bridge:setup` — preflight, project settings, credential guidance, and verification |
|
||||
|
||||
Because the plugin carries its own `.mcp.json`, an installed plugin needs no project-level MCP
|
||||
file at all. The setup skill writes one only when you want the mount to work *without* the plugin —
|
||||
for teammates who haven't installed it, or for CI.
|
||||
|
||||
## Verifying a setup
|
||||
|
||||
The setup skill ends by requiring a **real spawn**, not a health check. `/healthz` only reports
|
||||
that the daemon can reach herdr; a protocol mismatch between the daemon's adapter and the herdr
|
||||
binary leaves health green while every spawn fails. Only a spawn that reaches `ready` proves the
|
||||
fleet.
|
||||
|
||||
## Local development
|
||||
|
||||
```shell
|
||||
claude --plugin-dir ./plugin
|
||||
claude plugin validate ./plugin
|
||||
```
|
||||
|
||||
`/reload-plugins` picks up edits without restarting the session.
|
||||
@@ -0,0 +1,227 @@
|
||||
---
|
||||
name: setup
|
||||
description: Make the current project bridge-ready — check the prerequisites, mount the bridged MCP gateway into the project's .mcp.json, apply standard Claude Code settings, and verify this session resolves as the primary. Writes no credentials. Load this when asked to set up, install, configure, or onboard a project onto claude-bridge, or when bridge_* tools are expected but absent.
|
||||
---
|
||||
|
||||
# Bridge setup — make this project bridge-ready
|
||||
|
||||
This skill configures **the project you are currently in** so that this Claude Code session can
|
||||
orchestrate a fleet of delegated workers through `bridged`.
|
||||
|
||||
**It writes no credentials, ever.** Every secret is referenced by environment-variable *name*, and
|
||||
the user exports the value themselves. Nothing this skill creates is unsafe to commit. If you are
|
||||
ever about to write a token, key, or password into a file, you have misread this skill — stop.
|
||||
|
||||
Work through the steps in order. Each one has a check; **report what actually happened**, including
|
||||
failures. A setup that half-worked and was reported as done is worse than one that failed loudly.
|
||||
|
||||
## 0. Establish where you are
|
||||
|
||||
```bash
|
||||
pwd
|
||||
git rev-parse --show-toplevel 2>/dev/null || echo "(not a git repo)"
|
||||
ls -a | head -30
|
||||
```
|
||||
|
||||
Everything below is written into **this** project root. If the user meant a different directory,
|
||||
confirm before writing anything.
|
||||
|
||||
## 1. Preflight — what must already exist
|
||||
|
||||
The bridge is three moving parts, and the plugin is only one of them. Check all of it before
|
||||
changing any file, so you can tell the user the whole story at once instead of failing one step at
|
||||
a time.
|
||||
|
||||
```bash
|
||||
command -v herdr && herdr --version 2>&1 | head -1 || echo "MISSING: herdr"
|
||||
command -v ccs && ccs version 2>&1 | head -1 || echo "MISSING: ccs (needed for worker profiles)"
|
||||
command -v codex && codex --version 2>&1 | head -1 || echo "absent: codex (optional)"
|
||||
curl -s -m 5 http://127.0.0.1:8765/healthz || echo "MISSING: bridged daemon is not reachable"
|
||||
```
|
||||
|
||||
A healthy daemon answers with its status **and the herdr protocol it negotiated**:
|
||||
|
||||
```json
|
||||
{"status":"ok","herdr":{"version":"0.8.0","protocol":19}}
|
||||
```
|
||||
|
||||
| Missing | What to tell the user |
|
||||
|---|---|
|
||||
| `herdr` | The PTY multiplexer that owns worker terminals. Install it first; nothing else works without it. |
|
||||
| `bridged` | The daemon. It is a separate service, not part of this plugin — the plugin only *mounts* it. Point the user at the project's own install instructions. |
|
||||
| `ccs` | Only needed to launch worker profiles. The bridge itself will still start. |
|
||||
| `codex` | Optional. Only needed if this fleet will run Codex peers. |
|
||||
|
||||
**Do not attempt to install these yourself.** They are system services with their own lifecycles;
|
||||
guessing at an install is how you end up with two daemons on one socket. Report what is missing and
|
||||
let the user install it.
|
||||
|
||||
## 2. Mount the bridge MCP — merge, never overwrite
|
||||
|
||||
The project's `.mcp.json` may already declare servers. **Read it first and merge**; clobbering
|
||||
someone's existing MCP config is not a recoverable mistake.
|
||||
|
||||
```bash
|
||||
cat .mcp.json 2>/dev/null || echo "(no .mcp.json yet)"
|
||||
```
|
||||
|
||||
The entry to add, exactly:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"bridged": {
|
||||
"type": "http",
|
||||
"url": "http://127.0.0.1:8765/mcp"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
If `.mcp.json` already exists, add only the `bridged` key and leave every other server untouched.
|
||||
If a `bridged` entry is already there with a different URL, **ask** rather than assuming yours is
|
||||
right — a non-default port usually means a deliberate second daemon.
|
||||
|
||||
> **If this plugin is installed, you can skip this step entirely.** The plugin ships its own
|
||||
> `.mcp.json`, so `bridged` is already mounted for any session with the plugin enabled. Write the
|
||||
> project-level file only when the user wants the mount to work *without* the plugin — for
|
||||
> teammates who have not installed it, or for CI.
|
||||
|
||||
**Before writing it, settle whether `.mcp.json` is committed here:**
|
||||
|
||||
```bash
|
||||
git ls-files --error-unmatch .mcp.json 2>/dev/null && echo "TRACKED" || echo "untracked"
|
||||
```
|
||||
|
||||
A tracked `.mcp.json` is inherited by every checkout of this repo — including git worktrees the
|
||||
bridge provisions for workers. Servers bound to *your* machine (an IDE index, a local language
|
||||
server) will then be mounted by workers too, and every path they return points into **your**
|
||||
checkout rather than the worker's. That failure is silent and expensive: it has produced a worker
|
||||
that made all of its edits in the wrong tree while its builds passed, because it was building the
|
||||
tree it was not editing. Keep machine-local servers out of a tracked `.mcp.json`, or keep the file
|
||||
untracked.
|
||||
|
||||
## 3. Standard project settings
|
||||
|
||||
Create or merge `.claude/settings.json`. These are defaults, not requirements — keep anything the
|
||||
project already set.
|
||||
|
||||
```json
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/claude-code-settings.json",
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"mcp__bridged__bridge_whoami",
|
||||
"mcp__bridged__bridge_list",
|
||||
"mcp__bridged__bridge_status",
|
||||
"mcp__bridged__bridge_profiles",
|
||||
"mcp__bridged__bridge_poll"
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Only the **read-only** bridge verbs are pre-allowed. `bridge_spawn`, `bridge_send`, and
|
||||
`bridge_stop` start processes, deliver work, and tear down terminals — those stay behind a prompt
|
||||
on purpose. Do not "helpfully" add them.
|
||||
|
||||
Never write `settings.local.json` on the user's behalf; that file is personal and usually
|
||||
gitignored.
|
||||
|
||||
## 4. Credentials — by reference only
|
||||
|
||||
The bridge takes every secret from the **environment**, and the daemon's config names the variable
|
||||
rather than holding the value. Your job is to tell the user which variables to export, not to
|
||||
collect or store them.
|
||||
|
||||
| Variable | Needed for | Notes |
|
||||
|---|---|---|
|
||||
| `BRIDGED_WORKER_TOKEN` | authenticating a worker to the daemon | only when the daemon is configured with `tokenEnv` |
|
||||
| `GITEA_TOKEN` / equivalent | letting a worker open its own PR | **minimal `write:repository` scope** — see below |
|
||||
| `GITEA_HOST` | the forge base URL | no secret; safe anywhere |
|
||||
|
||||
Two rules to state plainly to the user:
|
||||
|
||||
- **The PR token must not be able to merge.** A worker opens a PR; the primary is the gate. A token
|
||||
that can merge makes the gate decorative. Mint a narrow, repo-scoped token — never reuse a
|
||||
personal admin token.
|
||||
- **Never set `ANTHROPIC_BASE_URL` or `ANTHROPIC_AUTH_TOKEN`** in this project, this shell, or any
|
||||
settings file. The primary stays on subscription; only the daemon moves a *worker* off it, at
|
||||
spawn. Mounting the bridge must never move a session across that boundary — if setup appears to
|
||||
need this, something is wrong and you should stop and say so.
|
||||
|
||||
Write none of these into any file. Show the user the `export` lines to run themselves.
|
||||
|
||||
## 5. Verify — and do not trust a green health check
|
||||
|
||||
Reconnect MCP if needed (`/mcp`), then confirm the tools are live and this session is the primary:
|
||||
|
||||
```
|
||||
bridge_whoami
|
||||
```
|
||||
|
||||
- `{"role":"primary"}` — correct, you are done with this step.
|
||||
- `{"role":"worker", …}` — **this is the trap.** If the primary runs inside a herdr pane, the
|
||||
daemon resolves it to a terminal and classifies it as a worker, refusing `spawn`/`send`/`stop`:
|
||||
every verb an orchestrator exists to call. It is **self-locking**, because the daemon can only
|
||||
*learn* the primary's terminal from those same refused calls. The only way out is an
|
||||
operator-set pin in the daemon's config:
|
||||
|
||||
```yaml
|
||||
primary:
|
||||
terminal: term_xxxxxxxxxxxx # the terminalId bridge_whoami just reported
|
||||
```
|
||||
|
||||
The daemon reads this **at boot**, so it needs a restart. Re-pin whenever the primary moves
|
||||
panes — a stale pin fails exactly as silently as no pin.
|
||||
|
||||
Then prove the fleet actually works, with a real spawn:
|
||||
|
||||
```
|
||||
bridge_profiles → the configured backends
|
||||
bridge_spawn{profile: "<one of them>"} → must reach state "ready"
|
||||
bridge_stop{paneId: "<from spawn>"}
|
||||
```
|
||||
|
||||
**`/healthz` reporting `ok` is not evidence that spawning works.** It reports that the daemon can
|
||||
reach herdr — nothing more. A version mismatch between the daemon's adapter and the herdr binary
|
||||
leaves health green while every single spawn fails. Only a real spawn proves the fleet. Do this
|
||||
even when everything above looked fine.
|
||||
|
||||
## 6. Optional — Codex parity
|
||||
|
||||
Only if the user wants Codex and Claude Code to share instructions, skills, and MCP config:
|
||||
|
||||
```bash
|
||||
npm install -g ai-config-sync-manager
|
||||
ai-config-sync connect
|
||||
ai-config-sync status # compare both hosts
|
||||
ai-config-sync sync --dry-run # preview — always look before applying
|
||||
ai-config-sync sync --apply
|
||||
```
|
||||
|
||||
It maps `~/.claude/CLAUDE.md` ↔ `~/.codex/AGENTS.md`, `~/.claude/skills/` ↔ `~/.codex/skills/`, and
|
||||
Claude's MCP servers ↔ `[mcp_servers.*]` in `~/.codex/config.toml`.
|
||||
|
||||
**Raise the boundary before running it.** That sync is *user-level* and bidirectional, while the
|
||||
bridge deliberately isolates each worker's tool surface (§2). Syncing your MCP servers into
|
||||
`~/.codex/config.toml` gives every Codex session your machine-local servers — the same
|
||||
wrong-tree failure as §2, in a different runtime. Use the sync for the two CLIs *you* drive
|
||||
interactively; leave anything the bridge spawns isolated. Always `--dry-run` first.
|
||||
|
||||
## 7. Report
|
||||
|
||||
State plainly:
|
||||
|
||||
```
|
||||
prereqs: herdr <version> · bridged <protocol> · ccs <version> · codex <version|absent>
|
||||
written: <files created or merged, or "none">
|
||||
role: <bridge_whoami result — and the pin, if one was needed>
|
||||
spawn: <real spawn result: profile, state reached, torn down>
|
||||
env: <variables the USER still needs to export — names only, never values>
|
||||
skipped: <anything not done, and why>
|
||||
```
|
||||
|
||||
Never report a step as done that you did not verify. If the daemon was unreachable, say so and stop
|
||||
— the remaining steps cannot be checked, and guessing at them is how a broken setup gets called
|
||||
finished.
|
||||
+1
-1
Submodule wiki updated: 0c896eb49b...4320c1ca52
Reference in New Issue
Block a user