From 18ddc189489b03209d2fb6bfee4677a2c0c10eb5 Mon Sep 17 00:00:00 2001 From: CatJuly <1471635770@qq.com> Date: Fri, 31 Jul 2026 09:59:27 +0800 Subject: [PATCH] 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. --- opc/layer3_agent/adapters/claude_code.py | 34 ++++++++- tests/test_external_agent_monitoring.py | 93 +++++++++++++++++++++++- 2 files changed, 121 insertions(+), 6 deletions(-) diff --git a/opc/layer3_agent/adapters/claude_code.py b/opc/layer3_agent/adapters/claude_code.py index 8e9a155..5f8bddd 100644 --- a/opc/layer3_agent/adapters/claude_code.py +++ b/opc/layer3_agent/adapters/claude_code.py @@ -3,6 +3,8 @@ from __future__ import annotations import asyncio +import os +import shutil from pathlib import Path from typing import Any @@ -250,10 +252,16 @@ class ClaudeCodeAdapter(ExternalAgentAdapter): } def _build_stdin_prompt_metadata(self, prompt: str) -> dict[str, object]: + prompt_bytes = len(prompt.encode("utf-8")) return { "prompt_transport": "stdin", - "prompt_bytes": len(prompt.encode("utf-8")), + "prompt_bytes": prompt_bytes, "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": ( "large initial prompt is delivered through stdin; live approval replies " "are unavailable after stdin closes" @@ -261,8 +269,28 @@ class ClaudeCodeAdapter(ExternalAgentAdapter): } def _interactive_prompt_transport(self, prompt: str) -> str: - prompt_bytes = len(prompt.encode("utf-8")) - return "argv" if prompt_bytes <= self._INTERACTIVE_ARGV_PROMPT_MAX_BYTES else "stdin" + if len(prompt.encode("utf-8")) > self._INTERACTIVE_ARGV_PROMPT_MAX_BYTES: + 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 def _redact_prompt_arg(cmd: list[str], prompt: str) -> list[str]: diff --git a/tests/test_external_agent_monitoring.py b/tests/test_external_agent_monitoring.py index 89dcb78..c20e2d1 100644 --- a/tests/test_external_agent_monitoring.py +++ b/tests/test_external_agent_monitoring.py @@ -3137,7 +3137,8 @@ class ExternalAgentMonitoringTests(unittest.IsolatedAsyncioTestCase): task = Task(title="demo", description="body") prompt = adapter.build_task_prompt(task) - cmd, metadata = adapter.build_interactive_invocation(task, workspace_path="/repo") + with patch.object(ClaudeCodeAdapter, "_windows_multiline_argv_is_unsafe", return_value=False): + cmd, metadata = adapter.build_interactive_invocation(task, workspace_path="/repo") self.assertEqual(cmd[-2:], ["--", prompt]) self.assertEqual(metadata["prompt_transport"], "argv") @@ -3150,7 +3151,8 @@ class ExternalAgentMonitoringTests(unittest.IsolatedAsyncioTestCase): config=ExternalAgentConfig(command="claude", approval_mode="full-auto") ) task = Task(title="demo", description="body") - cmd, metadata = adapter.build_interactive_invocation(task, workspace_path="/repo") + with patch.object(ClaudeCodeAdapter, "_windows_multiline_argv_is_unsafe", return_value=False): + cmd, metadata = adapter.build_interactive_invocation(task, workspace_path="/repo") proc = type("Proc", (), {"pid": 321, "stdin": None, "stdout": object(), "stderr": object()})() tmpdir = _make_test_dir("claude-full-auto-devnull") @@ -3179,7 +3181,8 @@ class ExternalAgentMonitoringTests(unittest.IsolatedAsyncioTestCase): config=ExternalAgentConfig(command="claude", approval_mode="auto") ) task = Task(title="demo", description="body") - cmd, metadata = adapter.build_interactive_invocation(task, workspace_path="/repo") + with patch.object(ClaudeCodeAdapter, "_windows_multiline_argv_is_unsafe", return_value=False): + cmd, metadata = adapter.build_interactive_invocation(task, workspace_path="/repo") proc = type("Proc", (), {"pid": 321, "stdin": None, "stdout": object(), "stderr": object()})() tmpdir = _make_test_dir("claude-auto-approval-stdin") @@ -3203,6 +3206,90 @@ class ExternalAgentMonitoringTests(unittest.IsolatedAsyncioTestCase): self.assertEqual(spawn_mock.await_args.kwargs["stdin"], asyncio.subprocess.PIPE) 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: adapter = ClaudeCodeAdapter() prompt = "x" * (ClaudeCodeAdapter._INTERACTIVE_ARGV_PROMPT_MAX_BYTES + 1)