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 +1 -1
- package/package.json +2 -2
- package/runner/src/adapter.ts +4 -0
- package/src/config-store.ts +28 -0
- package/src/mcp/tools-spawn.ts +6 -0
- package/src/project-mcp.ts +52 -0
- package/src/routes/agents-spawn.ts +4 -0
- package/src/routes/provider-quotas.ts +30 -3
- package/src/services/spawn-agent.ts +36 -0
- package/src/shared-mcp.ts +57 -0
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.
|
|
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.
|
|
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.
|
|
36
|
+
"agent-relay-sdk": "0.2.70",
|
|
37
37
|
"ajv": "^8.20.0"
|
|
38
38
|
},
|
|
39
39
|
"scripts": {
|
package/runner/src/adapter.ts
CHANGED
|
@@ -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
|
};
|
package/src/config-store.ts
CHANGED
|
@@ -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"),
|
package/src/mcp/tools-spawn.ts
CHANGED
|
@@ -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 =
|
|
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 =
|
|
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
|
+
}
|