fix(gateway): preserve owner MCP tools for agent RPC

This commit is contained in:
Vincent Koc
2026-06-21 18:01:22 +02:00
parent 9d27583190
commit c2ee9b0be8
14 changed files with 257 additions and 61 deletions

View File

@@ -10,6 +10,12 @@ import {
resolveClaudeCliExecutionArgs,
} from "./cli-shared.js";
function expectDefaultDisallowedTools(args: readonly string[] | undefined) {
const disallowedIndex = args?.indexOf("--disallowedTools") ?? -1;
expect(disallowedIndex).toBeGreaterThanOrEqual(0);
expect(args?.[disallowedIndex + 1]).toBe("ScheduleWakeup,CronCreate");
}
describe("normalizeClaudePermissionArgs", () => {
it("leaves args alone when they omit permission flags", () => {
expect(
@@ -356,8 +362,10 @@ describe("normalizeClaudeBackendConfig", () => {
expect(backend.config.input).toBe("stdin");
expect(backend.config.args).toContain("--setting-sources");
expect(backend.config.args).toContain("user");
expectDefaultDisallowedTools(backend.config.args);
expect(backend.config.resumeArgs).toContain("--setting-sources");
expect(backend.config.resumeArgs).toContain("user");
expectDefaultDisallowedTools(backend.config.resumeArgs);
expect(backend.config.clearEnv).toEqual([...CLAUDE_CLI_CLEAR_ENV]);
expect(backend.config.clearEnv).toContain("ANTHROPIC_API_TOKEN");
expect(backend.config.clearEnv).toContain("ANTHROPIC_BASE_URL");

View File

@@ -1,6 +1,7 @@
/**
* Claude CLI argument helpers for OpenClaw-managed bundle MCP config.
*/
import fs from "node:fs/promises";
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
/** Find an existing Claude `--mcp-config` argument value. */
@@ -43,3 +44,45 @@ export function injectClaudeMcpConfigArgs(
next.push("--strict-mcp-config", "--mcp-config", mcpConfigPath);
return next;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
/** Writes the active per-attempt capture token into OpenClaw's generated Claude MCP config. */
export async function writeClaudeMcpCaptureConfig(params: {
mcpConfigPath: string;
captureKey: string;
}): Promise<void> {
const raw = JSON.parse(await fs.readFile(params.mcpConfigPath, "utf-8")) as unknown;
if (!isRecord(raw)) {
throw new Error("Claude MCP capture requires an object config");
}
const mcpServers = isRecord(raw.mcpServers) ? raw.mcpServers : {};
const openclaw = isRecord(mcpServers.openclaw) ? mcpServers.openclaw : undefined;
if (!openclaw) {
throw new Error("Claude MCP capture requires an openclaw server config");
}
const headers = isRecord(openclaw.headers) ? openclaw.headers : {};
await fs.writeFile(
params.mcpConfigPath,
`${JSON.stringify(
{
...raw,
mcpServers: {
...mcpServers,
openclaw: {
...openclaw,
headers: {
...headers,
"x-openclaw-cli-capture-key": params.captureKey,
},
},
},
},
null,
2,
)}\n`,
"utf-8",
);
}

View File

@@ -69,6 +69,7 @@ function createEnabledBundleProbeConfig(): OpenClawConfig {
export async function prepareBundleProbeCliConfig(params?: {
additionalConfig?: Parameters<typeof prepareCliBundleMcpConfig>[0]["additionalConfig"];
env?: Parameters<typeof prepareCliBundleMcpConfig>[0]["env"];
}) {
// Bundle discovery reads HOME for per-user plugin roots.
return await withEnvAsync({ HOME: bundleProbeHomeDir }, async () => {
@@ -82,6 +83,7 @@ export async function prepareBundleProbeCliConfig(params?: {
workspaceDir: bundleProbeWorkspaceDir,
config: createEnabledBundleProbeConfig(),
additionalConfig: params?.additionalConfig,
env: params?.env,
});
});
}

View File

@@ -3,7 +3,7 @@ import fs from "node:fs/promises";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { writeClaudeBundleManifest } from "../../plugins/bundle-mcp.test-support.js";
import { prepareCliBundleMcpConfig } from "./bundle-mcp.js";
import { prepareCliBundleMcpCaptureAttempt, prepareCliBundleMcpConfig } from "./bundle-mcp.js";
import {
cliBundleMcpHarness,
prepareBundleProbeCliConfig,
@@ -116,18 +116,31 @@ describe("prepareCliBundleMcpConfig", () => {
});
it("merges loopback overlay config with bundle MCP servers", async () => {
const prepared = await prepareBundleProbeCliConfig({
additionalConfig: {
mcpServers: {
openclaw: {
type: "http",
url: "http://127.0.0.1:23119/mcp",
headers: {
Authorization: "Bearer ${OPENCLAW_MCP_TOKEN}",
},
const additionalConfig = {
mcpServers: {
openclaw: {
type: "http",
url: "http://127.0.0.1:23119/mcp",
headers: {
Authorization: "Bearer ${OPENCLAW_MCP_TOKEN}",
"x-openclaw-cli-capture-key": "${OPENCLAW_MCP_CLI_CAPTURE_KEY}",
},
},
},
};
const prepared = await prepareBundleProbeCliConfig({
additionalConfig,
env: {
OPENCLAW_MCP_TOKEN: "loopback-token-123",
OPENCLAW_MCP_CLI_CAPTURE_KEY: "",
},
});
const otherEnvPrepared = await prepareBundleProbeCliConfig({
additionalConfig,
env: {
OPENCLAW_MCP_TOKEN: "other-loopback-token",
OPENCLAW_MCP_CLI_CAPTURE_KEY: "",
},
});
const generatedConfigPath = requireMcpConfigPath(prepared.backend.args);
@@ -136,9 +149,28 @@ describe("prepareCliBundleMcpConfig", () => {
};
expect(Object.keys(raw.mcpServers ?? {}).toSorted()).toEqual(["bundleProbe", "openclaw"]);
expect(raw.mcpServers?.openclaw?.url).toBe("http://127.0.0.1:23119/mcp");
expect(raw.mcpServers?.openclaw?.headers?.Authorization).toBe("Bearer ${OPENCLAW_MCP_TOKEN}");
expect(raw.mcpServers?.openclaw?.headers?.Authorization).toBe("Bearer loopback-token-123");
expect(raw.mcpServers?.openclaw?.headers?.["x-openclaw-cli-capture-key"]).toBe("");
await prepareCliBundleMcpCaptureAttempt({
mode: "claude-config-file",
backend: prepared.backend,
env: prepared.env,
captureKey: "attempt-123",
});
const attemptRaw = JSON.parse(await fs.readFile(generatedConfigPath, "utf-8")) as {
mcpServers?: Record<string, { url?: string; headers?: Record<string, string> }>;
};
expect(attemptRaw.mcpServers?.openclaw?.headers?.Authorization).toBe(
"Bearer loopback-token-123",
);
expect(attemptRaw.mcpServers?.openclaw?.headers?.["x-openclaw-cli-capture-key"]).toBe(
"attempt-123",
);
expect(prepared.mcpConfigHash).toBe(otherEnvPrepared.mcpConfigHash);
expect(prepared.mcpResumeHash).toBe(otherEnvPrepared.mcpResumeHash);
await prepared.cleanup?.();
await otherEnvPrepared.cleanup?.();
});
it("preserves extra env values alongside generated MCP config", async () => {

View File

@@ -13,7 +13,11 @@ import { extractMcpServerMap, type BundleMcpConfig } from "../../plugins/bundle-
import type { CliBundleMcpMode } from "../../plugins/types.js";
import { loadMergedBundleMcpConfig, toCliBundleMcpServerConfig } from "../bundle-mcp-config.js";
import { isRecord } from "./bundle-mcp-adapter-shared.js";
import { findClaudeMcpConfigPath, injectClaudeMcpConfigArgs } from "./bundle-mcp-claude.js";
import {
findClaudeMcpConfigPath,
injectClaudeMcpConfigArgs,
writeClaudeMcpCaptureConfig,
} from "./bundle-mcp-claude.js";
import { injectCodexMcpConfigArgs } from "./bundle-mcp-codex.js";
import { writeGeminiMcpCaptureSettings, writeGeminiSystemSettings } from "./bundle-mcp-gemini.js";
@@ -78,6 +82,28 @@ function canonicalizeBundleMcpConfigForResume(config: BundleMcpConfig): BundleMc
};
}
const OPENCLAW_MCP_ENV_TEMPLATE_PATTERN = /\$\{(OPENCLAW_MCP_[A-Z0-9_]+)\}/g;
function resolveOpenClawMcpEnvTemplates(value: unknown, env?: Record<string, string>): unknown {
if (!env) {
return value;
}
if (typeof value === "string") {
return value.replace(OPENCLAW_MCP_ENV_TEMPLATE_PATTERN, (match, name: string) => {
return Object.hasOwn(env, name) ? env[name] : match;
});
}
if (Array.isArray(value)) {
return value.map((entry) => resolveOpenClawMcpEnvTemplates(entry, env));
}
if (!isRecord(value)) {
return value;
}
return Object.fromEntries(
Object.entries(value).map(([key, entry]) => [key, resolveOpenClawMcpEnvTemplates(entry, env)]),
);
}
async function prepareModeSpecificBundleMcpConfig(params: {
mode: CliBundleMcpMode;
backend: CliBackendConfig;
@@ -122,7 +148,11 @@ async function prepareModeSpecificBundleMcpConfig(params: {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-cli-mcp-"));
const mcpConfigPath = path.join(tempDir, "mcp.json");
await fs.writeFile(mcpConfigPath, serializedConfig, "utf-8");
const runtimeConfig = resolveOpenClawMcpEnvTemplates(
params.mergedConfig,
params.env,
) as BundleMcpConfig;
await fs.writeFile(mcpConfigPath, `${JSON.stringify(runtimeConfig, null, 2)}\n`, "utf-8");
return {
backend: {
...params.backend,
@@ -201,6 +231,7 @@ export async function prepareCliBundleMcpConfig(params: {
/** Prepares a per-attempt capture token without changing resume compatibility hashes. */
export async function prepareCliBundleMcpCaptureAttempt(params: {
mode?: CliBundleMcpMode;
backend?: CliBackendConfig;
env?: Record<string, string>;
captureKey?: string;
}): Promise<{ env?: Record<string, string>; cleanup?: () => Promise<void> }> {
@@ -213,6 +244,17 @@ export async function prepareCliBundleMcpCaptureAttempt(params: {
captureKey: params.captureKey,
});
}
if (resolveBundleMcpMode(params.mode) === "claude-config-file") {
const mcpConfigPath =
findClaudeMcpConfigPath(params.backend?.args) ??
findClaudeMcpConfigPath(params.backend?.resumeArgs);
if (mcpConfigPath) {
await writeClaudeMcpCaptureConfig({
mcpConfigPath,
captureKey: params.captureKey,
});
}
}
return {
env: {
...params.env,

View File

@@ -33,6 +33,7 @@ import {
} from "../cli-output.js";
import { classifyFailoverReason } from "../embedded-agent-helpers.js";
import { FailoverError, resolveFailoverStatus } from "../failover-error.js";
import { prepareCliBundleMcpCaptureAttempt } from "./bundle-mcp.js";
import { buildClaudeOwnerKey } from "./helpers.js";
import { cliBackendLog, formatCliBackendOutputDigest } from "./log.js";
import type { PreparedCliRunContext } from "./types.js";
@@ -1063,39 +1064,49 @@ async function createClaudeLiveSession(params: {
cleanup: () => Promise<void>;
}): Promise<ClaudeLiveSession> {
let session: ClaudeLiveSession | null = null;
const managedRun = await params.supervisor.spawn({
sessionId: params.context.params.sessionId,
backendId: params.context.backendResolved.id,
scopeKey: `claude-live:${params.key}`,
replaceExistingScope: true,
mode: "child",
argv: params.argv,
cwd: params.context.cwd ?? params.context.workspaceDir,
env: params.mcpCaptureKey
? { ...params.env, OPENCLAW_MCP_CLI_CAPTURE_KEY: params.mcpCaptureKey }
: params.env,
stdinMode: "pipe-open",
captureOutput: false,
onStdout: (chunk) => {
if (session) {
handleClaudeStdout(session, chunk);
}
},
onStderr: (chunk) => {
if (session) {
session.stderr += chunk;
if (session.stderr.length > CLAUDE_LIVE_MAX_STDERR_CHARS) {
closeLiveSession(
session,
"abort",
createOutputLimitError(session, "Claude CLI stderr exceeded limit."),
);
return;
}
resetNoOutputTimer(session);
}
},
const mcpCaptureAttempt = await prepareCliBundleMcpCaptureAttempt({
mode: params.context.backendResolved.bundleMcpMode,
backend: params.context.preparedBackend.backend,
env: params.env,
captureKey: params.mcpCaptureKey,
});
let managedRun: ManagedRun;
try {
managedRun = await params.supervisor.spawn({
sessionId: params.context.params.sessionId,
backendId: params.context.backendResolved.id,
scopeKey: `claude-live:${params.key}`,
replaceExistingScope: true,
mode: "child",
argv: params.argv,
cwd: params.context.cwd ?? params.context.workspaceDir,
env: mcpCaptureAttempt.env ?? params.env,
stdinMode: "pipe-open",
captureOutput: false,
onStdout: (chunk) => {
if (session) {
handleClaudeStdout(session, chunk);
}
},
onStderr: (chunk) => {
if (session) {
session.stderr += chunk;
if (session.stderr.length > CLAUDE_LIVE_MAX_STDERR_CHARS) {
closeLiveSession(
session,
"abort",
createOutputLimitError(session, "Claude CLI stderr exceeded limit."),
);
return;
}
resetNoOutputTimer(session);
}
},
});
} catch (error) {
await mcpCaptureAttempt.cleanup?.();
throw error;
}
session = {
key: params.key,
fingerprint: params.fingerprint,
@@ -1109,7 +1120,10 @@ async function createClaudeLiveSession(params: {
drainTimer: null,
drainingAbortedTurn: false,
idleTimer: null,
cleanup: params.cleanup,
cleanup: async () => {
await mcpCaptureAttempt.cleanup?.();
await params.cleanup();
},
cleanupPromise: null,
closing: false,
mcpCaptureKey: params.mcpCaptureKey,

View File

@@ -677,6 +677,7 @@ export async function executePreparedCliRun(
: buildCliMcpCaptureKey(context);
const mcpCaptureAttempt = await prepareCliBundleMcpCaptureAttempt({
mode: context.backendResolved.bundleMcpMode,
backend,
env: context.preparedBackend.env,
captureKey: initialGatewayCaptureKey,
});

View File

@@ -112,6 +112,7 @@ function createTestMcpLoopbackServerConfig(port: number) {
openclaw: {
type: "http",
url: `http://127.0.0.1:${port}/mcp`,
alwaysLoad: true,
headers: {
Authorization: "Bearer ${OPENCLAW_MCP_TOKEN}",
"x-session-key": "${OPENCLAW_MCP_SESSION_KEY}",

View File

@@ -44,14 +44,19 @@ describe("live-agent-probes", () => {
agentId: "codex",
sessionKey: "agent:codex:acp:test",
});
expect(
buildLiveCronProbeMessage({
agent: "claude-cli",
argsJson: spec.argsJson,
attempt: 1,
exactReply: spec.name,
}),
).toContain("Preserve job.sessionTarget and job.sessionKey exactly as provided.");
const claudeRetryPrompt = buildLiveCronProbeMessage({
agent: "claude-cli",
argsJson: spec.argsJson,
attempt: 1,
exactReply: spec.name,
});
expect(claudeRetryPrompt).toContain(
"Preserve job.sessionTarget and job.sessionKey exactly as provided.",
);
expect(claudeRetryPrompt).toContain("search/load MCP tools for `openclaw cron` or `cron`");
expect(claudeRetryPrompt).toContain("mcp__openclaw__cron");
expect(claudeRetryPrompt).toContain("Do not use Claude native `CronCreate`");
expect(claudeRetryPrompt).not.toContain("openclaw-tools");
expect(
buildLiveCronProbeMessage({
agent: "future-agent",

View File

@@ -103,8 +103,10 @@ export function buildLiveCronProbeMessage(params: {
const claudeLike = isClaudeLikeLiveAgent(params.agent);
if (params.attempt === 0) {
return (
"Use the OpenClaw MCP tool `openclaw-tools/cron` (server `openclaw-tools`, tool `cron`). " +
"If the harness shows Claude-style MCP names, use `mcp__openclaw-tools__cron` or `mcp__openclaw_tools__cron`. " +
"Use the OpenClaw MCP cron tool from server `openclaw`. " +
"If it is not already visible, search/load MCP tools for `openclaw cron` or `cron`, " +
"then call the matching OpenClaw MCP tool; Claude-style names may appear as `mcp__openclaw__cron`. " +
"Do not use Claude native `CronCreate`, `CronList`, or `CronDelete`; those are not OpenClaw proof. " +
`Call it with JSON arguments ${params.argsJson}. ` +
"Preserve the JSON exactly, including job.sessionTarget and job.sessionKey; do not omit, rename, or flatten those fields. " +
"Do the actual tool call; I will verify externally with the OpenClaw cron CLI. " +
@@ -113,8 +115,10 @@ export function buildLiveCronProbeMessage(params: {
}
if (claudeLike) {
return (
"Retry the OpenClaw MCP tool `openclaw-tools/cron` now. " +
"If the harness shows Claude-style MCP names, use `mcp__openclaw-tools__cron` or `mcp__openclaw_tools__cron`. " +
"Retry the OpenClaw MCP cron tool from server `openclaw` now. " +
"If it is not already visible, search/load MCP tools for `openclaw cron` or `cron`, " +
"then call the matching OpenClaw MCP tool; Claude-style names may appear as `mcp__openclaw__cron`. " +
"Do not use Claude native `CronCreate`, `CronList`, or `CronDelete`; those are not OpenClaw proof. " +
`Use these exact JSON arguments: ${params.argsJson}. ` +
"Preserve job.sessionTarget and job.sessionKey exactly as provided. " +
`If the cron job is created, reply exactly: ${params.exactReply}. ` +
@@ -125,8 +129,8 @@ export function buildLiveCronProbeMessage(params: {
}
return (
"Your previous OpenClaw cron MCP tool call was cancelled before the job was created. " +
"Retry the OpenClaw MCP tool `openclaw-tools/cron` now. " +
"If the harness shows Claude-style MCP names, use `mcp__openclaw-tools__cron` or `mcp__openclaw_tools__cron`. " +
"Retry the OpenClaw MCP cron tool from server `openclaw` now. " +
"If the harness shows Claude-style MCP names, use `mcp__openclaw__cron`. " +
`Use these exact JSON arguments: ${params.argsJson}. ` +
"Preserve job.sessionTarget and job.sessionKey exactly as provided. " +
`If the cron job is created, reply exactly: ${params.exactReply}. ` +

View File

@@ -373,6 +373,7 @@ export function createMcpLoopbackServerConfig(port: number) {
openclaw: {
type: "http",
url: `http://127.0.0.1:${port}/mcp`,
alwaysLoad: true,
headers: {
Authorization: "Bearer ${OPENCLAW_MCP_TOKEN}",
"x-session-key": "${OPENCLAW_MCP_SESSION_KEY}",

View File

@@ -1688,9 +1688,13 @@ describe("mcp loopback server", () => {
describe("createMcpLoopbackServerConfig", () => {
it("builds a server entry with env-driven headers", () => {
const config = createMcpLoopbackServerConfig(23119) as {
mcpServers?: Record<string, { url?: string; headers?: Record<string, string> }>;
mcpServers?: Record<
string,
{ alwaysLoad?: boolean; url?: string; headers?: Record<string, string> }
>;
};
expect(config.mcpServers?.openclaw?.url).toBe("http://127.0.0.1:23119/mcp");
expect(config.mcpServers?.openclaw?.alwaysLoad).toBe(true);
expect(config.mcpServers?.openclaw?.headers?.Authorization).toBe(
"Bearer ${OPENCLAW_MCP_TOKEN}",
);

View File

@@ -2233,6 +2233,44 @@ describe("gateway agent handler", () => {
});
});
it("forwards admin caller ownership to ingress agent runs", async () => {
primeMainAgentRun({ cfg: mocks.loadConfigReturn });
mocks.agentCommand.mockClear();
await invokeAgent(
{
message: "owner tool check",
agentId: "main",
sessionKey: "agent:main:main",
idempotencyKey: "test-admin-sender-owner",
},
{
reqId: "admin-sender-owner",
client: { connect: { scopes: ["operator.admin"] } } as AgentHandlerArgs["client"],
},
);
expect((await waitForAgentCommandCall<{ senderIsOwner?: boolean }>()).senderIsOwner).toBe(true);
mocks.agentCommand.mockClear();
await invokeAgent(
{
message: "non-owner tool check",
agentId: "main",
sessionKey: "agent:main:main",
idempotencyKey: "test-write-sender-owner",
},
{
reqId: "write-sender-owner",
client: backendGatewayClient(),
},
);
expect((await waitForAgentCommandCall<{ senderIsOwner?: boolean }>()).senderIsOwner).toBe(
false,
);
});
it("rejects public transcriptMessage overrides", async () => {
primeMainAgentRun({ cfg: mocks.loadConfigReturn });
mocks.agentCommand.mockClear();

View File

@@ -2770,6 +2770,7 @@ export const agentHandlers: GatewayRequestHandlers = {
acpTurnSource: request.acpTurnSource,
internalEvents: request.internalEvents,
inputProvenance,
senderIsOwner: clientHasAdminScope(client),
sessionEffects,
skipInitialSessionTouch: skipAgentInitialSessionTouch,
preserveUserFacingSessionModelState,