From 6701facca65f43b0be45f0284254d21f41a9b164 Mon Sep 17 00:00:00 2001 From: Nii Yeboah Date: Fri, 26 Jun 2026 14:21:49 -0400 Subject: [PATCH 1/5] Add opt-in cross-repo agent messaging (DISPATCH_CROSS_REPO_MESSAGING) dispatch_send_message and list_agents scope the addressable peer set to the sender's git repo root, so an agent in one repo can't message or see an agent in another. Add an opt-in DISPATCH_CROSS_REPO_MESSAGING env flag that lifts the scoping for local multi-repo workflows; when set, senderRepoRoot is no longer required and every other agent is addressable. Default behavior is unchanged. Adds tests covering both handlers with the flag on, and documents the flag in .env.example. Co-Authored-By: Claude Opus 4.8 (1M context) --- .env.example | 5 +++ apps/server/src/server/mcp-handlers.ts | 38 +++++++++++++---- apps/server/test/mcp-handlers.test.ts | 58 ++++++++++++++++++++++++++ 3 files changed, 94 insertions(+), 7 deletions(-) diff --git a/.env.example b/.env.example index fb3c4da9..48708980 100644 --- a/.env.example +++ b/.env.example @@ -12,6 +12,11 @@ MEDIA_ROOT=~/.dispatch/media # Virtual X display for clipboard image paste on Linux (requires Xvfb + xclip) # DISPATCH_COPY_DISPLAY=:99 +# Let agents message/list agents in OTHER repositories (dispatch_send_message, +# list_agents). Default scopes the addressable peer set to the sender's repo. +# Enable for local multi-repo workflows. ("1" | "true" | "yes") +# DISPATCH_CROSS_REPO_MESSAGING=1 + # TLS (optional — both must be set to enable HTTPS) # TLS_CERT=~/.dispatch/tls/cert.pem # TLS_KEY=~/.dispatch/tls/key.pem diff --git a/apps/server/src/server/mcp-handlers.ts b/apps/server/src/server/mcp-handlers.ts index 41dbbdb2..0d7d1aaa 100644 --- a/apps/server/src/server/mcp-handlers.ts +++ b/apps/server/src/server/mcp-handlers.ts @@ -110,6 +110,20 @@ export function mcpMethodNotAllowed(): { }; } +/** + * Opt-in flag that lets agent-to-agent messaging (dispatch_send_message / + * list_agents) address agents in *other* repositories. By default the + * addressable peer set is scoped to the sender's git repo root; for local + * multi-repo workflows (e.g. an agent in repo A messaging an agent in repo B) + * set DISPATCH_CROSS_REPO_MESSAGING=1 to lift that scoping. + */ +const CROSS_REPO_MESSAGING_ENV = "DISPATCH_CROSS_REPO_MESSAGING"; + +function crossRepoMessagingEnabled(): boolean { + const value = process.env[CROSS_REPO_MESSAGING_ENV]?.toLowerCase(); + return value === "1" || value === "true" || value === "yes"; +} + export function createMcpHandlers(deps: CreateMcpHandlersDeps) { const { pool, @@ -790,7 +804,8 @@ export function createMcpHandlers(deps: CreateMcpHandlersDeps) { if (!sender) throw new Error("Sender agent not found."); const senderRepoRoot = input.senderRepoRoot; - if (!senderRepoRoot) { + const crossRepo = crossRepoMessagingEnabled(); + if (!crossRepo && !senderRepoRoot) { throw new Error( "Cannot send messages: unable to determine your project's repository root." ); @@ -800,6 +815,11 @@ export function createMcpHandlers(deps: CreateMcpHandlersDeps) { const allAgents: typeof allAgentsRaw = []; for (const a of allAgentsRaw) { if (a.id === agentId) continue; + if (crossRepo) { + // Cross-repo messaging enabled: address every other agent. + allAgents.push(a); + continue; + } try { const aRoot = await resolveRepoRoot(a.cwd); if (aRoot === senderRepoRoot) allAgents.push(a); @@ -891,7 +911,8 @@ export function createMcpHandlers(deps: CreateMcpHandlersDeps) { latestEvent: { type: string; message: string } | null; }> > { - if (!senderRepoRoot) { + const crossRepo = crossRepoMessagingEnabled(); + if (!crossRepo && !senderRepoRoot) { throw new Error( "Cannot list agents: unable to determine your project's repository root." ); @@ -906,11 +927,14 @@ export function createMcpHandlers(deps: CreateMcpHandlersDeps) { }> = []; for (const a of agents) { if (a.id === agentId) continue; - try { - const aRoot = await resolveRepoRoot(a.cwd); - if (aRoot !== senderRepoRoot) continue; - } catch { - continue; + if (!crossRepo) { + // Default: only list agents sharing the sender's repo root. + try { + const aRoot = await resolveRepoRoot(a.cwd); + if (aRoot !== senderRepoRoot) continue; + } catch { + continue; + } } result.push({ id: a.id, diff --git a/apps/server/test/mcp-handlers.test.ts b/apps/server/test/mcp-handlers.test.ts index 8555f806..2b468785 100644 --- a/apps/server/test/mcp-handlers.test.ts +++ b/apps/server/test/mcp-handlers.test.ts @@ -1122,6 +1122,34 @@ describe("createMcpHandlers", () => { }) ).rejects.toThrow('No agent found matching "other"'); }); + + it("delivers cross-repo when DISPATCH_CROSS_REPO_MESSAGING is enabled", async () => { + process.env.DISPATCH_CROSS_REPO_MESSAGING = "1"; + try { + deps.agentManager.listAgents.mockResolvedValue([ + { + id: "agt_test1", + name: "sender", + cwd: "/repo-a", + status: "running", + }, + { id: "agt_other", name: "other", cwd: "/repo-b", status: "running" }, + ]); + vi.mocked(resolveRepoRoot).mockImplementation( + async (cwd) => cwd as string + ); + // senderRepoRoot null is tolerated once cross-repo messaging is on. + const result = await handlers.sendMessage("agt_test1", { + target: "other", + message: "hi", + senderRepoRoot: null, + }); + expect(result.delivered).toBe(true); + expect(result.targetAgentId).toBe("agt_other"); + } finally { + delete process.env.DISPATCH_CROSS_REPO_MESSAGING; + } + }); }); describe("listAgentsForAgent", () => { @@ -1168,6 +1196,36 @@ describe("createMcpHandlers", () => { handlers.listAgentsForAgent("agt_self", null) ).rejects.toThrow("Cannot list agents"); }); + + it("lists agents across repos when DISPATCH_CROSS_REPO_MESSAGING is enabled", async () => { + process.env.DISPATCH_CROSS_REPO_MESSAGING = "1"; + try { + deps.agentManager.listAgents.mockResolvedValue([ + { + id: "agt_self", + name: "self", + cwd: "/repo", + status: "running", + latestEvent: null, + }, + { + id: "agt_other", + name: "other", + cwd: "/other-repo", + status: "running", + latestEvent: null, + }, + ]); + vi.mocked(resolveRepoRoot).mockImplementation( + async (cwd) => cwd as string + ); + // senderRepoRoot null is tolerated once cross-repo messaging is on. + const result = await handlers.listAgentsForAgent("agt_self", null); + expect(result.map((a) => a.id)).toEqual(["agt_other"]); + } finally { + delete process.env.DISPATCH_CROSS_REPO_MESSAGING; + } + }); }); describe("shareMedia", () => { From 10f21bd084b268d51b7c465f12b524d6cd8f0dcd Mon Sep 17 00:00:00 2001 From: Nii Yeboah Date: Fri, 26 Jun 2026 14:35:23 -0400 Subject: [PATCH 2/5] Document cross-repo name-matching caveat in .env.example Per security review: when DISPATCH_CROSS_REPO_MESSAGING is enabled, name-based targeting matches across all repos, so recommend agt_ IDs for unambiguous addressing. Co-Authored-By: Claude Opus 4.8 (1M context) --- .env.example | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.env.example b/.env.example index 48708980..90fd4144 100644 --- a/.env.example +++ b/.env.example @@ -15,6 +15,9 @@ MEDIA_ROOT=~/.dispatch/media # Let agents message/list agents in OTHER repositories (dispatch_send_message, # list_agents). Default scopes the addressable peer set to the sender's repo. # Enable for local multi-repo workflows. ("1" | "true" | "yes") +# NOTE: when enabled, name-based targeting matches agents across ALL repos, so a +# name can resolve to a same-named agent in a different repo. Use the agent ID +# (agt_...) to address a specific agent unambiguously. # DISPATCH_CROSS_REPO_MESSAGING=1 # TLS (optional — both must be set to enable HTTPS) From f22a2edb85a0840a78bc057ef987bb5cb514ee5c Mon Sep 17 00:00:00 2001 From: Nii Yeboah Date: Fri, 26 Jun 2026 14:38:40 -0400 Subject: [PATCH 3/5] Address review: single addressable-peer helper, strict bool env - Extract addressableAgents() so dispatch_send_message and list_agents share one definition of the visibility boundary (flag check + self-exclusion + repo-root scoping), preventing drift (architecture review #113). - Parse DISPATCH_CROSS_REPO_MESSAGING with strict === "true" to match the codebase convention instead of a third 1/true/yes form (review #115). - Update .env.example and tests accordingly. Co-Authored-By: Claude Opus 4.8 (1M context) --- .env.example | 4 +- apps/server/src/server/mcp-handlers.ts | 78 +++++++++++++++----------- apps/server/test/mcp-handlers.test.ts | 4 +- 3 files changed, 48 insertions(+), 38 deletions(-) diff --git a/.env.example b/.env.example index 90fd4144..f4bdb85c 100644 --- a/.env.example +++ b/.env.example @@ -14,11 +14,11 @@ MEDIA_ROOT=~/.dispatch/media # Let agents message/list agents in OTHER repositories (dispatch_send_message, # list_agents). Default scopes the addressable peer set to the sender's repo. -# Enable for local multi-repo workflows. ("1" | "true" | "yes") +# Set to "true" to enable for local multi-repo workflows. # NOTE: when enabled, name-based targeting matches agents across ALL repos, so a # name can resolve to a same-named agent in a different repo. Use the agent ID # (agt_...) to address a specific agent unambiguously. -# DISPATCH_CROSS_REPO_MESSAGING=1 +# DISPATCH_CROSS_REPO_MESSAGING=true # TLS (optional — both must be set to enable HTTPS) # TLS_CERT=~/.dispatch/tls/cert.pem diff --git a/apps/server/src/server/mcp-handlers.ts b/apps/server/src/server/mcp-handlers.ts index 0d7d1aaa..8404f612 100644 --- a/apps/server/src/server/mcp-handlers.ts +++ b/apps/server/src/server/mcp-handlers.ts @@ -115,13 +115,42 @@ export function mcpMethodNotAllowed(): { * list_agents) address agents in *other* repositories. By default the * addressable peer set is scoped to the sender's git repo root; for local * multi-repo workflows (e.g. an agent in repo A messaging an agent in repo B) - * set DISPATCH_CROSS_REPO_MESSAGING=1 to lift that scoping. + * set DISPATCH_CROSS_REPO_MESSAGING=true to lift that scoping. */ const CROSS_REPO_MESSAGING_ENV = "DISPATCH_CROSS_REPO_MESSAGING"; function crossRepoMessagingEnabled(): boolean { - const value = process.env[CROSS_REPO_MESSAGING_ENV]?.toLowerCase(); - return value === "1" || value === "true" || value === "yes"; + return process.env[CROSS_REPO_MESSAGING_ENV] === "true"; +} + +/** + * The set of agents a sender may address via dispatch_send_message and + * list_agents: every other agent (self excluded), scoped to the sender's git + * repo root unless cross-repo messaging is enabled. This is the single + * definition of that visibility boundary — both message delivery and agent + * listing consult it, so they can never disagree about who is reachable. + */ +async function addressableAgents( + all: T[], + agentId: string, + senderRepoRoot: string | null +): Promise { + const crossRepo = crossRepoMessagingEnabled(); + const result: T[] = []; + for (const a of all) { + if (a.id === agentId) continue; + if (crossRepo) { + result.push(a); + continue; + } + try { + const aRoot = await resolveRepoRoot(a.cwd); + if (aRoot === senderRepoRoot) result.push(a); + } catch { + // agent cwd not in a git repo — skip + } + } + return result; } export function createMcpHandlers(deps: CreateMcpHandlersDeps) { @@ -804,29 +833,17 @@ export function createMcpHandlers(deps: CreateMcpHandlersDeps) { if (!sender) throw new Error("Sender agent not found."); const senderRepoRoot = input.senderRepoRoot; - const crossRepo = crossRepoMessagingEnabled(); - if (!crossRepo && !senderRepoRoot) { + if (!crossRepoMessagingEnabled() && !senderRepoRoot) { throw new Error( "Cannot send messages: unable to determine your project's repository root." ); } - const allAgentsRaw = await agentManager.listAgents(); - const allAgents: typeof allAgentsRaw = []; - for (const a of allAgentsRaw) { - if (a.id === agentId) continue; - if (crossRepo) { - // Cross-repo messaging enabled: address every other agent. - allAgents.push(a); - continue; - } - try { - const aRoot = await resolveRepoRoot(a.cwd); - if (aRoot === senderRepoRoot) allAgents.push(a); - } catch { - // agent cwd not in a git repo — skip - } - } + const allAgents = await addressableAgents( + await agentManager.listAgents(), + agentId, + senderRepoRoot + ); const isAgentId = input.target.startsWith("agt_"); @@ -911,14 +928,17 @@ export function createMcpHandlers(deps: CreateMcpHandlersDeps) { latestEvent: { type: string; message: string } | null; }> > { - const crossRepo = crossRepoMessagingEnabled(); - if (!crossRepo && !senderRepoRoot) { + if (!crossRepoMessagingEnabled() && !senderRepoRoot) { throw new Error( "Cannot list agents: unable to determine your project's repository root." ); } - const agents = await agentManager.listAgents(); + const agents = await addressableAgents( + await agentManager.listAgents(), + agentId, + senderRepoRoot + ); const result: Array<{ id: string; name: string; @@ -926,16 +946,6 @@ export function createMcpHandlers(deps: CreateMcpHandlersDeps) { latestEvent: { type: string; message: string } | null; }> = []; for (const a of agents) { - if (a.id === agentId) continue; - if (!crossRepo) { - // Default: only list agents sharing the sender's repo root. - try { - const aRoot = await resolveRepoRoot(a.cwd); - if (aRoot !== senderRepoRoot) continue; - } catch { - continue; - } - } result.push({ id: a.id, name: a.name, diff --git a/apps/server/test/mcp-handlers.test.ts b/apps/server/test/mcp-handlers.test.ts index 2b468785..d5625591 100644 --- a/apps/server/test/mcp-handlers.test.ts +++ b/apps/server/test/mcp-handlers.test.ts @@ -1124,7 +1124,7 @@ describe("createMcpHandlers", () => { }); it("delivers cross-repo when DISPATCH_CROSS_REPO_MESSAGING is enabled", async () => { - process.env.DISPATCH_CROSS_REPO_MESSAGING = "1"; + process.env.DISPATCH_CROSS_REPO_MESSAGING = "true"; try { deps.agentManager.listAgents.mockResolvedValue([ { @@ -1198,7 +1198,7 @@ describe("createMcpHandlers", () => { }); it("lists agents across repos when DISPATCH_CROSS_REPO_MESSAGING is enabled", async () => { - process.env.DISPATCH_CROSS_REPO_MESSAGING = "1"; + process.env.DISPATCH_CROSS_REPO_MESSAGING = "true"; try { deps.agentManager.listAgents.mockResolvedValue([ { From 04617a67b99c05937c86603b7d28be21b7a84733 Mon Sep 17 00:00:00 2001 From: Nii Yeboah Date: Tue, 30 Jun 2026 13:34:16 -0700 Subject: [PATCH 4/5] Replace cross-repo messaging env var with an Agents settings toggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per review feedback (Brad), swap the DISPATCH_CROSS_REPO_MESSAGING env var for a setting controlled from the Agents settings page (default off). - cross-repo-messaging-settings.ts: a dedicated accessor module owning the settings key and the "true"/"false" <-> boolean encoding, mirroring agent-type-settings.ts. Both the system route and the MCP handler call isCrossRepoMessagingEnabled / setCrossRepoMessagingEnabled, so the encoding lives in exactly one place (no bare key in db/settings.ts). - mcp-handlers.ts: the gate reads the setting instead of the env var. - system.ts: GET/POST /api/v1/app/settings/cross-repo-messaging. - Frontend: a jotai atom backs the toggle, but the server is authoritative — the component GETs the value on mount to hydrate the atom and only POSTs on an explicit user toggle. This avoids a mount-time write that would otherwise let one device clobber the server-wide gate just by opening settings. Copy reflects the server-wide effect; errors use role="alert"; in-flight requests are sequence-guarded so a stale response can't override newer intent. - Drop the env var from .env.example; update unit tests to drive the setting; add an e2e regression test. Co-Authored-By: Claude Opus 4.8 (1M context) --- .env.example | 8 -- .../src/cross-repo-messaging-settings.ts | 28 +++++ apps/server/src/routes/system.ts | 20 ++++ apps/server/src/server/mcp-handlers.ts | 30 ++---- apps/server/test/mcp-handlers.test.ts | 102 +++++++++--------- .../app/cross-repo-messaging-settings.tsx | 100 +++++++++++++++++ apps/web/src/components/app/settings-pane.tsx | 4 + apps/web/src/lib/store.ts | 10 ++ e2e/settings.spec.ts | 41 +++++++ 9 files changed, 262 insertions(+), 81 deletions(-) create mode 100644 apps/server/src/cross-repo-messaging-settings.ts create mode 100644 apps/web/src/components/app/cross-repo-messaging-settings.tsx diff --git a/.env.example b/.env.example index f4bdb85c..fb3c4da9 100644 --- a/.env.example +++ b/.env.example @@ -12,14 +12,6 @@ MEDIA_ROOT=~/.dispatch/media # Virtual X display for clipboard image paste on Linux (requires Xvfb + xclip) # DISPATCH_COPY_DISPLAY=:99 -# Let agents message/list agents in OTHER repositories (dispatch_send_message, -# list_agents). Default scopes the addressable peer set to the sender's repo. -# Set to "true" to enable for local multi-repo workflows. -# NOTE: when enabled, name-based targeting matches agents across ALL repos, so a -# name can resolve to a same-named agent in a different repo. Use the agent ID -# (agt_...) to address a specific agent unambiguously. -# DISPATCH_CROSS_REPO_MESSAGING=true - # TLS (optional — both must be set to enable HTTPS) # TLS_CERT=~/.dispatch/tls/cert.pem # TLS_KEY=~/.dispatch/tls/key.pem diff --git a/apps/server/src/cross-repo-messaging-settings.ts b/apps/server/src/cross-repo-messaging-settings.ts new file mode 100644 index 00000000..d72aa57f --- /dev/null +++ b/apps/server/src/cross-repo-messaging-settings.ts @@ -0,0 +1,28 @@ +import type { Pool } from "pg"; + +import { getSetting, setSetting } from "./db/settings.js"; + +/** + * Whether agent-to-agent messaging (dispatch_send_message / list_agents) may + * address agents in *other* repositories. By default the addressable peer set + * is scoped to the sender's git repo root; enabling this lifts that scoping for + * local multi-repo workflows. This is a single server-wide setting — the gate + * applies to every agent on this Dispatch server, not per device. + * + * The key and the "true"/"false" <-> boolean encoding live only here so the + * route and the MCP handler share one definition (mirrors agent-type-settings). + */ +const CROSS_REPO_MESSAGING_KEY = "cross_repo_messaging_enabled"; + +export async function isCrossRepoMessagingEnabled( + pool: Pool +): Promise { + return (await getSetting(pool, CROSS_REPO_MESSAGING_KEY)) === "true"; +} + +export async function setCrossRepoMessagingEnabled( + pool: Pool, + enabled: boolean +): Promise { + await setSetting(pool, CROSS_REPO_MESSAGING_KEY, enabled ? "true" : "false"); +} diff --git a/apps/server/src/routes/system.ts b/apps/server/src/routes/system.ts index 7ec9b584..9d95be0a 100644 --- a/apps/server/src/routes/system.ts +++ b/apps/server/src/routes/system.ts @@ -6,6 +6,10 @@ import type { FastifyBaseLogger, FastifyInstance } from "fastify"; import type { Pool } from "pg"; import { deleteSetting, getSetting, setSetting } from "../db/settings.js"; +import { + isCrossRepoMessagingEnabled, + setCrossRepoMessagingEnabled, +} from "../cross-repo-messaging-settings.js"; import { JobService } from "../jobs/service.js"; import { AGENT_TYPES, @@ -416,6 +420,22 @@ export async function registerSystemRoutes( return { enabledIdes: await setEnabledIdes(deps.pool, uniqueIdes) }; }); + app.get("/api/v1/app/settings/cross-repo-messaging", async () => { + return { enabled: await isCrossRepoMessagingEnabled(deps.pool) }; + }); + + app.post( + "/api/v1/app/settings/cross-repo-messaging", + async (request, reply) => { + const body = request.body as { enabled?: unknown } | null; + if (typeof body?.enabled !== "boolean") { + return reply.code(400).send({ error: "enabled must be a boolean." }); + } + await setCrossRepoMessagingEnabled(deps.pool, body.enabled); + return { enabled: body.enabled }; + } + ); + app.post("/api/v1/energy-report", async (request, reply) => { try { deps.appLog.info( diff --git a/apps/server/src/server/mcp-handlers.ts b/apps/server/src/server/mcp-handlers.ts index 8404f612..4a4279e6 100644 --- a/apps/server/src/server/mcp-handlers.ts +++ b/apps/server/src/server/mcp-handlers.ts @@ -18,6 +18,7 @@ import { getEnabledAgentTypes, isCliAgentType, } from "../agent-type-settings.js"; +import { isCrossRepoMessagingEnabled } from "../cross-repo-messaging-settings.js"; import type { JobService } from "../jobs/service.js"; import type { NotifyInput, @@ -110,19 +111,6 @@ export function mcpMethodNotAllowed(): { }; } -/** - * Opt-in flag that lets agent-to-agent messaging (dispatch_send_message / - * list_agents) address agents in *other* repositories. By default the - * addressable peer set is scoped to the sender's git repo root; for local - * multi-repo workflows (e.g. an agent in repo A messaging an agent in repo B) - * set DISPATCH_CROSS_REPO_MESSAGING=true to lift that scoping. - */ -const CROSS_REPO_MESSAGING_ENV = "DISPATCH_CROSS_REPO_MESSAGING"; - -function crossRepoMessagingEnabled(): boolean { - return process.env[CROSS_REPO_MESSAGING_ENV] === "true"; -} - /** * The set of agents a sender may address via dispatch_send_message and * list_agents: every other agent (self excluded), scoped to the sender's git @@ -133,9 +121,9 @@ function crossRepoMessagingEnabled(): boolean { async function addressableAgents( all: T[], agentId: string, - senderRepoRoot: string | null + senderRepoRoot: string | null, + crossRepo: boolean ): Promise { - const crossRepo = crossRepoMessagingEnabled(); const result: T[] = []; for (const a of all) { if (a.id === agentId) continue; @@ -833,7 +821,8 @@ export function createMcpHandlers(deps: CreateMcpHandlersDeps) { if (!sender) throw new Error("Sender agent not found."); const senderRepoRoot = input.senderRepoRoot; - if (!crossRepoMessagingEnabled() && !senderRepoRoot) { + const crossRepo = await isCrossRepoMessagingEnabled(pool); + if (!crossRepo && !senderRepoRoot) { throw new Error( "Cannot send messages: unable to determine your project's repository root." ); @@ -842,7 +831,8 @@ export function createMcpHandlers(deps: CreateMcpHandlersDeps) { const allAgents = await addressableAgents( await agentManager.listAgents(), agentId, - senderRepoRoot + senderRepoRoot, + crossRepo ); const isAgentId = input.target.startsWith("agt_"); @@ -928,7 +918,8 @@ export function createMcpHandlers(deps: CreateMcpHandlersDeps) { latestEvent: { type: string; message: string } | null; }> > { - if (!crossRepoMessagingEnabled() && !senderRepoRoot) { + const crossRepo = await isCrossRepoMessagingEnabled(pool); + if (!crossRepo && !senderRepoRoot) { throw new Error( "Cannot list agents: unable to determine your project's repository root." ); @@ -937,7 +928,8 @@ export function createMcpHandlers(deps: CreateMcpHandlersDeps) { const agents = await addressableAgents( await agentManager.listAgents(), agentId, - senderRepoRoot + senderRepoRoot, + crossRepo ); const result: Array<{ id: string; diff --git a/apps/server/test/mcp-handlers.test.ts b/apps/server/test/mcp-handlers.test.ts index d5625591..e9e46f57 100644 --- a/apps/server/test/mcp-handlers.test.ts +++ b/apps/server/test/mcp-handlers.test.ts @@ -1123,32 +1123,29 @@ describe("createMcpHandlers", () => { ).rejects.toThrow('No agent found matching "other"'); }); - it("delivers cross-repo when DISPATCH_CROSS_REPO_MESSAGING is enabled", async () => { - process.env.DISPATCH_CROSS_REPO_MESSAGING = "true"; - try { - deps.agentManager.listAgents.mockResolvedValue([ - { - id: "agt_test1", - name: "sender", - cwd: "/repo-a", - status: "running", - }, - { id: "agt_other", name: "other", cwd: "/repo-b", status: "running" }, - ]); - vi.mocked(resolveRepoRoot).mockImplementation( - async (cwd) => cwd as string - ); - // senderRepoRoot null is tolerated once cross-repo messaging is on. - const result = await handlers.sendMessage("agt_test1", { - target: "other", - message: "hi", - senderRepoRoot: null, - }); - expect(result.delivered).toBe(true); - expect(result.targetAgentId).toBe("agt_other"); - } finally { - delete process.env.DISPATCH_CROSS_REPO_MESSAGING; - } + it("delivers cross-repo when the cross-repo messaging setting is enabled", async () => { + // getSetting(cross_repo_messaging_enabled) -> "true" + deps.pool.query.mockResolvedValue({ rows: [{ value: "true" }] }); + deps.agentManager.listAgents.mockResolvedValue([ + { + id: "agt_test1", + name: "sender", + cwd: "/repo-a", + status: "running", + }, + { id: "agt_other", name: "other", cwd: "/repo-b", status: "running" }, + ]); + vi.mocked(resolveRepoRoot).mockImplementation( + async (cwd) => cwd as string + ); + // senderRepoRoot null is tolerated once cross-repo messaging is on. + const result = await handlers.sendMessage("agt_test1", { + target: "other", + message: "hi", + senderRepoRoot: null, + }); + expect(result.delivered).toBe(true); + expect(result.targetAgentId).toBe("agt_other"); }); }); @@ -1197,34 +1194,31 @@ describe("createMcpHandlers", () => { ).rejects.toThrow("Cannot list agents"); }); - it("lists agents across repos when DISPATCH_CROSS_REPO_MESSAGING is enabled", async () => { - process.env.DISPATCH_CROSS_REPO_MESSAGING = "true"; - try { - deps.agentManager.listAgents.mockResolvedValue([ - { - id: "agt_self", - name: "self", - cwd: "/repo", - status: "running", - latestEvent: null, - }, - { - id: "agt_other", - name: "other", - cwd: "/other-repo", - status: "running", - latestEvent: null, - }, - ]); - vi.mocked(resolveRepoRoot).mockImplementation( - async (cwd) => cwd as string - ); - // senderRepoRoot null is tolerated once cross-repo messaging is on. - const result = await handlers.listAgentsForAgent("agt_self", null); - expect(result.map((a) => a.id)).toEqual(["agt_other"]); - } finally { - delete process.env.DISPATCH_CROSS_REPO_MESSAGING; - } + it("lists agents across repos when the cross-repo messaging setting is enabled", async () => { + // getSetting(cross_repo_messaging_enabled) -> "true" + deps.pool.query.mockResolvedValue({ rows: [{ value: "true" }] }); + deps.agentManager.listAgents.mockResolvedValue([ + { + id: "agt_self", + name: "self", + cwd: "/repo", + status: "running", + latestEvent: null, + }, + { + id: "agt_other", + name: "other", + cwd: "/other-repo", + status: "running", + latestEvent: null, + }, + ]); + vi.mocked(resolveRepoRoot).mockImplementation( + async (cwd) => cwd as string + ); + // senderRepoRoot null is tolerated once cross-repo messaging is on. + const result = await handlers.listAgentsForAgent("agt_self", null); + expect(result.map((a) => a.id)).toEqual(["agt_other"]); }); }); diff --git a/apps/web/src/components/app/cross-repo-messaging-settings.tsx b/apps/web/src/components/app/cross-repo-messaging-settings.tsx new file mode 100644 index 00000000..0f05e836 --- /dev/null +++ b/apps/web/src/components/app/cross-repo-messaging-settings.tsx @@ -0,0 +1,100 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { useAtom } from "jotai"; + +import { Checkbox } from "@/components/ui/checkbox"; +import { api } from "@/lib/api"; +import { crossRepoMessagingEnabledAtom } from "@/lib/store"; + +type CrossRepoMessagingResponse = { enabled: boolean }; + +const ENDPOINT = "/api/v1/app/settings/cross-repo-messaging"; + +/** + * Toggle for the server-wide cross-repo messaging gate. The gate is enforced + * server-side (a single settings row read by the MCP handler for every agent), + * so the server is the source of truth: on mount we GET the current value and + * hydrate the jotai atom from it, and we only ever POST in response to an + * explicit user toggle. The atom is a cached view that keeps the checkbox + * reactive and gives an instant first paint; it is never re-asserted to the + * server on its own. + */ +export function CrossRepoMessagingSettings(): JSX.Element { + const [enabled, setEnabled] = useAtom(crossRepoMessagingEnabledAtom); + const [error, setError] = useState(""); + // Monotonic id for the most recent request (mount GET or a toggle). A + // response only applies if it is still the latest, so a stale mount GET or + // an out-of-order toggle response can't clobber a newer user intent. + const latestReq = useRef(0); + + useEffect(() => { + const seq = (latestReq.current += 1); + void api(ENDPOINT) + .then((data) => { + if (seq === latestReq.current) setEnabled(data.enabled); + }) + .catch(() => { + if (seq === latestReq.current) { + setError("Failed to load cross-repo messaging setting."); + } + }); + }, [setEnabled]); + + const handleToggle = useCallback( + (next: boolean) => { + const seq = (latestReq.current += 1); + setError(""); + setEnabled(next); + void api(ENDPOINT, { + method: "POST", + body: JSON.stringify({ enabled: next }), + }).catch((err) => { + if (seq !== latestReq.current) return; + // Revert the optimistic change — the server gate is unchanged. + setEnabled(!next); + setError( + err instanceof Error + ? err.message + : "Failed to save cross-repo messaging setting." + ); + }); + }, + [setEnabled] + ); + + return ( +
+
+ Cross-repo messaging +
+

