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/package.json +3 -3
- package/plugins/claude/.claude-plugin/plugin.json +1 -1
- package/plugins/claude/monitors/relay-monitor.provisioned.mjs +8 -8
- package/src/adapter.ts +113 -11
- package/src/adapters/claude-delivery.ts +1 -7
- package/src/adapters/claude-exit.ts +289 -0
- package/src/adapters/claude-prompt-gates.ts +59 -79
- package/src/adapters/claude-session-capture.ts +13 -2
- package/src/adapters/claude-session-probe.ts +98 -7
- package/src/adapters/claude-shutdown.ts +149 -0
- package/src/adapters/claude-status-detectors.ts +201 -69
- package/src/adapters/claude-tmux.ts +94 -5
- package/src/adapters/claude-transcript-tail.ts +22 -3
- package/src/adapters/claude-transcript.ts +95 -1
- package/src/adapters/claude.ts +179 -76
- package/src/adapters/codex.ts +46 -8
- package/src/busy-reconciler.ts +67 -29
- package/src/config.ts +16 -0
- package/src/launch-assembly.ts +251 -82
- package/src/native-memory-lever.ts +308 -0
- package/src/outbox.ts +22 -4
- package/src/profile-projection.ts +45 -1
- package/src/provider-input-queue.ts +100 -0
- package/src/rate-limit.ts +106 -36
- package/src/relay-injection-events.ts +24 -0
- package/src/relay-instructions.ts +9 -0
- package/src/relay-mcp-proxy.ts +79 -8
- package/src/relay-mcp.ts +217 -8
- package/src/runner-core.ts +323 -47
- package/src/runner-helpers.ts +10 -0
- package/src/transcript-context.ts +83 -0
- package/src/turn-reconcile-event.ts +48 -0
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
|
-
|
|
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
|
|
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
|
-
|
|
154
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
+
}
|