Files
CatJuly 18ddc18948 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.
2026-07-31 19:47:41 +08:00

688 lines
28 KiB
Python

"""Claude Code CLI adapter."""
from __future__ import annotations
import asyncio
import os
import shutil
from pathlib import Path
from typing import Any
from loguru import logger
from opc.core.models import AgentStatus, Task, TaskResult, TaskStatus
from opc.layer3_agent.adapters.base import (
ExternalAgentAdapter,
ExternalAgentStdinPolicy,
ExternalApprovalRequest,
)
from opc.layer3_agent.skill_installer import install_opc_collab_skill
class ClaudeCodeAdapter(ExternalAgentAdapter):
"""Invokes the Claude Code CLI (claude command) for task execution."""
agent_type = "claude_code"
default_command = "claude"
_INTERACTIVE_ARGV_PROMPT_MAX_BYTES = 16 * 1024
# Lazily populated by ``_user_shell_proxy_env``. Cached at class level
# so the (slow) ``zsh -i -c`` probe runs at most once per process.
# ``None`` means "not probed yet"; ``{}`` means "probed, no proxy found".
_user_shell_proxy_env_cache: dict[str, str] | None = None
def __init__(self, config=None) -> None:
super().__init__(config=config)
self._process: asyncio.subprocess.Process | None = None
async def start_process(
self,
cmd: list[str],
workspace_path: str,
extra_env: dict[str, str] | None = None,
task: Task | None = None,
launch_metadata: dict[str, Any] | None = None,
) -> asyncio.subprocess.Process:
prompt_transport = str((launch_metadata or {}).get("prompt_transport") or "").strip().lower()
prompt = self._prompt_text_from_task(task) if prompt_transport == "stdin" else ""
# Small prompts stay on argv. Keep stdin open only when Claude's
# permission mode can emit live prompts; otherwise DEVNULL avoids
# provider-side stdin probes on Windows wrappers.
env = self.build_process_env(extra_env)
env = self._merge_user_shell_proxy(env)
launch_cmd = self._resolve_launch_command(
cmd,
extra_env=extra_env,
launch_metadata=launch_metadata,
)
stdin_policy = self.stdin_policy_for_process(launch_cmd, launch_metadata)
stdin_target = self._stdin_target_for_policy(stdin_policy)
if isinstance(launch_metadata, dict):
self._record_stdin_policy_metadata(launch_metadata, stdin_policy)
proc = await asyncio.create_subprocess_exec(
*launch_cmd,
stdin=stdin_target,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=workspace_path,
env=env,
**self._subprocess_group_kwargs(),
)
if prompt_transport == "stdin":
if isinstance(launch_metadata, dict):
launch_metadata["interactive_input_channel"] = "pipe"
delivered = await self._seed_pipe_prompt(proc, prompt)
if not delivered:
if isinstance(launch_metadata, dict):
launch_metadata["prompt_delivery_failed"] = True
logger.warning(
"Large stdin prompt delivery to {} (pid={}) may be incomplete",
self.agent_type,
proc.pid,
)
return proc
@classmethod
def _merge_user_shell_proxy(
cls, env: dict[str, str] | None
) -> dict[str, str] | None:
"""Layer extracted user-shell proxy vars onto the spawn env as a
fallback (only fills in vars that are not already set).
Returns ``None`` unchanged if there were no extracted vars and no
``env`` was supplied — that preserves the "inherit os.environ"
behavior expected by :func:`asyncio.create_subprocess_exec`.
"""
proxy = cls._user_shell_proxy_env()
if not proxy:
return env
if env is None:
import os
env = {**os.environ}
for key, value in proxy.items():
env.setdefault(key, value)
return env
@classmethod
def _user_shell_proxy_env(cls) -> dict[str, str]:
"""Probe the user's login shell for proxy env vars defined inside a
``claude`` shell function.
Developers behind a national firewall commonly wrap ``claude`` in
a shell function that injects ``HTTPS_PROXY`` per invocation::
claude() {
HTTPS_PROXY=http://... command claude "$@"
}
Those vars are only set when ``claude`` is invoked *through the
shell*. OpenOPC spawns claude via
:func:`asyncio.create_subprocess_exec`, which bypasses the shell
and the function — so the proxy never reaches claude and the API
call lands on a network gateway that returns ``403 Request not
allowed``.
Run ``$SHELL -i -c 'declare -f claude'`` once at first call and
parse any ``*_PROXY=`` assignments out of the function body. The
result is cached at class level. Failures (no shell function,
unusual shell, timeout, non-zero exit) silently degrade to an
empty dict so the spawn path is unchanged.
"""
if cls._user_shell_proxy_env_cache is not None:
return cls._user_shell_proxy_env_cache
import os
import re
import subprocess
shell = os.environ.get("SHELL", "").strip() or "/bin/zsh"
try:
result = subprocess.run(
[shell, "-i", "-c", "declare -f claude 2>/dev/null || true"],
capture_output=True,
text=True,
timeout=5,
)
except (OSError, subprocess.TimeoutExpired) as exc:
logger.debug(
"Skipping user-shell proxy probe ({}): {}", shell, exc
)
cls._user_shell_proxy_env_cache = {}
return cls._user_shell_proxy_env_cache
text = result.stdout or ""
extracted: dict[str, str] = {}
# Match `VAR="value"` or `VAR='value'` for the standard proxy
# env names. Unquoted assignments are intentionally skipped —
# they may contain shell variable expansions we cannot resolve
# without actually executing the function.
pattern = re.compile(
r"\b("
r"HTTPS?_PROXY|https?_proxy|"
r"NO_PROXY|no_proxy|"
r"ALL_PROXY|all_proxy"
r")="
r"([\"'])([^\"']*)\2"
)
for var, _, val in pattern.findall(text):
extracted[var] = val
if extracted:
logger.info(
"Extracted {} proxy var(s) from user `claude` shell function: {}",
len(extracted),
sorted(extracted.keys()),
)
cls._user_shell_proxy_env_cache = extracted
return cls._user_shell_proxy_env_cache
async def is_available(self) -> bool:
return self.resolve_binary() is not None
async def get_status(self) -> AgentStatus:
if self._process and self._process.returncode is None:
return AgentStatus.RUNNING
return AgentStatus.IDLE
def supports_interactive(self) -> bool:
return True
def supports_session_resume(self) -> bool:
return True
def agent_isolation_home_slug(self) -> str:
# Keep a broker-owned home so the shared ``opc-collab`` CLI shim is
# installed and PATH is wired consistently. Claude Code authentication
# intentionally remains global; setting CLAUDE_CONFIG_DIR makes Claude
# ignore the user's normal login/keychain state and can leave company
# mode stuck on stale isolated OAuth credentials.
return "claude"
def agent_home_env_vars(self, home: str) -> dict[str, str]:
_ = home
return {}
def post_install_agent_home(self, home: str) -> None:
# Claude Code discovers skills from the active user config directory.
# Because we do not set CLAUDE_CONFIG_DIR, install the OpenOPC skill
# into the user's normal Claude home and let the CLI use its existing
# authenticated state.
Path(home).mkdir(parents=True, exist_ok=True)
user_home = Path.home() / ".claude"
try:
install_opc_collab_skill(user_home)
except OSError as exc:
logger.warning("Unable to install opc-collab into Claude user home: {}", exc)
def can_resume_without_session_id(self) -> bool:
return True
def build_workspace_args(self, workspace_path: str | None = None) -> list[str]:
if not workspace_path:
return []
return ["--add-dir", workspace_path]
def _build_extra_dir_args(self, task: Task | None) -> list[str]:
# Surface extra roots that Claude may need to edit outside the
# primary workspace: collaboration files and durable memory.
if task is None:
return []
extra: list[str] = []
seen: set[str] = set()
workspace = str((task.metadata or {}).get("target_output_dir") or "").strip()
def _add(path: str) -> None:
normalized = str(path or "").strip()
if normalized and normalized != workspace and normalized not in seen:
seen.add(normalized)
extra.extend(["--add-dir", normalized])
comms_root = str((task.metadata or {}).get("comms_workspace_root") or "").strip()
_add(comms_root)
try:
from opc.core.config import get_opc_home
_add(str(get_opc_home() / "memory"))
except Exception:
pass
return extra
def _build_argv_prompt_metadata(self, prompt: str) -> dict[str, object]:
return {
"prompt_transport": "argv",
"prompt_bytes": len(prompt.encode("utf-8")),
}
def _build_stdin_prompt_metadata(self, prompt: str) -> dict[str, object]:
prompt_bytes = len(prompt.encode("utf-8"))
return {
"prompt_transport": "stdin",
"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"
),
}
def _interactive_prompt_transport(self, prompt: str) -> str:
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]:
redacted = list(cmd)
if redacted:
redacted[-1] = f"<prompt:{len(prompt.encode('utf-8'))}-bytes>"
return redacted
def _prompt_text_from_task(self, task: Task | None) -> str:
if task is None:
return ""
return self.build_task_prompt(task)
@staticmethod
async def _seed_pipe_prompt(
proc: asyncio.subprocess.Process,
prompt: str,
) -> bool:
writer = getattr(proc, "stdin", None)
if writer is None:
return False
payload = prompt.encode("utf-8")
delivered = True
try:
writer.write(payload)
await writer.drain()
except (BrokenPipeError, ConnectionResetError, OSError) as exc:
logger.warning(
"Pipe prompt delivery failed ({} bytes planned): {}",
len(payload),
exc,
)
delivered = False
writer.close()
wait_closed = getattr(writer, "wait_closed", None)
if callable(wait_closed):
try:
await wait_closed()
except (BrokenPipeError, ConnectionResetError, OSError):
delivered = False
return delivered
def build_invocation(
self,
task: Task,
workspace_path: str | None = None,
) -> tuple[list[str], dict[str, object]]:
prompt = self.build_task_prompt(task)
prompt_transport = self._interactive_prompt_transport(prompt)
cmd = [
self.configured_command(),
"--print",
"--output-format", "text",
*(["--input-format", "text"] if prompt_transport == "stdin" else []),
*self.build_workspace_args(workspace_path),
*self._build_extra_dir_args(task),
*self._build_session_args(),
*self.build_common_args(),
]
if prompt_transport == "argv":
cmd.extend(["--", prompt])
metadata = self.build_invocation_metadata(self._redact_prompt_arg(cmd, prompt))
metadata.update(self._build_argv_prompt_metadata(prompt))
else:
metadata = self.build_invocation_metadata(cmd)
metadata.update(self._build_stdin_prompt_metadata(prompt))
return cmd, metadata
def build_interactive_invocation(
self,
task: Task,
workspace_path: str | None = None,
) -> tuple[list[str], dict[str, object]]:
prompt = self.build_task_prompt(task)
prompt_transport = self._interactive_prompt_transport(prompt)
# Claude Code 2.x rejects `--print --output-format stream-json` unless
# `--verbose` is also passed (`Error: When using --print,
# --output-format=stream-json requires --verbose`). Inject it when the
# user has not already supplied it via `extra_args`.
verbose_args: list[str] = []
if not any(arg == "--verbose" for arg in self.config.extra_args):
verbose_args = ["--verbose"]
cmd = [
self.configured_command(),
"--print",
"--output-format", "stream-json",
*(["--input-format", "text"] if prompt_transport == "stdin" else []),
*verbose_args,
"--include-partial-messages",
*self.build_workspace_args(workspace_path),
*self._build_extra_dir_args(task),
*self._build_permission_args(),
*self._build_session_args(),
*self.build_common_args(),
]
if prompt_transport == "argv":
cmd.extend(["--", prompt])
metadata = self.build_invocation_metadata(self._redact_prompt_arg(cmd, prompt))
metadata.update(self._build_argv_prompt_metadata(prompt))
else:
metadata = self.build_invocation_metadata(cmd)
metadata.update(self._build_stdin_prompt_metadata(prompt))
return cmd, metadata
async def execute(self, task: Task, workspace_path: str) -> TaskResult:
if not await self.is_available():
return TaskResult(status=TaskStatus.FAILED, content="Claude Code CLI not found")
cmd, metadata = self.build_invocation(task, workspace_path=workspace_path)
logger.info(f"Claude Code executing: {task.title}")
try:
self._process = await self.start_process(
cmd,
workspace_path,
task=task,
launch_metadata=metadata,
)
stdout, stderr = await asyncio.wait_for(
self._process.communicate(), timeout=600
)
output = stdout.decode("utf-8", errors="replace")
errors = stderr.decode("utf-8", errors="replace")
if self._process.returncode == 0:
return TaskResult(
status=TaskStatus.DONE,
content=output,
artifacts={**metadata, "stderr": errors} if errors else metadata,
)
else:
return TaskResult(
status=TaskStatus.FAILED,
content=f"Claude Code exited with code {self._process.returncode}\n{errors}\n{output}",
artifacts=metadata,
)
except asyncio.TimeoutError:
if self._process:
self._process.kill()
return TaskResult(
status=TaskStatus.FAILED,
content="Claude Code timed out after 600s",
artifacts=metadata,
)
except Exception as e:
return TaskResult(
status=TaskStatus.FAILED,
content=f"Claude Code error: {e}",
artifacts=metadata,
)
finally:
self._process = None
def supports_approval_prompt_handling(
self,
cmd: list[str],
metadata: dict[str, Any] | None = None,
) -> bool:
return self.stdin_policy_for_process(cmd, metadata) == "pipe_open"
def stdin_policy_for_process(
self,
cmd: list[str],
metadata: dict[str, Any] | None = None,
) -> ExternalAgentStdinPolicy:
if str((metadata or {}).get("prompt_transport") or "").strip().lower() == "stdin":
return "pipe_prompt_then_close"
for index, arg in enumerate(cmd):
value = str(arg or "").strip()
if value == "--dangerously-skip-permissions":
return "devnull"
if value == "--permission-mode":
mode = str(cmd[index + 1] if index + 1 < len(cmd) else "").strip()
return "devnull" if mode in {"bypassPermissions", "dontAsk"} else "pipe_open"
if value.startswith("--permission-mode="):
mode = value.split("=", 1)[1].strip()
return "devnull" if mode in {"bypassPermissions", "dontAsk"} else "pipe_open"
return "pipe_open"
# ──────────────────────────────────────────────────────────────────────
# stream-json parsing for UI progress / transcript / approval suppression
# ──────────────────────────────────────────────────────────────────────
@classmethod
def _parse_runtime_event(cls, text: str) -> dict[str, Any] | None:
envelope = cls._parse_json_line(text)
if not isinstance(envelope, dict):
return None
return envelope
@staticmethod
def _trim_text(text: str, *, limit: int) -> str:
stripped = str(text or "").strip()
if len(stripped) <= limit:
return stripped
return stripped[: limit - 1].rstrip() + "…"
@classmethod
def _summarize_tool_use(cls, block: dict[str, Any]) -> str:
name = str(block.get("name") or "").strip() or "tool"
inp = block.get("input") if isinstance(block.get("input"), dict) else {}
if name == "Bash":
command = str(inp.get("command") or "").strip()
if command:
return f"$ {cls._trim_text(command.replace(chr(10), ' '), limit=240)}"
if name in {"Write", "Edit", "NotebookEdit"}:
path = str(inp.get("file_path") or inp.get("notebook_path") or "").strip()
if path:
return f"{name} {path}"
if name == "Read":
path = str(inp.get("file_path") or "").strip()
if path:
return f"Read {path}"
if name in {"Glob", "Grep"}:
pat = str(inp.get("pattern") or "").strip()
path = str(inp.get("path") or "").strip()
tail = f" in {path}" if path else ""
return f"{name} {pat}{tail}".strip()
if name in {"WebFetch", "WebSearch"}:
target = str(inp.get("url") or inp.get("query") or "").strip()
if target:
return f"{name} {target}"
# Fallback: name + compact JSON of inputs
try:
import json as _json
payload = _json.dumps(inp, ensure_ascii=False, default=str)
except Exception:
payload = ""
if payload and payload != "{}":
return f"{name} {cls._trim_text(payload, limit=200)}"
return name
def format_progress_update(self, text: str, stream_name: str) -> str | None:
# stderr lines: only surface real warnings/errors, drop noise.
if stream_name != "stdout":
stripped = str(text or "").strip()
if not stripped:
return None
# Drop Claude's harmless stdin-probe message; OpenOPC owns initial
# prompt transport explicitly via argv or stdin metadata.
if "no stdin data received" in stripped.lower():
return None
return f"[External:{self.agent_type}:stderr] {self._trim_text(stripped, limit=400)}"
envelope = self._parse_runtime_event(text)
if not envelope:
return None
envelope_type = str(envelope.get("type") or "").strip()
# Suppress all the partial-streaming chatter — we surface the full
# message once `assistant` arrives. Without this filter the UI gets
# flooded with raw `content_block_delta` JSON.
if envelope_type in {
"stream_event",
"user", # tool_result echoes — too verbose for the UI
"rate_limit_event",
}:
return None
if envelope_type == "system" and str(envelope.get("subtype") or "") == "init":
session_id = str(envelope.get("session_id") or "").strip()
model = str(envelope.get("model") or "").strip()
bits = [b for b in (model, f"session={session_id[:8]}" if session_id else "") if b]
return f"[External:{self.agent_type}:init] {' '.join(bits) or 'started'}"
if envelope_type == "assistant":
message = envelope.get("message") if isinstance(envelope.get("message"), dict) else None
if not isinstance(message, dict):
return None
content = message.get("content") if isinstance(message.get("content"), list) else []
text_parts: list[str] = []
tool_lines: list[str] = []
for block in content:
if not isinstance(block, dict):
continue
btype = str(block.get("type") or "").strip()
if btype == "text":
chunk = str(block.get("text") or "").strip()
if chunk:
text_parts.append(chunk)
elif btype == "tool_use":
tool_lines.append(self._summarize_tool_use(block))
elif btype == "thinking":
chunk = str(block.get("thinking") or "").strip()
if chunk:
text_parts.append(chunk)
if tool_lines:
# When the assistant turn ends with one or more tool calls,
# surface the tool call(s) — that's the actionable signal.
return f"[External:{self.agent_type}:tool] " + "\n".join(tool_lines)
if text_parts:
joined = "\n\n".join(text_parts)
return f"[External:{self.agent_type}:thinking] {self._trim_text(joined, limit=2400)}"
return None
if envelope_type == "result":
subtype = str(envelope.get("subtype") or "").strip()
result_text = str(envelope.get("result") or "").strip()
if subtype and subtype != "success":
return f"[External:{self.agent_type}:result] {subtype}: {self._trim_text(result_text, limit=600)}"
if result_text:
return f"[External:{self.agent_type}:thinking] {self._trim_text(result_text, limit=2400)}"
return None
return None
def normalize_result_output(self, output: str) -> str:
"""Extract the final assistant message from a stream-json transcript.
Falls back to the raw output if no `result` envelope is found, so
downstream gate logic still has *something* to work with.
"""
last_result_text = ""
last_assistant_text = ""
for line in output.splitlines():
envelope = self._parse_runtime_event(line)
if not envelope:
continue
etype = str(envelope.get("type") or "").strip()
if etype == "result":
rt = str(envelope.get("result") or "").strip()
if rt:
last_result_text = rt
elif etype == "assistant":
message = envelope.get("message") if isinstance(envelope.get("message"), dict) else None
if not isinstance(message, dict):
continue
parts: list[str] = []
for block in message.get("content") or []:
if isinstance(block, dict) and str(block.get("type") or "") == "text":
chunk = str(block.get("text") or "").strip()
if chunk:
parts.append(chunk)
if parts:
last_assistant_text = "\n\n".join(parts)
return last_result_text or last_assistant_text or output
def parse_approval_request(
self,
text: str,
stream_name: str,
) -> ExternalApprovalRequest | None:
# Claude Code's stream-json transport should only surface
# structured approval events here. We intentionally do *not*
# fall back to the base class's free-text prompt parser because
# ordinary assistant output that mentions "allow"/"approve"
# would otherwise stall the broker waiting for human input.
event = self._parse_runtime_event(text)
if not isinstance(event, dict):
return None
if str(event.get("type") or "").strip() == "permission_denial":
return None
return self._parse_generic_json_approval_request(text, stream_name)
async def cancel(self, task_id: str) -> bool:
if self._process and self._process.returncode is None:
self._process.kill()
return True
return False
def _build_permission_args(self) -> list[str]:
common_args = self.build_common_args()
if any(
arg == "--permission-mode" or arg.startswith("--permission-mode=")
for arg in common_args
):
return []
mode = str(self.config.approval_mode or "auto").strip().lower()
if mode == "user-settings":
return []
if mode == "full-auto":
return ["--permission-mode", "bypassPermissions"]
return ["--permission-mode", "auto"]
def _build_session_args(self) -> list[str]:
extra_args = list(self.config.extra_args)
common_args = self.build_common_args()
if any(
arg in {"-c", "--continue", "-r", "--resume"} or arg.startswith("--resume=")
for arg in [*extra_args, *common_args]
):
return []
mode = str(self.config.session_mode or "auto").strip().lower()
if mode != "resume":
return []
session_id = str(self.config.session_id or "").strip()
if session_id:
return ["--resume", session_id]
return ["--continue"]