Skip to content

fix(agents): deliver OpenClaw prompt via --message-file on Windows - #145

Open
oleg-ai-dev wants to merge 2 commits into
nexu-io:mainfrom
oleg-ai-dev:fix/issue-96-windows-openclaw-message
Open

fix(agents): deliver OpenClaw prompt via --message-file on Windows#145
oleg-ai-dev wants to merge 2 commits into
nexu-io:mainfrom
oleg-ai-dev:fix/issue-96-windows-openclaw-message

Conversation

@oleg-ai-dev

Copy link
Copy Markdown

Summary

Fixes #96 — OpenClaw is unusable on Windows whenever the prompt contains a newline or a space.

Root cause

invokeAgent in next/src/lib/agents/invoke.ts spawns agent CLIs with shell: true on Windows (needed since npm installs CLIs like OpenClaw as .cmd shims, which CreateProcess can't launch directly — landed in #13/#15). Node's shell: true on Windows joins file + args into a single command string with a plain ' '.join(...) and no escaping, then hands that string to cmd.exe /d /s /c.

OpenClaw is the only adapter using the argv-message protocol, so invoke.ts appends --message <prompt text> directly to argv. Once the prompt contains:

  • an embedded newline — cmd.exe treats it as a statement separator; everything after the first \n is 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.
  • an embedded space — with no per-argument quoting, cmd.exe splits it into extra tokens ("Too many arguments").

#97 (already merged) fixes a related-but-different symptom (binary paths containing spaces) and explicitly doesn't touch this — quoting bin alone 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 exact openclaw agent ... invocation this codebase uses.

  • next/src/lib/agents/argv.ts: added quoteWindowsShellArg() and buildMessageFlagArgv(platform, prompt, messageFilePath) — a pure function returning ["--message-file", quotedPath] on win32, and the original ["--message", prompt] everywhere else (POSIX spawns via execve with no shell involved, so nothing needed to change there).
  • next/src/lib/agents/invoke.ts: on win32 only, writes opts.prompt to a per-invocation temp file (mkdtempSync under os.tmpdir()) before spawning, and builds the argv tail via buildMessageFlagArgv. Added cleanupMessageFile(), called from every exit path (spawn throwing, child.on("error"), child.on("close")) so the temp directory never leaks.
  • Added next/src/lib/agents/__tests__/invoke-message-argv.test.ts covering 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-Bash tar path-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.
  • Independent end-to-end repro: called spawnSync with the exact options invoke.ts uses (shell: true, windowsVerbatimArguments: false) against a fake .cmd shim that echoes back what it received.
    • Before this fix (--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.
    • After this fix (--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.

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
@lefarcen
lefarcen requested a review from nettee August 28, 2026 07:39
@lefarcen lefarcen added size/M Medium change: 100-299 lines risk/medium Medium risk change type/bugfix Bug fix labels Aug 28, 2026
…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.
@oleg-ai-dev

Copy link
Copy Markdown
Author

Pushed a follow-up commit after a self-review pass turned up a few things worth hardening in the original fix:

  • Switched the temp-file setup/cleanup to node:fs/promises (matching how install.ts already handles its own scratch-temp-dir case) instead of the sync fs calls, since this all runs inside an already-async handler.
  • Wrapped the mkdtemp+write in try/catch so a failure there (e.g. an unwritable tmpdir) surfaces through the same {type:"error"} event every other failure path uses, and cleans up any partial directory instead of leaking it.
  • The start SSE event was including the real server-side temp file path (which embeds the OS username) in the argv field forwarded to the browser — added a small redactStartEventArgv() helper so only <message file> is shown there instead.
  • On abort, killing the cmd.exe-wrapped child on Windows doesn't reliably propagate to the actual openclaw process it launched (that's a pre-existing platform limitation, not something this PR introduces) — so I skip temp-file cleanup specifically when the request was aborted, to avoid deleting the file out from under a possibly-still-running orphaned process. Trade-off is a small leaked temp file on that path, which felt safer than the alternative.

No behavior change to the core fix — same --message-file approach, same test coverage plus a couple more cases for the redaction helper.

@nettee nettee left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@oleg-ai-dev

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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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-"));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@lefarcen

Copy link
Copy Markdown

Heads-up: #146 is also open against the same OpenClaw multiline-prompt path — both PRs touch next/src/lib/agents/invoke.ts and are trying to move prompt delivery onto --message-file. You and @haoke016 may want to compare approaches so review effort doesn't split unnecessarily.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

risk/medium Medium risk change size/M Medium change: 100-299 lines type/bugfix Bug fix

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Windows下OpenClaw无法使用

3 participants