fix(adapter): route multiline prompts over stdin on Windows .cmd shims
On Windows, an npm-installed `claude` resolves to a .cmd shim, so _resolve_launch_command wraps it in `cmd.exe /d /s /c ...`. cmd.exe treats a newline inside an argument as end-of-command, silently truncating a multiline prompt at the first line break -- the agent received only the "## Task Brief" heading and replied that no task was included. Detect this case in _interactive_prompt_transport and deliver the prompt over the existing stdin channel instead. The guard requires all three of: nt platform, a newline in the prompt, and a command that resolves to .cmd/.bat. It reuses _resolve_windows_command_shim so the check matches the same resolution logic that decides whether cmd.exe wrapping happens. Also records prompt_transport_reason in the stdin metadata to distinguish this trigger from the pre-existing oversized-prompt path. Verified against the real claude CLI: transport flips to stdin and the CLI confirms all prompt lines arrive intact.
This commit is contained in:
@@ -3,6 +3,8 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -250,10 +252,16 @@ class ClaudeCodeAdapter(ExternalAgentAdapter):
|
|||||||
}
|
}
|
||||||
|
|
||||||
def _build_stdin_prompt_metadata(self, prompt: str) -> dict[str, object]:
|
def _build_stdin_prompt_metadata(self, prompt: str) -> dict[str, object]:
|
||||||
|
prompt_bytes = len(prompt.encode("utf-8"))
|
||||||
return {
|
return {
|
||||||
"prompt_transport": "stdin",
|
"prompt_transport": "stdin",
|
||||||
"prompt_bytes": len(prompt.encode("utf-8")),
|
"prompt_bytes": prompt_bytes,
|
||||||
"stdin_prompt_channel": "pipe",
|
"stdin_prompt_channel": "pipe",
|
||||||
|
"prompt_transport_reason": (
|
||||||
|
"prompt_too_large_for_argv"
|
||||||
|
if prompt_bytes > self._INTERACTIVE_ARGV_PROMPT_MAX_BYTES
|
||||||
|
else "windows_multiline_argv_unsafe"
|
||||||
|
),
|
||||||
"interactive_input_limitation": (
|
"interactive_input_limitation": (
|
||||||
"large initial prompt is delivered through stdin; live approval replies "
|
"large initial prompt is delivered through stdin; live approval replies "
|
||||||
"are unavailable after stdin closes"
|
"are unavailable after stdin closes"
|
||||||
@@ -261,8 +269,28 @@ class ClaudeCodeAdapter(ExternalAgentAdapter):
|
|||||||
}
|
}
|
||||||
|
|
||||||
def _interactive_prompt_transport(self, prompt: str) -> str:
|
def _interactive_prompt_transport(self, prompt: str) -> str:
|
||||||
prompt_bytes = len(prompt.encode("utf-8"))
|
if len(prompt.encode("utf-8")) > self._INTERACTIVE_ARGV_PROMPT_MAX_BYTES:
|
||||||
return "argv" if prompt_bytes <= self._INTERACTIVE_ARGV_PROMPT_MAX_BYTES else "stdin"
|
return "stdin"
|
||||||
|
return "stdin" if self._windows_multiline_argv_is_unsafe(prompt) else "argv"
|
||||||
|
|
||||||
|
def _windows_multiline_argv_is_unsafe(self, prompt: str) -> bool:
|
||||||
|
# `claude` installed via npm resolves to a `.cmd` shim on Windows, so
|
||||||
|
# the spawn path wraps it in `cmd.exe /d /s /c ...`. cmd.exe treats a
|
||||||
|
# newline inside an argument as end-of-command, which silently
|
||||||
|
# truncates a multiline prompt at the first line break (the agent then
|
||||||
|
# sees just "## Task Brief"). Route such prompts over stdin instead.
|
||||||
|
if os.name != "nt":
|
||||||
|
return False
|
||||||
|
if "\n" not in prompt and "\r" not in prompt:
|
||||||
|
return False
|
||||||
|
command = self.configured_command()
|
||||||
|
resolved = shutil.which(command)
|
||||||
|
if not resolved:
|
||||||
|
# Unresolvable here means _resolve_launch_command will also fail to
|
||||||
|
# resolve it and spawn the bare name, which cmd.exe never wraps.
|
||||||
|
return False
|
||||||
|
resolved = self._resolve_windows_command_shim(command, resolved)
|
||||||
|
return os.path.splitext(str(resolved))[1].lower() in {".cmd", ".bat"}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _redact_prompt_arg(cmd: list[str], prompt: str) -> list[str]:
|
def _redact_prompt_arg(cmd: list[str], prompt: str) -> list[str]:
|
||||||
|
|||||||
@@ -3137,6 +3137,7 @@ class ExternalAgentMonitoringTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
task = Task(title="demo", description="body")
|
task = Task(title="demo", description="body")
|
||||||
prompt = adapter.build_task_prompt(task)
|
prompt = adapter.build_task_prompt(task)
|
||||||
|
|
||||||
|
with patch.object(ClaudeCodeAdapter, "_windows_multiline_argv_is_unsafe", return_value=False):
|
||||||
cmd, metadata = adapter.build_interactive_invocation(task, workspace_path="/repo")
|
cmd, metadata = adapter.build_interactive_invocation(task, workspace_path="/repo")
|
||||||
|
|
||||||
self.assertEqual(cmd[-2:], ["--", prompt])
|
self.assertEqual(cmd[-2:], ["--", prompt])
|
||||||
@@ -3150,6 +3151,7 @@ class ExternalAgentMonitoringTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
config=ExternalAgentConfig(command="claude", approval_mode="full-auto")
|
config=ExternalAgentConfig(command="claude", approval_mode="full-auto")
|
||||||
)
|
)
|
||||||
task = Task(title="demo", description="body")
|
task = Task(title="demo", description="body")
|
||||||
|
with patch.object(ClaudeCodeAdapter, "_windows_multiline_argv_is_unsafe", return_value=False):
|
||||||
cmd, metadata = adapter.build_interactive_invocation(task, workspace_path="/repo")
|
cmd, metadata = adapter.build_interactive_invocation(task, workspace_path="/repo")
|
||||||
proc = type("Proc", (), {"pid": 321, "stdin": None, "stdout": object(), "stderr": object()})()
|
proc = type("Proc", (), {"pid": 321, "stdin": None, "stdout": object(), "stderr": object()})()
|
||||||
|
|
||||||
@@ -3179,6 +3181,7 @@ class ExternalAgentMonitoringTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
config=ExternalAgentConfig(command="claude", approval_mode="auto")
|
config=ExternalAgentConfig(command="claude", approval_mode="auto")
|
||||||
)
|
)
|
||||||
task = Task(title="demo", description="body")
|
task = Task(title="demo", description="body")
|
||||||
|
with patch.object(ClaudeCodeAdapter, "_windows_multiline_argv_is_unsafe", return_value=False):
|
||||||
cmd, metadata = adapter.build_interactive_invocation(task, workspace_path="/repo")
|
cmd, metadata = adapter.build_interactive_invocation(task, workspace_path="/repo")
|
||||||
proc = type("Proc", (), {"pid": 321, "stdin": None, "stdout": object(), "stderr": object()})()
|
proc = type("Proc", (), {"pid": 321, "stdin": None, "stdout": object(), "stderr": object()})()
|
||||||
|
|
||||||
@@ -3203,6 +3206,90 @@ class ExternalAgentMonitoringTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
self.assertEqual(spawn_mock.await_args.kwargs["stdin"], asyncio.subprocess.PIPE)
|
self.assertEqual(spawn_mock.await_args.kwargs["stdin"], asyncio.subprocess.PIPE)
|
||||||
self.assertEqual(metadata["stdin_policy"], "pipe_open")
|
self.assertEqual(metadata["stdin_policy"], "pipe_open")
|
||||||
|
|
||||||
|
def test_claude_adapter_windows_multiline_prompt_uses_stdin_transport(self) -> None:
|
||||||
|
adapter = ClaudeCodeAdapter()
|
||||||
|
task = Task(
|
||||||
|
title="demo",
|
||||||
|
description="## Task Brief\n在工作区里创建一个 hello.py\n\n## OpenOPC Context\nctx",
|
||||||
|
metadata={"external_prompt_contract": "description_is_full_prompt"},
|
||||||
|
)
|
||||||
|
prompt = adapter.build_task_prompt(task)
|
||||||
|
|
||||||
|
with patch("opc.layer3_agent.adapters.claude_code.os.name", "nt"), \
|
||||||
|
patch(
|
||||||
|
"opc.layer3_agent.adapters.claude_code.shutil.which",
|
||||||
|
return_value=r"C:\Users\me\AppData\Roaming\npm\claude.CMD",
|
||||||
|
):
|
||||||
|
cmd, metadata = adapter.build_interactive_invocation(task, workspace_path="/repo")
|
||||||
|
|
||||||
|
self.assertNotIn("--", cmd)
|
||||||
|
self.assertNotIn(prompt, cmd)
|
||||||
|
self.assertIn("--input-format", cmd)
|
||||||
|
self.assertEqual(cmd[cmd.index("--input-format") + 1], "text")
|
||||||
|
self.assertEqual(metadata["prompt_transport"], "stdin")
|
||||||
|
self.assertEqual(metadata["stdin_prompt_channel"], "pipe")
|
||||||
|
self.assertEqual(metadata["prompt_transport_reason"], "windows_multiline_argv_unsafe")
|
||||||
|
self.assertEqual(
|
||||||
|
adapter.stdin_policy_for_process(cmd, metadata),
|
||||||
|
"pipe_prompt_then_close",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_claude_adapter_windows_single_line_prompt_stays_on_argv(self) -> None:
|
||||||
|
adapter = ClaudeCodeAdapter()
|
||||||
|
task = Task(
|
||||||
|
title="demo",
|
||||||
|
description="创建一个 hello.py",
|
||||||
|
metadata={"external_prompt_contract": "description_is_full_prompt"},
|
||||||
|
)
|
||||||
|
prompt = adapter.build_task_prompt(task)
|
||||||
|
|
||||||
|
with patch("opc.layer3_agent.adapters.claude_code.os.name", "nt"), \
|
||||||
|
patch(
|
||||||
|
"opc.layer3_agent.adapters.claude_code.shutil.which",
|
||||||
|
return_value=r"C:\Users\me\AppData\Roaming\npm\claude.CMD",
|
||||||
|
):
|
||||||
|
cmd, metadata = adapter.build_interactive_invocation(task, workspace_path="/repo")
|
||||||
|
|
||||||
|
self.assertEqual(cmd[-2:], ["--", prompt])
|
||||||
|
self.assertEqual(metadata["prompt_transport"], "argv")
|
||||||
|
|
||||||
|
def test_claude_adapter_windows_multiline_batch_prompt_uses_stdin_transport(self) -> None:
|
||||||
|
adapter = ClaudeCodeAdapter()
|
||||||
|
task = Task(
|
||||||
|
title="demo",
|
||||||
|
description="## Task Brief\n在工作区里创建一个 hello.py",
|
||||||
|
metadata={"external_prompt_contract": "description_is_full_prompt"},
|
||||||
|
)
|
||||||
|
prompt = adapter.build_task_prompt(task)
|
||||||
|
|
||||||
|
with patch("opc.layer3_agent.adapters.claude_code.os.name", "nt"), \
|
||||||
|
patch(
|
||||||
|
"opc.layer3_agent.adapters.claude_code.shutil.which",
|
||||||
|
return_value=r"C:\Users\me\AppData\Roaming\npm\claude.CMD",
|
||||||
|
):
|
||||||
|
cmd, metadata = adapter.build_invocation(task, workspace_path="/repo")
|
||||||
|
|
||||||
|
self.assertNotIn("--", cmd)
|
||||||
|
self.assertNotIn(prompt, cmd)
|
||||||
|
self.assertIn("--input-format", cmd)
|
||||||
|
self.assertEqual(metadata["prompt_transport"], "stdin")
|
||||||
|
self.assertEqual(metadata["prompt_transport_reason"], "windows_multiline_argv_unsafe")
|
||||||
|
|
||||||
|
def test_claude_adapter_posix_multiline_prompt_stays_on_argv(self) -> None:
|
||||||
|
adapter = ClaudeCodeAdapter()
|
||||||
|
task = Task(
|
||||||
|
title="demo",
|
||||||
|
description="## Task Brief\nline two",
|
||||||
|
metadata={"external_prompt_contract": "description_is_full_prompt"},
|
||||||
|
)
|
||||||
|
prompt = adapter.build_task_prompt(task)
|
||||||
|
|
||||||
|
with patch("opc.layer3_agent.adapters.claude_code.os.name", "posix"):
|
||||||
|
cmd, metadata = adapter.build_interactive_invocation(task, workspace_path="/repo")
|
||||||
|
|
||||||
|
self.assertEqual(cmd[-2:], ["--", prompt])
|
||||||
|
self.assertEqual(metadata["prompt_transport"], "argv")
|
||||||
|
|
||||||
def test_claude_adapter_large_interactive_prompt_uses_stdin(self) -> None:
|
def test_claude_adapter_large_interactive_prompt_uses_stdin(self) -> None:
|
||||||
adapter = ClaudeCodeAdapter()
|
adapter = ClaudeCodeAdapter()
|
||||||
prompt = "x" * (ClaudeCodeAdapter._INTERACTIVE_ARGV_PROMPT_MAX_BYTES + 1)
|
prompt = "x" * (ClaudeCodeAdapter._INTERACTIVE_ARGV_PROMPT_MAX_BYTES + 1)
|
||||||
|
|||||||
Reference in New Issue
Block a user