diff --git a/CHANGELOG.md b/CHANGELOG.md index 39a630fd502..fe9af6ea21b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -69,6 +69,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- CLI/agent: keep `openclaw agent --json` stdout reserved for the JSON response by routing gateway, plugin, and embedded-fallback diagnostics to stderr before execution starts. Fixes #71319. - Agents/Gemini: retry reasoning-only, empty, and planning-only Gemini turns instead of letting sessions silently stall. Fixes #71074. (#71362) Thanks @neeravmakwana. - Providers/DeepSeek: add missing `reasoning_content` placeholders for replayed assistant tool-call turns when DeepSeek V4 thinking is enabled, so switching an existing session to `deepseek-v4-flash` or `deepseek-v4-pro` no longer trips the provider's 400 replay check. Fixes #71372. Thanks @yangyang1719. - Exec approvals: allow bare command-name allowlist patterns to match PATH-resolved executable basenames without trusting `./tool` or absolute path-selected binaries. Fixes #71315. Thanks @chen-zhang-cs-code and @dengluozhang. diff --git a/docs/cli/agent.md b/docs/cli/agent.md index 98a65f7b071..ef1c165eb5e 100644 --- a/docs/cli/agent.md +++ b/docs/cli/agent.md @@ -53,6 +53,7 @@ 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. - `--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. - When this command triggers `models.json` regeneration, SecretRef-managed provider credentials are persisted as non-secret markers (for example env var names, `secretref-env:ENV_VAR_NAME`, or `secretref-managed`), not resolved secret plaintext. - Marker writes are source-authoritative: OpenClaw persists markers from the active source config snapshot, not from resolved runtime secret values. diff --git a/src/commands/agent-via-gateway.test.ts b/src/commands/agent-via-gateway.test.ts index 6d6b10ffcee..254e44b0bc7 100644 --- a/src/commands/agent-via-gateway.test.ts +++ b/src/commands/agent-via-gateway.test.ts @@ -1,8 +1,9 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; +import { loggingState } from "../logging/state.js"; import type { RuntimeEnv } from "../runtime.js"; import { agentCliCommand } from "./agent-via-gateway.js"; import type { agentCommand as AgentCommand } from "./agent.js"; @@ -17,6 +18,14 @@ const runtime: RuntimeEnv = { exit: vi.fn(), }; +const jsonRuntime = { + log: vi.fn(), + error: vi.fn(), + writeStdout: vi.fn(), + writeJson: vi.fn(), + exit: vi.fn(), +}; + function mockConfig(storePath: string, overrides?: Partial) { loadConfig.mockReturnValue({ agents: { @@ -76,8 +85,16 @@ vi.mock("../gateway/call.js", () => ({ })); vi.mock("./agent.js", () => ({ agentCommand })); +let originalForceConsoleToStderr = false; + beforeEach(() => { vi.clearAllMocks(); + originalForceConsoleToStderr = loggingState.forceConsoleToStderr; + loggingState.forceConsoleToStderr = false; +}); + +afterEach(() => { + loggingState.forceConsoleToStderr = originalForceConsoleToStderr; }); describe("agentCliCommand", () => { @@ -105,6 +122,28 @@ describe("agentCliCommand", () => { }); }); + it("routes diagnostics to stderr before JSON gateway execution", async () => { + await withTempStore(async () => { + const response = { + runId: "idem-1", + status: "ok", + result: { + payloads: [{ text: "hello" }], + meta: { stub: true }, + }, + }; + callGateway.mockImplementationOnce(async () => { + expect(loggingState.forceConsoleToStderr).toBe(true); + return response; + }); + + await agentCliCommand({ message: "hi", to: "+1555", json: true }, jsonRuntime); + + expect(jsonRuntime.writeJson).toHaveBeenCalledWith(response, 2); + expect(jsonRuntime.log).not.toHaveBeenCalled(); + }); + }); + it("falls back to embedded agent when gateway fails", async () => { await withTempStore(async () => { callGateway.mockRejectedValue(new Error("gateway not connected")); @@ -118,6 +157,25 @@ describe("agentCliCommand", () => { }); }); + it("keeps diagnostics on stderr before JSON embedded fallback", async () => { + await withTempStore(async () => { + callGateway.mockRejectedValue(new Error("gateway not connected")); + agentCommand.mockImplementationOnce(async (_opts, rt) => { + expect(loggingState.forceConsoleToStderr).toBe(true); + rt?.log?.("local"); + return { + payloads: [{ text: "local" }], + meta: { durationMs: 1, agentMeta: { sessionId: "s", provider: "p", model: "m" } }, + } as unknown as Awaited>; + }); + + await agentCliCommand({ message: "hi", to: "+1555", json: true }, jsonRuntime); + + expect(agentCommand).toHaveBeenCalledTimes(1); + expect(loggingState.forceConsoleToStderr).toBe(true); + }); + }); + it("skips gateway when --local is set", async () => { await withTempStore(async () => { mockLocalAgentReply(); diff --git a/src/commands/agent-via-gateway.ts b/src/commands/agent-via-gateway.ts index 156078abccb..e3b7de264aa 100644 --- a/src/commands/agent-via-gateway.ts +++ b/src/commands/agent-via-gateway.ts @@ -7,6 +7,7 @@ import { loadConfig } from "../config/config.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { callGateway, randomIdempotencyKey } from "../gateway/call.js"; import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../gateway/protocol/client-info.js"; +import { routeLogsToStderr } from "../logging/console.js"; import { normalizeAgentId } from "../routing/session-key.js"; import { type RuntimeEnv, writeRuntimeJson } from "../runtime.js"; import { normalizeOptionalString } from "../shared/string-coerce.js"; @@ -53,6 +54,12 @@ export type AgentCliOpts = { local?: boolean; }; +function protectJsonStdout(opts: Pick): void { + if (opts.json === true) { + routeLogsToStderr(); + } +} + function parseTimeoutSeconds(opts: { cfg: OpenClawConfig; timeout?: string }) { const raw = opts.timeout !== undefined @@ -85,6 +92,7 @@ function formatPayloadForLog(payload: { } export async function agentViaGatewayCommand(opts: AgentCliOpts, runtime: RuntimeEnv) { + protectJsonStdout(opts); const body = (opts.message ?? "").trim(); if (!body) { throw new Error("Message (--message) is required"); @@ -178,6 +186,7 @@ export async function agentViaGatewayCommand(opts: AgentCliOpts, runtime: Runtim } export async function agentCliCommand(opts: AgentCliOpts, runtime: RuntimeEnv, deps?: CliDeps) { + protectJsonStdout(opts); const localOpts = { ...opts, agentId: opts.agent,