Skip to content

Commit c2ee9b0

Browse files
committed
fix(gateway): preserve owner MCP tools for agent RPC
1 parent 9d27583 commit c2ee9b0

14 files changed

Lines changed: 257 additions & 61 deletions

extensions/anthropic/cli-shared.test.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,12 @@ import {
1010
resolveClaudeCliExecutionArgs,
1111
} from "./cli-shared.js";
1212

13+
function expectDefaultDisallowedTools(args: readonly string[] | undefined) {
14+
const disallowedIndex = args?.indexOf("--disallowedTools") ?? -1;
15+
expect(disallowedIndex).toBeGreaterThanOrEqual(0);
16+
expect(args?.[disallowedIndex + 1]).toBe("ScheduleWakeup,CronCreate");
17+
}
18+
1319
describe("normalizeClaudePermissionArgs", () => {
1420
it("leaves args alone when they omit permission flags", () => {
1521
expect(
@@ -356,8 +362,10 @@ describe("normalizeClaudeBackendConfig", () => {
356362
expect(backend.config.input).toBe("stdin");
357363
expect(backend.config.args).toContain("--setting-sources");
358364
expect(backend.config.args).toContain("user");
365+
expectDefaultDisallowedTools(backend.config.args);
359366
expect(backend.config.resumeArgs).toContain("--setting-sources");
360367
expect(backend.config.resumeArgs).toContain("user");
368+
expectDefaultDisallowedTools(backend.config.resumeArgs);
361369
expect(backend.config.clearEnv).toEqual([...CLAUDE_CLI_CLEAR_ENV]);
362370
expect(backend.config.clearEnv).toContain("ANTHROPIC_API_TOKEN");
363371
expect(backend.config.clearEnv).toContain("ANTHROPIC_BASE_URL");

src/agents/cli-runner/bundle-mcp-claude.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
/**
22
* Claude CLI argument helpers for OpenClaw-managed bundle MCP config.
33
*/
4+
import fs from "node:fs/promises";
45
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
56

67
/** Find an existing Claude `--mcp-config` argument value. */
@@ -43,3 +44,45 @@ export function injectClaudeMcpConfigArgs(
4344
next.push("--strict-mcp-config", "--mcp-config", mcpConfigPath);
4445
return next;
4546
}
47+
48+
function isRecord(value: unknown): value is Record<string, unknown> {
49+
return typeof value === "object" && value !== null && !Array.isArray(value);
50+
}
51+
52+
/** Writes the active per-attempt capture token into OpenClaw's generated Claude MCP config. */
53+
export async function writeClaudeMcpCaptureConfig(params: {
54+
mcpConfigPath: string;
55+
captureKey: string;
56+
}): Promise<void> {
57+
const raw = JSON.parse(await fs.readFile(params.mcpConfigPath, "utf-8")) as unknown;
58+
if (!isRecord(raw)) {
59+
throw new Error("Claude MCP capture requires an object config");
60+
}
61+
const mcpServers = isRecord(raw.mcpServers) ? raw.mcpServers : {};
62+
const openclaw = isRecord(mcpServers.openclaw) ? mcpServers.openclaw : undefined;
63+
if (!openclaw) {
64+
throw new Error("Claude MCP capture requires an openclaw server config");
65+
}
66+
const headers = isRecord(openclaw.headers) ? openclaw.headers : {};
67+
await fs.writeFile(
68+
params.mcpConfigPath,
69+
`${JSON.stringify(
70+
{
71+
...raw,
72+
mcpServers: {
73+
...mcpServers,
74+
openclaw: {
75+
...openclaw,
76+
headers: {
77+
...headers,
78+
"x-openclaw-cli-capture-key": params.captureKey,
79+
},
80+
},
81+
},
82+
},
83+
null,
84+
2,
85+
)}\n`,
86+
"utf-8",
87+
);
88+
}

src/agents/cli-runner/bundle-mcp.test-support.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ function createEnabledBundleProbeConfig(): OpenClawConfig {
6969

7070
export async function prepareBundleProbeCliConfig(params?: {
7171
additionalConfig?: Parameters<typeof prepareCliBundleMcpConfig>[0]["additionalConfig"];
72+
env?: Parameters<typeof prepareCliBundleMcpConfig>[0]["env"];
7273
}) {
7374
// Bundle discovery reads HOME for per-user plugin roots.
7475
return await withEnvAsync({ HOME: bundleProbeHomeDir }, async () => {
@@ -82,6 +83,7 @@ export async function prepareBundleProbeCliConfig(params?: {
8283
workspaceDir: bundleProbeWorkspaceDir,
8384
config: createEnabledBundleProbeConfig(),
8485
additionalConfig: params?.additionalConfig,
86+
env: params?.env,
8587
});
8688
});
8789
}

src/agents/cli-runner/bundle-mcp.test.ts

Lines changed: 43 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import fs from "node:fs/promises";
33
import path from "node:path";
44
import { describe, expect, it } from "vitest";
55
import { writeClaudeBundleManifest } from "../../plugins/bundle-mcp.test-support.js";
6-
import { prepareCliBundleMcpConfig } from "./bundle-mcp.js";
6+
import { prepareCliBundleMcpCaptureAttempt, prepareCliBundleMcpConfig } from "./bundle-mcp.js";
77
import {
88
cliBundleMcpHarness,
99
prepareBundleProbeCliConfig,
@@ -116,18 +116,31 @@ describe("prepareCliBundleMcpConfig", () => {
116116
});
117117

118118
it("merges loopback overlay config with bundle MCP servers", async () => {
119-
const prepared = await prepareBundleProbeCliConfig({
120-
additionalConfig: {
121-
mcpServers: {
122-
openclaw: {
123-
type: "http",
124-
url: "http://127.0.0.1:23119/mcp",
125-
headers: {
126-
Authorization: "Bearer ${OPENCLAW_MCP_TOKEN}",
127-
},
119+
const additionalConfig = {
120+
mcpServers: {
121+
openclaw: {
122+
type: "http",
123+
url: "http://127.0.0.1:23119/mcp",
124+
headers: {
125+
Authorization: "Bearer ${OPENCLAW_MCP_TOKEN}",
126+
"x-openclaw-cli-capture-key": "${OPENCLAW_MCP_CLI_CAPTURE_KEY}",
128127
},
129128
},
130129
},
130+
};
131+
const prepared = await prepareBundleProbeCliConfig({
132+
additionalConfig,
133+
env: {
134+
OPENCLAW_MCP_TOKEN: "loopback-token-123",
135+
OPENCLAW_MCP_CLI_CAPTURE_KEY: "",
136+
},
137+
});
138+
const otherEnvPrepared = await prepareBundleProbeCliConfig({
139+
additionalConfig,
140+
env: {
141+
OPENCLAW_MCP_TOKEN: "other-loopback-token",
142+
OPENCLAW_MCP_CLI_CAPTURE_KEY: "",
143+
},
131144
});
132145

133146
const generatedConfigPath = requireMcpConfigPath(prepared.backend.args);
@@ -136,9 +149,28 @@ describe("prepareCliBundleMcpConfig", () => {
136149
};
137150
expect(Object.keys(raw.mcpServers ?? {}).toSorted()).toEqual(["bundleProbe", "openclaw"]);
138151
expect(raw.mcpServers?.openclaw?.url).toBe("http://127.0.0.1:23119/mcp");
139-
expect(raw.mcpServers?.openclaw?.headers?.Authorization).toBe("Bearer ${OPENCLAW_MCP_TOKEN}");
152+
expect(raw.mcpServers?.openclaw?.headers?.Authorization).toBe("Bearer loopback-token-123");
153+
expect(raw.mcpServers?.openclaw?.headers?.["x-openclaw-cli-capture-key"]).toBe("");
154+
await prepareCliBundleMcpCaptureAttempt({
155+
mode: "claude-config-file",
156+
backend: prepared.backend,
157+
env: prepared.env,
158+
captureKey: "attempt-123",
159+
});
160+
const attemptRaw = JSON.parse(await fs.readFile(generatedConfigPath, "utf-8")) as {
161+
mcpServers?: Record<string, { url?: string; headers?: Record<string, string> }>;
162+
};
163+
expect(attemptRaw.mcpServers?.openclaw?.headers?.Authorization).toBe(
164+
"Bearer loopback-token-123",
165+
);
166+
expect(attemptRaw.mcpServers?.openclaw?.headers?.["x-openclaw-cli-capture-key"]).toBe(
167+
"attempt-123",
168+
);
169+
expect(prepared.mcpConfigHash).toBe(otherEnvPrepared.mcpConfigHash);
170+
expect(prepared.mcpResumeHash).toBe(otherEnvPrepared.mcpResumeHash);
140171

141172
await prepared.cleanup?.();
173+
await otherEnvPrepared.cleanup?.();
142174
});
143175

144176
it("preserves extra env values alongside generated MCP config", async () => {

src/agents/cli-runner/bundle-mcp.ts

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,11 @@ import { extractMcpServerMap, type BundleMcpConfig } from "../../plugins/bundle-
1313
import type { CliBundleMcpMode } from "../../plugins/types.js";
1414
import { loadMergedBundleMcpConfig, toCliBundleMcpServerConfig } from "../bundle-mcp-config.js";
1515
import { isRecord } from "./bundle-mcp-adapter-shared.js";
16-
import { findClaudeMcpConfigPath, injectClaudeMcpConfigArgs } from "./bundle-mcp-claude.js";
16+
import {
17+
findClaudeMcpConfigPath,
18+
injectClaudeMcpConfigArgs,
19+
writeClaudeMcpCaptureConfig,
20+
} from "./bundle-mcp-claude.js";
1721
import { injectCodexMcpConfigArgs } from "./bundle-mcp-codex.js";
1822
import { writeGeminiMcpCaptureSettings, writeGeminiSystemSettings } from "./bundle-mcp-gemini.js";
1923

@@ -78,6 +82,28 @@ function canonicalizeBundleMcpConfigForResume(config: BundleMcpConfig): BundleMc
7882
};
7983
}
8084

85+
const OPENCLAW_MCP_ENV_TEMPLATE_PATTERN = /\$\{(OPENCLAW_MCP_[A-Z0-9_]+)\}/g;
86+
87+
function resolveOpenClawMcpEnvTemplates(value: unknown, env?: Record<string, string>): unknown {
88+
if (!env) {
89+
return value;
90+
}
91+
if (typeof value === "string") {
92+
return value.replace(OPENCLAW_MCP_ENV_TEMPLATE_PATTERN, (match, name: string) => {
93+
return Object.hasOwn(env, name) ? env[name] : match;
94+
});
95+
}
96+
if (Array.isArray(value)) {
97+
return value.map((entry) => resolveOpenClawMcpEnvTemplates(entry, env));
98+
}
99+
if (!isRecord(value)) {
100+
return value;
101+
}
102+
return Object.fromEntries(
103+
Object.entries(value).map(([key, entry]) => [key, resolveOpenClawMcpEnvTemplates(entry, env)]),
104+
);
105+
}
106+
81107
async function prepareModeSpecificBundleMcpConfig(params: {
82108
mode: CliBundleMcpMode;
83109
backend: CliBackendConfig;
@@ -122,7 +148,11 @@ async function prepareModeSpecificBundleMcpConfig(params: {
122148

123149
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-cli-mcp-"));
124150
const mcpConfigPath = path.join(tempDir, "mcp.json");
125-
await fs.writeFile(mcpConfigPath, serializedConfig, "utf-8");
151+
const runtimeConfig = resolveOpenClawMcpEnvTemplates(
152+
params.mergedConfig,
153+
params.env,
154+
) as BundleMcpConfig;
155+
await fs.writeFile(mcpConfigPath, `${JSON.stringify(runtimeConfig, null, 2)}\n`, "utf-8");
126156
return {
127157
backend: {
128158
...params.backend,
@@ -201,6 +231,7 @@ export async function prepareCliBundleMcpConfig(params: {
201231
/** Prepares a per-attempt capture token without changing resume compatibility hashes. */
202232
export async function prepareCliBundleMcpCaptureAttempt(params: {
203233
mode?: CliBundleMcpMode;
234+
backend?: CliBackendConfig;
204235
env?: Record<string, string>;
205236
captureKey?: string;
206237
}): Promise<{ env?: Record<string, string>; cleanup?: () => Promise<void> }> {
@@ -213,6 +244,17 @@ export async function prepareCliBundleMcpCaptureAttempt(params: {
213244
captureKey: params.captureKey,
214245
});
215246
}
247+
if (resolveBundleMcpMode(params.mode) === "claude-config-file") {
248+
const mcpConfigPath =
249+
findClaudeMcpConfigPath(params.backend?.args) ??
250+
findClaudeMcpConfigPath(params.backend?.resumeArgs);
251+
if (mcpConfigPath) {
252+
await writeClaudeMcpCaptureConfig({
253+
mcpConfigPath,
254+
captureKey: params.captureKey,
255+
});
256+
}
257+
}
216258
return {
217259
env: {
218260
...params.env,

src/agents/cli-runner/claude-live-session.ts

Lines changed: 47 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import {
3333
} from "../cli-output.js";
3434
import { classifyFailoverReason } from "../embedded-agent-helpers.js";
3535
import { FailoverError, resolveFailoverStatus } from "../failover-error.js";
36+
import { prepareCliBundleMcpCaptureAttempt } from "./bundle-mcp.js";
3637
import { buildClaudeOwnerKey } from "./helpers.js";
3738
import { cliBackendLog, formatCliBackendOutputDigest } from "./log.js";
3839
import type { PreparedCliRunContext } from "./types.js";
@@ -1063,39 +1064,49 @@ async function createClaudeLiveSession(params: {
10631064
cleanup: () => Promise<void>;
10641065
}): Promise<ClaudeLiveSession> {
10651066
let session: ClaudeLiveSession | null = null;
1066-
const managedRun = await params.supervisor.spawn({
1067-
sessionId: params.context.params.sessionId,
1068-
backendId: params.context.backendResolved.id,
1069-
scopeKey: `claude-live:${params.key}`,
1070-
replaceExistingScope: true,
1071-
mode: "child",
1072-
argv: params.argv,
1073-
cwd: params.context.cwd ?? params.context.workspaceDir,
1074-
env: params.mcpCaptureKey
1075-
? { ...params.env, OPENCLAW_MCP_CLI_CAPTURE_KEY: params.mcpCaptureKey }
1076-
: params.env,
1077-
stdinMode: "pipe-open",
1078-
captureOutput: false,
1079-
onStdout: (chunk) => {
1080-
if (session) {
1081-
handleClaudeStdout(session, chunk);
1082-
}
1083-
},
1084-
onStderr: (chunk) => {
1085-
if (session) {
1086-
session.stderr += chunk;
1087-
if (session.stderr.length > CLAUDE_LIVE_MAX_STDERR_CHARS) {
1088-
closeLiveSession(
1089-
session,
1090-
"abort",
1091-
createOutputLimitError(session, "Claude CLI stderr exceeded limit."),
1092-
);
1093-
return;
1094-
}
1095-
resetNoOutputTimer(session);
1096-
}
1097-
},
1067+
const mcpCaptureAttempt = await prepareCliBundleMcpCaptureAttempt({
1068+
mode: params.context.backendResolved.bundleMcpMode,
1069+
backend: params.context.preparedBackend.backend,
1070+
env: params.env,
1071+
captureKey: params.mcpCaptureKey,
10981072
});
1073+
let managedRun: ManagedRun;
1074+
try {
1075+
managedRun = await params.supervisor.spawn({
1076+
sessionId: params.context.params.sessionId,
1077+
backendId: params.context.backendResolved.id,
1078+
scopeKey: `claude-live:${params.key}`,
1079+
replaceExistingScope: true,
1080+
mode: "child",
1081+
argv: params.argv,
1082+
cwd: params.context.cwd ?? params.context.workspaceDir,
1083+
env: mcpCaptureAttempt.env ?? params.env,
1084+
stdinMode: "pipe-open",
1085+
captureOutput: false,
1086+
onStdout: (chunk) => {
1087+
if (session) {
1088+
handleClaudeStdout(session, chunk);
1089+
}
1090+
},
1091+
onStderr: (chunk) => {
1092+
if (session) {
1093+
session.stderr += chunk;
1094+
if (session.stderr.length > CLAUDE_LIVE_MAX_STDERR_CHARS) {
1095+
closeLiveSession(
1096+
session,
1097+
"abort",
1098+
createOutputLimitError(session, "Claude CLI stderr exceeded limit."),
1099+
);
1100+
return;
1101+
}
1102+
resetNoOutputTimer(session);
1103+
}
1104+
},
1105+
});
1106+
} catch (error) {
1107+
await mcpCaptureAttempt.cleanup?.();
1108+
throw error;
1109+
}
10991110
session = {
11001111
key: params.key,
11011112
fingerprint: params.fingerprint,
@@ -1109,7 +1120,10 @@ async function createClaudeLiveSession(params: {
11091120
drainTimer: null,
11101121
drainingAbortedTurn: false,
11111122
idleTimer: null,
1112-
cleanup: params.cleanup,
1123+
cleanup: async () => {
1124+
await mcpCaptureAttempt.cleanup?.();
1125+
await params.cleanup();
1126+
},
11131127
cleanupPromise: null,
11141128
closing: false,
11151129
mcpCaptureKey: params.mcpCaptureKey,

src/agents/cli-runner/execute.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -677,6 +677,7 @@ export async function executePreparedCliRun(
677677
: buildCliMcpCaptureKey(context);
678678
const mcpCaptureAttempt = await prepareCliBundleMcpCaptureAttempt({
679679
mode: context.backendResolved.bundleMcpMode,
680+
backend,
680681
env: context.preparedBackend.env,
681682
captureKey: initialGatewayCaptureKey,
682683
});

src/agents/cli-runner/prepare.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,7 @@ function createTestMcpLoopbackServerConfig(port: number) {
112112
openclaw: {
113113
type: "http",
114114
url: `http://127.0.0.1:${port}/mcp`,
115+
alwaysLoad: true,
115116
headers: {
116117
Authorization: "Bearer ${OPENCLAW_MCP_TOKEN}",
117118
"x-session-key": "${OPENCLAW_MCP_SESSION_KEY}",

0 commit comments

Comments
 (0)