diff --git a/Agent.md b/Agent.md index cd7c1754..2e0b587e 100644 --- a/Agent.md +++ b/Agent.md @@ -93,7 +93,7 @@ Community needs voiced in HN agent-UI discussions map directly to EMRG's design: pkill -f "emrg.server"; rm -f ~/.emrg/emrgd.port; python -m emrg ``` -Python: `uv run pytest tests/ -v` (490) — import check: `uv run python -c "from emrg.client.app import run_client"` +Python: `uv run pytest tests/ -v` (493) — import check: `uv run python -c "from emrg.client.app import run_client"` GUI: `cd emrg/gui && npm test` (86: 22 daemon_client + 22 app-commands + 17 renderer smoke + 15 i18n + 7 integration + 3 commands) — syntax: `node --check main.js preload.js daemon_client.js renderer/js/*.js` CI: `uv run pytest` + GUI tests + **actionlint workflow lint** (`rhysd/actionlint@v1.7.12` gate, #444 — workflow 解析错误在 PR CI 即失败,如 `if:` secrets 上下文) diff --git a/README.md b/README.md index e7fd85de..ebcec140 100644 --- a/README.md +++ b/README.md @@ -276,7 +276,7 @@ EMRG doesn't just keep up — it catches up on its own. git clone https://github.com/argszero/emrg.git cd emrg uv sync # install deps -uv run pytest tests/ -v # run tests (currently 490 items) +uv run pytest tests/ -v # run tests (currently 493 items) uv run python -m emrg # launch TUI # CI includes actionlint workflow gate (#444): workflow parse errors fail PR CI diff --git a/emrg/server/llm.py b/emrg/server/llm.py index 26e279ee..6bfca750 100644 --- a/emrg/server/llm.py +++ b/emrg/server/llm.py @@ -23,6 +23,31 @@ from emrg import __version__ from emrg.config import LlmConfig + +# ── 错误信息脱敏(20260807-0107)──────────────────────────── +# LLM 错误日志/异常可能包含 response headers(set-cookie/auth 回显)与 body +# (模型回显的密钥/令牌)。复用 daemon._redact_string 的内联凭据遮蔽能力, +# 避免 emrgd.log / 会话历史泄露。 +def _redact_text(text: str) -> str: + """遮蔽字符串中的内联凭据(sk-/ghp_/Bearer/JWT/base64-JSON)。""" + try: + from emrg.server.daemon import _redact_string + return _redact_string(text) + except Exception: + return text + + +def _redact_headers(headers: dict) -> dict: + """遮蔽 response headers 中的敏感键值(set-cookie/authorization/token 等)。""" + sensitive = ("cookie", "set-cookie", "authorization", "token", "api-key", "apikey", "x-api-key") + out = {} + for k, v in headers.items(): + if any(s in k.lower() for s in sensitive): + out[k] = "***" + else: + out[k] = _redact_text(str(v)) + return out + logger = logging.getLogger(__name__) # HTTP status codes that warrant a retry @@ -115,18 +140,22 @@ async def chat( delay = RETRY_BASE_DELAY * (2 ** attempt) logger.warning( "LLM transient error %d, retrying in %.1fs (attempt %d/%d): %s", - resp.status_code, delay, attempt + 1, MAX_RETRIES, text[:200], + resp.status_code, delay, attempt + 1, MAX_RETRIES, + _redact_text(text[:200]), ) await asyncio.sleep(delay) last_error = RuntimeError( - f"LLM request failed: {resp.status_code} - {text}" + f"LLM request failed: {resp.status_code} - {_redact_text(text)}" ) continue - hdr = dict(resp.headers) - logger.error("LLM error: %s headers=%s body=%s", resp.status_code, hdr, text) + # 错误日志与异常信息脱敏:response headers 可能回显 set-cookie/auth, + # body 可能含敏感回显;统一经脱敏 + 截断(防止 API key 等泄露到 emrgd.log / 会话)。 + hdr = _redact_headers(dict(resp.headers)) + text_redacted = _redact_text(text) + logger.error("LLM error: %s headers=%s body=%s", resp.status_code, hdr, text_redacted[:2000]) raise RuntimeError( - f"LLM request failed: {resp.status_code} headers={hdr} body={text}" + f"LLM request failed: {resp.status_code} headers={hdr} body={text_redacted[:2000]}" ) raise last_error # type: ignore[misc] @@ -186,18 +215,18 @@ async def chat_stream( "LLM stream transient error %d, retrying in %.1fs " "(attempt %d/%d): %s", resp.status_code, delay, attempt + 1, MAX_RETRIES, - text[:200], + _redact_text(text[:200]), ) await asyncio.sleep(delay) last_error = RuntimeError( - f"LLM stream request failed: {resp.status_code} - {text}" + f"LLM stream request failed: {resp.status_code} - {_redact_text(text[:500])}" ) continue - logger.error("LLM stream error: %s %s", resp.status_code, text[:500]) - hdr = dict(resp.headers) + logger.error("LLM stream error: %s %s", resp.status_code, _redact_text(text[:500])) + hdr = _redact_headers(dict(resp.headers)) raise RuntimeError( f"LLM stream request failed: {resp.status_code} " - f"headers={hdr} body={text[:1000]}" + f"headers={hdr} body={_redact_text(text[:1000])}" ) # Capture response metadata for llm.jsonl logging diff --git a/tests/test_llm.py b/tests/test_llm.py index f9b5ea8e..f8b849da 100644 --- a/tests/test_llm.py +++ b/tests/test_llm.py @@ -140,3 +140,34 @@ def test_payload_temperature_default(): c = LlmClient(default_cfg) p = c._make_payload([{"role": "user", "content": "x"}]) assert p["temperature"] == 0.7 + + +# ── LLM 错误信息脱敏(20260807-0107)────────────────────────── + + +def test_redact_headers_masks_sensitive(): + """response headers 敏感键(set-cookie/authorization/token)被遮蔽。""" + from emrg.server.llm import _redact_headers + h = {"set-cookie": "session=abc; HttpOnly", "content-type": "application/json", + "x-request-id": "req-123", "x-api-key": "sk-A1b2C3d4A1b2C3d4A1b2C3d4A1b2C3d4"} + r = _redact_headers(h) + assert r["set-cookie"] == "***" + assert r["x-api-key"] == "***" + assert r["content-type"] == "application/json" + assert r["x-request-id"] == "req-123" + + +def test_redact_headers_masks_inline_secret_in_values(): + """非敏感键但值内联密钥也被遮蔽(如 server 回显 x-error: invalid sk-...)。""" + from emrg.server.llm import _redact_headers + r = _redact_headers({"x-error": "invalid key sk-A1b2C3d4A1b2C3d4A1b2C3d4A1b2C3d4"}) + assert "sk-" not in r["x-error"] + assert "invalid key ***" in r["x-error"] + + +def test_redact_text_masks_inline_credentials(): + """LLM 错误 body 内联凭据被遮蔽,普通文本保留。""" + from emrg.server.llm import _redact_text + assert "sk-" not in _redact_text("bad key sk-A1b2C3d4A1b2C3d4A1b2C3d4A1b2C3d4 supplied") + assert "ghp_" not in _redact_text("token ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890 rejected") + assert _redact_text("rate limit exceeded, try later") == "rate limit exceeded, try later"