fix(agents): deliver OpenClaw prompt via --message-file on Windows - #145
fix(agents): deliver OpenClaw prompt via --message-file on Windows#145oleg-ai-dev wants to merge 2 commits into
Conversation
On Windows, invokeAgent spawns agent CLIs with shell:true so npm's .cmd shims can launch (nexu-io#13/nexu-io#15). Node's shell:true join has no escaping of its own, so a --message <text> value that OpenClaw's argv-message protocol puts on the command line gets mangled by cmd.exe's own parsing before OpenClaw ever sees it: an embedded newline truncates the command there (cmd.exe has no way to embed a literal newline in a single /c "..." invocation), and unquoted spaces split the value into extra arguments. Neither survives quoting --message harder — quoting doesn't help across an embedded newline. Route the prompt through a temp file and OpenClaw's documented --message-file <path> flag on win32 instead, so the only argv element crossing the cmd.exe boundary is a short, controlled path (quoted when it contains a space). Off Windows this is unchanged: args reach OpenClaw via execve with no shell involved, so --message <text> already works. Fixes nexu-io#96
…temp path Addresses review findings on the nexu-io#96 fix: - Switch mkdtemp/writeFile/rm to node:fs/promises, matching the codebase's own async-fs convention (install.ts's identical scratch-temp-dir pattern) and avoiding sync I/O inside the already-async ReadableStream start(). - Wrap the temp-file setup in try/catch so a failure (e.g. an unwritable tmpdir) surfaces through the normal {type:"error"} event instead of an unhandled stream rejection, and cleans up any partial directory instead of leaking it. - Redact the real message-file path out of the "start" SSE event via a new redactStartEventArgv() helper — the path lives on the server filesystem and embeds the OS username, which shouldn't reach the browser. - Skip cleanup when the request was aborted: on Windows, killing the shell-spawned wrapper process doesn't propagate to the openclaw grandchild it launched, so an abort-triggered "close" could otherwise delete the message file while that orphaned process is still reading it. A small leaked temp file is the safer trade-off.
|
Pushed a follow-up commit after a self-review pass turned up a few things worth hardening in the original fix:
No behavior change to the core fix — same |
nettee
left a comment
There was a problem hiding this comment.
I reviewed all three changed files and the complete head-to-main diff. The targeted and full Next test suites, typecheck, guard, and production build all pass, but the Windows path and cancellation handling still have blocking correctness and lifecycle issues described in the inline comments.
🔁 Powered by Looper · runner=reviewer · agent=codex · An autonomous AI dev team for your GitHub repos.| * tokens once cmd.exe parses that string. | ||
| */ | ||
| export function quoteWindowsShellArg(arg: string): string { | ||
| return /\s/.test(arg) ? `"${arg}"` : arg; |
There was a problem hiding this comment.
Blocking: quoteWindowsShellArg only quotes whitespace, but this value is still concatenated into the Windows cmd.exe command. A valid TEMP/TMP root such as C:\Temp&Data\html-anything-msg-abc\prompt.txt, or one containing ^, |, <, >, or %, can contain no whitespace, so this function returns the path unquoted; cmd.exe then treats those metacharacters as command separators or expansions. That can break the OpenClaw invocation and, for an environment-controlled temp root, can turn the shell boundary into command injection. Because os.tmpdir() is environment-controlled, this is part of the input crossing the shell boundary. Use a proper cmd.exe argument-escaping routine (or a guaranteed shell-safe temp root) that handles all metacharacters, and add regression cases for metacharacter paths before passing the result to spawn.
🔁 Powered by Looper · runner=reviewer · agent=codex · An autonomous AI dev team for your GitHub repos.| let messageFilePath = ""; | ||
| if (usesWindowsMessageFile) { | ||
| try { | ||
| messageFileDir = await mkdtemp(join(tmpdir(), "html-anything-msg-")); |
There was a problem hiding this comment.
Blocking: the new awaited mkdtemp/writeFile setup runs before the abort listener is installed at the end of start. If the request signal aborts while either await is pending, the abort event is missed; adding a listener later does not replay an already-aborted signal, so this code still spawns OpenClaw after the caller has cancelled and aborted remains false. That wastes a process after disconnect/cancel and bypasses the intended cleanup decision. Check opts.signal.aborted before setup and again after each await (or install an early setup handler), remove any created directory, and return without spawning; add a regression test for abort during setup.
🔁 Powered by Looper · runner=reviewer · agent=codex · An autonomous AI dev team for your GitHub repos.| argv = [...argv, ...buildMessageFlagArgv(process.platform, opts.prompt, messageFilePath)]; | ||
| } | ||
| const cleanupMessageFile = async () => { | ||
| if (!messageFileDir || aborted) return; |
There was a problem hiding this comment.
Blocking: onAbort sets aborted to true and closes the stream, then the child close handler calls cleanupMessageFile. Because this guard returns whenever aborted is true, every cancelled Windows OpenClaw run leaves its mkdtemp directory and prompt file behind; there is no later cleanup or bounded janitor. The UI aborts requests on cancel and re-run, and request disconnects can do the same, so repeated cancellations cause unbounded TEMP growth. Keep the path for a delayed, bounded cleanup after killing the process tree (or use taskkill /T before removing it), and make failed removal observable or retryable; add a cancellation test that verifies eventual cleanup without deleting a file still needed during the grace period.
🔁 Powered by Looper · runner=reviewer · agent=codex · An autonomous AI dev team for your GitHub repos.
Summary
Fixes #96 — OpenClaw is unusable on Windows whenever the prompt contains a newline or a space.
Root cause
invokeAgentinnext/src/lib/agents/invoke.tsspawns agent CLIs withshell: trueon Windows (needed since npm installs CLIs like OpenClaw as.cmdshims, whichCreateProcesscan't launch directly — landed in #13/#15). Node'sshell: trueon Windows joinsfile+argsinto a single command string with a plain' '.join(...)and no escaping, then hands that string tocmd.exe /d /s /c.OpenClaw is the only adapter using the
argv-messageprotocol, soinvoke.tsappends--message <prompt text>directly to argv. Once the prompt contains:\nis silently lost. cmd.exe has no way to embed a literal newline inside a single/c "..."invocation the way an actual shell script could, so this isn't fixable by quoting--message's value harder.#97 (already merged) fixes a related-but-different symptom (binary paths containing spaces) and explicitly doesn't touch this — quoting
binalone can't fix content splitting inside--message.Fix
Since the underlying problem is that any prompt content crossing the cmd.exe command-line boundary is unsafe, the fix avoids putting the prompt there at all on Windows. Checked OpenClaw's own CLI docs (
docs/cli/agent.md) rather than assuming a fix direction:--message-file <path>is documented as a supported alternative to-m/--message <text>for the exactopenclaw agent ...invocation this codebase uses.next/src/lib/agents/argv.ts: addedquoteWindowsShellArg()andbuildMessageFlagArgv(platform, prompt, messageFilePath)— a pure function returning["--message-file", quotedPath]onwin32, and the original["--message", prompt]everywhere else (POSIX spawns viaexecvewith no shell involved, so nothing needed to change there).next/src/lib/agents/invoke.ts: onwin32only, writesopts.promptto a per-invocation temp file (mkdtempSyncunderos.tmpdir()) before spawning, and builds the argv tail viabuildMessageFlagArgv. AddedcleanupMessageFile(), called from every exit path (spawnthrowing,child.on("error"),child.on("close")) so the temp directory never leaks.next/src/lib/agents/__tests__/invoke-message-argv.test.tscovering both helpers, including the regression case that the raw multi-line/space-containing prompt must never appear as a literal argv element on Windows.Verification
pnpm exec tsx scripts/guard.ts— passes.pnpm -F @html-anything/next typecheck— clean.pnpm -F @html-anything/next test— full suite green except pre-existing/unrelated flakes (traced to a local Git-Bashtarpath-translation quirk and one timing-sensitive gzip-bomb decompression test — both confirmed unrelated by isolated re-runs via PowerShell/native tar).pnpm -F @html-anything/next build— succeeds.spawnSyncwith the exact optionsinvoke.tsuses (shell: true,windowsVerbatimArguments: false) against a fake.cmdshim that echoes back what it received.--message "line one\nline two with spaces"): shim received only--message line one— everything after the newline was dropped, and even "line one" split into two args.--message-file "<a 'John Doe'-style path with a space>"): shim received exactly the two intended args, and reading the temp file back gave byte-for-byte the original multi-line prompt.No lint script or changesets config exists in this repo, so typecheck/test/build/guard is the full verification bar.