agent-relay-runner 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/package.json +2 -2
- package/plugins/claude/.claude-plugin/plugin.json +1 -1
- package/src/adapter.ts +4 -0
- package/src/launch-assembly.ts +80 -6
- package/src/profile-projection.ts +29 -0
- package/src/provisioning.ts +52 -2
- package/src/relay-injection-events.ts +2 -0
- package/src/relay-mcp.ts +25 -0
- package/src/runner-core.ts +4 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-relay-runner",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.91.0",
|
|
4
4
|
"description": "Unified provider lifecycle runner for Agent Relay",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
"directory": "runner"
|
|
21
21
|
},
|
|
22
22
|
"dependencies": {
|
|
23
|
-
"agent-relay-sdk": "0.2.
|
|
23
|
+
"agent-relay-sdk": "0.2.70"
|
|
24
24
|
},
|
|
25
25
|
"devDependencies": {
|
|
26
26
|
"@types/bun": "latest",
|
package/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/launch-assembly.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { existsSync, readdirSync } from "node:fs";
|
|
2
2
|
import { resolve, join } from "node:path";
|
|
3
|
-
import type { AgentProfile, AgentProfileBase, SpawnProvider } from "agent-relay-sdk";
|
|
3
|
+
import type { AgentProfile, AgentProfileBase, ProvisioningMcpServer, SpawnProvider } from "agent-relay-sdk";
|
|
4
4
|
import {
|
|
5
5
|
profileAllowsRelayFeature,
|
|
6
6
|
profileIsVanillaBase,
|
|
@@ -13,14 +13,18 @@ import {
|
|
|
13
13
|
resolveClaudeProvisionedPlugins,
|
|
14
14
|
resolveClaudeProvisionedSkills,
|
|
15
15
|
resolveCodexProvisionedSkills,
|
|
16
|
+
resolveProjectMcpServers,
|
|
16
17
|
profileProvisioning,
|
|
17
18
|
} from "./provisioning";
|
|
18
19
|
import {
|
|
19
20
|
claudeMcpLaunchArgs,
|
|
20
21
|
codexProvisionedMcpArgs,
|
|
21
22
|
relayMcpCodexConfigArgs,
|
|
23
|
+
resolveSharedMcpUrl,
|
|
24
|
+
sharedMcpBridgeServer,
|
|
22
25
|
tomlString,
|
|
23
26
|
RELAY_MCP_SERVER_NAME,
|
|
27
|
+
SHARED_MCP_SERVER_NAME,
|
|
24
28
|
} from "./relay-mcp";
|
|
25
29
|
|
|
26
30
|
// #557 — THE single launch-assembly contract. Given a resolved AgentProfile (+ its
|
|
@@ -73,11 +77,24 @@ export interface AssembledMcp {
|
|
|
73
77
|
strict: boolean;
|
|
74
78
|
/** Relay-provisioned server names (excludes the relay endpoint itself). */
|
|
75
79
|
servers: string[];
|
|
80
|
+
/** #667 — project `.mcp.json` server names projected from the spawn cwd's committed
|
|
81
|
+
* `.mcp.json` (via `--mcp-config`, so they survive strict). Sourced from the project,
|
|
82
|
+
* not ambient host MCP. Empty unless the profile/spawn opted into `projectMcp`, and a
|
|
83
|
+
* name already declared by relay-provisioned `servers` wins (not double-listed here). */
|
|
84
|
+
projectServers: string[];
|
|
76
85
|
/** #662 — host USER-scope MCP server names (`~/.claude.json` `mcpServers`) re-injected
|
|
77
86
|
* into `--mcp-config` for an `mcp.mode:"host"` profile, so `--strict-mcp-config` keeps
|
|
78
87
|
* the ambient host MCP (callmux/qmd/gh) instead of dropping it. Empty for non-host mcp
|
|
79
88
|
* modes, isolated bases (own config home, never had host MCP), and Codex. */
|
|
80
89
|
hostServers: string[];
|
|
90
|
+
/** #672 — when the profile/spawn opted into `sharedMcp`, the agent's NON-IDENTITY MCP
|
|
91
|
+
* (tokenlean, github) is routed through one orchestrator-owned shared `callmux` listener via
|
|
92
|
+
* a per-agent `callmux bridge --cwd "$WORKTREE"` (session-cwd isolation), injected through
|
|
93
|
+
* the same `--mcp-config` / `-c` channel so it survives strict. `url` is the listener the
|
|
94
|
+
* bridge connects to. Undefined when projection is OFF (the default). The bridge is opaque
|
|
95
|
+
* at assembly time — its server SET is owned by the listener config, not enumerated here.
|
|
96
|
+
* The identity-bearing relay endpoint is never on the shared listener (#215). */
|
|
97
|
+
sharedListener?: { url: string };
|
|
81
98
|
}
|
|
82
99
|
|
|
83
100
|
export interface AssembledLaunch {
|
|
@@ -202,6 +219,34 @@ function assembleClaude(config: RunnerSpawnConfig, providerConfig: ProviderConfi
|
|
|
202
219
|
const mcpServers = Object.keys(provisionedMcp).filter((name) => name !== RELAY_MCP_SERVER_NAME);
|
|
203
220
|
const relayEndpoint = profileAllowsRelayFeature(config, "mcp");
|
|
204
221
|
|
|
222
|
+
// #667 — project `.mcp.json` projection (opt-in). The spawn cwd's committed servers are
|
|
223
|
+
// injected through the SAME `--mcp-config` channel as relay-provisioned servers, so they
|
|
224
|
+
// survive `--strict-mcp-config` without the ambient project-discovery modal. A relay-
|
|
225
|
+
// provisioned server of the same name wins (the profile is the explicit, relay-owned
|
|
226
|
+
// source); the relay endpoint name is reserved. `declaredMcp` is the union relay injects.
|
|
227
|
+
const projectMcpRaw = resolveProjectMcpServers(config);
|
|
228
|
+
const projectMcp = Object.fromEntries(
|
|
229
|
+
Object.entries(projectMcpRaw).filter(
|
|
230
|
+
([name]) => name !== RELAY_MCP_SERVER_NAME && !(name in provisionedMcp),
|
|
231
|
+
),
|
|
232
|
+
);
|
|
233
|
+
const projectServers = Object.keys(projectMcp);
|
|
234
|
+
|
|
235
|
+
// #672 — shared host-listener MCP (opt-in, default OFF). When on, route the agent's
|
|
236
|
+
// NON-IDENTITY MCP (tokenlean, github) through ONE orchestrator-owned shared `callmux`
|
|
237
|
+
// listener via a per-agent `callmux bridge --url <listener> --cwd "$WORKTREE"` stdio entry:
|
|
238
|
+
// the MCP session survives a listener/downstream restart, and --cwd gives each isolated
|
|
239
|
+
// worktree session-cwd isolation. It's a plain stdio server descriptor, so it rides the same
|
|
240
|
+
// controlled --mcp-config channel as provisioned servers (survives --strict-mcp-config) — no
|
|
241
|
+
// ambient discovery, no modal. Relay-owned infra → authoritative on its name (spread last);
|
|
242
|
+
// the relay endpoint stays per-agent (#215), NEVER on the shared listener.
|
|
243
|
+
const sharedListenerOn = Boolean(config.agentProfile?.sharedMcp);
|
|
244
|
+
const sharedListenerUrl = sharedListenerOn ? resolveSharedMcpUrl(config.sharedMcpUrl) : undefined;
|
|
245
|
+
const sharedMcp: Record<string, ProvisioningMcpServer> = sharedListenerUrl
|
|
246
|
+
? { [SHARED_MCP_SERVER_NAME]: sharedMcpBridgeServer(sharedListenerUrl, config.cwd) }
|
|
247
|
+
: {};
|
|
248
|
+
const declaredMcp = { ...projectMcp, ...provisionedMcp, ...sharedMcp };
|
|
249
|
+
|
|
205
250
|
// #662 — EVERY managed Claude launch is --strict-mcp-config, not just vanilla. A managed
|
|
206
251
|
// agent runs headless (no human at the keyboard), so Claude's blocking "N new MCP servers
|
|
207
252
|
// found in this project — select to enable" modal (raised on UNDECIDED cwd `.mcp.json`
|
|
@@ -224,7 +269,7 @@ function assembleClaude(config: RunnerSpawnConfig, providerConfig: ProviderConfi
|
|
|
224
269
|
&& (!config.agentProfile || config.agentProfile.mcp.mode === "host");
|
|
225
270
|
const hostMcp = usesHostMcp ? hostUserScopeMcpServers() : {};
|
|
226
271
|
const hostMcpServers = Object.keys(hostMcp).filter(
|
|
227
|
-
(name) => name !== RELAY_MCP_SERVER_NAME && !(name in
|
|
272
|
+
(name) => name !== RELAY_MCP_SERVER_NAME && !(name in declaredMcp),
|
|
228
273
|
);
|
|
229
274
|
|
|
230
275
|
const instructions = assembleInstructions(config);
|
|
@@ -238,7 +283,7 @@ function assembleClaude(config: RunnerSpawnConfig, providerConfig: ProviderConfi
|
|
|
238
283
|
relayUrl: config.relayUrl,
|
|
239
284
|
...(config.relayMcpEndpoint ? { endpoint: config.relayMcpEndpoint } : {}),
|
|
240
285
|
includeRelay: relayEndpoint,
|
|
241
|
-
servers:
|
|
286
|
+
servers: declaredMcp,
|
|
242
287
|
hostServers: hostMcp,
|
|
243
288
|
strict: strictMcp,
|
|
244
289
|
}),
|
|
@@ -258,7 +303,10 @@ function assembleClaude(config: RunnerSpawnConfig, providerConfig: ProviderConfi
|
|
|
258
303
|
statusLine,
|
|
259
304
|
skills: skills.map((s) => ({ name: s.name, dir: s.dir })),
|
|
260
305
|
plugins: provisionedPlugins.map((p) => ({ name: p.name, dir: p.dir })),
|
|
261
|
-
mcp: {
|
|
306
|
+
mcp: {
|
|
307
|
+
relayEndpoint, strict: strictMcp, servers: mcpServers, projectServers, hostServers: hostMcpServers,
|
|
308
|
+
...(sharedListenerUrl ? { sharedListener: { url: sharedListenerUrl } } : {}),
|
|
309
|
+
},
|
|
262
310
|
instructions,
|
|
263
311
|
args,
|
|
264
312
|
env: configHome ? { CLAUDE_CONFIG_DIR: configHome } : {},
|
|
@@ -278,6 +326,29 @@ function assembleCodex(config: RunnerSpawnConfig, _providerConfig: ProviderConfi
|
|
|
278
326
|
const provisionedMcp = profileProvisioning(config).mcpServers;
|
|
279
327
|
const mcpServers = Object.keys(provisionedMcp).filter((name) => name !== RELAY_MCP_SERVER_NAME);
|
|
280
328
|
|
|
329
|
+
// #667 — project `.mcp.json` projection (opt-in), same union rule as Claude: a relay-
|
|
330
|
+
// provisioned server of the same name wins; the relay endpoint name is reserved. Codex has
|
|
331
|
+
// no `.mcp.json` discovery of its own, so this is purely additive (no strict/modal concern).
|
|
332
|
+
const projectMcpRaw = resolveProjectMcpServers(config);
|
|
333
|
+
const projectMcp = Object.fromEntries(
|
|
334
|
+
Object.entries(projectMcpRaw).filter(
|
|
335
|
+
([name]) => name !== RELAY_MCP_SERVER_NAME && !(name in provisionedMcp),
|
|
336
|
+
),
|
|
337
|
+
);
|
|
338
|
+
const projectServers = Object.keys(projectMcp);
|
|
339
|
+
|
|
340
|
+
// #672 — shared host-listener MCP (opt-in, default OFF), same as Claude: a per-agent
|
|
341
|
+
// `callmux bridge --url <listener> --cwd "$WORKTREE"` stdio entry routes the agent's
|
|
342
|
+
// NON-IDENTITY MCP (tokenlean, github) through the shared listener (session survives a
|
|
343
|
+
// restart; --cwd gives session-cwd isolation). Emitted via the same `-c mcp_servers.*`
|
|
344
|
+
// overrides as provisioned servers. The relay endpoint stays per-agent (#215).
|
|
345
|
+
const sharedListenerOn = Boolean(config.agentProfile?.sharedMcp);
|
|
346
|
+
const sharedListenerUrl = sharedListenerOn ? resolveSharedMcpUrl(config.sharedMcpUrl) : undefined;
|
|
347
|
+
const sharedMcp: Record<string, ProvisioningMcpServer> = sharedListenerUrl
|
|
348
|
+
? { [SHARED_MCP_SERVER_NAME]: sharedMcpBridgeServer(sharedListenerUrl, config.cwd) }
|
|
349
|
+
: {};
|
|
350
|
+
const declaredMcp = { ...projectMcp, ...provisionedMcp, ...sharedMcp };
|
|
351
|
+
|
|
281
352
|
const instructions = assembleInstructions(config);
|
|
282
353
|
|
|
283
354
|
// The skills/MCP/project-doc block; core launch config (model/approval/listen) stays
|
|
@@ -285,7 +356,7 @@ function assembleCodex(config: RunnerSpawnConfig, _providerConfig: ProviderConfi
|
|
|
285
356
|
const args = [
|
|
286
357
|
...bundledSkillConfigArgs(skillDirs),
|
|
287
358
|
...(relayEndpoint ? relayMcpCodexConfigArgs(config.relayUrl, config.relayMcpEndpoint) : []),
|
|
288
|
-
...codexProvisionedMcpArgs(
|
|
359
|
+
...codexProvisionedMcpArgs(declaredMcp),
|
|
289
360
|
...codexProjectDocSuppressionArgs(config),
|
|
290
361
|
];
|
|
291
362
|
|
|
@@ -301,7 +372,10 @@ function assembleCodex(config: RunnerSpawnConfig, _providerConfig: ProviderConfi
|
|
|
301
372
|
statusLine: false,
|
|
302
373
|
skills: provisionedSkills.map((s) => ({ name: s.name, dir: s.dir })),
|
|
303
374
|
plugins: [],
|
|
304
|
-
mcp: {
|
|
375
|
+
mcp: {
|
|
376
|
+
relayEndpoint, strict: false, servers: mcpServers, projectServers, hostServers: [],
|
|
377
|
+
...(sharedListenerUrl ? { sharedListener: { url: sharedListenerUrl } } : {}),
|
|
378
|
+
},
|
|
305
379
|
instructions,
|
|
306
380
|
args,
|
|
307
381
|
env: configHome ? { CODEX_HOME: configHome } : {},
|
|
@@ -33,6 +33,8 @@ export function agentProfileProjectionReport(input: AgentProfileProjectionInput)
|
|
|
33
33
|
add(provisioningSkillsEntry(assembled));
|
|
34
34
|
add(provisioningPluginsEntry(assembled));
|
|
35
35
|
add(provisioningMcpEntry(assembled));
|
|
36
|
+
add(projectMcpEntry(assembled));
|
|
37
|
+
add(sharedMcpEntry(assembled));
|
|
36
38
|
if (profile) add(filesystemEntry(profile));
|
|
37
39
|
add(configHomeEntry(assembled));
|
|
38
40
|
if (assembled.vanilla) add(vanillaLaunchEntry(provider));
|
|
@@ -143,6 +145,33 @@ function provisioningMcpEntry(a: AssembledLaunch): AgentProfileProjectionEntry {
|
|
|
143
145
|
: `Relay-provisioned MCP servers emitted as -c mcp_servers.* overrides (${names.join(", ")}).`);
|
|
144
146
|
}
|
|
145
147
|
|
|
148
|
+
// #667 — project `.mcp.json` projection provenance. `not-applicable` when the profile/spawn
|
|
149
|
+
// didn't opt in (the default); `applied` lists the spawn-cwd servers relay injected through
|
|
150
|
+
// the controlled --mcp-config / -c path (sourced from the project, not ambient host MCP).
|
|
151
|
+
function projectMcpEntry(a: AssembledLaunch): AgentProfileProjectionEntry {
|
|
152
|
+
const optedIn = Boolean(a.profile?.projectMcp);
|
|
153
|
+
const names = a.mcp.projectServers;
|
|
154
|
+
if (!optedIn) return notApplicable("projectMcp", "off", "Project .mcp.json projection is off (opt in via the projectMcp profile/spawn option).");
|
|
155
|
+
if (!names.length) return applied("projectMcp", "none", "Project .mcp.json projection is on, but the spawn cwd declared no injectable MCP servers.");
|
|
156
|
+
const label = `${names.length} server${names.length === 1 ? "" : "s"}`;
|
|
157
|
+
return applied("projectMcp", label, a.provider === "claude"
|
|
158
|
+
? `Project .mcp.json servers composed into --mcp-config (${names.join(", ")})${a.mcp.strict ? "; survives --strict-mcp-config" : ""}.`
|
|
159
|
+
: `Project .mcp.json servers emitted as -c mcp_servers.* overrides (${names.join(", ")}).`);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// #672 — shared host-listener MCP provenance. `not-applicable` when the profile/spawn didn't
|
|
163
|
+
// opt in (the default); `applied` reports the per-agent `callmux bridge` routing the agent's
|
|
164
|
+
// non-identity MCP (tokenlean, github) through the shared listener it points at. The server SET
|
|
165
|
+
// is owned by the listener config, so this surfaces the route + URL, not an enumerated list.
|
|
166
|
+
function sharedMcpEntry(a: AssembledLaunch): AgentProfileProjectionEntry {
|
|
167
|
+
const optedIn = Boolean(a.profile?.sharedMcp);
|
|
168
|
+
const listener = a.mcp.sharedListener;
|
|
169
|
+
if (!optedIn || !listener) return notApplicable("sharedMcp", "off", "Shared host-listener MCP is off (opt in via the sharedMcp profile/spawn option); non-identity MCP is injected per-agent.");
|
|
170
|
+
return applied("sharedMcp", "on", a.provider === "claude"
|
|
171
|
+
? `Non-identity MCP (e.g. tokenlean, github) routed through the shared callmux listener (${listener.url}) via a per-agent 'callmux bridge --cwd "$WORKTREE"', injected into --mcp-config (survives --strict-mcp-config); session survives a listener restart, session-cwd isolated. Relay endpoint stays per-agent (#215).`
|
|
172
|
+
: `Non-identity MCP (e.g. tokenlean, github) routed through the shared callmux listener (${listener.url}) via a per-agent 'callmux bridge --cwd "$WORKTREE"' emitted as -c mcp_servers.* overrides; session survives a listener restart, session-cwd isolated. Relay endpoint stays per-agent (#215).`);
|
|
173
|
+
}
|
|
174
|
+
|
|
146
175
|
function globalInstructionsEntry(a: AssembledLaunch): AgentProfileProjectionEntry {
|
|
147
176
|
const disposition = a.instructions.global;
|
|
148
177
|
if (disposition === "allow") return applied("instructions.global", "allow", "Host/global provider instructions are left available.");
|
package/src/provisioning.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
3
3
|
import { sanitizeFsName } from "agent-relay-sdk/fs-name";
|
|
4
|
-
import type { ProvisioningAssetFile, ResolvedProvisioning } from "agent-relay-sdk";
|
|
4
|
+
import type { ProvisioningAssetFile, ProvisioningMcpServer, ResolvedProvisioning } from "agent-relay-sdk";
|
|
5
5
|
import type { RunnerSpawnConfig } from "./adapter";
|
|
6
6
|
|
|
7
7
|
// Runner-side materialization of the relay-resolved provisioning bundle
|
|
@@ -24,6 +24,56 @@ export function profileProvisioning(config: Pick<RunnerSpawnConfig, "agentProfil
|
|
|
24
24
|
return { skills: p.skills ?? [], plugins: p.plugins ?? [], mcpServers: p.mcpServers ?? {} };
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
// The `mcpServers` keys a Claude `.mcp.json` entry may carry; coerced into the
|
|
28
|
+
// provider-neutral ProvisioningMcpServer shape so the launch assembler projects them the
|
|
29
|
+
// same way it does relay-provisioned servers (Claude `--mcp-config`, Codex `-c mcp_servers.*`).
|
|
30
|
+
function coerceProjectMcpServer(raw: unknown): ProvisioningMcpServer | null {
|
|
31
|
+
if (!raw || typeof raw !== "object") return null;
|
|
32
|
+
const r = raw as Record<string, unknown>;
|
|
33
|
+
const server: ProvisioningMcpServer = {};
|
|
34
|
+
if (r.type === "stdio" || r.type === "http" || r.type === "sse") server.type = r.type;
|
|
35
|
+
if (typeof r.command === "string") server.command = r.command;
|
|
36
|
+
if (Array.isArray(r.args) && r.args.every((a) => typeof a === "string")) server.args = r.args as string[];
|
|
37
|
+
if (r.env && typeof r.env === "object") server.env = r.env as Record<string, string>;
|
|
38
|
+
if (typeof r.url === "string") server.url = r.url;
|
|
39
|
+
if (r.headers && typeof r.headers === "object") server.headers = r.headers as Record<string, string>;
|
|
40
|
+
// Drop an entry that names no transport (neither a command nor a url) — nothing to launch.
|
|
41
|
+
return server.command || server.url ? server : null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// #667 — project `.mcp.json` MCP projection. OPT-IN via the resolved profile's `projectMcp`
|
|
45
|
+
// (set per-spawn or by profile, relay-side). Reads the spawn cwd's COMMITTED `.mcp.json` and
|
|
46
|
+
// returns its `mcpServers` as a provider-neutral record, so the launch assembler injects them
|
|
47
|
+
// through the CONTROLLED `--mcp-config` (Claude) / `-c mcp_servers.*` (Codex) path that
|
|
48
|
+
// survives `--strict-mcp-config` — the project's own servers travel with the repo like they do
|
|
49
|
+
// for a human who clones it, WITHOUT enabling Claude's ambient project-discovery trust modal
|
|
50
|
+
// (which wedges headless agents, the reason #662 made every managed launch strict). The runner
|
|
51
|
+
// is the filesystem authority for the spawn cwd (relay may run on another host), so the read
|
|
52
|
+
// lands here. Best-effort: a missing/malformed file (or one with no `mcpServers`) yields {} and
|
|
53
|
+
// never aborts the launch. Returns {} when projection is OFF.
|
|
54
|
+
export function resolveProjectMcpServers(
|
|
55
|
+
config: Pick<RunnerSpawnConfig, "agentProfile" | "cwd">,
|
|
56
|
+
): Record<string, ProvisioningMcpServer> {
|
|
57
|
+
if (!config.agentProfile?.projectMcp) return {};
|
|
58
|
+
const file = join(config.cwd, ".mcp.json");
|
|
59
|
+
if (!existsSync(file)) return {};
|
|
60
|
+
let parsed: unknown;
|
|
61
|
+
try {
|
|
62
|
+
parsed = JSON.parse(readFileSync(file, "utf8"));
|
|
63
|
+
} catch (err) {
|
|
64
|
+
console.warn(`project .mcp.json projection: failed to parse ${file}: ${(err as Error).message}`);
|
|
65
|
+
return {};
|
|
66
|
+
}
|
|
67
|
+
const servers = (parsed as { mcpServers?: unknown } | null)?.mcpServers;
|
|
68
|
+
if (!servers || typeof servers !== "object") return {};
|
|
69
|
+
const out: Record<string, ProvisioningMcpServer> = {};
|
|
70
|
+
for (const [name, raw] of Object.entries(servers as Record<string, unknown>)) {
|
|
71
|
+
const server = coerceProjectMcpServer(raw);
|
|
72
|
+
if (server) out[name] = server;
|
|
73
|
+
}
|
|
74
|
+
return out;
|
|
75
|
+
}
|
|
76
|
+
|
|
27
77
|
// A resolved provisioning asset: WHERE it lands (`dir`) and HOW it materializes
|
|
28
78
|
// (`source`). `files` writes the inline set into `dir`; `link` symlinks a pre-existing
|
|
29
79
|
// source dir at `dir`; `inPlace` uses the source dir as-is (no write, dir IS the source).
|
|
@@ -39,6 +39,7 @@ function launchInjectionEvents(input: RunnerLaunchInjectionInput): KeyedRelayInj
|
|
|
39
39
|
if (!supportsLaunchAssembly(input.provider)) return events;
|
|
40
40
|
const assembled = assembleLaunch(input.provider, input.config, input.providerConfig);
|
|
41
41
|
const toolCount = assembled.skills.length + assembled.plugins.length + assembled.mcp.servers.length
|
|
42
|
+
+ assembled.mcp.projectServers.length
|
|
42
43
|
+ (assembled.relaySkills ? 1 : 0)
|
|
43
44
|
+ (assembled.relayPlugin ? 1 : 0)
|
|
44
45
|
+ (assembled.mcp.relayEndpoint ? 1 : 0)
|
|
@@ -52,6 +53,7 @@ function launchInjectionEvents(input: RunnerLaunchInjectionInput): KeyedRelayInj
|
|
|
52
53
|
assembled.skills.length ? `${assembled.skills.length} skill${assembled.skills.length === 1 ? "" : "s"}` : "",
|
|
53
54
|
assembled.plugins.length ? `${assembled.plugins.length} plugin${assembled.plugins.length === 1 ? "" : "s"}` : "",
|
|
54
55
|
assembled.mcp.servers.length ? `${assembled.mcp.servers.length} MCP server${assembled.mcp.servers.length === 1 ? "" : "s"}` : "",
|
|
56
|
+
assembled.mcp.projectServers.length ? `${assembled.mcp.projectServers.length} project MCP server${assembled.mcp.projectServers.length === 1 ? "" : "s"}` : "",
|
|
55
57
|
].filter(Boolean);
|
|
56
58
|
events.push({
|
|
57
59
|
key: "tools",
|
package/src/relay-mcp.ts
CHANGED
|
@@ -19,6 +19,24 @@ export const RELAY_MCP_SERVER_NAME = "agent-relay";
|
|
|
19
19
|
export const RELAY_MCP_PATH = "/api/mcp";
|
|
20
20
|
export const RELAY_MCP_TOKEN_ENV = "AGENT_RELAY_SESSION_TOKEN";
|
|
21
21
|
|
|
22
|
+
// #672 — shared host-listener MCP. The agent connects to ONE orchestrator-owned shared
|
|
23
|
+
// `callmux` listener via a per-agent `callmux bridge`, fronting the host's NON-IDENTITY MCP
|
|
24
|
+
// (tokenlean, github). The bridge is a stdio child (auto-reconnect + retry-once on a
|
|
25
|
+
// listener/downstream hiccup; `--cwd` injects `x-callmux-cwd` for per-worktree session-cwd
|
|
26
|
+
// isolation), so it rides the SAME provider injection as any provisioned stdio server. The
|
|
27
|
+
// identity-bearing relay endpoint is NEVER routed here — it stays per-agent on #215.
|
|
28
|
+
export const SHARED_MCP_SERVER_NAME = "callmux";
|
|
29
|
+
// callmux's documented manual-listener endpoint (`callmux --listen 4860`, Streamable HTTP at
|
|
30
|
+
// /mcp). In managed launches the orchestrator owns the real listener URL and passes it through
|
|
31
|
+
// AGENT_RELAY_SHARED_MCP_URL; this fallback is for direct runner/manual-listener use.
|
|
32
|
+
export const DEFAULT_SHARED_MCP_URL = "http://localhost:4860/mcp";
|
|
33
|
+
export const SHARED_MCP_URL_ENV = "AGENT_RELAY_SHARED_MCP_URL";
|
|
34
|
+
|
|
35
|
+
/** Resolve the shared-listener URL: explicit runner option → env → documented default. */
|
|
36
|
+
export function resolveSharedMcpUrl(explicit?: string): string {
|
|
37
|
+
return explicit ?? process.env[SHARED_MCP_URL_ENV] ?? DEFAULT_SHARED_MCP_URL;
|
|
38
|
+
}
|
|
39
|
+
|
|
22
40
|
export function relayMcpEndpoint(relayUrl: string): string {
|
|
23
41
|
return `${relayUrl.replace(/\/+$/, "")}${RELAY_MCP_PATH}`;
|
|
24
42
|
}
|
|
@@ -51,6 +69,13 @@ export function tomlString(value: string): string {
|
|
|
51
69
|
// strict then preserves exactly that set and nothing else.
|
|
52
70
|
import type { ProvisioningMcpServer } from "agent-relay-sdk";
|
|
53
71
|
|
|
72
|
+
// The shared-listener bridge as a provider-neutral stdio server descriptor: a `callmux bridge`
|
|
73
|
+
// child fronting the host's shared listener, with per-agent `--cwd "$WORKTREE"` for session-cwd
|
|
74
|
+
// isolation (#672). It flows through the existing claude/codex stdio injection unchanged.
|
|
75
|
+
export function sharedMcpBridgeServer(url: string, cwd: string): ProvisioningMcpServer {
|
|
76
|
+
return { command: "callmux", args: ["bridge", "--url", url, "--cwd", cwd] };
|
|
77
|
+
}
|
|
78
|
+
|
|
54
79
|
function claudeMcpServerEntry(server: ProvisioningMcpServer): Record<string, unknown> {
|
|
55
80
|
const entry: Record<string, unknown> = {};
|
|
56
81
|
if (server.type) entry.type = server.type;
|
package/src/runner-core.ts
CHANGED
|
@@ -17,7 +17,7 @@ import { ReplyObligationCache, obligationRequiresExplicitReply, responseCaptureR
|
|
|
17
17
|
import { Outbox, type OutboxRecord } from "./outbox";
|
|
18
18
|
import { extractLastAssistantTurn, extractLastAssistantTurnAfterEntry, countTranscriptEntries, extractFinalAssistantMessage, extractFinalAssistantMessageAfterEntry, extractHookAssistantMessage, extractLatestTurnSteps, stepDedupKeys, transcriptLooksComplete } from "./adapters/claude-transcript";
|
|
19
19
|
import { profileUsesHostProviderGlobals } from "./profile-home";
|
|
20
|
-
import { RELAY_MCP_TOKEN_ENV, relayMcpEndpoint } from "./relay-mcp";
|
|
20
|
+
import { RELAY_MCP_TOKEN_ENV, relayMcpEndpoint, resolveSharedMcpUrl } from "./relay-mcp";
|
|
21
21
|
import { RelayMcpProxy } from "./relay-mcp-proxy";
|
|
22
22
|
import { runtimeMetadata } from "./version";
|
|
23
23
|
import { logger, parseLogLevel } from "./logger";
|
|
@@ -581,6 +581,9 @@ export class AgentRunner {
|
|
|
581
581
|
// Stage 2 (#215): the MCP endpoint the agent's client should target — the runner-local
|
|
582
582
|
// proxy when active, undefined when disabled (adapters fall back to the direct relay URL).
|
|
583
583
|
...(this.mcpProxyEndpoint ? { relayMcpEndpoint: this.mcpProxyEndpoint } : {}),
|
|
584
|
+
// #672: the shared host-listener URL the `callmux bridge` connects to (env → default).
|
|
585
|
+
// Always resolved; the assembler only emits the bridge when `agentProfile.sharedMcp` is on.
|
|
586
|
+
sharedMcpUrl: resolveSharedMcpUrl(),
|
|
584
587
|
monitor: {
|
|
585
588
|
deliver: (messages) => this.control!.deliverToMonitor(messages),
|
|
586
589
|
},
|