agent-relay-server 0.90.0 → 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 +14 -0
- package/src/mcp/tools-spawn.ts +3 -0
- package/src/routes/agents-spawn.ts +2 -0
- package/src/services/spawn-agent.ts +18 -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
|
@@ -6,6 +6,7 @@ import { DEFAULT_SELF_RESUME_CONFIG, validateSelfResumeConfig } from "./self-res
|
|
|
6
6
|
import { TEAM_TEMPLATE_NAMESPACE, validateTeamTemplate } from "./team-template-config";
|
|
7
7
|
import { PROJECT_INSTRUCTION_POLICY_NAMES } from "./project-instructions";
|
|
8
8
|
import { PROJECT_MCP_POLICY_NAMES } from "./project-mcp";
|
|
9
|
+
import { SHARED_MCP_POLICY_NAMES } from "./shared-mcp";
|
|
9
10
|
import { resolveProviderSelection, type ProviderEffort } from "agent-relay-sdk/provider-catalog";
|
|
10
11
|
import { errMessage, isRecord, SPAWN_PROVIDERS, VALID_WORKSPACE_MODES } from "agent-relay-sdk"; import { effectiveProviderCatalogList } from "./provider-catalog-store";
|
|
11
12
|
import type {
|
|
@@ -237,6 +238,17 @@ function cleanAgentProfileProjectMcp(value: unknown): AgentProfile["projectMcp"]
|
|
|
237
238
|
return { policy: cleanEnum(policy, "projectMcp.policy", PROJECT_MCP_POLICY_NAMES) as NonNullable<AgentProfile["projectMcp"]>["policy"] };
|
|
238
239
|
}
|
|
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
|
+
|
|
240
252
|
function agentProfileDefaults(input: Pick<AgentProfile, "name" | "base"> & Partial<AgentProfile>): AgentProfile {
|
|
241
253
|
const isolated = input.base !== "host";
|
|
242
254
|
// "vanilla" (#552) additionally suppresses the REPO instruction cascade
|
|
@@ -258,6 +270,7 @@ function agentProfileDefaults(input: Pick<AgentProfile, "name" | "base"> & Parti
|
|
|
258
270
|
},
|
|
259
271
|
...(input.projectInstructions ? { projectInstructions: input.projectInstructions } : {}),
|
|
260
272
|
...(input.projectMcp ? { projectMcp: input.projectMcp } : {}),
|
|
273
|
+
...(input.sharedMcp ? { sharedMcp: input.sharedMcp } : {}),
|
|
261
274
|
relay: {
|
|
262
275
|
context: input.relay?.context ?? !isolated,
|
|
263
276
|
skills: input.relay?.skills ?? !isolated,
|
|
@@ -333,6 +346,7 @@ function validateAgentProfile(key: string, value: unknown): AgentProfile {
|
|
|
333
346
|
},
|
|
334
347
|
projectInstructions: cleanAgentProfileProjectInstructions(value.projectInstructions),
|
|
335
348
|
projectMcp: cleanAgentProfileProjectMcp(value.projectMcp),
|
|
349
|
+
sharedMcp: cleanAgentProfileSharedMcp(value.sharedMcp),
|
|
336
350
|
relay: {
|
|
337
351
|
context: relay.context === undefined ? defaults.relay.context : cleanBoolean(relay.context, "relay.context"),
|
|
338
352
|
skills: relay.skills === undefined ? defaults.relay.skills : cleanBoolean(relay.skills, "relay.skills"),
|
package/src/mcp/tools-spawn.ts
CHANGED
|
@@ -5,6 +5,7 @@ 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
7
|
import { parseProjectMcp, PROJECT_MCP_POLICY_NAMES } from "../project-mcp";
|
|
8
|
+
import { parseSharedMcp, SHARED_MCP_POLICY_NAMES } from "../shared-mcp";
|
|
8
9
|
import { countLiveSpawnedAgents, listAgents, listWorkspaces, ValidationError } from "../db";
|
|
9
10
|
import { listCommands } from "../commands-db";
|
|
10
11
|
import { DEFAULT_SPAWN_PROFILE_NAME } from "../config-store";
|
|
@@ -36,6 +37,7 @@ export const SPAWN_TOOLS: ToolDefinition[] = [
|
|
|
36
37
|
systemPromptAppend: { type: "string" },
|
|
37
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 },
|
|
38
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 },
|
|
39
41
|
profile: { type: "string", description: `Agent profile name to apply (env, instructions, permissions, MCP/skills, spawn quota). Omit to use \`${DEFAULT_SPAWN_PROFILE_NAME}\`.` },
|
|
40
42
|
tags: { type: "array", items: { type: "string" } },
|
|
41
43
|
capabilities: { type: "array", items: { type: "string" } },
|
|
@@ -111,6 +113,7 @@ export async function relaySpawnAgent(auth: McpAuthContext, args: Record<string,
|
|
|
111
113
|
systemPromptAppend: optionalString(args.systemPromptAppend, "systemPromptAppend", 64_000),
|
|
112
114
|
projectInstructions: parseProjectInstructions(args.projectInstructions, (m) => new ValidationError(m)),
|
|
113
115
|
projectMcp: parseProjectMcp(args.projectMcp, (m) => new ValidationError(m)),
|
|
116
|
+
sharedMcp: parseSharedMcp(args.sharedMcp, (m) => new ValidationError(m)),
|
|
114
117
|
tags: optionalStringArray(args.tags, "tags"),
|
|
115
118
|
capabilities: optionalStringArray(args.capabilities, "capabilities"),
|
|
116
119
|
providerArgs: optionalStringArray(args.providerArgs, "providerArgs"),
|
|
@@ -8,6 +8,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
10
|
import { parseProjectMcp } from "../project-mcp";
|
|
11
|
+
import { parseSharedMcp } from "../shared-mcp";
|
|
11
12
|
import { authContextFromRequest } from "../services/auth-context";
|
|
12
13
|
import { listHostDirectories } from "../agent-spawn";
|
|
13
14
|
import { rankRouteCandidates, type RouteAdvisorInput } from "../context-router";
|
|
@@ -123,6 +124,7 @@ export const postAgentSpawn: Handler = async (req) => {
|
|
|
123
124
|
systemPromptAppend: cleanString(parsed.body.systemPromptAppend, "systemPromptAppend", { max: 64_000 }),
|
|
124
125
|
projectInstructions: parseProjectInstructions(parsed.body.projectInstructions, (m) => new ValidationError(m)),
|
|
125
126
|
projectMcp: parseProjectMcp(parsed.body.projectMcp, (m) => new ValidationError(m)),
|
|
127
|
+
sharedMcp: parseSharedMcp(parsed.body.sharedMcp, (m) => new ValidationError(m)),
|
|
126
128
|
tags: cleanStringArray(parsed.body.tags, "tags", { itemMax: 80, maxItems: 50 }),
|
|
127
129
|
capabilities: cleanStringArray(parsed.body.capabilities, "capabilities", { itemMax: 80, maxItems: 50 }),
|
|
128
130
|
providerArgs: cleanStringArray(parsed.body.providerArgs, "providerArgs", { itemMax: 80, maxItems: 50 }),
|
|
@@ -34,6 +34,7 @@ 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
36
|
import type { ProjectMcpPolicy } from "../project-mcp";
|
|
37
|
+
import type { SharedMcpPolicy } from "../shared-mcp";
|
|
37
38
|
import { autoSeedProjectForCwd } from "./project";
|
|
38
39
|
import { hasComponentScope, isComponentAuthorizedFor } from "../security";
|
|
39
40
|
import { isPathWithinBase } from "../utils";
|
|
@@ -84,6 +85,16 @@ export interface SpawnAgentInput {
|
|
|
84
85
|
* to projectInstructions; carried to the runner on the in-flight `agentProfile.projectMcp`.
|
|
85
86
|
*/
|
|
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;
|
|
87
98
|
/**
|
|
88
99
|
* Legacy report-back contract flag. Spawned children now report up by lineage: their
|
|
89
100
|
* turn-final response is delivered to the spawner automatically, without a reply obligation.
|
|
@@ -188,6 +199,13 @@ export async function spawnAgent(input: SpawnAgentInput, ctx: AuthContext): Prom
|
|
|
188
199
|
// turn a profile default OFF — opt-in is the trust gate, not opt-out.
|
|
189
200
|
if (input.projectMcp !== undefined) agentProfile.projectMcp = input.projectMcp;
|
|
190
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
|
+
|
|
191
209
|
// --- THE GATE (uniform across transports) ---------------------------------
|
|
192
210
|
// Coarse capability: spawning requires command:spawn. The MCP dispatcher already enforced
|
|
193
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
|
+
}
|