Files
openclaw/src/gateway/mcp-http.ts
T
Peter Steinberger f94a7dc183 feat(codex): supervise native Codex sessions (#104045)
* feat(codex): add native session supervision

* fix(codex): harden supervision integration

* fix(codex): preserve locked harness ownership

* fix(codex): fence native session archive

* fix(codex): revalidate archive binding ownership

* feat(codex): integrate supervision runtime

* feat(sessions): preserve harness-owned execution

* feat(sessions): persist harness ownership invariants

* feat(gateway): enforce harness-owned sessions

* feat(setup): enable detected Codex supervision

* feat(mac): expose supervised Codex sessions

* feat(ui): make Codex sessions actionable

* docs(codex): document session supervision

* test(codex): cover integration ownership

* chore(i18n): refresh supervision inventories

* fix(setup): finalize Codex activation atomically

* test(codex): narrow binding store update

* fix(sessions): preserve legacy model locks

* test(macos): serialize Codex catalog fixtures

* fix(sessions): preserve legacy lock admission

* chore(i18n): reconcile supervision metadata

* test(sessions): mark legacy lock fixture

* fix(macos): drain final Codex catalog frame

* docs: leave supervision note to release

* style(macos): satisfy Codex catalog type length

* chore: record session accessor seam owners

* fix(macos): honor configured Codex supervision

* fix(codex): preserve harness-owned model locks

* fix(codex): satisfy supervision lint gates

* chore(i18n): refresh native supervision inventory

* fix(codex): align supervision validation contracts

* fix(codex): close supervision boundary gaps

* fix(codex): preserve supervision activation contracts

* fix(codex): dispose standalone supervision runtime

* fix(codex): pin supervised source connection

* fix(plugins): bind delegated runs to exact session target

* fix(codex): scope supervised sessions to configured agents

* fix(codex): fingerprint effective supervision home

* fix(codex): normalize supervision plugin policy

* fix(codex): keep supervised bindings stable across upgrades

* fix(codex): guard all supervised binding connections

* fix(codex): preserve catalog filters and pending CAS identity

* fix(codex): preserve supervision identity for diagnostics

* fix(codex): bind uncertain commits to supervision connection

* fix(codex): satisfy supervision type boundaries

* fix(macos): reconcile current main validation

* fix(codex): handle absent runtime config in supervision

* fix(doctor): own local audio acceleration check

* fix(codex): satisfy integration lint gates

* fix(codex): satisfy lifecycle safety guards
2026-07-11 00:12:08 -07:00

510 lines
19 KiB
TypeScript

// MCP loopback HTTP server.
// Exposes Gateway-scoped tools to local MCP clients over bearer-auth loopback.
import crypto from "node:crypto";
import {
createServer as createHttpServer,
type IncomingMessage,
type ServerResponse,
} from "node:http";
import { isRecord } from "@openclaw/normalization-core/record-coerce";
import { resolveToolLoopDetectionConfig } from "../agents/tool-loop-detection-config.js";
import { getRuntimeConfig } from "../config/io.js";
import { resolveSessionEntryAccessTarget } from "../config/sessions/session-accessor.js";
import { isTruthyEnvValue } from "../infra/env.js";
import { formatErrorMessage } from "../infra/errors.js";
import { logDebug, logWarn } from "../logger.js";
import {
AGENT_HARNESS_SESSION_KEY_RESERVED_MESSAGE,
isAgentHarnessSessionKey,
isAgentHarnessSessionStoreEntryProtected,
} from "../sessions/agent-harness-session-key.js";
import {
resolveMcpLoopbackClientGrant,
revokeMcpLoopbackClientGrantsForRuntime,
} from "./mcp-grant-store.js";
import { handleMcpJsonRpc } from "./mcp-http.handlers.js";
import {
clearActiveMcpLoopbackRuntimeByOwnerToken,
markMcpLoopbackRequestClassified,
markMcpLoopbackRequestFinished,
markMcpLoopbackRequestStarted,
markMcpLoopbackToolCallFinished,
markMcpLoopbackToolCallStarted,
recordMcpLoopbackToolCallResult,
resolveMcpLoopbackYieldContext,
setActiveMcpLoopbackRuntime,
updateMcpLoopbackToolCallCapture,
} from "./mcp-http.loopback-runtime.js";
import { jsonRpcError, type JsonRpcRequest } from "./mcp-http.protocol.js";
import {
isMcpHttpBodyTooLargeError,
isMcpHttpBodyTimeoutError,
readMcpHttpBody,
resolveMcpCliCaptureKey,
resolveMcpHttpBodyTimeoutMs,
resolveMcpRequestContext,
validateMcpLoopbackRequest,
} from "./mcp-http.request.js";
import { McpLoopbackToolCache } from "./mcp-http.runtime.js";
// Loopback MCP server exposes gateway-scoped tools to local MCP clients over a
// bearer-token HTTP endpoint bound to 127.0.0.1. Only one active server/runtime
// is registered per process.
export {
createMcpAttachGrantServerConfig,
createMcpLoopbackServerConfig,
getActiveMcpLoopbackRuntime,
} from "./mcp-http.loopback-runtime.js";
type McpLoopbackServer = {
port: number;
close: () => Promise<void>;
};
let activeMcpLoopbackServer: McpLoopbackServer | undefined;
let activeMcpLoopbackServerPromise: Promise<McpLoopbackServer> | null = null;
function createMcpJsonParseError(error: unknown): Error & { code: "mcp_json_parse_error" } {
return Object.assign(new Error("MCP JSON parse error"), {
cause: error,
code: "mcp_json_parse_error" as const,
});
}
function isMcpJsonParseError(error: unknown): error is Error & { code: "mcp_json_parse_error" } {
return (
typeof error === "object" &&
error !== null &&
(error as { code?: unknown }).code === "mcp_json_parse_error"
);
}
function parseMcpJsonBody(body: string): JsonRpcRequest | JsonRpcRequest[] {
try {
return JSON.parse(body) as JsonRpcRequest | JsonRpcRequest[];
} catch (error) {
throw createMcpJsonParseError(error);
}
}
function readJsonRpcRequestId(message: unknown) {
if (!isRecord(message)) {
return null;
}
const id = message.id;
return typeof id === "string" || typeof id === "number" || id === null ? id : undefined;
}
function isJsonRpcRequest(message: unknown): message is JsonRpcRequest {
return isRecord(message) && message.jsonrpc === "2.0" && typeof message.method === "string";
}
function jsonRpcInternalError(parsed: JsonRpcRequest | JsonRpcRequest[] | undefined) {
if (Array.isArray(parsed)) {
return parsed.map((message) =>
jsonRpcError(readJsonRpcRequestId(message), -32603, "Internal error"),
);
}
return jsonRpcError(readJsonRpcRequestId(parsed), -32603, "Internal error");
}
function shouldLogMcpLoopbackTraffic(): boolean {
return (
isTruthyEnvValue(process.env.OPENCLAW_CLI_BACKEND_LOG_OUTPUT) ||
isTruthyEnvValue(process.env.OPENCLAW_LIVE_CLI_BACKEND_DEBUG)
);
}
function logMcpLoopbackTraffic(step: string, details: Record<string, unknown>): void {
if (!shouldLogMcpLoopbackTraffic()) {
return;
}
console.error(`[mcp-loopback] ${step} ${JSON.stringify(details)}`);
}
// Abort tool calls when the request disconnects before completion, but keep
// completed responses alive through normal response close notifications.
function createRequestAbortSignal(req: IncomingMessage, res: ServerResponse) {
const controller = new AbortController();
const abort = () => {
if (!controller.signal.aborted) {
controller.abort();
}
};
const abortIfRequestIncomplete = () => {
if (!req.complete) {
abort();
}
};
const abortIfResponseStillOpen = () => {
if (!res.writableEnded) {
abort();
}
};
req.once("close", abortIfRequestIncomplete);
res.once("close", abortIfResponseStillOpen);
if (req.destroyed && !req.complete) {
abort();
}
return {
signal: controller.signal,
cleanup: () => {
req.off("close", abortIfRequestIncomplete);
res.off("close", abortIfResponseStillOpen);
},
};
}
/** Starts a new MCP loopback HTTP server and registers its bearer tokens. */
export async function startMcpLoopbackServer(port = 0): Promise<{
port: number;
close: () => Promise<void>;
}> {
const ownerToken = crypto.randomBytes(32).toString("hex");
const nonOwnerToken = crypto.randomBytes(32).toString("hex");
const toolCache = new McpLoopbackToolCache();
// GET notification streams are intentionally long-lived; shutdown must end
// them itself before waiting for httpServer.close() to drain active responses.
const activeSseResponses = new Set<ServerResponse>();
const trackSseResponse = (res: ServerResponse): void => {
activeSseResponses.add(res);
const cleanup = () => {
activeSseResponses.delete(res);
res.off("close", cleanup);
res.off("finish", cleanup);
};
res.once("close", cleanup);
res.once("finish", cleanup);
};
const closeActiveSseResponses = (): void => {
for (const res of activeSseResponses) {
if (!res.destroyed && !res.writableEnded) {
const socket = res.socket;
res.end();
socket?.end();
}
}
};
const httpServer = createHttpServer((req, res) => {
const auth = validateMcpLoopbackRequest({
req,
res,
ownerToken,
nonOwnerToken,
onSseResponse: trackSseResponse,
});
if (!auth) {
return;
}
// Bind the request before body parsing/tool resolution. A CLI may exit while
// an accepted request is still uploading, and retries must not outrun it.
const cliCaptureKey = resolveMcpCliCaptureKey(req, auth);
const cliRequestCaptureHandle = markMcpLoopbackRequestStarted(cliCaptureKey);
const requestAbort = createRequestAbortSignal(req, res);
void (async () => {
let parsed: JsonRpcRequest | JsonRpcRequest[] | undefined;
let cliCaptureHandles: Array<ReturnType<typeof markMcpLoopbackToolCallStarted>> = [];
try {
const body = await readMcpHttpBody(req, { timeoutMs: resolveMcpHttpBodyTimeoutMs() });
parsed = parseMcpJsonBody(body);
const messages = Array.isArray(parsed) ? parsed : [parsed];
cliCaptureHandles = messages.map((message) => {
if (
!cliRequestCaptureHandle ||
!isJsonRpcRequest(message) ||
message.method !== "tools/call"
) {
return undefined;
}
const admittedToolName =
isRecord(message.params) && typeof message.params.name === "string"
? message.params.name
: "";
const toolArgs =
isRecord(message.params) && isRecord(message.params.arguments)
? message.params.arguments
: {};
return markMcpLoopbackToolCallStarted({
requestCaptureHandle: cliRequestCaptureHandle,
toolName: admittedToolName,
args: toolArgs,
});
});
markMcpLoopbackRequestClassified(cliRequestCaptureHandle);
const cfg = getRuntimeConfig();
const requestContext = resolveMcpRequestContext(req, cfg, auth);
const { boundGrantToken, boundCaptureKey } = auth;
const authorizeToolCall =
boundGrantToken && boundCaptureKey
? () =>
Boolean(
resolveMcpLoopbackClientGrant({
token: boundGrantToken,
runtimeOwnerToken: ownerToken,
captureKey: boundCaptureKey,
}),
)
: undefined;
const harnessEntry = isAgentHarnessSessionKey(requestContext.sessionKey)
? resolveSessionEntryAccessTarget({ cfg, sessionKey: requestContext.sessionKey }).entry
: undefined;
if (
isAgentHarnessSessionKey(requestContext.sessionKey) &&
(!harnessEntry ||
isAgentHarnessSessionStoreEntryProtected(requestContext.sessionKey, harnessEntry))
) {
const errors = messages.map((message) =>
jsonRpcError(
readJsonRpcRequestId(message),
-32600,
AGENT_HARNESS_SESSION_KEY_RESERVED_MESSAGE,
),
);
const payload = Array.isArray(parsed)
? JSON.stringify(errors)
: JSON.stringify(errors[0]);
res.writeHead(200, { "Content-Type": "application/json" });
res.end(payload);
return;
}
const yieldContext = resolveMcpLoopbackYieldContext(cliRequestCaptureHandle);
const scopedTools = toolCache.resolve({
cfg,
sessionKey: requestContext.sessionKey,
runtimePolicySessionKey: requestContext.runtimePolicySessionKey,
agentId: requestContext.agentId,
sessionId: requestContext.sessionId,
modelProvider: requestContext.modelProvider,
modelId: requestContext.modelId,
yieldContextCacheKey: yieldContext?.cacheKey,
onYield: yieldContext?.onYield,
messageProvider: requestContext.messageProvider,
clientCaps: requestContext.clientCaps,
currentChannelId: requestContext.currentChannelId,
currentThreadTs: requestContext.currentThreadTs,
currentMessageId: requestContext.currentMessageId,
currentInboundAudio: requestContext.currentInboundAudio,
accountId: requestContext.accountId,
inboundEventKind: requestContext.inboundEventKind,
sourceReplyDeliveryMode: requestContext.sourceReplyDeliveryMode,
taskSuggestionDeliveryMode: requestContext.taskSuggestionDeliveryMode,
requireExplicitMessageTarget: requestContext.requireExplicitMessageTarget,
senderIsOwner: requestContext.senderIsOwner,
nodeExecAllowed: requestContext.nodeExecAllowed,
execSession: requestContext.execSession,
execOverrides: requestContext.execOverrides,
bashElevated: requestContext.bashElevated,
trigger: requestContext.trigger,
approvalReviewerDeviceId: requestContext.approvalReviewerDeviceId,
channelContext: requestContext.channelContext,
senderName: requestContext.senderName,
senderUsername: requestContext.senderUsername,
senderE164: requestContext.senderE164,
groupId: requestContext.groupId,
groupChannel: requestContext.groupChannel,
groupSpace: requestContext.groupSpace,
spawnedBy: requestContext.spawnedBy,
});
logMcpLoopbackTraffic("request", {
batchSize: messages.length,
methods: messages.map((message) =>
isJsonRpcRequest(message) ? message.method : undefined,
),
sessionKey: requestContext.sessionKey,
inboundEventKind: requestContext.inboundEventKind,
senderIsOwner: requestContext.senderIsOwner,
toolCount: scopedTools.toolSchema.length,
cronVisible: scopedTools.toolSchema.some((tool) => tool.name === "cron"),
});
const responses: object[] = [];
for (const [messageIndex, message] of messages.entries()) {
if (!isJsonRpcRequest(message)) {
responses.push(jsonRpcError(readJsonRpcRequestId(message), -32600, "Invalid Request"));
continue;
}
const cliCaptureHandle = cliCaptureHandles[messageIndex];
let response: object | null;
try {
response = await handleMcpJsonRpc({
message,
tools: scopedTools.tools,
toolSchema: scopedTools.toolSchema,
hookContext: {
agentId: scopedTools.agentId,
config: cfg,
sessionKey: requestContext.sessionKey,
sessionId: requestContext.sessionId,
runId: requestContext.runId,
approvalReviewerDeviceId: requestContext.approvalReviewerDeviceId,
channelId: requestContext.currentChannelId,
turnSourceChannel: requestContext.messageProvider,
turnSourceTo: requestContext.currentChannelId,
turnSourceAccountId: requestContext.accountId,
turnSourceThreadId: requestContext.currentThreadTs,
loopDetection: resolveToolLoopDetectionConfig({
cfg,
agentId: scopedTools.agentId,
}),
},
signal: requestAbort.signal,
authorizeToolCall,
onToolCallPrepared: cliCaptureHandle
? ({ toolName: preparedToolName, args }) => {
updateMcpLoopbackToolCallCapture(cliCaptureHandle, {
toolName: preparedToolName,
args,
});
}
: undefined,
onToolCallResult: cliCaptureHandle
? (result) => {
recordMcpLoopbackToolCallResult({
captureHandle: cliCaptureHandle,
...result,
});
}
: undefined,
});
} finally {
markMcpLoopbackToolCallFinished(cliCaptureHandle);
}
if (response !== null) {
const responseToolName =
message.method === "tools/call" && isRecord(message.params)
? message.params.name
: undefined;
const isError =
isRecord(response) && isRecord(response.result) && response.result.isError === true;
logMcpLoopbackTraffic("response", {
method: message.method,
toolName: typeof responseToolName === "string" ? responseToolName : undefined,
isError,
});
responses.push(response);
}
}
if (responses.length === 0) {
res.writeHead(202);
res.end();
return;
}
const payload = Array.isArray(parsed)
? JSON.stringify(responses)
: JSON.stringify(responses[0]);
res.writeHead(200, { "Content-Type": "application/json" });
res.end(payload);
} catch (error) {
logWarn(`mcp loopback: request handling failed: ${formatErrorMessage(error)}`);
logMcpLoopbackTraffic("request-failed", {
message: formatErrorMessage(error),
});
if (!res.headersSent) {
if (isMcpHttpBodyTooLargeError(error)) {
res.writeHead(413, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "payload_too_large" }), () => {
req.destroy();
});
} else if (isMcpHttpBodyTimeoutError(error)) {
res.writeHead(408, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "request_body_timeout" }), () => {
req.destroy();
});
} else if (isMcpJsonParseError(error)) {
res.writeHead(400, { "Content-Type": "application/json" });
res.end(JSON.stringify(jsonRpcError(null, -32700, "Parse error")));
} else {
res.writeHead(500, { "Content-Type": "application/json" });
res.end(JSON.stringify(jsonRpcInternalError(parsed)));
}
}
} finally {
requestAbort.cleanup();
for (const captureHandle of cliCaptureHandles) {
markMcpLoopbackToolCallFinished(captureHandle);
}
markMcpLoopbackRequestFinished(cliRequestCaptureHandle);
}
})();
});
await new Promise<void>((resolve, reject) => {
httpServer.once("error", reject);
httpServer.listen(port, "127.0.0.1", () => {
httpServer.removeListener("error", reject);
resolve();
});
});
const address = httpServer.address();
if (!address || typeof address === "string") {
throw new Error("mcp loopback did not bind to a TCP port");
}
// Register tokens only after the TCP listener is live so clients never learn
// a bearer token for a server that failed to bind.
setActiveMcpLoopbackRuntime({ port: address.port, ownerToken, nonOwnerToken });
logDebug(`mcp loopback listening on 127.0.0.1:${address.port}`);
const server: McpLoopbackServer = {
port: address.port,
close: () => {
// Stop admitting this runtime's child grants before draining accepted
// requests. A delayed old-server close cannot revoke a successor runtime.
clearActiveMcpLoopbackRuntimeByOwnerToken(ownerToken);
revokeMcpLoopbackClientGrantsForRuntime(ownerToken);
return new Promise<void>((resolve, reject) => {
httpServer.close((error) => {
if (!error) {
if (activeMcpLoopbackServer === server) {
activeMcpLoopbackServer = undefined;
}
}
if (error) {
reject(error);
return;
}
resolve();
});
closeActiveSseResponses();
});
},
};
return server;
}
/** Returns the active MCP loopback server or starts one if none exists. */
export async function ensureMcpLoopbackServer(port = 0): Promise<McpLoopbackServer> {
if (activeMcpLoopbackServer) {
return activeMcpLoopbackServer;
}
if (!activeMcpLoopbackServerPromise) {
activeMcpLoopbackServerPromise = startMcpLoopbackServer(port)
.then((server) => {
activeMcpLoopbackServer = server;
return server;
})
.finally(() => {
activeMcpLoopbackServerPromise = null;
});
}
return activeMcpLoopbackServerPromise;
}
/** Closes the active MCP loopback server if one has been started. */
export async function closeMcpLoopbackServer(): Promise<void> {
const server =
activeMcpLoopbackServer ??
(activeMcpLoopbackServerPromise ? await activeMcpLoopbackServerPromise : undefined);
if (!server) {
return;
}
activeMcpLoopbackServer = undefined;
await server.close();
}