|
1 | 1 | // Gateway-first agent CLI implementation with embedded fallback for local/runtime failures. |
2 | 2 | import { randomUUID } from "node:crypto"; |
| 3 | +import { readFileSync, statSync } from "node:fs"; |
3 | 4 | import { resolveTimerTimeoutMs } from "@openclaw/normalization-core/number-coercion"; |
4 | 5 | import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; |
5 | 6 | import { |
@@ -62,9 +63,12 @@ const EMBEDDED_FALLBACK_META = { |
62 | 63 | } as const; |
63 | 64 | const GATEWAY_TIMEOUT_FALLBACK_SESSION_PREFIX = "gateway-fallback-"; |
64 | 65 | const GATEWAY_TRANSIENT_CONNECT_RETRY_DELAYS_MS = [1_000, 2_000, 5_000, 10_000, 15_000] as const; |
| 66 | +const MAX_AGENT_MESSAGE_INPUT_BYTES = 10 * 1024 * 1024; |
65 | 67 |
|
66 | 68 | type AgentCliOpts = { |
67 | | - message: string; |
| 69 | + message?: string; |
| 70 | + messageFile?: string; |
| 71 | + messageStdin?: boolean; |
68 | 72 | agent?: string; |
69 | 73 | model?: string; |
70 | 74 | to?: string; |
@@ -94,6 +98,7 @@ type AgentCliProcessLike = { |
94 | 98 | type AgentCliDeps = CliDeps & { |
95 | 99 | process?: AgentCliProcessLike; |
96 | 100 | }; |
| 101 | +type ResolvedAgentCliOpts = AgentCliOpts & { message: string }; |
97 | 102 | type AgentGatewayCallIdentity = Pick< |
98 | 103 | Parameters<typeof callGateway>[0], |
99 | 104 | "clientName" | "mode" | "scopes" |
@@ -192,6 +197,86 @@ function resolveGatewayAgentTimeoutMs(timeoutSeconds: number): number { |
192 | 197 | return resolveTimerTimeoutMs((timeoutSeconds + 30) * 1000, 10_000, 10_000); |
193 | 198 | } |
194 | 199 |
|
| 200 | +function formatMissingMessageError(): string { |
| 201 | + return `Missing message. Use ${formatCliCommand('openclaw agent --message "..." --agent <id>')}, --message-file <path>, or --message-stdin.`; |
| 202 | +} |
| 203 | + |
| 204 | +function formatMessageInputSizeError(source: string): string { |
| 205 | + return `Message input from ${source} exceeds ${MAX_AGENT_MESSAGE_INPUT_BYTES} bytes. Use a smaller prompt or split the request.`; |
| 206 | +} |
| 207 | + |
| 208 | +function assertAgentMessageInputSize(value: string, source: string): void { |
| 209 | + if (Buffer.byteLength(value, "utf8") > MAX_AGENT_MESSAGE_INPUT_BYTES) { |
| 210 | + throw new Error(formatMessageInputSizeError(source)); |
| 211 | + } |
| 212 | +} |
| 213 | + |
| 214 | +function readAgentMessageFile(filePath: string): string { |
| 215 | + const stat = statSync(filePath); |
| 216 | + if (stat.size > MAX_AGENT_MESSAGE_INPUT_BYTES) { |
| 217 | + throw new Error(formatMessageInputSizeError("--message-file")); |
| 218 | + } |
| 219 | + const value = readFileSync(filePath, "utf8"); |
| 220 | + assertAgentMessageInputSize(value, "--message-file"); |
| 221 | + return value; |
| 222 | +} |
| 223 | + |
| 224 | +async function readAgentMessageStdin(): Promise<string> { |
| 225 | + if (process.stdin.isTTY) { |
| 226 | + throw new Error( |
| 227 | + "--message-stdin requires piped input. Pipe a prompt on stdin or use --message-file <path>.", |
| 228 | + ); |
| 229 | + } |
| 230 | + const chunks: string[] = []; |
| 231 | + let byteLength = 0; |
| 232 | + process.stdin.setEncoding("utf8"); |
| 233 | + for await (const chunk of process.stdin) { |
| 234 | + const text = |
| 235 | + typeof chunk === "string" ? chunk : Buffer.from(chunk as Uint8Array).toString("utf8"); |
| 236 | + byteLength += Buffer.byteLength(text, "utf8"); |
| 237 | + if (byteLength > MAX_AGENT_MESSAGE_INPUT_BYTES) { |
| 238 | + throw new Error(formatMessageInputSizeError("--message-stdin")); |
| 239 | + } |
| 240 | + chunks.push(text); |
| 241 | + } |
| 242 | + return chunks.join(""); |
| 243 | +} |
| 244 | + |
| 245 | +async function resolveAgentCliMessageInput(opts: AgentCliOpts): Promise<ResolvedAgentCliOpts> { |
| 246 | + const messageFile = normalizeOptionalString(opts.messageFile); |
| 247 | + const modes = [ |
| 248 | + typeof opts.message === "string", |
| 249 | + Boolean(messageFile), |
| 250 | + opts.messageStdin === true, |
| 251 | + ].filter(Boolean); |
| 252 | + if (modes.length !== 1) { |
| 253 | + throw new Error( |
| 254 | + "Choose exactly one message input: --message, --message-file, or --message-stdin.", |
| 255 | + ); |
| 256 | + } |
| 257 | + |
| 258 | + let rawMessage = ""; |
| 259 | + if (typeof opts.message === "string") { |
| 260 | + rawMessage = opts.message; |
| 261 | + assertAgentMessageInputSize(rawMessage, "--message"); |
| 262 | + } else if (messageFile) { |
| 263 | + rawMessage = readAgentMessageFile(messageFile); |
| 264 | + } else { |
| 265 | + rawMessage = await readAgentMessageStdin(); |
| 266 | + } |
| 267 | + |
| 268 | + const message = rawMessage.trim(); |
| 269 | + if (!message) { |
| 270 | + throw new Error(formatMissingMessageError()); |
| 271 | + } |
| 272 | + return { |
| 273 | + ...opts, |
| 274 | + message, |
| 275 | + messageFile: undefined, |
| 276 | + messageStdin: undefined, |
| 277 | + }; |
| 278 | +} |
| 279 | + |
195 | 280 | async function getGatewayDispatchConfig(options?: { |
196 | 281 | skipShellEnvFallback?: boolean; |
197 | 282 | }): Promise<OpenClawConfig> { |
@@ -231,7 +316,7 @@ function isGatewayAgentTimeoutError(err: unknown): boolean { |
231 | 316 | return err instanceof Error && err.message.includes("gateway request timeout for agent"); |
232 | 317 | } |
233 | 318 |
|
234 | | -function isControlCommandThatMustNotFallback(opts: Pick<AgentCliOpts, "message">): boolean { |
| 319 | +function isControlCommandThatMustNotFallback(opts: Pick<ResolvedAgentCliOpts, "message">): boolean { |
235 | 320 | const normalized = opts.message.trim().toLowerCase(); |
236 | 321 | return normalized === "/compact" || normalized.startsWith("/compact "); |
237 | 322 | } |
@@ -288,7 +373,7 @@ function validateExplicitSessionKeyForDispatch( |
288 | 373 | } |
289 | 374 | } |
290 | 375 |
|
291 | | -async function normalizeSessionKeyOptsForDispatch(opts: AgentCliOpts): Promise<AgentCliOpts> { |
| 376 | +async function normalizeSessionKeyOptsForDispatch<T extends AgentCliOpts>(opts: T): Promise<T> { |
292 | 377 | const rawSessionKey = opts.sessionKey?.trim(); |
293 | 378 | const rawTo = opts.to?.trim(); |
294 | 379 | if (!rawSessionKey && !opts.sessionId?.trim() && classifySessionKeyShape(rawTo) === "agent") { |
@@ -620,17 +705,15 @@ function formatInFlightGatewayAgentMessage(response: GatewayAgentResponse): stri |
620 | 705 | } |
621 | 706 |
|
622 | 707 | async function agentViaGatewayCommand( |
623 | | - opts: AgentCliOpts, |
| 708 | + opts: ResolvedAgentCliOpts, |
624 | 709 | runtime: RuntimeEnv, |
625 | 710 | signalBridge: ReturnType<typeof createAgentCliSignalBridge>, |
626 | 711 | ) { |
627 | 712 | protectJsonStdout(opts); |
628 | | - const body = (opts.message ?? "").trim(); |
| 713 | + const body = opts.message.trim(); |
629 | 714 | const explicitSessionKey = opts.sessionKey?.trim(); |
630 | 715 | if (!body) { |
631 | | - throw new Error( |
632 | | - `Missing message. Use ${formatCliCommand('openclaw agent --message "..." --agent <id>')} or pass --to/--session-key/--session-id for an existing conversation.`, |
633 | | - ); |
| 716 | + throw new Error(formatMissingMessageError()); |
634 | 717 | } |
635 | 718 | if (!opts.to && !opts.sessionId && !opts.agent && !explicitSessionKey) { |
636 | 719 | throw new Error( |
@@ -806,7 +889,7 @@ async function agentViaGatewayCommand( |
806 | 889 | } |
807 | 890 |
|
808 | 891 | async function agentViaGatewayCommandWithTransientRetries( |
809 | | - opts: AgentCliOpts, |
| 892 | + opts: ResolvedAgentCliOpts, |
810 | 893 | runtime: RuntimeEnv, |
811 | 894 | signalBridge: ReturnType<typeof createAgentCliSignalBridge>, |
812 | 895 | ) { |
@@ -839,7 +922,8 @@ export async function agentCliCommand( |
839 | 922 | deps?: AgentCliDeps, |
840 | 923 | ) { |
841 | 924 | protectJsonStdout(opts); |
842 | | - const dispatchOpts = await normalizeSessionKeyOptsForDispatch(opts); |
| 925 | + const resolvedOpts = await resolveAgentCliMessageInput(opts); |
| 926 | + const dispatchOpts = await normalizeSessionKeyOptsForDispatch(resolvedOpts); |
843 | 927 | validateExplicitSessionKeyForDispatch(dispatchOpts); |
844 | 928 | const gatewayDispatchOpts = dispatchOpts.runId |
845 | 929 | ? dispatchOpts |
|
0 commit comments