+ By default agents can only message and list other agents in the same git + repository. Enable this to let agents coordinate across repositories for + local multi-repo workflows. Applies to all agents on this Dispatch + server. +

+
+ +
+ {error ? ( +

+ {error} +

+ ) : null} +
+ ); +} diff --git a/apps/web/src/components/app/settings-pane.tsx b/apps/web/src/components/app/settings-pane.tsx index 2d45ccc6..5b1fa280 100644 --- a/apps/web/src/components/app/settings-pane.tsx +++ b/apps/web/src/components/app/settings-pane.tsx @@ -2,6 +2,7 @@ import { Database, Server, Settings } from "lucide-react"; import { AgentTypeSettings } from "@/components/app/agent-type-settings"; import { AppearanceSettings } from "@/components/app/appearance-settings"; +import { CrossRepoMessagingSettings } from "@/components/app/cross-repo-messaging-settings"; import { IdeSettings } from "@/components/app/ide-settings"; import { InstanceNameSettings } from "@/components/app/instance-name-settings"; import { DocsContent, DOCS_SECTION_NAV } from "@/components/app/docs-pane"; @@ -217,6 +218,9 @@ export function SettingsContent({ onChange={onEnabledIdesChange} /> +
+ +
diff --git a/apps/web/src/lib/store.ts b/apps/web/src/lib/store.ts index 33e00265..6b4255a8 100644 --- a/apps/web/src/lib/store.ts +++ b/apps/web/src/lib/store.ts @@ -69,6 +69,16 @@ export const preferredIdeAtom = atomWithLocalStorage( "vscode" ); +// Cached view of the server-wide cross-repo messaging gate (lets agents +// message/list agents in OTHER repositories). The server enforces and owns the +// value; CrossRepoMessagingSettings hydrates this atom from the GET endpoint on +// mount and writes back only on an explicit toggle. localStorage just gives an +// instant first paint before the GET resolves. Default off. +export const crossRepoMessagingEnabledAtom = atomWithLocalStorage( + "dispatch:crossRepoMessaging", + false +); + // Per-cwd preferences for the Create Agent dialog. Each cwd gets its own // atom backed by localStorage; the family caches them by trimmed cwd. export const createNewBranchPrefAtom = atomFamily((cwd: string) => diff --git a/e2e/settings.spec.ts b/e2e/settings.spec.ts index 17eedb91..dd38dc53 100644 --- a/e2e/settings.spec.ts +++ b/e2e/settings.spec.ts @@ -17,6 +17,12 @@ test.describe("Settings pane", () => { webNotifyEvents: ["done", "waiting_user", "blocked"], }, }); + await request.post("/api/v1/app/settings/cross-repo-messaging", { + headers: { + Authorization: `Bearer ${process.env.AUTH_TOKEN ?? "dev-token"}`, + }, + data: { enabled: false }, + }); }); test("opens and closes the settings pane", async ({ page }) => { @@ -91,6 +97,41 @@ test.describe("Settings pane", () => { ).not.toBeVisible(); }); + test("cross-repo messaging toggle defaults off and persists to the server", async ({ + page, + request, + }) => { + await loadApp(page); + + await page.getByTestId("settings-button").click(); + await page + .getByTestId("sidebar-shell") + .getByText("Agents", { exact: true }) + .click(); + + const toggle = page.getByTestId("cross-repo-messaging-toggle"); + await toggle.scrollIntoViewIfNeeded(); + await expect(toggle).not.toBeChecked(); + + await toggle.check(); + await expect(toggle).toBeChecked(); + + // The local toggle is mirrored to the server, which enforces the boundary. + await expect + .poll(async () => { + const res = await request.get( + "/api/v1/app/settings/cross-repo-messaging", + { + headers: { + Authorization: `Bearer ${process.env.AUTH_TOKEN ?? "dev-token"}`, + }, + } + ); + return (await res.json()).enabled; + }) + .toBe(true); + }); + test("single enabled agent type removes split buttons", async ({ page, request, From c8ee072a7483ed2f54dcbaec90e28c98354dd139 Mon Sep 17 00:00:00 2001 From: Brad Harris Date: Mon, 6 Jul 2026 09:40:43 -0600 Subject: [PATCH 5/5] Fix optimistic revert bug and remove dead self-exclusion checks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Track last server-confirmed value in a ref so the optimistic revert on POST failure restores the correct state instead of blindly using !next (which desyncs after a double-toggle where both POSTs fail). Remove three dead self-exclusion checks in sendMessage — addressableAgents already excludes self at line 129. Co-Authored-By: Claude Opus 4.6 --- apps/server/src/server/mcp-handlers.ts | 10 ++---- .../app/cross-repo-messaging-settings.tsx | 32 +++++++++++-------- 2 files changed, 20 insertions(+), 22 deletions(-) diff --git a/apps/server/src/server/mcp-handlers.ts b/apps/server/src/server/mcp-handlers.ts index 4a4279e6..8331e628 100644 --- a/apps/server/src/server/mcp-handlers.ts +++ b/apps/server/src/server/mcp-handlers.ts @@ -844,9 +844,7 @@ export function createMcpHandlers(deps: CreateMcpHandlersDeps) { const lowerTarget = input.target.toLowerCase(); const matches = allAgents.filter( (a) => - a.id !== agentId && - a.status === "running" && - a.name.toLowerCase().includes(lowerTarget) + a.status === "running" && a.name.toLowerCase().includes(lowerTarget) ); if (matches.length === 1) { target = matches[0]; @@ -860,7 +858,7 @@ export function createMcpHandlers(deps: CreateMcpHandlersDeps) { if (!target) { const running = allAgents - .filter((a) => a.id !== agentId && a.status === "running") + .filter((a) => a.status === "running") .map((a) => ` ${a.id} "${a.name}"`) .join("\n"); throw new Error( @@ -868,10 +866,6 @@ export function createMcpHandlers(deps: CreateMcpHandlersDeps) { ); } - if (target.id === agentId) { - throw new Error("Cannot send a message to yourself."); - } - if (target.status !== "running") { throw new Error( `Agent "${target.name}" (${target.id}) is ${target.status}, not running.` diff --git a/apps/web/src/components/app/cross-repo-messaging-settings.tsx b/apps/web/src/components/app/cross-repo-messaging-settings.tsx index 0f05e836..43a4d648 100644 --- a/apps/web/src/components/app/cross-repo-messaging-settings.tsx +++ b/apps/web/src/components/app/cross-repo-messaging-settings.tsx @@ -21,16 +21,17 @@ const ENDPOINT = "/api/v1/app/settings/cross-repo-messaging"; export function CrossRepoMessagingSettings(): JSX.Element { const [enabled, setEnabled] = useAtom(crossRepoMessagingEnabledAtom); const [error, setError] = useState(""); - // Monotonic id for the most recent request (mount GET or a toggle). A - // response only applies if it is still the latest, so a stale mount GET or - // an out-of-order toggle response can't clobber a newer user intent. const latestReq = useRef(0); + const confirmedValue = useRef(enabled); useEffect(() => { const seq = (latestReq.current += 1); void api(ENDPOINT) .then((data) => { - if (seq === latestReq.current) setEnabled(data.enabled); + if (seq === latestReq.current) { + confirmedValue.current = data.enabled; + setEnabled(data.enabled); + } }) .catch(() => { if (seq === latestReq.current) { @@ -47,16 +48,19 @@ export function CrossRepoMessagingSettings(): JSX.Element { void api(ENDPOINT, { method: "POST", body: JSON.stringify({ enabled: next }), - }).catch((err) => { - if (seq !== latestReq.current) return; - // Revert the optimistic change — the server gate is unchanged. - setEnabled(!next); - setError( - err instanceof Error - ? err.message - : "Failed to save cross-repo messaging setting." - ); - }); + }) + .then(() => { + if (seq === latestReq.current) confirmedValue.current = next; + }) + .catch((err) => { + if (seq !== latestReq.current) return; + setEnabled(confirmedValue.current); + setError( + err instanceof Error + ? err.message + : "Failed to save cross-repo messaging setting." + ); + }); }, [setEnabled] );