diff --git a/bridged/fleetd.example.yaml b/bridged/fleetd.example.yaml index eea90c6..be66f0c 100644 --- a/bridged/fleetd.example.yaml +++ b/bridged/fleetd.example.yaml @@ -126,6 +126,10 @@ herdrSocket: ~/.config/herdr/herdr.sock # Use `pane` for the legacy behaviour (split the focused tab). # mcpUrl → bridged mounts the bridge MCP (--mcp-config, inline) + reply charter # (--append-system-prompt) as launch flags; nothing is written to the profile. +# ideMcpUrl → opt-in (CB-634), default off. When set, bridged mounts the IDE Index MCP as a +# second inline server named `intellij`, and adds an IDE charter that pins every +# ide_* call to the member's own worktree. A URL, not a boolean — host and port +# are host-specific. Set it only on a host where the IDE actually runs. # tokenEnv → host env var holding the worker's auth token (value never stored in config); # omit for a backend that needs no token (e.g. a local ollama). # cwd → pin this profile's working directory (CB-112). Omit to inherit the primary's @@ -135,7 +139,8 @@ herdrSocket: ~/.config/herdr/herdr.sock # skills/MCP/hooks. Omit to leave the worker on the host default. # parityOverlay → repo-relative paths copied primary→worktree so a worker in a provisioned # worktree sees the same local config (CB-301-ext). Omit for the default set: -# [.claude/settings.local.json, .env, .envrc]. +# [.env, .envrc]. (.claude/settings.local.json is NOT in the default — it +# pre-approves IDE/tool grants a member must not hold ambiently; CB-525/CB-634.) # # Do NOT add .mcp.json (CB-525). A worker's tools are whatever its launcher # mounts — the bridge, and nothing else. Replicating the primary's MCP config @@ -237,7 +242,8 @@ profiles: # credentialId: shared-openai # opt-in: quarantine together with every other profile sharing this id (CB-578) # 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 + # parityOverlay: [".env", ".envrc"] # the default; never add .mcp.json or .claude/settings.local.json — see above + # ideMcpUrl: http://127.0.0.1:29170/index-mcp/streamable-http # opt-in (CB-634): IDE code intelligence, pinned to the worktree gx11: # a second backend, so `placement: weighted` has a choice baseUrl: http://gx01.gw:8000 # self-hosted; ccs handles the model + token placement: tab diff --git a/bridged/src/main/java/dev/ltms/fleet/config/ConfigRef.java b/bridged/src/main/java/dev/ltms/fleet/config/ConfigRef.java index 651c6a8..f230e3b 100644 --- a/bridged/src/main/java/dev/ltms/fleet/config/ConfigRef.java +++ b/bridged/src/main/java/dev/ltms/fleet/config/ConfigRef.java @@ -276,6 +276,9 @@ public final class ConfigRef implements Supplier { && Objects.equals(a.workspace(), b.workspace()) && Objects.equals(a.tabLabel(), b.tabLabel()) && Objects.equals(a.mcpUrl(), b.mcpUrl()) + // CB-634: the IDE MCP mount is a launch flag, fixed at spawn like mcpUrl — a + // reload changes it only for members spawned after, so a changed value is deferred. + && Objects.equals(a.ideMcpUrl(), b.ideMcpUrl()) && Objects.equals(a.cwd(), b.cwd()) && Objects.equals(a.parityOverlay(), b.parityOverlay()) && Objects.equals(a.gitTokenEnv(), b.gitTokenEnv()) diff --git a/bridged/src/main/java/dev/ltms/fleet/config/FleetConfig.java b/bridged/src/main/java/dev/ltms/fleet/config/FleetConfig.java index ea84552..b67738f 100644 --- a/bridged/src/main/java/dev/ltms/fleet/config/FleetConfig.java +++ b/bridged/src/main/java/dev/ltms/fleet/config/FleetConfig.java @@ -289,7 +289,8 @@ public record FleetConfig( Integer maxLoad, Boolean subscription, String exhaustedPattern, - String credentialId) { + String credentialId, + String ideMcpUrl) { /** Peer kind spawned by {@link dev.ltms.fleet.member.ClaudeCodeLauncher} (the default). */ public static final String KIND_CLAUDE_CODE = "claude-code"; @@ -348,6 +349,10 @@ public record FleetConfig( // "quarantines alone" fallback actually lives, so today's behaviour needs no defaulting // here at all. credentialId = (credentialId == null || credentialId.isBlank()) ? null : credentialId; + // CB-634: opt-in per profile, default off. A URL, not a boolean — host and port are + // host-specific, mirroring mcpUrl. When set, the member gets the IDE Index MCP mounted + // (pinned to its own worktree via the charter). Blank ⇒ off. + ideMcpUrl = (ideMcpUrl == null || ideMcpUrl.isBlank()) ? null : ideMcpUrl; } /** @@ -392,7 +397,7 @@ public record FleetConfig( public Profile withProfile(String p) { return new Profile(p, baseUrl, model, configDir, tokenEnv, argv, placement, workspace, tabLabel, mcpUrl, cwd, parityOverlay, gitTokenEnv, gitHostEnv, kind, env, weight, maxLoad, subscription, - exhaustedPattern, credentialId); + exhaustedPattern, credentialId, ideMcpUrl); } /** True when this profile is served by the Claude Code adapter (the default kind). */ @@ -441,7 +446,7 @@ public record FleetConfig( Boolean subscription, String exhaustedPattern) { this(profile, baseUrl, model, configDir, tokenEnv, argv, placement, workspace, tabLabel, mcpUrl, cwd, parityOverlay, gitTokenEnv, gitHostEnv, kind, env, weight, maxLoad, - subscription, exhaustedPattern, null); + subscription, exhaustedPattern, null, null); } /** True when this profile's CB-578 stage A backend-exhausted classification is configured. */ @@ -489,6 +494,19 @@ public record FleetConfig( return mcpUrl != null && !mcpUrl.isBlank(); } + /** + * True when the IDE Index MCP should be mounted into a spawned worker (CB-634), pinned to + * the worker's own worktree via the charter. Opt-in per profile, default off. + */ + public boolean hasIdeMcp() { + return ideMcpUrl != null && !ideMcpUrl.isBlank(); + } + + /** True when this profile mounts any MCP server into its member — the bridge, the IDE, or both. */ + public boolean mountsAnyMcp() { + return hasMcp() || hasIdeMcp(); + } + /** * Render this member's tab label (CB-557): {@code {role}}, {@code {profile}}, * {@code {model}} and {@code {n}} are substituted. diff --git a/bridged/src/main/java/dev/ltms/fleet/member/ClaudeCodeLauncher.java b/bridged/src/main/java/dev/ltms/fleet/member/ClaudeCodeLauncher.java index c972731..d60e678 100644 --- a/bridged/src/main/java/dev/ltms/fleet/member/ClaudeCodeLauncher.java +++ b/bridged/src/main/java/dev/ltms/fleet/member/ClaudeCodeLauncher.java @@ -292,26 +292,34 @@ public final class ClaudeCodeLauncher extends HerdrPeerLauncher { */ private List argvWithFleet(FleetConfig.Profile cfg, LaunchSpec spec) { String roleCharter = nonBlank(spec.roleCharter()); + // CB-634: the IDE charter is only mounted when the profile also mounts the IDE MCP, and it + // pins every ide_* call to the member's own worktree (spec.cwd()). Ordered between role and + // reply — the reply charter must stay last, it is the rule that must survive. + String ideCharter = cfg.hasIdeMcp() ? ideCharter(spec.cwd()) : null; String replyCharter = nonBlank(spec.replyCharter()); Path agentFile = agentDefinitionFile(spec.cwd(), spec.role(), ".claude", "agents"); - if (!cfg.hasMcp() && roleCharter == null && replyCharter == null && agentFile == null) { + if (!cfg.mountsAnyMcp() && roleCharter == null && ideCharter == null + && replyCharter == null && agentFile == null) { return cfg.argv(); } List argv = mutableArgv(cfg.argv()); - if (cfg.hasMcp()) { - String mcpJson = "{\"mcpServers\":{\"" + PeerLauncher.MCP_MOUNT_NAME - + "\":{\"type\":\"http\",\"url\":\"" - + cfg.mcpUrl() + "\"}}}"; + if (cfg.mountsAnyMcp()) { argv.add("--mcp-config"); - argv.add(mcpJson); + argv.add(mcpConfigJson(cfg)); } - if (roleCharter != null) { - String combined = replyCharter == null ? roleCharter : roleCharter + "\n\n" + replyCharter; - argv.add("--append-system-prompt-file"); - argv.add(writeCharterFile(combined).toString()); - } else if (replyCharter != null) { + // Combine the charters in order role -> ide -> reply, dropping any that are absent. When two + // or more survive they must ride one --append-system-prompt-file (CB-618 forbids the inline + // flag and the file flag together). A lone reply charter keeps its proven inline delivery. + List charters = new java.util.ArrayList<>(3); + if (roleCharter != null) charters.add(roleCharter); + if (ideCharter != null) charters.add(ideCharter); + if (replyCharter != null) charters.add(replyCharter); + if (charters.size() == 1 && replyCharter != null && roleCharter == null && ideCharter == null) { argv.add("--append-system-prompt"); argv.add(replyCharter); + } else if (!charters.isEmpty()) { + argv.add("--append-system-prompt-file"); + argv.add(writeCharterFile(String.join("\n\n", charters)).toString()); } if (agentFile != null) { argv.add("--agent"); @@ -320,6 +328,44 @@ public final class ClaudeCodeLauncher extends HerdrPeerLauncher { return argv; } + /** + * The {@code --mcp-config} JSON for this member: always the bridge mount when {@link + * FleetConfig.Profile#hasMcp()}, plus the IDE Index MCP as a second server named {@code + * intellij} when {@link FleetConfig.Profile#hasIdeMcp()} (CB-634). At least one is present — + * the caller only reaches here when {@link FleetConfig.Profile#mountsAnyMcp()} is true. + */ + private static String mcpConfigJson(FleetConfig.Profile cfg) { + StringBuilder servers = new StringBuilder(); + if (cfg.hasMcp()) { + servers.append('"').append(PeerLauncher.MCP_MOUNT_NAME) + .append("\":{\"type\":\"http\",\"url\":\"").append(cfg.mcpUrl()).append("\"}"); + } + if (cfg.hasIdeMcp()) { + if (servers.length() > 0) servers.append(','); + servers.append("\"intellij\":{\"type\":\"http\",\"url\":\"") + .append(cfg.ideMcpUrl()).append("\"}"); + } + return "{\"mcpServers\":{" + servers + "}}"; + } + + /** + * The IDE charter fragment (CB-634): tells the member to prefer the mounted IDE Index MCP over + * text search, and — the load-bearing rule — to pin every {@code ide_*} call to its own + * worktree. A bare call errors {@code multiple_projects_open}; a call with any other path reads + * a different checkout, which is exactly the CB-525 wrong-tree failure this pin prevents. + */ + private static String ideCharter(String worktree) { + return "## IDE code intelligence — your worktree only\n" + + "An IntelliJ IDE Index MCP server is mounted as `mcp__intellij__ide_*`. Prefer it " + + "over `grep`/`find` for symbol lookups, references, call and type hierarchy, and " + + "diagnostics — it resolves the real AST, text search does not.\n\n" + + "Every `ide_*` call MUST pass `project_path: \"" + worktree + "\"` — your own " + + "worktree — and never any other path. A call without it errors " + + "`multiple_projects_open`; a call with a different path reads another checkout, " + + "not your changes. This is not the primary's IDE: it is your worktree, pinned to " + + "you."; + } + /** {@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; diff --git a/bridged/src/test/java/dev/ltms/fleet/mcp/FleetMcpTest.java b/bridged/src/test/java/dev/ltms/fleet/mcp/FleetMcpTest.java index f522887..f21fc6c 100644 --- a/bridged/src/test/java/dev/ltms/fleet/mcp/FleetMcpTest.java +++ b/bridged/src/test/java/dev/ltms/fleet/mcp/FleetMcpTest.java @@ -444,7 +444,7 @@ class FleetMcpTest { FleetConfig.Profile wcfg = new FleetConfig.Profile( "ltms-local", "http://gx00.gw:8000", "coder", null, "BRIDGED_WORKER_TOKEN", null, "tab", "bridged-workers", "worker: {profile} #{n}", null, - null, null, null, null, null, null, null, 0, null, null, null); + null, null, null, null, null, null, null, 0, null, null, null, null); Map profiles = Map.of(wcfg.profile(), wcfg); ClaudeCodeLauncher delegate = new ClaudeCodeLauncher( new AgentControl(h), new WorkspaceControl(h), new SubscriptionGuard(Set.of("gx00.gw")), diff --git a/bridged/src/test/java/dev/ltms/fleet/member/ClaudeCodeLauncherTest.java b/bridged/src/test/java/dev/ltms/fleet/member/ClaudeCodeLauncherTest.java index 1659739..c1fbf71 100644 --- a/bridged/src/test/java/dev/ltms/fleet/member/ClaudeCodeLauncherTest.java +++ b/bridged/src/test/java/dev/ltms/fleet/member/ClaudeCodeLauncherTest.java @@ -63,6 +63,82 @@ class ClaudeCodeLauncherTest { assertTrue(args.stream().anyMatch(a -> a.contains("fleet_reply")), "reply charter present"); } + // CB-634: a profile with ideMcpUrl set mounts the IDE Index MCP as a second server and pins + // every ide_* call to the member's own worktree via the charter. + + /** A profile carrying an ideMcpUrl (plus optional bridge mcpUrl and cwd). ideMcpUrl is the last record component. */ + private FleetConfig.Profile ideProfile(String mcpUrl, String ideMcpUrl, String cwd) { + return new FleetConfig.Profile( + "ltms-local", "http://gx00.gw:8000", "coder", null, "BRIDGED_WORKER_TOKEN", + List.of("claude"), "tab", "bridged-workers", "w #{n}", mcpUrl, cwd, null, + null, null, null, null, null, null, null, null, null, ideMcpUrl); + } + + private ClaudeCodeLauncher launcher(FakeHerdr herdr, FleetConfig.Profile cfg) { + return new ClaudeCodeLauncher(new AgentControl(herdr), new WorkspaceControl(herdr), + new SubscriptionGuard(Set.of("gx00.gw")), Map.of(cfg.profile(), cfg), cfg.profile(), _ -> null); + } + + @Test + void mountsIdeMcpAsSecondServerWhenIdeMcpUrlSet() { + FakeHerdr herdr = new FakeHerdr(); + launcher(herdr, ideProfile("http://127.0.0.1:8765/mcp", + "http://127.0.0.1:29170/index-mcp/streamable-http", null)).spawn(); + + List args = spawnedArgs(herdr); + assertTrue(args.contains("--mcp-config")); + String json = args.get(args.indexOf("--mcp-config") + 1); + assertTrue(json.contains("\"fleet\"") && json.contains("http://127.0.0.1:8765/mcp"), + "bridge mount still present: " + json); + assertTrue(json.contains("\"intellij\"") && json.contains("29170"), + "IDE Index MCP mounted as a second server named intellij: " + json); + } + + @Test + void ideMcpUrlAloneStillEmitsTheMount() { + FakeHerdr herdr = new FakeHerdr(); + launcher(herdr, ideProfile(null, "http://127.0.0.1:29170/index-mcp/streamable-http", null)).spawn(); + + List args = spawnedArgs(herdr); + assertTrue(args.contains("--mcp-config"), + "the mount gate fires on ideMcpUrl alone, not only on mcpUrl"); + String json = args.get(args.indexOf("--mcp-config") + 1); + assertTrue(json.contains("\"intellij\""), "IDE server present: " + json); + assertFalse(json.contains("\"fleet\""), "no bridge server when mcpUrl is unset: " + json); + } + + @Test + void ideCharterPinsTheWorktreeAndSitsBetweenRoleAndReply() { + FakeHerdr herdr = new FakeHerdr(); + String roleCharter = "You review changes."; + String worktree = "/tmp/.fleet-worktrees/rev-1"; + FleetConfig.Profile cfg = ideProfile("http://127.0.0.1:8765/mcp", + "http://127.0.0.1:29170/index-mcp/streamable-http", worktree); + ClaudeCodeLauncher svc = new ClaudeCodeLauncher(new AgentControl(herdr), new WorkspaceControl(herdr), + new SubscriptionGuard(Set.of("gx00.gw")), Map.of(cfg.profile(), cfg), cfg.profile(), _ -> null, + 0, 0L, () -> fleet(Map.of("reviewer", roleCharter), null)); + + svc.spawn(new SpawnRequest("ltms-local", null, null, null, null, MemberRole.REVIEWER)); + + List args = spawnedArgs(herdr); + assertTrue(args.stream().noneMatch(a -> a.contains("\n")), + "no argv element may be multi-line: " + args); + assertFalse(args.contains("--append-system-prompt"), + "CB-618: role + ide + reply ride one --append-system-prompt-file, never both flags: " + args); + int fileFlag = args.indexOf("--append-system-prompt-file"); + assertTrue(fileFlag >= 0, "the combined charter is mounted via file: " + args); + assertDoesNotThrow(() -> { + String w = Files.readString(Path.of(args.get(fileFlag + 1))); + assertTrue(w.startsWith(roleCharter), "role charter first: " + w); + assertTrue(w.contains("project_path: \"" + worktree + "\""), + "the ide charter pins the member's own worktree: " + w); + assertTrue(w.indexOf("project_path") < w.indexOf(HerdrPeerLauncher.REPLY_CHARTER), + "ide charter sits before the reply charter"); + assertTrue(w.endsWith(HerdrPeerLauncher.REPLY_CHARTER), + "the reply charter is last — it is the rule that must survive: " + w); + }, "the --append-system-prompt-file path must be a readable file"); + } + @Test void startRetriesWhileTheSeedShellBoots() { // tab.create returns before the seed shell reaches its prompt; herdr refuses agent.start diff --git a/bridged/src/test/java/dev/ltms/fleet/member/CompositePeerLauncherTest.java b/bridged/src/test/java/dev/ltms/fleet/member/CompositePeerLauncherTest.java index 98db5df..f8cf234 100644 --- a/bridged/src/test/java/dev/ltms/fleet/member/CompositePeerLauncherTest.java +++ b/bridged/src/test/java/dev/ltms/fleet/member/CompositePeerLauncherTest.java @@ -136,7 +136,7 @@ class CompositePeerLauncherTest { return new FleetConfig.Profile(profile, "http://gx00.gw:8000", "coder", null, "BRIDGED_WORKER_TOKEN", List.of("claude"), "tab", "bridged-workers", "w #{n}", null, null, null, null, null, null, null, null, null, - null, null, credentialId); + null, null, credentialId, null); } /** diff --git a/bridged/src/test/java/dev/ltms/fleet/rest/FleetAppTest.java b/bridged/src/test/java/dev/ltms/fleet/rest/FleetAppTest.java index be58c7c..5e3778b 100644 --- a/bridged/src/test/java/dev/ltms/fleet/rest/FleetAppTest.java +++ b/bridged/src/test/java/dev/ltms/fleet/rest/FleetAppTest.java @@ -252,7 +252,7 @@ class FleetAppTest { FleetConfig.Profile wcfg = new FleetConfig.Profile( "ltms-local", "http://gx00.gw:8000", "coder", null, "BRIDGED_WORKER_TOKEN", null, "tab", "bridged-workers", "worker: {profile} #{n}", null, - null, null, null, null, null, null, null, 0, null, null, null); + null, null, null, null, null, null, null, 0, null, null, null, null); Map profiles = Map.of(wcfg.profile(), wcfg); ClaudeCodeLauncher delegate = new ClaudeCodeLauncher( new AgentControl(herdr), new WorkspaceControl(herdr), new SubscriptionGuard(Set.of("gx00.gw")),