fix(cli): bound --message-file reads for agent command (#101442)

* fix(cli): bound --message-file reads for agent command

* fix(cli): preserve symlinked --message-file paths with bounded reads

* fix(cli): preserve FIFO message files with bounded reads, document 4 MiB cap

* fix(cli): accept FIFO --message-file targets via bounded descriptor read

* test(cli): drop duplicate FIFO message-file read test

* docs(cli): note 4 MiB --message-file cap in agent help text

* style(commands): format agent-via-gateway test

* fix(cli): preserve procfs message-file reads

Co-authored-by: 陈宪彪0668000387 <chen.xianbiao@xydigit.com>

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
Co-authored-by: Peter Steinberger <peter@steipete.me>
This commit is contained in:
cxbAsDev
2026-07-18 12:29:33 +01:00
committed by GitHub
co-authored by Peter Steinberger Peter Steinberger
parent 60cb6d78de
commit 05fb8e6e61
5 changed files with 160 additions and 8 deletions
+1 -1
View File
@@ -50,7 +50,7 @@ openclaw agent --agent ops --message "Run locally" --local
## Notes
- Pass exactly one of `--message` or `--message-file`. `--message-file` strips a leading UTF-8 BOM and preserves multiline content; it rejects files that are not valid UTF-8.
- Pass exactly one of `--message` or `--message-file`. `--message-file` strips a leading UTF-8 BOM and preserves multiline content; it rejects files that are not valid UTF-8. Files larger than 4 MiB are rejected before dispatch.
- Slash commands (for example `/compact`) cannot run through `--message`. The CLI rejects them and points you at the first-class command instead (`openclaw sessions compact <key>` for compaction).
- `--local` and embedded fallback runs are one-shot: bundled MCP loopback resources and warm Claude stdio sessions opened for the run are retired after the reply, so scripted invocations do not leave local child processes running. Gateway-backed runs keep Gateway-owned MCP loopback resources under the running Gateway process instead.
- Standalone embedded execution (`--local` and transport fallback) refuses to reuse an existing main session while restart recovery is pending. Run the turn through a healthy Gateway, or reset it there with `/new` or `/reset`; an independent embedded process cannot safely coordinate that recovery owner with the Gateway scanner.
+3 -2
View File
@@ -67,7 +67,7 @@ programmatic delivery. Full flag and behavior reference:
| Flag | Description |
| --------------------------- | -------------------------------------------------------------------- |
| `--message <text>` | Inline message to send |
| `--message-file <path>` | Read the message from a valid UTF-8 file |
| `--message-file <path>` | Read the message from a valid UTF-8 file (max 4 MiB) |
| `--to <dest>` | Derive session key from a target (phone, chat id) |
| `--session-key <key>` | Use an explicit session key |
| `--agent <id>` | Target a configured agent (uses its `main` session) |
@@ -89,7 +89,8 @@ programmatic delivery. Full flag and behavior reference:
- By default, the CLI goes **through the Gateway**. Add `--local` to force the
embedded runtime on the current machine.
- Pass exactly one of `--message` or `--message-file`. File messages preserve
multiline content after removing an optional UTF-8 BOM.
multiline content after removing an optional UTF-8 BOM. Files larger than
4 MiB are rejected before dispatch.
- If the Gateway request fails, the CLI **falls back** to the local embedded
run; a Gateway timeout falls back with a fresh session instead of racing the
original transcript.
+1 -1
View File
@@ -35,7 +35,7 @@ export function registerAgentTurnCommand(
.command("agent")
.description("Run an agent turn via the Gateway (use --local for embedded)")
.option("-m, --message <text>", "Message body for the agent")
.option("--message-file <path>", "Read the agent message body from a UTF-8 file")
.option("--message-file <path>", "Read the agent message body from a UTF-8 file (max 4 MiB)")
.option("-t, --to <number>", "Recipient number in E.164 used to derive the session key")
.option("--session-key <key>", "Explicit session key (agent:<id>:<key>, or scoped to --agent)")
.option("--session-id <id>", "Use an explicit session id")
+125
View File
@@ -1,4 +1,5 @@
// Agent via gateway tests cover gateway-backed agent command dispatch and session loading.
import { execFileSync } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
@@ -416,6 +417,130 @@ describe("agentCliCommand", () => {
});
});
it("rejects message files that exceed the size cap", async () => {
await withTempStore(async ({ dir }) => {
const messageFile = path.join(dir, "huge.md");
fs.writeFileSync(messageFile, Buffer.alloc(5 * 1024 * 1024, "x"));
await expect(
agentCliCommand({ messageFile, sessionKey: "agent:main:incident-42" }, runtime),
).rejects.toThrow(/File exceeds 4194304 bytes/);
expect(callGateway).not.toHaveBeenCalled();
});
});
it("reports a directory message file with the legacy EISDIR message", async () => {
await withTempStore(async ({ dir }) => {
const messageFile = path.join(dir, "not-a-file");
fs.mkdirSync(messageFile);
await expect(
agentCliCommand({ messageFile, sessionKey: "agent:main:incident-42" }, runtime),
).rejects.toThrow("Message file is a directory:");
expect(callGateway).not.toHaveBeenCalled();
});
});
it("follows a symlinked message file to a regular file", async () => {
await withTempStore(async ({ dir }) => {
const realFile = path.join(dir, "real.md");
const messageFile = path.join(dir, "link.md");
fs.writeFileSync(realFile, "hello from symlink target", "utf-8");
fs.symlinkSync(realFile, messageFile);
await agentCliCommand({ messageFile, sessionKey: "agent:main:incident-42" }, runtime);
expect(callGateway).toHaveBeenCalledTimes(1);
const request = requireRecord(requireFirstCallArg(callGateway, "gateway"), "gateway request");
const params = requireRecord(request.params, "gateway request params");
expect(params.message).toBe("hello from symlink target");
});
});
it("follows a chain of symlinks to the final regular message file", async () => {
await withTempStore(async ({ dir }) => {
const realFile = path.join(dir, "real.md");
const linkA = path.join(dir, "link-a.md");
const linkB = path.join(dir, "link-b.md");
const messageFile = path.join(dir, "link-c.md");
fs.writeFileSync(realFile, "hello from chained symlink target", "utf-8");
fs.symlinkSync(realFile, linkA);
fs.symlinkSync(linkA, linkB);
fs.symlinkSync(linkB, messageFile);
await agentCliCommand({ messageFile, sessionKey: "agent:main:incident-42" }, runtime);
expect(callGateway).toHaveBeenCalledTimes(1);
const request = requireRecord(requireFirstCallArg(callGateway, "gateway"), "gateway request");
const params = requireRecord(request.params, "gateway request params");
expect(params.message).toBe("hello from chained symlink target");
});
});
it.skipIf(process.platform !== "linux")(
"reads a procfs file-descriptor link without resolving it to a pathname",
async () => {
await withTempStore(async ({ dir }) => {
const realFile = path.join(dir, "procfs-source.md");
fs.writeFileSync(realFile, "hello from procfs descriptor", "utf-8");
const sourceHandle = await fs.promises.open(realFile, "r");
fs.unlinkSync(realFile);
try {
await agentCliCommand(
{
messageFile: `/proc/self/fd/${sourceHandle.fd}`,
sessionKey: "agent:main:incident-42",
},
runtime,
);
} finally {
await sourceHandle.close();
}
expect(callGateway).toHaveBeenCalledTimes(1);
const request = requireRecord(
requireFirstCallArg(callGateway, "gateway"),
"gateway request",
);
const params = requireRecord(request.params, "gateway request params");
expect(params.message).toBe("hello from procfs descriptor");
});
},
);
// FIFOs have no stat-able size; the bounded descriptor read must drain them
// until EOF like the legacy fs.readFile path did. Windows lacks mkfifo.
it.skipIf(process.platform === "win32")("reads a FIFO message file until EOF", async () => {
await withTempStore(async ({ dir }) => {
const messageFile = path.join(dir, "pipe.md");
execFileSync("mkfifo", [messageFile]);
mockGatewaySuccessReply();
const dispatch = agentCliCommand(
{ messageFile, sessionKey: "agent:main:incident-42" },
runtime,
);
// Opening a FIFO for reading blocks until a writer arrives; start the
// writer after dispatch kicks off, then close so the bounded read sees
// EOF instead of waiting forever.
const writer = (async () => {
const handle = await fs.promises.open(messageFile, "w");
try {
await handle.writeFile("hello from fifo");
} finally {
await handle.close();
}
})();
await dispatch;
await writer;
expect(callGateway).toHaveBeenCalledTimes(1);
const request = requireRecord(requireFirstCallArg(callGateway, "gateway"), "gateway request");
const params = requireRecord(request.params, "gateway request params");
expect(params.message).toBe("hello from fifo");
});
});
it.each(["/new", "/RESET", "/reset check status"] as const)(
"uses backend admin authority for %s gateway commands",
async (message) => {
+30 -4
View File
@@ -1,6 +1,6 @@
// Gateway-first agent CLI implementation with embedded fallback for local/runtime failures.
import { randomUUID } from "node:crypto";
import { readFile } from "node:fs/promises";
// Gateway-first agent CLI implementation with embedded fallback for local/runtime failures.
import fs from "node:fs/promises";
import { TextDecoder } from "node:util";
import { resolveTimerTimeoutMs } from "@openclaw/normalization-core/number-coercion";
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
@@ -28,6 +28,7 @@ import {
import { isGatewaySecretRefUnavailableError } from "../gateway/credentials.js";
import { ADMIN_SCOPE } from "../gateway/operator-scopes.js";
import { createAbortError } from "../infra/abort-signal.js";
import { readFileDescriptorBounded } from "../infra/file-descriptor-read.js";
import { parseStrictNonNegativeInteger } from "../infra/parse-finite-number.js";
import { routeLogsToStderr } from "../logging/console.js";
import {
@@ -195,13 +196,38 @@ function formatMessageFileReadFailure(messageFile: string, err: unknown): string
return `Unable to read message file ${messageFile}: ${message}`;
}
// Agent messages are prompt text; a 4 MiB cap gives generous headroom for
// long system prompts while preventing a symlink/huge-file path from OOMing
// the CLI before dispatch.
const AGENT_MESSAGE_FILE_MAX_BYTES = 4 * 1024 * 1024;
async function readAgentMessageFile(messageFile: string): Promise<string> {
let buffer: Buffer;
// Open the original path so the kernel preserves symlink and procfs magic-link
// behavior (notably piped /dev/stdin), then inspect that exact descriptor.
let handle: Awaited<ReturnType<typeof fs.open>>;
try {
buffer = await readFile(messageFile);
handle = await fs.open(messageFile, "r");
} catch (err) {
throw new Error(formatMessageFileReadFailure(messageFile, err), { cause: err });
}
let buffer: Buffer;
try {
const stat = await handle.stat();
if (stat.isDirectory()) {
// Keep the legacy fs.readFile directory UX.
throw Object.assign(new Error("Message file is a directory"), { code: "EISDIR" });
}
// Regular files fail fast. Streams report size 0, so the descriptor reader
// enforces the same limit byte-by-byte while preserving FIFO behavior.
if (stat.isFile() && stat.size > AGENT_MESSAGE_FILE_MAX_BYTES) {
throw new Error(`File exceeds ${AGENT_MESSAGE_FILE_MAX_BYTES} bytes: ${messageFile}`);
}
buffer = await readFileDescriptorBounded(handle.fd, AGENT_MESSAGE_FILE_MAX_BYTES);
} catch (err) {
throw new Error(formatMessageFileReadFailure(messageFile, err), { cause: err });
} finally {
await handle.close().catch(() => undefined);
}
try {
return MESSAGE_FILE_DECODER.decode(buffer).replace(/^\uFEFF/, "");
} catch {