agent-relay-runner 0.129.11 → 0.129.13

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/src/relay-mcp.ts CHANGED
@@ -85,7 +85,7 @@ export function tomlString(value: string): string {
85
85
  // project-cwd `.mcp.json` is never discovered (its enable modal can't wedge a headless
86
86
  // agent). Whatever the assembler decides host mode should keep is injected here explicitly;
87
87
  // strict then preserves exactly that set and nothing else.
88
- import { existsSync } from "node:fs";
88
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
89
89
  import { dirname, join, resolve as resolvePath } from "node:path";
90
90
  import { fileURLToPath } from "node:url";
91
91
  import { SHARED_CALLMUX_TOOL_CALL_TIMEOUT_MS, type ProvisioningMcpServer } from "agent-relay-sdk";
@@ -120,7 +120,106 @@ export function sharedMcpBridgeServer(url: string, cwd: string): ProvisioningMcp
120
120
  };
121
121
  }
122
122
 
123
- function mergedConfigEnv(server: ProvisioningMcpServer): ProvisioningMcpServer {
123
+ // #1575 the per-agent metaOnly callmux for Codex workers. The header callmux reads to scope
124
+ // session-cwd stdio downstreams (tokenlean) per worktree; the shared bridge injects it dynamically,
125
+ // and here we pin it statically because a stdio callmux has no incoming request headers to forward.
126
+ const CALLMUX_CWD_HEADER = "x-callmux-cwd";
127
+ // The filename the per-agent metaOnly config is materialized under, inside the agent's relay-owned
128
+ // provisioning home. Shared by the assembler (points `callmux --config <path>` at it) and the
129
+ // materializer (writes it) so disk == launch.
130
+ export const SHARED_MCP_METAONLY_CONFIG_FILENAME = "callmux-metaonly.json";
131
+
132
+ // #1450 — the request header the per-agent metaOnly callmux sets on its relay downstream. The runner
133
+ // proxy reads it to decide which relay tools/list view to serve: WITHOUT it (the agent's DIRECT relay
134
+ // client) the proxy hides the on-demand set so only the eager hot-path set boots; WITH it (this
135
+ // callmux, fronting the on-demand set) the proxy serves the FULL set the relay already returned so
136
+ // callmux can route callmux_call to any of them. It can only ever widen the LIST back to that
137
+ // relay-returned set — never authorization: tools/call stays scope-gated regardless (#215 / capability
138
+ // neutrality). Kept out of `forward` so the relay itself never sees it.
139
+ export const RELAY_MCP_ONDEMAND_SURFACE_HEADER = "x-agent-relay-ondemand-front";
140
+ export const RELAY_MCP_ONDEMAND_SURFACE_VALUE = "1";
141
+
142
+ // #1450 — a Codex worker's relay downstream inside the per-agent metaOnly callmux. It points at the
143
+ // runner-LOCAL proxy (never the relay directly): the proxy injects the live relay token, so identity
144
+ // rides through and the agent/callmux never hold the real token on disk (#215). `authSecret` is the
145
+ // per-session PROXY secret (which the agent already holds in AGENT_RELAY_SESSION_TOKEN — writing it to
146
+ // this 0600 config adds no exposure, and it is NOT the relay token). cachePolicy denies every tool:
147
+ // relay ops are identity-bearing/mutating and must never be served from cache.
148
+ export interface MetaOnlyRelayDownstream {
149
+ /** The runner-local proxy MCP endpoint (config.relayMcpEndpoint when the proxy is active). */
150
+ endpoint: string;
151
+ /** The per-session proxy secret the agent authenticates to the local proxy with. */
152
+ authSecret: string;
153
+ }
154
+
155
+ function metaOnlyRelayServerConfig(relay: MetaOnlyRelayDownstream): Record<string, unknown> {
156
+ return {
157
+ url: relay.endpoint,
158
+ transport: "streamable-http",
159
+ headers: {
160
+ Authorization: `Bearer ${relay.authSecret}`,
161
+ [RELAY_MCP_ONDEMAND_SURFACE_HEADER]: RELAY_MCP_ONDEMAND_SURFACE_VALUE,
162
+ },
163
+ // #1450 — never cache relay ops (identity-bearing/mutating). Wildcard-deny is explicit belt-and-
164
+ // suspenders on top of the config's disabled global cache TTL.
165
+ cachePolicy: { denyTools: ["*"] },
166
+ callTimeoutMs: SHARED_CALLMUX_TOOL_CALL_TIMEOUT_MS,
167
+ };
168
+ }
169
+
170
+ // The per-agent metaOnly callmux config: streamable-http downstreams exposing ONLY the callmux
171
+ // meta-tools (callmux_search_tools/callmux_call/…). A Codex worker boots with ~11 meta-tool schemas
172
+ // instead of the flat downstream schemas (#1570 dominant lever) and reaches the real tools on demand
173
+ // through callmux_call.
174
+ // • The shared listener (SHARED_MCP_SERVER_NAME): the worktree cwd is a STATIC downstream header —
175
+ // the per-agent callmux runs over stdio (no incoming headers to forwardHeaders) and the worktree
176
+ // is fixed per agent, so a fixed x-callmux-cwd routes the shared listener's session-cwd tools to
177
+ // this worktree exactly as the dynamic bridge header did.
178
+ // • #1450 — when `relay` is given, the identity-bearing relay endpoint is added as a SECOND
179
+ // downstream (via the runner-local proxy) so the on-demand relay set loads through callmux_call.
180
+ // Timeouts mirror the bridge's SHARED_CALLMUX_TOOL_CALL_TIMEOUT_MS floor so nested calls keep the same
181
+ // call-timeout budget.
182
+ export function sharedMcpMetaOnlyConfig(url: string, cwd: string, relay?: MetaOnlyRelayDownstream): Record<string, unknown> {
183
+ return {
184
+ $schema: "callmux/schema.json",
185
+ metaOnly: true,
186
+ exposeMetaTools: true,
187
+ callTimeoutMs: SHARED_CALLMUX_TOOL_CALL_TIMEOUT_MS,
188
+ servers: {
189
+ [SHARED_MCP_SERVER_NAME]: {
190
+ url,
191
+ transport: "streamable-http",
192
+ headers: { [CALLMUX_CWD_HEADER]: cwd },
193
+ callTimeoutMs: SHARED_CALLMUX_TOOL_CALL_TIMEOUT_MS,
194
+ },
195
+ ...(relay ? { [RELAY_MCP_SERVER_NAME]: metaOnlyRelayServerConfig(relay) } : {}),
196
+ },
197
+ };
198
+ }
199
+
200
+ // The per-agent metaOnly callmux as a provider-neutral stdio server descriptor: a `callmux --config
201
+ // <path>` child running the config above. Rides the SAME claude/codex stdio injection as the plain
202
+ // bridge. The config path is non-secret (a relay-owned scratch file), so it is safe in argv/config.
203
+ export function sharedMcpMetaOnlyServer(configPath: string): ProvisioningMcpServer {
204
+ return {
205
+ command: resolveCallmuxBin(),
206
+ args: ["--config", configPath],
207
+ };
208
+ }
209
+
210
+ // Materialize the per-agent metaOnly config `sharedMcpMetaOnlyServer(configPath)` points at. The
211
+ // shared-listener URL is non-secret. #1450 — when a `relay` downstream is given, the config carries
212
+ // the per-session PROXY secret (NOT the relay token — the proxy injects that; #215). The agent
213
+ // already holds the proxy secret in AGENT_RELAY_SESSION_TOKEN, so the 0600 file adds no exposure.
214
+ export function writeSharedMcpMetaOnlyConfig(configPath: string, url: string, cwd: string, relay?: MetaOnlyRelayDownstream): void {
215
+ mkdirSync(dirname(configPath), { recursive: true });
216
+ writeFileSync(configPath, JSON.stringify(sharedMcpMetaOnlyConfig(url, cwd, relay), null, 2) + "\n", { mode: 0o600 });
217
+ }
218
+
219
+ // #1292 — shared by the Claude JSON payload builder and the Codex TOML materializer, so
220
+ // both projections merge `configEnv` (non-secret, allowlisted literals) into `env` the
221
+ // same way instead of re-deriving the merge twice.
222
+ export function mergedConfigEnv(server: ProvisioningMcpServer): ProvisioningMcpServer {
124
223
  if (!server.configEnv || Object.keys(server.configEnv).length === 0) return server;
125
224
  return {
126
225
  ...server,
@@ -140,7 +239,7 @@ function claudeMcpServerEntry(server: ProvisioningMcpServer): Record<string, unk
140
239
  return entry;
141
240
  }
142
241
 
143
- export function claudeMcpLaunchArgs(opts: {
242
+ export interface ClaudeMcpConfigOpts {
144
243
  relayUrl: string;
145
244
  endpoint?: string;
146
245
  includeRelay: boolean;
@@ -150,8 +249,12 @@ export function claudeMcpLaunchArgs(opts: {
150
249
  * ambient host MCP a host-base agent loaded before strict. Lowest precedence: the relay
151
250
  * endpoint and relay-provisioned servers win on a name collision (relay decides). */
152
251
  hostServers?: Record<string, ProvisioningMcpServer>;
153
- strict?: boolean;
154
- }): string[] {
252
+ }
253
+
254
+ // #1292 — the `{ mcpServers: {...} }` object Claude parses, extracted so both the launch
255
+ // assembler (decides whether it's written to argv or a file) and the materializer (writes
256
+ // the file) build the IDENTICAL payload from the same inputs.
257
+ export function claudeMcpConfigPayload(opts: ClaudeMcpConfigOpts): Record<string, unknown> {
155
258
  const mcpServers: Record<string, unknown> = {};
156
259
  // Host user-scope servers go in first so relay endpoint + provisioned servers below can
157
260
  // override them by name (relay-owned config is authoritative over ambient host MCP).
@@ -173,13 +276,35 @@ export function claudeMcpLaunchArgs(opts: {
173
276
  const entry = claudeMcpServerEntry(server);
174
277
  if (Object.keys(entry).length) mcpServers[name] = entry;
175
278
  }
279
+ return mcpServers;
280
+ }
281
+
282
+ export function claudeMcpLaunchArgs(opts: ClaudeMcpConfigOpts & {
283
+ strict?: boolean;
284
+ /** #1292 — when set, `--mcp-config` points at this path instead of carrying the JSON
285
+ * inline. Inline JSON lands verbatim in argv, which is world-readable via `ps`/
286
+ * `/proc/<pid>/cmdline`; a provisioned server's `env`/`headers` can carry a literal
287
+ * secret, so any launch with a relay-owned scratch dir to write into (see
288
+ * `providerProvisioningHomePathFor`) MUST use this instead. The caller is responsible
289
+ * for actually writing the file (the launch assembler is pure) — see
290
+ * `materializeLaunchAssembly` / `claudeMcpConfigPayload`. */
291
+ configFilePath?: string;
292
+ }): string[] {
293
+ const mcpServers = claudeMcpConfigPayload(opts);
176
294
  // Nothing to inject and not enforcing strict isolation → don't pass an empty config.
177
295
  if (Object.keys(mcpServers).length === 0 && !opts.strict) return [];
178
- return ["--mcp-config", JSON.stringify({ mcpServers }), ...(opts.strict ? ["--strict-mcp-config"] : [])];
296
+ const configArg = opts.configFilePath ?? JSON.stringify({ mcpServers });
297
+ return ["--mcp-config", configArg, ...(opts.strict ? ["--strict-mcp-config"] : [])];
179
298
  }
180
299
 
181
- // #555 (Codex) — `-c mcp_servers.<name>.*` overrides for each relay-provisioned
182
- // server, the same shape relayMcpCodexConfigArgs uses for the relay endpoint.
300
+ // #555 (Codex) — `-c mcp_servers.<name>.*` overrides for each relay-provisioned server, the
301
+ // same shape relayMcpCodexConfigArgs uses for the relay endpoint. #1292 — this puts a
302
+ // provisioned server's literal `env`/`headers` secret material in argv (world-readable via
303
+ // `ps`/`/proc/<pid>/cmdline`), so it's ONLY safe to use when there is no isolated CODEX_HOME
304
+ // to materialize `codexProvisionedMcpToml` into instead (host-base Codex reuses the real
305
+ // `~/.codex`, which relay must not write provisioned secrets into — see
306
+ // `providerHomePathFor`). Callers with an isolated CODEX_HOME must use
307
+ // `codexProvisionedMcpTimeoutArgs` + `codexProvisionedMcpToml` instead.
183
308
  export function codexProvisionedMcpArgs(servers?: Record<string, ProvisioningMcpServer>): string[] {
184
309
  const args: string[] = [];
185
310
  for (const [name, rawServer] of Object.entries(servers ?? {})) {
@@ -196,3 +321,87 @@ export function codexProvisionedMcpArgs(servers?: Record<string, ProvisioningMcp
196
321
  }
197
322
  return args;
198
323
  }
324
+
325
+ // #1292 — the non-secret half of the provisioned-server `-c` overrides (per-server
326
+ // tool/startup timeouts only). Pairs with `codexProvisionedMcpToml`, which carries
327
+ // command/args/url/env into CODEX_HOME/config.toml instead of argv.
328
+ export function codexProvisionedMcpTimeoutArgs(servers?: Record<string, ProvisioningMcpServer>): string[] {
329
+ const args: string[] = [];
330
+ for (const name of Object.keys(servers ?? {})) {
331
+ if (name === RELAY_MCP_SERVER_NAME) continue;
332
+ args.push(...codexTimeoutConfigArgs(`mcp_servers.${name}`));
333
+ }
334
+ return args;
335
+ }
336
+
337
+ // #1292 — renders provisioned servers as `[mcp_servers.<name>]` / `[mcp_servers.<name>.env]`
338
+ // TOML tables for CODEX_HOME/config.toml, mirroring the `-c mcp_servers.<name>.*` dotted-key
339
+ // shape `codexProvisionedMcpArgs` uses, so command/args/url/env never touch argv. Table/key
340
+ // names are always TOML-quoted (server + env-var names aren't guaranteed bare-key safe).
341
+ export function codexProvisionedMcpToml(servers?: Record<string, ProvisioningMcpServer>): string {
342
+ const blocks: string[] = [];
343
+ for (const [name, rawServer] of Object.entries(servers ?? {})) {
344
+ if (name === RELAY_MCP_SERVER_NAME) continue;
345
+ const server = mergedConfigEnv(rawServer);
346
+ const tableKey = `mcp_servers.${tomlString(name)}`;
347
+ const lines: string[] = [`[${tableKey}]`];
348
+ if (server.command) lines.push(`command = ${tomlString(server.command)}`);
349
+ if (server.args?.length) lines.push(`args = [${server.args.map(tomlString).join(", ")}]`);
350
+ if (server.url) lines.push(`url = ${tomlString(server.url)}`);
351
+ if (lines.length > 1) blocks.push(lines.join("\n"));
352
+ const envEntries = Object.entries(server.env ?? {});
353
+ if (envEntries.length) {
354
+ blocks.push([`[${tableKey}.env]`, ...envEntries.map(([k, v]) => `${tomlString(k)} = ${tomlString(v)}`)].join("\n"));
355
+ }
356
+ }
357
+ return blocks.length ? `${blocks.join("\n\n")}\n` : "";
358
+ }
359
+
360
+ // Replace a `[header]` TOML table's body in-place (or append it if absent). Used to keep
361
+ // repeated materialize calls for the same instance idempotent — a changed server env
362
+ // replaces the old table body instead of duplicating it.
363
+ export function upsertTomlTable(config: string, header: string, bodyLines: string[]): string {
364
+ const headerLine = `[${header}]`;
365
+ const lines = config.length ? config.split("\n") : [];
366
+ const headerIndex = lines.findIndex((line) => line.trim() === headerLine);
367
+ if (headerIndex < 0) {
368
+ const prefix = config.length ? (config.endsWith("\n") ? config : `${config}\n`) : "";
369
+ return `${prefix}${prefix.length ? "\n" : ""}${headerLine}\n${bodyLines.join("\n")}${bodyLines.length ? "\n" : ""}`;
370
+ }
371
+ let nextHeaderIndex = lines.length;
372
+ for (let i = headerIndex + 1; i < lines.length; i += 1) {
373
+ if (/^\s*\[[^\]]+\]\s*(?:#.*)?$/.test(lines[i] ?? "")) {
374
+ nextHeaderIndex = i;
375
+ break;
376
+ }
377
+ }
378
+ lines.splice(headerIndex + 1, nextHeaderIndex - (headerIndex + 1), ...bodyLines);
379
+ return lines.join("\n");
380
+ }
381
+
382
+ // #1292 — idempotently upsert provisioned MCP servers' command/args/url/env into
383
+ // CODEX_HOME/config.toml, the isolated per-instance home Codex actually reads. Pairs with
384
+ // `codexProvisionedMcpTimeoutArgs` (the non-secret `-c` overrides that stay in argv). Only
385
+ // safe to call for an ISOLATED CODEX_HOME (host-base reuses the real `~/.codex`, which relay
386
+ // must never write provisioned secrets into) — callers gate on `providerHomePathFor`.
387
+ export function writeCodexMcpServersToml(codexHome: string, servers: Record<string, ProvisioningMcpServer> | undefined): void {
388
+ const relevant = Object.entries(servers ?? {}).filter(([name]) => name !== RELAY_MCP_SERVER_NAME);
389
+ if (!relevant.length) return;
390
+ mkdirSync(codexHome, { recursive: true });
391
+ const configPath = join(codexHome, "config.toml");
392
+ let config = existsSync(configPath) ? readFileSync(configPath, "utf8") : "";
393
+ for (const [name, rawServer] of relevant) {
394
+ const server = mergedConfigEnv(rawServer);
395
+ const tableKey = `mcp_servers.${tomlString(name)}`;
396
+ const bodyLines: string[] = [];
397
+ if (server.command) bodyLines.push(`command = ${tomlString(server.command)}`);
398
+ if (server.args?.length) bodyLines.push(`args = [${server.args.map(tomlString).join(", ")}]`);
399
+ if (server.url) bodyLines.push(`url = ${tomlString(server.url)}`);
400
+ config = upsertTomlTable(config, tableKey, bodyLines);
401
+ const envEntries = Object.entries(server.env ?? {});
402
+ if (envEntries.length) {
403
+ config = upsertTomlTable(config, `${tableKey}.env`, envEntries.map(([k, v]) => `${tomlString(k)} = ${tomlString(v)}`));
404
+ }
405
+ }
406
+ writeFileSync(configPath, config, { mode: 0o600 });
407
+ }