diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d44face5c3..0f463e6a42d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -73,6 +73,7 @@ Docs: https://docs.openclaw.ai - CLI/tasks: ship the task-registry control runtime in npm packages so `openclaw tasks cancel` can load ACP/subagent cancellation helpers from published builds. Fixes #68997. Thanks @1OAKDesign. - Channels/Telegram: preserve unsent generated media after partial reply streaming has already delivered the text, so `image_generate` outputs still reach Telegram as photos instead of being dropped from the final payload. Fixes #73253. Thanks @mlaihk. - Memory-core/dreaming: cap detached Dream Diary narrative subagents across cron sweeps so multi-workspace dreaming no longer fans out unbounded subagent sessions, lock contention, and cascading narrative timeouts. Fixes #73198. (#73287) Thanks @KeWang0622. +- CLI/agents: close local one-shot Claude live stdio sessions and bundled MCP loopback resources after embedded `openclaw agent --local` runs, while keeping gateway-owned MCP loopback cleanup internal to the Gateway. Thanks @frankekn. - Export/session: keep inline export HTML scripts and vendor libraries injected after template formatting so generated session exports open with the app code, markdown renderer, and syntax highlighter present. Fixes #41862 and #49957; carries forward #41861 and #68947. Thanks @briannewman, @martenzi, and @armanddp. - Agents/ACPX: stage the patched Claude ACP adapter as an ACPX runtime dependency and route known Codex/Claude ACP commands through local wrappers, so Gateway runtime no longer depends on live `npx` adapter resolution. Fixes #73202. Thanks @joerod26. - Memory/compaction: let pre-compaction memory flush use an exact `agents.defaults.compaction.memoryFlush.model` override such as `ollama/qwen3:8b` without inheriting the active session fallback chain, so local housekeeping can avoid paid conversation models. Fixes #53772. Thanks @limen96. diff --git a/docs/cli/agent.md b/docs/cli/agent.md index 3195031524c..a25d06e0eb1 100644 --- a/docs/cli/agent.md +++ b/docs/cli/agent.md @@ -54,7 +54,8 @@ openclaw agent --agent ops --message "Run locally" --local - Gateway mode falls back to the embedded agent when the Gateway request fails. Use `--local` to force embedded execution up front. - `--local` still preloads the plugin registry first, so plugin-provided providers, tools, and channels stay available during embedded runs. -- Each `openclaw agent` invocation is treated as a one-shot run. Bundled or user-configured MCP servers opened for that run are retired after the reply, even when the command uses the Gateway path, so stdio MCP child processes do not stay alive between scripted invocations. +- `--local` and embedded fallback runs are treated as one-shot runs. Bundled MCP loopback resources and warm Claude stdio sessions opened for that local process are retired after the reply, so scripted invocations do not keep local child processes alive. +- Gateway-backed runs leave Gateway-owned MCP loopback resources under the running Gateway process; older clients may still send the historical cleanup flag, but the Gateway accepts it as a compatibility no-op. - `--channel`, `--reply-channel`, and `--reply-account` affect reply delivery, not session routing. - `--json` keeps stdout reserved for the JSON response. Gateway, plugin, and embedded-fallback diagnostics are routed to stderr so scripts can parse stdout directly. - Embedded fallback JSON includes `meta.transport: "embedded"` and `meta.fallbackFrom: "gateway"` so scripts can distinguish fallback runs from Gateway runs. diff --git a/src/agents/bundle-mcp.test-harness.ts b/src/agents/bundle-mcp.test-harness.ts index 379eb242d3c..baa6a572d24 100644 --- a/src/agents/bundle-mcp.test-harness.ts +++ b/src/agents/bundle-mcp.test-harness.ts @@ -11,6 +11,107 @@ const SDK_CLIENT_STDIO_PATH = require.resolve("@modelcontextprotocol/sdk/client/ export { writeBundleProbeMcpServer, writeClaudeBundle, writeExecutable }; +export async function writeFakeClaudeLiveCli(params: { + filePath: string; + pidPath?: string; +}): Promise { + await writeExecutable( + params.filePath, + `#!/usr/bin/env node +import fs from "node:fs/promises"; +import { randomUUID } from "node:crypto"; +import readline from "node:readline/promises"; +import { Client } from ${JSON.stringify(SDK_CLIENT_INDEX_PATH)}; +import { StdioClientTransport } from ${JSON.stringify(SDK_CLIENT_STDIO_PATH)}; + +const pidPath = ${JSON.stringify(params.pidPath ?? "")}; +if (pidPath) { + await fs.writeFile(pidPath, String(process.pid), "utf-8"); +} + +function readArg(name) { + const args = process.argv.slice(2); + for (let i = 0; i < args.length; i += 1) { + const arg = args[i] ?? ""; + if (arg === name) { + return args[i + 1]; + } + if (arg.startsWith(name + "=")) { + return arg.slice(name.length + 1); + } + } + return undefined; +} + +async function readBundleProbeText(mcpConfigPath) { + const raw = JSON.parse(await fs.readFile(mcpConfigPath, "utf-8")); + const servers = raw?.mcpServers ?? raw?.servers ?? {}; + const server = servers.bundleProbe ?? Object.values(servers)[0]; + if (!server || typeof server !== "object") { + throw new Error("missing bundleProbe MCP server"); + } + const transport = new StdioClientTransport({ + command: server.command, + args: Array.isArray(server.args) ? server.args : [], + env: server.env && typeof server.env === "object" ? server.env : undefined, + cwd: + typeof server.cwd === "string" + ? server.cwd + : typeof server.workingDirectory === "string" + ? server.workingDirectory + : undefined, + }); + const client = new Client({ name: "fake-live-claude", version: "1.0.0" }); + await client.connect(transport); + try { + const result = await client.callTool({ name: "bundle_probe", arguments: {} }); + return Array.isArray(result.content) + ? result.content + .filter((entry) => entry?.type === "text" && typeof entry.text === "string") + .map((entry) => entry.text) + .join("\\n") + : ""; + } finally { + await transport.close(); + } +} + +const mcpConfigPath = readArg("--mcp-config"); +if (!mcpConfigPath) { + throw new Error("missing --mcp-config"); +} + +const keepAlive = setInterval(() => {}, 1000); +const input = readline.createInterface({ input: process.stdin }); +try { + for await (const line of input) { + if (!line.trim()) { + continue; + } + const text = await readBundleProbeText(mcpConfigPath); + process.stdout.write( + JSON.stringify({ + type: "system", + subtype: "init", + session_id: readArg("--session-id") ?? randomUUID(), + }) + "\\n", + ); + process.stdout.write( + JSON.stringify({ + type: "result", + session_id: readArg("--session-id") ?? randomUUID(), + result: "LIVE BUNDLE MCP OK " + text, + }) + "\\n", + ); + } +} finally { + input.close(); + clearInterval(keepAlive); +} +`, + ); +} + export async function writeFakeClaudeCli(filePath: string): Promise { await writeExecutable( filePath, diff --git a/src/agents/cli-runner.before-agent-reply-cron.test.ts b/src/agents/cli-runner.before-agent-reply-cron.test.ts index dcbf1f1e3a5..4e94b50c6ae 100644 --- a/src/agents/cli-runner.before-agent-reply-cron.test.ts +++ b/src/agents/cli-runner.before-agent-reply-cron.test.ts @@ -20,6 +20,7 @@ const { executePreparedCliRunMock, prepareCliRunContextMock, closeClaudeLiveSessionForContextMock, + closeMcpLoopbackServerMock, } = vi.hoisted(() => ({ hasHooksMock: vi.fn<(hookName: string) => boolean>(() => false), runBeforeAgentReplyMock: vi.fn<(event: unknown, ctx: unknown) => Promise>( @@ -30,6 +31,7 @@ const { })), prepareCliRunContextMock: vi.fn(), closeClaudeLiveSessionForContextMock: vi.fn(), + closeMcpLoopbackServerMock: vi.fn(), })); vi.mock("../plugins/hook-runner-global.js", () => ({ @@ -51,6 +53,10 @@ vi.mock("./cli-runner/claude-live-session.js", () => ({ closeClaudeLiveSessionForContext: closeClaudeLiveSessionForContextMock, })); +vi.mock("../gateway/mcp-http.js", () => ({ + closeMcpLoopbackServer: closeMcpLoopbackServerMock, +})); + const baseRunParams = { sessionId: "test-session", sessionKey: "test-session-key", @@ -93,6 +99,7 @@ beforeEach(() => { makeStubContext(params as typeof baseRunParams & { trigger?: string }), ); closeClaudeLiveSessionForContextMock.mockReset(); + closeMcpLoopbackServerMock.mockReset(); }); afterEach(() => { @@ -185,4 +192,14 @@ describe("runCliAgent cron before_agent_reply seam", () => { await prepareCliRunContextMock.mock.results[0].value, ); }); + + it("can close temporary bundle MCP loopback resources after a run", async () => { + const { runCliAgent } = await import("./cli-runner.js"); + executePreparedCliRunMock.mockResolvedValue({ text: "real reply" }); + + await runCliAgent({ ...baseRunParams, cleanupBundleMcpOnRunEnd: true }); + + expect(executePreparedCliRunMock).toHaveBeenCalledTimes(1); + expect(closeMcpLoopbackServerMock).toHaveBeenCalledTimes(1); + }); }); diff --git a/src/agents/cli-runner.bundle-mcp.e2e.test.ts b/src/agents/cli-runner.bundle-mcp.e2e.test.ts index 11293ed6c24..f2a2a43af1a 100644 --- a/src/agents/cli-runner.bundle-mcp.e2e.test.ts +++ b/src/agents/cli-runner.bundle-mcp.e2e.test.ts @@ -8,6 +8,7 @@ import { writeBundleProbeMcpServer, writeClaudeBundle, writeFakeClaudeCli, + writeFakeClaudeLiveCli, } from "./bundle-mcp.test-harness.js"; vi.mock("./cli-runner/helpers.js", async () => { @@ -92,4 +93,80 @@ describe("runCliAgent bundle MCP e2e", () => { } }, ); + + it( + "exits one-shot Claude live-session runs and closes the MCP loopback server", + { timeout: E2E_TIMEOUT_MS }, + async () => { + const { runCliAgent } = await import("./cli-runner.js"); + const { closeMcpLoopbackServer, getActiveMcpLoopbackRuntime } = + await import("../gateway/mcp-http.js"); + const { resetGlobalHookRunner } = await import("../plugins/hook-runner-global.js"); + const envSnapshot = captureEnv(["HOME"]); + const tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-cli-live-cleanup-")); + process.env.HOME = tempHome; + resetGlobalHookRunner(); + await closeMcpLoopbackServer(); + + const workspaceDir = path.join(tempHome, "workspace"); + const sessionFile = path.join(tempHome, "session.jsonl"); + const binDir = path.join(tempHome, "bin"); + const serverScriptPath = path.join(tempHome, "mcp", "bundle-probe.mjs"); + const fakeClaudePath = path.join(binDir, "fake-live-claude.mjs"); + const fakeClaudePidPath = path.join(tempHome, "fake-live-claude.pid"); + const pluginRoot = path.join(tempHome, ".openclaw", "extensions", "bundle-probe"); + await fs.mkdir(workspaceDir, { recursive: true }); + await writeBundleProbeMcpServer(serverScriptPath); + await writeFakeClaudeLiveCli({ filePath: fakeClaudePath, pidPath: fakeClaudePidPath }); + await writeClaudeBundle({ pluginRoot, serverScriptPath }); + + const config: OpenClawConfig = { + agents: { + defaults: { + workspace: workspaceDir, + cliBackends: { + "claude-cli": { + command: "node", + args: [fakeClaudePath], + clearEnv: [], + liveSession: "claude-stdio", + }, + }, + }, + }, + plugins: { + entries: { + "bundle-probe": { enabled: true }, + }, + }, + }; + + try { + const result = await runCliAgent({ + sessionId: "session:test-live-cleanup", + sessionFile, + workspaceDir, + config, + prompt: "Use your configured MCP tools and report the bundle probe text.", + provider: "claude-cli", + model: "test-live-bundle", + timeoutMs: 20_000, + runId: "bundle-mcp-live-cleanup-e2e", + cleanupBundleMcpOnRunEnd: true, + cleanupCliLiveSessionOnRunEnd: true, + }); + + expect(result.payloads?.[0]?.text).toContain("LIVE BUNDLE MCP OK FROM-BUNDLE"); + expect(getActiveMcpLoopbackRuntime()).toBeUndefined(); + const fakeClaudePid = Number.parseInt(await fs.readFile(fakeClaudePidPath, "utf-8"), 10); + expect(Number.isFinite(fakeClaudePid)).toBe(true); + expect(() => process.kill(fakeClaudePid, 0)).toThrow(); + } finally { + await closeMcpLoopbackServer(); + resetGlobalHookRunner(); + await fs.rm(tempHome, { recursive: true, force: true }); + envSnapshot.restore(); + } + }, + ); }); diff --git a/src/agents/cli-runner.ts b/src/agents/cli-runner.ts index 8c8236ef3dc..8fc807d7040 100644 --- a/src/agents/cli-runner.ts +++ b/src/agents/cli-runner.ts @@ -109,7 +109,11 @@ export async function runCliAgent(params: RunCliAgentParams): Promise(); const liveSessionCreates = new Map>(); @@ -71,11 +72,34 @@ export function resetClaudeLiveSessionsForTest(): void { liveSessionCreates.clear(); } -export function closeClaudeLiveSessionForContext(context: PreparedCliRunContext): void { +async function waitForManagedRunExit(managedRun: ManagedRun): Promise { + let timeout: NodeJS.Timeout | null = null; + try { + await Promise.race([ + managedRun.wait().then( + () => undefined, + () => undefined, + ), + new Promise((resolve) => { + timeout = setTimeout(resolve, CLAUDE_LIVE_CLOSE_WAIT_TIMEOUT_MS); + timeout.unref?.(); + }), + ]); + } finally { + if (timeout) { + clearTimeout(timeout); + } + } +} + +export async function closeClaudeLiveSessionForContext( + context: PreparedCliRunContext, +): Promise { const key = buildClaudeLiveKey(context); const session = liveSessions.get(key); if (session) { closeLiveSession(session, "restart"); + await waitForManagedRunExit(session.managedRun); } liveSessionCreates.delete(key); } diff --git a/src/agents/cli-runner/types.ts b/src/agents/cli-runner/types.ts index d2d91e2da60..3bd4971c09c 100644 --- a/src/agents/cli-runner/types.ts +++ b/src/agents/cli-runner/types.ts @@ -54,6 +54,12 @@ export type RunCliAgentParams = { * handles alive after returning. */ cleanupCliLiveSessionOnRunEnd?: boolean; + /** + * Close process-wide bundle MCP resources after this run. Intended for + * one-shot local CLI calls where the loopback server should not keep Node + * alive after the JSON response is emitted. + */ + cleanupBundleMcpOnRunEnd?: boolean; }; export type CliPreparedBackend = { diff --git a/src/agents/command/attempt-execution.cli.test.ts b/src/agents/command/attempt-execution.cli.test.ts index f5131e4aa0b..dbf0887d705 100644 --- a/src/agents/command/attempt-execution.cli.test.ts +++ b/src/agents/command/attempt-execution.cli.test.ts @@ -523,6 +523,58 @@ describe("CLI attempt execution", () => { }), ); }); + + it("forwards one-shot CLI cleanup to CLI providers", async () => { + const sessionKey = "agent:main:direct:cleanup-claude-cli"; + const sessionEntry: SessionEntry = { + sessionId: "openclaw-session-cleanup-cli", + updatedAt: Date.now(), + }; + const sessionStore: Record = { [sessionKey]: sessionEntry }; + await fs.writeFile(storePath, JSON.stringify(sessionStore, null, 2), "utf-8"); + runCliAgentMock.mockResolvedValueOnce(makeCliResult("cleanup cli")); + + await runAgentAttempt({ + providerOverride: "claude-cli", + modelOverride: "claude-opus-4-7", + cfg: {} as OpenClawConfig, + sessionEntry, + sessionId: sessionEntry.sessionId, + sessionKey, + sessionAgentId: "main", + sessionFile: path.join(tmpDir, "session.jsonl"), + workspaceDir: tmpDir, + body: "cleanup", + isFallbackRetry: false, + resolvedThinkLevel: "medium", + timeoutMs: 1_000, + runId: "run-cleanup-claude-cli", + opts: { + senderIsOwner: false, + cleanupBundleMcpOnRunEnd: true, + cleanupCliLiveSessionOnRunEnd: true, + } as Parameters[0]["opts"], + runContext: {} as Parameters[0]["runContext"], + spawnedBy: undefined, + messageChannel: undefined, + skillsSnapshot: undefined, + resolvedVerboseLevel: undefined, + agentDir: tmpDir, + onAgentEvent: vi.fn(), + authProfileProvider: "claude-cli", + sessionStore, + storePath, + sessionHasHistory: false, + }); + + expect(runCliAgentMock).toHaveBeenCalledWith( + expect.objectContaining({ + cleanupBundleMcpOnRunEnd: true, + cleanupCliLiveSessionOnRunEnd: true, + }), + ); + expect(runEmbeddedPiAgentMock).not.toHaveBeenCalled(); + }); }); describe("embedded attempt harness pinning", () => { diff --git a/src/agents/command/attempt-execution.ts b/src/agents/command/attempt-execution.ts index e667e790ec7..be684cce400 100644 --- a/src/agents/command/attempt-execution.ts +++ b/src/agents/command/attempt-execution.ts @@ -365,6 +365,8 @@ export function runAgentAttempt(params: { messageProvider: params.messageChannel, agentAccountId: params.runContext.accountId, senderIsOwner: params.opts.senderIsOwner, + cleanupBundleMcpOnRunEnd: params.opts.cleanupBundleMcpOnRunEnd, + cleanupCliLiveSessionOnRunEnd: params.opts.cleanupCliLiveSessionOnRunEnd, }); return resolveReusableCliSessionBinding().then(async (activeCliSessionBinding) => { try { diff --git a/src/agents/command/types.ts b/src/agents/command/types.ts index 00ca410b63e..85d2e2b432a 100644 --- a/src/agents/command/types.ts +++ b/src/agents/command/types.ts @@ -101,6 +101,8 @@ export type AgentCommandOpts = { workspaceDir?: SpawnedRunMetadata["workspaceDir"]; /** Force bundled MCP teardown when a one-shot local run completes. */ cleanupBundleMcpOnRunEnd?: boolean; + /** Force long-lived CLI live session teardown when a one-shot local run completes. */ + cleanupCliLiveSessionOnRunEnd?: boolean; /** Internal local CLI callers can annotate result metadata before JSON/text output. */ resultMetaOverrides?: AgentCommandResultMetaOverrides; /** Internal one-shot model probe mode: no tools, no workspace/chat prompt policy. */ diff --git a/src/commands/agent-via-gateway.test.ts b/src/commands/agent-via-gateway.test.ts index 96387704da6..06f97c017ca 100644 --- a/src/commands/agent-via-gateway.test.ts +++ b/src/commands/agent-via-gateway.test.ts @@ -117,11 +117,7 @@ describe("agentCliCommand", () => { await agentCliCommand({ message: "hi", to: "+1555" }, runtime); expect(callGateway).toHaveBeenCalledTimes(1); - expect(callGateway.mock.calls[0]?.[0]).toMatchObject({ - params: { - cleanupBundleMcpOnRunEnd: true, - }, - }); + expect(callGateway.mock.calls[0]?.[0]?.params).not.toHaveProperty("cleanupBundleMcpOnRunEnd"); expect(agentCommand).not.toHaveBeenCalled(); expect(runtime.log).toHaveBeenCalledWith("hello"); }); @@ -267,6 +263,7 @@ describe("agentCliCommand", () => { expect(agentCommand).toHaveBeenCalledTimes(1); expect(agentCommand.mock.calls[0]?.[0]).toMatchObject({ cleanupBundleMcpOnRunEnd: true, + cleanupCliLiveSessionOnRunEnd: true, }); expect(agentCommand.mock.calls[0]?.[0]).not.toHaveProperty("resultMetaOverrides"); expect(runtime.log).toHaveBeenCalledWith("local"); @@ -283,6 +280,7 @@ describe("agentCliCommand", () => { expect(agentCommand).toHaveBeenCalledTimes(1); expect(agentCommand.mock.calls[0]?.[0]).toMatchObject({ cleanupBundleMcpOnRunEnd: true, + cleanupCliLiveSessionOnRunEnd: true, }); }); }); diff --git a/src/commands/agent-via-gateway.ts b/src/commands/agent-via-gateway.ts index 42905339ef7..e01f8cf8042 100644 --- a/src/commands/agent-via-gateway.ts +++ b/src/commands/agent-via-gateway.ts @@ -158,7 +158,6 @@ export async function agentViaGatewayCommand(opts: AgentCliOpts, runtime: Runtim bestEffortDeliver: opts.bestEffortDeliver, timeout: timeoutSeconds, lane: opts.lane, - cleanupBundleMcpOnRunEnd: true, extraSystemPrompt: opts.extraSystemPrompt, idempotencyKey, }, @@ -199,6 +198,7 @@ export async function agentCliCommand(opts: AgentCliOpts, runtime: RuntimeEnv, d agentId: opts.agent, replyAccountId: opts.replyAccount, cleanupBundleMcpOnRunEnd: true, + cleanupCliLiveSessionOnRunEnd: true, }; if (opts.local === true) { return await agentCommand(localOpts, runtime, deps); diff --git a/src/commands/agent.test.ts b/src/commands/agent.test.ts index cdd48bd8c61..cf6e5d539cb 100644 --- a/src/commands/agent.test.ts +++ b/src/commands/agent.test.ts @@ -119,6 +119,7 @@ vi.mock("../agents/command/attempt-execution.runtime.js", () => { agentDir: params.agentDir, allowTransientCooldownProbe: params.allowTransientCooldownProbe, cleanupBundleMcpOnRunEnd: opts.cleanupBundleMcpOnRunEnd, + cleanupCliLiveSessionOnRunEnd: opts.cleanupCliLiveSessionOnRunEnd, modelRun: opts.modelRun, promptMode: opts.promptMode, disableTools: opts.modelRun === true, diff --git a/src/gateway/protocol/schema/agent.ts b/src/gateway/protocol/schema/agent.ts index 24f264f6ff3..70e86f84300 100644 --- a/src/gateway/protocol/schema/agent.ts +++ b/src/gateway/protocol/schema/agent.ts @@ -152,6 +152,8 @@ export const AgentParamsSchema = Type.Object( timeout: Type.Optional(Type.Integer({ minimum: 0 })), bestEffortDeliver: Type.Optional(Type.Boolean()), lane: Type.Optional(Type.String()), + // Backward-compatible no-op. Older CLI clients sent this field on gateway + // agent requests; the gateway accepts but intentionally ignores it. cleanupBundleMcpOnRunEnd: Type.Optional(Type.Boolean()), modelRun: Type.Optional(Type.Boolean()), promptMode: Type.Optional( diff --git a/src/gateway/server-methods/agent.ts b/src/gateway/server-methods/agent.ts index 640df493eb4..91085512d64 100644 --- a/src/gateway/server-methods/agent.ts +++ b/src/gateway/server-methods/agent.ts @@ -1184,7 +1184,6 @@ export const agentHandlers: GatewayRequestHandlers = { messageChannel: originMessageChannel, runId, lane: request.lane, - cleanupBundleMcpOnRunEnd: request.cleanupBundleMcpOnRunEnd === true, modelRun: request.modelRun === true, promptMode: request.promptMode, extraSystemPrompt: request.extraSystemPrompt, diff --git a/src/plugins/provider-runtime.test.ts b/src/plugins/provider-runtime.test.ts index a0e52063ae2..24a2b7c6444 100644 --- a/src/plugins/provider-runtime.test.ts +++ b/src/plugins/provider-runtime.test.ts @@ -149,7 +149,6 @@ function createOpenAiCatalogProviderPlugin( { provider: "openai", id: "gpt-5.4-nano", name: "gpt-5.4-nano" }, { provider: "openai-codex", id: "gpt-5.4", name: "gpt-5.4" }, { provider: "openai-codex", id: "gpt-5.4-pro", name: "gpt-5.4-pro" }, - { provider: "openai-codex", id: "gpt-5.4-mini", name: "gpt-5.4-mini" }, ], ...overrides, };