Merge pull request 'fix(claude_session): surface real CLI error + sanitize nested env (#85)' (#90) from worktree-task85-claude-session-nested into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m27s

This commit was merged in pull request #90.
This commit is contained in:
2026-06-06 19:30:22 +00:00
2 changed files with 87 additions and 2 deletions

View File

@@ -29,6 +29,7 @@ from __future__ import annotations
import asyncio
import json
import logging
import os
from legal_mcp.config import parse_llm_json
@@ -40,6 +41,32 @@ logger = logging.getLogger(__name__)
DEFAULT_TIMEOUT = 1800
LONG_TIMEOUT = 3600 # opus block writing on full case context
# Environment markers a running Claude Code session exports into its child
# processes. When an agent itself runs as ``claude -p`` (e.g. the CEO MCP
# instance launched by Paperclip's claude_local adapter), its MCP server
# inherits these, and a *nested* ``claude -p`` we spawn here inherits them
# too. Stripping them makes every nested invocation launch as a clean
# top-level session, decoupled from the parent's session/project state —
# defensive hardening against the #85 nested-``exit 1`` failure (which could
# not be reproduced in a plain interactive session, so the markers are a
# suspect, not a proven cause). Auth/config vars (CLAUDE_CONFIG_DIR,
# ANTHROPIC_*, PATH, HOME) are kept. See TaskMaster legal-ai #85.
_SESSION_MARKER_PREFIXES = ("CLAUDECODE", "CLAUDE_CODE_", "CLAUDE_AGENT_")
_SESSION_MARKER_EXACT = frozenset({"AI_AGENT", "CLAUDE_EFFORT"})
def _clean_subprocess_env() -> dict[str, str]:
"""Copy the current env minus Claude Code session markers.
Lets a nested ``claude -p`` start fresh instead of detecting it is
already inside a Claude Code session (the #85 nested-exit-1 bug).
"""
env = dict(os.environ)
for key in list(env):
if key in _SESSION_MARKER_EXACT or key.startswith(_SESSION_MARKER_PREFIXES):
del env[key]
return env
async def query(
prompt: str,
@@ -100,6 +127,8 @@ async def query(
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
env=_clean_subprocess_env(),
cwd=os.path.expanduser("~"),
)
except FileNotFoundError:
raise RuntimeError(
@@ -125,9 +154,20 @@ async def query(
raise RuntimeError(f"Claude CLI timed out after {timeout}s")
if proc.returncode != 0:
stderr = stderr_b.decode("utf-8", errors="replace").strip()[:500] or "unknown error"
# Surface the real cause: the CLI sometimes writes its diagnostic to
# stdout (or nowhere) rather than stderr, so a stderr-only message
# collapsed to "unknown error" and hid the actual failure (#85).
stderr = stderr_b.decode("utf-8", errors="replace").strip()
stdout = stdout_b.decode("utf-8", errors="replace").strip()
diagnostic = stderr or stdout or "no output on stderr/stdout"
size_info = f"; prompt_len={len(full_prompt):,} chars" if len(full_prompt) > 100_000 else ""
raise RuntimeError(f"Claude CLI failed (exit {proc.returncode}): {stderr}{size_info}")
logger.error(
"claude -p failed (exit %s): stderr=%r stdout=%r",
proc.returncode, stderr[:500], stdout[:500],
)
raise RuntimeError(
f"Claude CLI failed (exit {proc.returncode}): {diagnostic[:500]}{size_info}"
)
stdout = stdout_b.decode("utf-8", errors="replace").strip()
if not stdout:
@@ -232,6 +272,7 @@ async def query_streaming(
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=cwd,
env=_clean_subprocess_env(),
)
except FileNotFoundError:
yield {