agent-Specialization/modules/persistent_terminal/io.py
JOJO 2c2c5aba4d fix(modules): 修复 Windows UTF-8 编码链(对照 Codex #7290 类问题)
- persistent_terminal 解码写死 GBK 改为 UTF-8 优先 + GBK 回退逐块自适应,GBK 边界感知 + _pending_bytes 暂存半字,进程结束冲刷

- start.py:Windows 默认 cmd.exe 启动后注入 chcp 65001,与 PYTHONIOENCODING=utf-8 对齐,消除输出/解码自相矛盾

- 5 处 subprocess text=True 补 encoding=utf-8(后台命令x3、MCP stdio、版本管理、病毒扫描、容器管理),照 server/status/git.py 示范

- main.py:补 PYTHONUTF8/PYTHONIOENCODING 兜底 + stdout/stderr reconfigure;修复 print 中被双重编码固化的 emoji

- docx 验证器 XML 改二进制读取,由 lxml 按声明自解码
2026-07-31 22:39:51 +08:00

318 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# modules/persistent_terminal.py - 持久化终端实例(修复版)
import asyncio
import subprocess
import os
import sys
import time
import signal
from pathlib import Path
from typing import Optional, Callable, Dict, List, Tuple
from datetime import datetime
import threading
import queue
from collections import deque
import shutil
import uuid
import codecs
from modules.host_sandbox_runner import (
HostSandboxError,
build_host_sandbox_shell_plan,
host_sandbox_enabled,
)
try:
from config import (
OUTPUT_FORMATS,
TERMINAL_OUTPUT_WAIT,
TERMINAL_INPUT_MAX_CHARS,
TERMINAL_SANDBOX_MODE,
TERMINAL_SANDBOX_IMAGE,
TERMINAL_SANDBOX_MOUNT_PATH,
TERMINAL_SANDBOX_SHELL,
TERMINAL_SANDBOX_NETWORK,
TERMINAL_SANDBOX_CPUS,
TERMINAL_SANDBOX_MEMORY,
TERMINAL_SANDBOX_BINDS,
TERMINAL_SANDBOX_BIN,
TERMINAL_SANDBOX_NAME_PREFIX,
TERMINAL_SANDBOX_ENV,
TERMINAL_SANDBOX_REQUIRE,
)
except ImportError:
import sys
from pathlib import Path
project_root = Path(__file__).resolve().parents[1]
if str(project_root) not in sys.path:
sys.path.insert(0, str(project_root))
from config import (
OUTPUT_FORMATS,
TERMINAL_OUTPUT_WAIT,
TERMINAL_INPUT_MAX_CHARS,
TERMINAL_SANDBOX_MODE,
TERMINAL_SANDBOX_IMAGE,
TERMINAL_SANDBOX_MOUNT_PATH,
TERMINAL_SANDBOX_SHELL,
TERMINAL_SANDBOX_NETWORK,
TERMINAL_SANDBOX_CPUS,
TERMINAL_SANDBOX_MEMORY,
TERMINAL_SANDBOX_BINDS,
TERMINAL_SANDBOX_BIN,
TERMINAL_SANDBOX_NAME_PREFIX,
TERMINAL_SANDBOX_ENV,
TERMINAL_SANDBOX_REQUIRE,
)
def _gbk_complete_prefix_len(data: bytes) -> int:
"""返回 data 中按 GBK 可完整解析的前缀长度。
GBKASCII 单字节0x00-0x7F双字节 lead 0x81-0xFE + trail 0x40-0xFE不含 0x7F
末尾孤立的 lead byte 视为被块边界切断的半字,不计入前缀(留给下一块拼合)。
"""
i = 0
n = len(data)
while i < n:
b = data[i]
if b <= 0x7F:
i += 1
elif 0x81 <= b <= 0xFE:
if i + 1 < n and 0x40 <= data[i + 1] <= 0xFE and data[i + 1] != 0x7F:
i += 2
else:
break # 孤立 lead byte可能是边界切断的半字
else:
i += 1 # 0x80/0xFF 非法字节,按单字节跳过(由 replace 兜底)
return i
class IoMixin:
"""PersistentTerminal io 能力 mixin。"""
def _read_output(self):
"""后台线程:持续读取输出。
Windows 下不再写死 GBKcmd 会话启动时已注入 chcp 65001见 start.py
且 PYTHONIOENCODING=utf-8 使子进程 Python 程序输出 UTF-8但用户可能
手动 chcp 回 936、或运行只输出 GBK 的旧程序。因此逐块自适应解码:
先试 UTF-8失败则整块按 GBK块边界切断的多字节序列用
_pending_bytes 暂存,与下一块拼合后再解(见 _decode_chunk
"""
while self.is_reading and self.process:
try:
# 读取任意字节块,不依赖换行符
chunk = self.process.stdout.read(1024)
if chunk:
text = self._decode_chunk(chunk)
if text:
self.output_queue.put(text)
self._process_output(text)
elif self.process.poll() is not None:
# 进程已结束
self.is_running = False
break
else:
# 没有输出,短暂休眠
time.sleep(0.01)
except Exception as e:
# 不要因为单个错误而停止
print(f"[Terminal] 读取输出警告: {e}")
time.sleep(0.01)
continue
# 进程结束后冲刷残留的不完整字节(多为被截断的多字节序列半字)
pending = getattr(self, '_pending_bytes', b'')
if pending:
fallback = 'gbk' if self.is_windows else 'utf-8'
text = pending.decode(fallback, errors='replace')
self._pending_bytes = b''
if text:
self.output_queue.put(text)
self._process_output(text)
def _decode_chunk(self, chunk: bytes) -> str:
"""把 stdout 字节块解码为文本WindowsUTF-8 优先、GBK 回退)。
判定依据UTF-8 有严格的多字节结构校验GBK 字节流极少恰好构成
合法 UTF-8因此「UTF-8 能解」是强信号;解不了再按 GBK。
两种路径都会把块末尾不完整的多字节序列暂存 _pending_bytes
与下一块拼合后再解,避免 read 边界切断汉字。
已知限制业界通病VS Code/Windows Terminal 同样无法处理):
极少数 GBK 汉字对的字节恰好也是合法 UTF-8如「目录」= C4BF C2BC
会解为 Ŀ¼),此时若后续块紧跟 UTF-8 内容会连锁误判。终端会话已
注入 chcp 65001start.py正常路径全程 UTF-8GBK 仅为兜底,
该巧合仅影响显示、不丢数据,故不做「罕见字符惩罚」式启发式猜测。
"""
data = getattr(self, '_pending_bytes', b'') + chunk
self._pending_bytes = b''
if not data:
return ''
if not self.is_windows:
return data.decode('utf-8', errors='replace')
try:
return data.decode('utf-8')
except UnicodeDecodeError as exc:
# 末尾 1~3 字节可能是不完整 UTF-8 序列read 边界切断),暂存待拼
if exc.start >= len(data) - 3:
head, pending = data[:exc.start], data[exc.start:]
try:
text = head.decode('utf-8')
self._pending_bytes = pending
return text
except UnicodeDecodeError:
pass # head 也含非法序列 → 并非 UTF-8 流,落入 GBK 路径
# 非法序列位于中部(或 UTF-8 边界暂存失败)→ 确定不是 UTF-8
# (如 cmd 内置命令的 GBK 输出),按 GBK 边界感知解码
return self._decode_gbk_boundary(data)
def _decode_gbk_boundary(self, data: bytes) -> str:
"""按 GBK 解码,末尾不完整的双字节序列(被切断的半字)暂存待拼。"""
safe_len = _gbk_complete_prefix_len(data)
self._pending_bytes = data[safe_len:]
return data[:safe_len].decode('gbk', errors='replace')
def _decode_output(self, data):
"""安全地解码输出"""
# 如果已经是字符串,直接返回
if isinstance(data, str):
return data
# 如果是字节,尝试解码
if isinstance(data, bytes):
# Windows系统尝试的编码顺序
if self.is_windows:
encodings = ['utf-8', 'gbk', 'gb2312', 'cp936', 'latin-1']
else:
encodings = ['utf-8', 'latin-1']
for encoding in encodings:
try:
return data.decode(encoding)
except (UnicodeDecodeError, AttributeError):
continue
# 如果所有编码都失败,使用替换模式
return data.decode('utf-8', errors='replace')
# 其他类型,转换为字符串
return str(data)
def _process_output(self, output: str):
"""处理输出行"""
now = time.time()
noisy_markers = (
"bash: cannot set terminal process group",
"bash: no job control in this shell",
)
for line in output.splitlines(keepends=True):
if any(noise in line for noise in noisy_markers):
continue
self.output_buffer.append(line)
self.total_output_size += len(line)
now = time.time()
self.last_output_time = now
# 记录输出事件
self._output_event_counter += 1
self.output_history.append((self._output_event_counter, now, line))
self._append_io_event('output', line, timestamp=now)
# 控制输出历史长度
if len(self.output_history) > 2000:
self.output_history.popleft()
# 检查是否需要截断
if self.total_output_size > self.max_buffer_size:
self._truncate_buffer()
# 更新活动时间
self.last_activity = now
# 检测命令回显死循环
cleaned_output = output.replace('\r', '').strip()
cleaned_input = self.last_input_text.strip() if self.last_input_text else ""
if cleaned_output and cleaned_input and cleaned_output == cleaned_input:
self._consecutive_echo_matches += 1
else:
self._consecutive_echo_matches = 0
if cleaned_output:
self.echo_loop_detected = False
if self._consecutive_echo_matches >= 1 and self.last_input_time:
if now - self.last_input_time <= 2:
self.echo_loop_detected = True
# 检测交互式提示
self._detect_interactive_prompt(output)
# 广播输出
if self.broadcast:
self.broadcast('terminal_output', {
'session': self.session_name,
'data': output,
'timestamp': time.time()
})
def _truncate_buffer(self):
"""截断缓冲区以保持在限制内"""
# 保留最后的N个字符
while self.total_output_size > self.max_buffer_size and self.output_buffer:
removed = self.output_buffer.pop(0)
self.total_output_size -= len(removed)
self.truncated_lines += 1
if self.output_history:
self.output_history.popleft()
def _detect_interactive_prompt(self, output: str):
"""检测是否在等待交互输入"""
self.is_interactive = False
# 常见的交互提示模式
interactive_patterns = [
"? ", # 问题提示
": ", # 输入提示
"> ", # 命令提示
"$ ", # shell提示
"# ", # root提示
">>> ", # Python提示
"... ", # Python续行
"(y/n)", # 确认提示
"[Y/n]", # 确认提示
"Password:", # 密码提示
"password:", # 密码提示
"Enter", # 输入提示
"选择", # 中文选择
"请输入", # 中文输入
]
output_lower = output.lower().strip()
for pattern in interactive_patterns:
if pattern.lower() in output_lower:
self.is_interactive = True
return
# 如果输出以常见提示符结尾且没有换行,也认为是交互式
if output and not output.endswith('\n'):
last_chars = output.strip()[-3:]
if last_chars in ['> ', '$ ', '# ', ': ']:
self.is_interactive = True
def _capture_history_marker(self) -> int:
return self._output_event_counter
def _get_output_since_marker(self, marker: int) -> str:
if marker is None:
return ''.join(item[2] for item in self.output_history)
return ''.join(item[2] for item in self.output_history if item[0] > marker)
def _append_io_event(self, event_type: str, data: str, timestamp: Optional[float] = None):
"""记录终端输入输出事件"""
if timestamp is None:
timestamp = time.time()
self.io_history.append((event_type, timestamp, data))
while len(self.io_history) > self._io_history_max:
self.io_history.popleft()
def _seconds_since_last_output(self) -> Optional[float]:
if not self.last_output_time:
return None
return round(time.time() - self.last_output_time, 3)