mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 10:16:44 +00:00
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:
co-authored by
Peter Steinberger
Peter Steinberger
parent
60cb6d78de
commit
05fb8e6e61
+1
-1
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user