agent-relay-server 0.89.2 → 0.91.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/docs/openapi.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "openapi": "3.1.0",
3
3
  "info": {
4
4
  "title": "Agent Relay API",
5
- "version": "0.89.2",
5
+ "version": "0.91.0",
6
6
  "description": "Real-time message bus for inter-agent communication. Agent-first: this spec is designed for machine consumption — agents can self-discover the full API surface via GET /api/spec.",
7
7
  "license": {
8
8
  "name": "MIT",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-relay-server",
3
- "version": "0.89.2",
3
+ "version": "0.91.0",
4
4
  "description": "Lightweight HTTP message relay for inter-agent communication across machines",
5
5
  "module": "src/index.ts",
6
6
  "type": "module",
@@ -33,7 +33,7 @@
33
33
  "CONTRIBUTING.md"
34
34
  ],
35
35
  "dependencies": {
36
- "agent-relay-sdk": "0.2.68",
36
+ "agent-relay-sdk": "0.2.70",
37
37
  "ajv": "^8.20.0"
38
38
  },
39
39
  "scripts": {
@@ -100,6 +100,10 @@ export interface RunnerSpawnConfig {
100
100
  // Stage 2 (#215): the MCP endpoint the agent connects to — the runner-local proxy URL when the
101
101
  // proxy is active. Undefined → the adapter targets the relay's MCP endpoint directly (Stage 1).
102
102
  relayMcpEndpoint?: string;
103
+ // #672: the shared host-listener URL the per-agent `callmux bridge` connects to, when the
104
+ // profile/spawn opted into `agentProfile.sharedMcp`. Resolved runner-side (env → default);
105
+ // the launch assembler only emits the bridge entry when sharedMcp is on.
106
+ sharedMcpUrl?: string;
103
107
  monitor?: {
104
108
  deliver(messages: Message[]): Promise<number[]>;
105
109
  };
@@ -5,6 +5,8 @@ import { DEFAULT_QUOTA_THRESHOLD_CONFIG, validateQuotaThresholdConfig } from "./
5
5
  import { DEFAULT_SELF_RESUME_CONFIG, validateSelfResumeConfig } from "./self-resume-config";
6
6
  import { TEAM_TEMPLATE_NAMESPACE, validateTeamTemplate } from "./team-template-config";
7
7
  import { PROJECT_INSTRUCTION_POLICY_NAMES } from "./project-instructions";
8
+ import { PROJECT_MCP_POLICY_NAMES } from "./project-mcp";
9
+ import { SHARED_MCP_POLICY_NAMES } from "./shared-mcp";
8
10
  import { resolveProviderSelection, type ProviderEffort } from "agent-relay-sdk/provider-catalog";
9
11
  import { errMessage, isRecord, SPAWN_PROVIDERS, VALID_WORKSPACE_MODES } from "agent-relay-sdk"; import { effectiveProviderCatalogList } from "./provider-catalog-store";
10
12
  import type {
@@ -225,6 +227,28 @@ function cleanAgentProfileProjectInstructions(value: unknown): AgentProfile["pro
225
227
  return { policy: cleanEnum(policy, "projectInstructions.policy", PROJECT_INSTRUCTION_POLICY_NAMES) as NonNullable<AgentProfile["projectInstructions"]>["policy"] };
226
228
  }
227
229
 
230
+ // #667 — project `.mcp.json` projection profile default. Mirrors projectInstructions:
231
+ // boolean | { policy } → normalized { policy? } | undefined (OFF).
232
+ function cleanAgentProfileProjectMcp(value: unknown): AgentProfile["projectMcp"] {
233
+ if (value === undefined || value === null || value === false) return undefined;
234
+ if (value === true) return {};
235
+ if (!isRecord(value)) throw new ValidationError("projectMcp must be a boolean or { policy } object");
236
+ const policy = value.policy;
237
+ if (policy === undefined || policy === null) return {};
238
+ return { policy: cleanEnum(policy, "projectMcp.policy", PROJECT_MCP_POLICY_NAMES) as NonNullable<AgentProfile["projectMcp"]>["policy"] };
239
+ }
240
+
241
+ // #672 — shared host-listener MCP projection profile default. Mirrors projectMcp:
242
+ // boolean | { policy } → normalized { policy? } | undefined (OFF).
243
+ function cleanAgentProfileSharedMcp(value: unknown): AgentProfile["sharedMcp"] {
244
+ if (value === undefined || value === null || value === false) return undefined;
245
+ if (value === true) return {};
246
+ if (!isRecord(value)) throw new ValidationError("sharedMcp must be a boolean or { policy } object");
247
+ const policy = value.policy;
248
+ if (policy === undefined || policy === null) return {};
249
+ return { policy: cleanEnum(policy, "sharedMcp.policy", SHARED_MCP_POLICY_NAMES) as NonNullable<AgentProfile["sharedMcp"]>["policy"] };
250
+ }
251
+
228
252
  function agentProfileDefaults(input: Pick<AgentProfile, "name" | "base"> & Partial<AgentProfile>): AgentProfile {
229
253
  const isolated = input.base !== "host";
230
254
  // "vanilla" (#552) additionally suppresses the REPO instruction cascade
@@ -245,6 +269,8 @@ function agentProfileDefaults(input: Pick<AgentProfile, "name" | "base"> & Parti
245
269
  globalInstructions: input.instructions?.globalInstructions ?? (isolated ? "ignore" : "allow"),
246
270
  },
247
271
  ...(input.projectInstructions ? { projectInstructions: input.projectInstructions } : {}),
272
+ ...(input.projectMcp ? { projectMcp: input.projectMcp } : {}),
273
+ ...(input.sharedMcp ? { sharedMcp: input.sharedMcp } : {}),
248
274
  relay: {
249
275
  context: input.relay?.context ?? !isolated,
250
276
  skills: input.relay?.skills ?? !isolated,
@@ -319,6 +345,8 @@ function validateAgentProfile(key: string, value: unknown): AgentProfile {
319
345
  : cleanEnum(instructions.globalInstructions, "instructions.globalInstructions", VALID_PROFILE_INSTRUCTION_POLICIES),
320
346
  },
321
347
  projectInstructions: cleanAgentProfileProjectInstructions(value.projectInstructions),
348
+ projectMcp: cleanAgentProfileProjectMcp(value.projectMcp),
349
+ sharedMcp: cleanAgentProfileSharedMcp(value.sharedMcp),
322
350
  relay: {
323
351
  context: relay.context === undefined ? defaults.relay.context : cleanBoolean(relay.context, "relay.context"),
324
352
  skills: relay.skills === undefined ? defaults.relay.skills : cleanBoolean(relay.skills, "relay.skills"),
@@ -4,6 +4,8 @@ import { ShutdownAuthError, ShutdownTargetError, shutdownAgent } from "../servic
4
4
  import { buildSpawnTargets } from "../spawn-targets";
5
5
  import { McpAuthError, McpNotFoundError } from "../mcp-errors";
6
6
  import { parseProjectInstructions, PROJECT_INSTRUCTION_POLICY_NAMES } from "../project-instructions";
7
+ import { parseProjectMcp, PROJECT_MCP_POLICY_NAMES } from "../project-mcp";
8
+ import { parseSharedMcp, SHARED_MCP_POLICY_NAMES } from "../shared-mcp";
7
9
  import { countLiveSpawnedAgents, listAgents, listWorkspaces, ValidationError } from "../db";
8
10
  import { listCommands } from "../commands-db";
9
11
  import { DEFAULT_SPAWN_PROFILE_NAME } from "../config-store";
@@ -34,6 +36,8 @@ export const SPAWN_TOOLS: ToolDefinition[] = [
34
36
  expectReply: { type: "boolean", description: "Legacy compatibility flag. Spawned workers now report up automatically: each turn-final response is routed to the spawner by lineage, without a reply obligation or idle nag." },
35
37
  systemPromptAppend: { type: "string" },
36
38
  projectInstructions: { type: "object", description: "#409 — project instruction projection (default OFF). When set, relay composes a knowledge preamble from the spawn cwd's resolved project (own + inherited entries) and prepends it to systemPromptAppend, instead of relying on host CLAUDE.md. `policy: \"all\"` includes ingested host content; `\"vanilla\"` excludes it (manual+promoted only). Omit to keep current behavior.", properties: { policy: { type: "string", enum: PROJECT_INSTRUCTION_POLICY_NAMES } }, additionalProperties: false },
39
+ projectMcp: { type: "object", description: "#667 — project `.mcp.json` MCP projection (default OFF). When set, the worker gets the MCP servers committed in the spawn cwd's `.mcp.json` (e.g. an app's playtest/manuscript servers), injected by relay through the controlled `--mcp-config` path that survives `--strict-mcp-config` — sourced from the project, not ambient host MCP, and without triggering Claude's project-trust modal. `policy: \"all\"` (the default) injects every declared server. Omit to keep current host-only behavior.", properties: { policy: { type: "string", enum: PROJECT_MCP_POLICY_NAMES } }, additionalProperties: false },
40
+ sharedMcp: { type: "object", description: "#672 — shared host-listener MCP (default OFF). When set, the worker's NON-IDENTITY MCP (tokenlean, github) is routed through one orchestrator-owned shared `callmux` listener via a per-agent `callmux bridge --cwd \"$WORKTREE\"`, so the MCP session survives a listener/downstream restart and each isolated worktree keeps session-cwd isolation. Rides the controlled `--mcp-config` path (composes with `--strict-mcp-config`). The identity-bearing relay endpoint stays per-agent (#215), never on the shared listener. `policy: \"all\"` (the default) routes the listener's full non-identity set. Omit to keep current per-agent injection.", properties: { policy: { type: "string", enum: SHARED_MCP_POLICY_NAMES } }, additionalProperties: false },
37
41
  profile: { type: "string", description: `Agent profile name to apply (env, instructions, permissions, MCP/skills, spawn quota). Omit to use \`${DEFAULT_SPAWN_PROFILE_NAME}\`.` },
38
42
  tags: { type: "array", items: { type: "string" } },
39
43
  capabilities: { type: "array", items: { type: "string" } },
@@ -108,6 +112,8 @@ export async function relaySpawnAgent(auth: McpAuthContext, args: Record<string,
108
112
  expectReply: typeof args.expectReply === "boolean" ? args.expectReply : undefined,
109
113
  systemPromptAppend: optionalString(args.systemPromptAppend, "systemPromptAppend", 64_000),
110
114
  projectInstructions: parseProjectInstructions(args.projectInstructions, (m) => new ValidationError(m)),
115
+ projectMcp: parseProjectMcp(args.projectMcp, (m) => new ValidationError(m)),
116
+ sharedMcp: parseSharedMcp(args.sharedMcp, (m) => new ValidationError(m)),
111
117
  tags: optionalStringArray(args.tags, "tags"),
112
118
  capabilities: optionalStringArray(args.capabilities, "capabilities"),
113
119
  providerArgs: optionalStringArray(args.providerArgs, "providerArgs"),
@@ -0,0 +1,52 @@
1
+ // Project `.mcp.json` MCP projection (#667) — the relay-owned, OPT-IN path that hands a
2
+ // spawned worker its spawn-cwd's COMMITTED `.mcp.json` servers, sourced from the PROJECT
3
+ // (in-repo, trusted) rather than ambient host MCP. Sibling seam to project-instructions
4
+ // (#409): same opt-in + projection shape, default OFF.
5
+ //
6
+ // PROVENANCE, NOT TYPE: "vanilla/zero-host-bleed" means relay OWNS provisioning and sources
7
+ // it from the project, never the host. A repo's `.mcp.json` is part of the code the agent
8
+ // works in; it should travel with the repo like it does for a human who clones it.
9
+ //
10
+ // DIVISION OF LABOR: relay only carries the OPT-IN DECISION (this module + the spawn
11
+ // param/profile field). The actual file read + `--mcp-config` injection happens RUNNER-side
12
+ // (launch-assembly.ts → resolveProjectMcpServers), which is the filesystem authority for the
13
+ // spawn cwd (relay may run on a different host than the orchestrator). It composes with #662
14
+ // `--strict-mcp-config` — the controlled inject path strict PRESERVES — and deliberately does
15
+ // NOT enable Claude's ambient project-`.mcp.json` discovery, whose trust modal wedges headless
16
+ // agents (the very reason #662 added strict to all managed launches).
17
+
18
+ export const PROJECT_MCP_POLICY_NAMES = ["all"] as const;
19
+ export type ProjectMcpPolicyName = (typeof PROJECT_MCP_POLICY_NAMES)[number];
20
+
21
+ /** Per-spawn projection request. Presence (a non-undefined value) means "inject project
22
+ * MCP"; absence means OFF. `policy` selects the server set (`all` = every declared server;
23
+ * the sole value today). */
24
+ export interface ProjectMcpPolicy {
25
+ policy?: ProjectMcpPolicyName;
26
+ }
27
+
28
+ /**
29
+ * Parse a spawn-transport's `projectMcp` wire value into the normalized policy (or
30
+ * undefined = OFF). Shared by the MCP and HTTP spawn paths so they cannot drift. Mirrors
31
+ * parseProjectInstructions exactly:
32
+ * - undefined / null / false → undefined (OFF)
33
+ * - true → {} (ON, default policy)
34
+ * - { policy } → validated policy
35
+ * Throws `onInvalid(message)` for a malformed shape or unknown policy name.
36
+ */
37
+ export function parseProjectMcp(
38
+ value: unknown,
39
+ onInvalid: (message: string) => Error,
40
+ ): ProjectMcpPolicy | undefined {
41
+ if (value === undefined || value === null || value === false) return undefined;
42
+ if (value === true) return {};
43
+ if (typeof value !== "object") {
44
+ throw onInvalid("projectMcp must be a boolean or { policy } object");
45
+ }
46
+ const policy = (value as Record<string, unknown>).policy;
47
+ if (policy === undefined || policy === null) return {};
48
+ if (typeof policy !== "string" || !PROJECT_MCP_POLICY_NAMES.includes(policy as ProjectMcpPolicyName)) {
49
+ throw onInvalid(`projectMcp.policy must be one of: ${PROJECT_MCP_POLICY_NAMES.join(", ")}`);
50
+ }
51
+ return { policy: policy as ProjectMcpPolicyName };
52
+ }
@@ -7,6 +7,8 @@ import { cleanMeta, cleanNullableString, cleanString, cleanStringArray, optional
7
7
  import { registerAgent } from "../services/register-agent";
8
8
  import { spawnAgent, type SpawnAgentInput } from "../services/spawn-agent";
9
9
  import { parseProjectInstructions } from "../project-instructions";
10
+ import { parseProjectMcp } from "../project-mcp";
11
+ import { parseSharedMcp } from "../shared-mcp";
10
12
  import { authContextFromRequest } from "../services/auth-context";
11
13
  import { listHostDirectories } from "../agent-spawn";
12
14
  import { rankRouteCandidates, type RouteAdvisorInput } from "../context-router";
@@ -121,6 +123,8 @@ export const postAgentSpawn: Handler = async (req) => {
121
123
  prompt: cleanString(parsed.body.prompt, "prompt", { max: 16000 }),
122
124
  systemPromptAppend: cleanString(parsed.body.systemPromptAppend, "systemPromptAppend", { max: 64_000 }),
123
125
  projectInstructions: parseProjectInstructions(parsed.body.projectInstructions, (m) => new ValidationError(m)),
126
+ projectMcp: parseProjectMcp(parsed.body.projectMcp, (m) => new ValidationError(m)),
127
+ sharedMcp: parseSharedMcp(parsed.body.sharedMcp, (m) => new ValidationError(m)),
124
128
  tags: cleanStringArray(parsed.body.tags, "tags", { itemMax: 80, maxItems: 50 }),
125
129
  capabilities: cleanStringArray(parsed.body.capabilities, "capabilities", { itemMax: 80, maxItems: 50 }),
126
130
  providerArgs: cleanStringArray(parsed.body.providerArgs, "providerArgs", { itemMax: 80, maxItems: 50 }),
@@ -1,7 +1,8 @@
1
1
  import { isRecord } from "agent-relay-sdk";
2
- import { acquireProviderQuotaLease, getOrchestrator, listProviderQuotas, releaseProviderQuotaLease, upsertProviderQuota } from "../db";
2
+ import { acquireProviderQuotaLease, getOrchestrator, getProviderQuotaLease, listProviderQuotas, releaseProviderQuotaLease, upsertProviderQuota } from "../db";
3
3
  import { emitRelayEvent } from "../events";
4
4
  import { quotaStateFromUnknown, providerQuotaErrorFromUnknown } from "../quota";
5
+ import { authContextFromRequest } from "../services/auth-context";
5
6
  import { cleanString } from "../validation";
6
7
  import { authorizeRoute, error, json, parseBody, parseQueryInt, type Handler } from "./_shared";
7
8
  import type { ProviderQuotaUpdateInput } from "../types";
@@ -45,6 +46,8 @@ export const postProviderQuotaRoute: Handler = async (req) => {
45
46
  if (!parsed.ok) return error(parsed.error, parsed.status);
46
47
  const input = cleanProviderQuotaUpdate(parsed.body);
47
48
  if (typeof input === "string") return error(input);
49
+ const providerWriteGate = authorizeProviderQuotaWrite(req, input);
50
+ if (providerWriteGate) return providerWriteGate;
48
51
  const record = upsertProviderQuota(input);
49
52
  emitRelayEvent({ type: "provider-quota.updated", source: "server", subject: `${record.provider}:${record.accountKey}`, data: {} });
50
53
  return json(record, 201);
@@ -82,12 +85,36 @@ function cleanPublisherLeaseBody(body: unknown): {
82
85
 
83
86
  const runnerLeaseHolderId = (sourceAgentId: string) => `runner:${sourceAgentId}`;
84
87
 
88
+ function authorizeProviderQuotaSourceAgent(req: Request, sourceAgentId: string): Response | null {
89
+ const ctx = authContextFromRequest(req);
90
+ if (ctx.component?.role !== "provider") return null;
91
+ const denied = authorizeRoute(req, { scope: "quota:write" });
92
+ if (denied) return denied;
93
+ return ctx.callerAgentId === sourceAgentId ? null : error("forbidden", 403);
94
+ }
95
+
96
+ function authorizeProviderQuotaWrite(req: Request, input: ProviderQuotaUpdateInput): Response | null {
97
+ const ctx = authContextFromRequest(req);
98
+ if (ctx.component?.role !== "provider") return null;
99
+ if (!input.sourceAgentId) return error("sourceAgentId required for provider quota writer", 403);
100
+ const denied = authorizeProviderQuotaSourceAgent(req, input.sourceAgentId);
101
+ if (denied) return denied;
102
+ const lease = getProviderQuotaLease(input.provider, input.accountKey);
103
+ const activeLease = lease && lease.expiresAt > Date.now() ? lease : null;
104
+ if (activeLease?.orchestratorId === runnerLeaseHolderId(input.sourceAgentId)) return null;
105
+ return json({
106
+ skipped: true,
107
+ reason: "provider quota lease not held by source agent",
108
+ ...(activeLease ? { holder: { orchestratorId: activeLease.orchestratorId, expiresAt: activeLease.expiresAt } } : {}),
109
+ }, 202);
110
+ }
111
+
85
112
  export const postProviderQuotaPublisherLeaseAcquireRoute: Handler = async (req) => {
86
113
  const parsed = await parseBody<unknown>(req);
87
114
  if (!parsed.ok) return error(parsed.error, parsed.status);
88
115
  const input = cleanPublisherLeaseBody(parsed.body);
89
116
  if (typeof input === "string") return error(input);
90
- const denied = authorizeRoute(req, { scope: "quota:write", resource: { agentId: input.sourceAgentId } });
117
+ const denied = authorizeProviderQuotaSourceAgent(req, input.sourceAgentId);
91
118
  if (denied) return denied;
92
119
  return json(acquireProviderQuotaLease({
93
120
  provider: input.provider,
@@ -104,7 +131,7 @@ export const postProviderQuotaPublisherLeaseReleaseRoute: Handler = async (req)
104
131
  const input = cleanPublisherLeaseBody(parsed.body);
105
132
  if (typeof input === "string") return error(input);
106
133
  if (!input.leaseToken) return error("leaseToken required");
107
- const denied = authorizeRoute(req, { scope: "quota:write", resource: { agentId: input.sourceAgentId } });
134
+ const denied = authorizeProviderQuotaSourceAgent(req, input.sourceAgentId);
108
135
  if (denied) return denied;
109
136
  return json({
110
137
  released: releaseProviderQuotaLease({
@@ -33,6 +33,8 @@ import { runnerRuntimeTokenEnv } from "../runtime-tokens";
33
33
  import { selectSpawnOrchestrator } from "../spawn-targets";
34
34
  import { DEFAULT_SPAWN_PROFILE_NAME, getAgentProfile } from "../config-store";
35
35
  import { composeSpawnInstructionsWithEvents, type ProjectInstructionPolicy } from "../project-instructions";
36
+ import type { ProjectMcpPolicy } from "../project-mcp";
37
+ import type { SharedMcpPolicy } from "../shared-mcp";
36
38
  import { autoSeedProjectForCwd } from "./project";
37
39
  import { hasComponentScope, isComponentAuthorizedFor } from "../security";
38
40
  import { isPathWithinBase } from "../utils";
@@ -74,6 +76,25 @@ export interface SpawnAgentInput {
74
76
  * `vanilla` (manual+promoted; excludes `ingested` host-CLAUDE.md content).
75
77
  */
76
78
  projectInstructions?: ProjectInstructionPolicy;
79
+ /**
80
+ * #667 — project `.mcp.json` MCP projection. When set, the runner reads the spawn cwd's
81
+ * COMMITTED `.mcp.json` and injects those servers via the controlled `--mcp-config`
82
+ * (composing with #662 `--strict-mcp-config`), so a worker spawned into a repo gets that
83
+ * repo's MCP servers — sourced from the project, never ambient host MCP. Additive-from-
84
+ * empty + opt-in: absent (undefined) = OFF → the launch is byte-identical to today. Sibling
85
+ * to projectInstructions; carried to the runner on the in-flight `agentProfile.projectMcp`.
86
+ */
87
+ projectMcp?: ProjectMcpPolicy;
88
+ /**
89
+ * #672 — shared host-listener MCP. When set, the runner emits a per-agent `callmux bridge`
90
+ * stdio entry so the agent's NON-IDENTITY MCP (tokenlean, github) flows through the
91
+ * orchestrator-owned shared listener instead of per-agent server processes — the session
92
+ * survives a listener/downstream restart, with `--cwd "$WORKTREE"` session-cwd isolation.
93
+ * Additive-from-empty + opt-in: absent (undefined) = OFF → the launch is byte-identical to
94
+ * today. Sibling to projectMcp; carried to the runner on the in-flight `agentProfile.sharedMcp`.
95
+ * The identity-bearing relay endpoint stays per-agent (#215), never on the shared listener.
96
+ */
97
+ sharedMcp?: SharedMcpPolicy;
77
98
  /**
78
99
  * Legacy report-back contract flag. Spawned children now report up by lineage: their
79
100
  * turn-final response is delivered to the spawner automatically, without a reply obligation.
@@ -170,6 +191,21 @@ export async function spawnAgent(input: SpawnAgentInput, ctx: AuthContext): Prom
170
191
  const agentProfile = withResolvedProvisioning(getAgentProfile(profileName)?.value, input.provider);
171
192
  if (!agentProfile) throw new McpNotFoundError(input.profile ? "agent profile not found" : `default agent profile ${DEFAULT_SPAWN_PROFILE_NAME} not found`);
172
193
 
194
+ // #667 — project `.mcp.json` projection: a per-spawn `projectMcp` overrides the profile
195
+ // default. The runner does the filesystem read + injection from `agentProfile.projectMcp`
196
+ // (it owns the spawn cwd), so thread the resolved decision onto the in-flight profile copy
197
+ // (withResolvedProvisioning returned a fresh object; mutating it never touches the cached
198
+ // config). Like projectInstructions, an explicit `false` parses to undefined and so cannot
199
+ // turn a profile default OFF — opt-in is the trust gate, not opt-out.
200
+ if (input.projectMcp !== undefined) agentProfile.projectMcp = input.projectMcp;
201
+
202
+ // #672 — shared host-listener MCP: a per-spawn `sharedMcp` overrides the profile default,
203
+ // same opt-in trust gate as projectMcp (an explicit `false` parses to undefined and so
204
+ // cannot turn a profile default OFF). The runner emits the bridge entry from
205
+ // `agentProfile.sharedMcp` (it owns the spawn cwd → `--cwd "$WORKTREE"`); thread the resolved
206
+ // decision onto the in-flight profile copy (a fresh object, never the cached config).
207
+ if (input.sharedMcp !== undefined) agentProfile.sharedMcp = input.sharedMcp;
208
+
173
209
  // --- THE GATE (uniform across transports) ---------------------------------
174
210
  // Coarse capability: spawning requires command:spawn. The MCP dispatcher already enforced
175
211
  // this; the HTTP route did not — asserting here makes it transport-invariant.
@@ -0,0 +1,57 @@
1
+ // Shared host-listener MCP (#672, Phase 1 of epic #670) — the relay-owned, OPT-IN path that
2
+ // routes a spawned worker's NON-IDENTITY MCP (tokenlean, github) through ONE orchestrator-owned
3
+ // shared `callmux` listener per host, attached via a per-agent `callmux bridge` with
4
+ // `--cwd "$WORKTREE"` for session-cwd isolation. Sibling seam to project-mcp (#667): same
5
+ // opt-in + parse shape, default OFF.
6
+ //
7
+ // WHY: a shared listener turns "an MCP/listener restart kills the agent" into "the agent never
8
+ // notices" (the bridge reconnects + retries once on the next tool call — the #659 work-loss
9
+ // class), and collapses N per-agent server processes into one shared set (~100MB saved per
10
+ // extra listener). The spike (#671) proved both: session survives a listener restart, and
11
+ // session-cwd routing gives each isolated worktree its own cwd from the single shared listener.
12
+ //
13
+ // INVARIANT (epic #670): callmux owns transport; relay owns authz. The shared listener carries
14
+ // ONLY non-identity servers — the identity-bearing relay endpoint stays per-agent on #215 and
15
+ // is NEVER routed through the shared listener (the spike proved a shared listener can't carry
16
+ // per-agent relay tokens; that's Phase 2, gated on callmux#36).
17
+ //
18
+ // DIVISION OF LABOR: relay carries the OPT-IN DECISION (this module + the spawn
19
+ // param/profile field). The orchestrator owns the shared listener lifecycle/config (#673),
20
+ // and runner owns bridge-arg emission (launch-assembly.ts → sharedMcpBridgeServer), the
21
+ // filesystem/launch authority.
22
+
23
+ export const SHARED_MCP_POLICY_NAMES = ["all"] as const;
24
+ export type SharedMcpPolicyName = (typeof SHARED_MCP_POLICY_NAMES)[number];
25
+
26
+ /** Per-spawn shared-listener request. Presence (a non-undefined value) means "route
27
+ * non-identity MCP through the shared listener"; absence means OFF. `policy` selects the
28
+ * server set (`all` = the listener's full non-identity set; the sole value today). */
29
+ export interface SharedMcpPolicy {
30
+ policy?: SharedMcpPolicyName;
31
+ }
32
+
33
+ /**
34
+ * Parse a spawn-transport's `sharedMcp` wire value into the normalized policy (or
35
+ * undefined = OFF). Shared by the MCP and HTTP spawn paths so they cannot drift. Mirrors
36
+ * parseProjectMcp exactly:
37
+ * - undefined / null / false → undefined (OFF)
38
+ * - true → {} (ON, default policy)
39
+ * - { policy } → validated policy
40
+ * Throws `onInvalid(message)` for a malformed shape or unknown policy name.
41
+ */
42
+ export function parseSharedMcp(
43
+ value: unknown,
44
+ onInvalid: (message: string) => Error,
45
+ ): SharedMcpPolicy | undefined {
46
+ if (value === undefined || value === null || value === false) return undefined;
47
+ if (value === true) return {};
48
+ if (typeof value !== "object") {
49
+ throw onInvalid("sharedMcp must be a boolean or { policy } object");
50
+ }
51
+ const policy = (value as Record<string, unknown>).policy;
52
+ if (policy === undefined || policy === null) return {};
53
+ if (typeof policy !== "string" || !SHARED_MCP_POLICY_NAMES.includes(policy as SharedMcpPolicyName)) {
54
+ throw onInvalid(`sharedMcp.policy must be one of: ${SHARED_MCP_POLICY_NAMES.join(", ")}`);
55
+ }
56
+ return { policy: policy as SharedMcpPolicyName };
57
+ }