') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ', 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ', 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ', 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); })(); emrg: Redact LLM error logging — mask response headers + inline credentials in error body by argszero · Pull Request #518 · argszero/emrg · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Agent.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 上下文)

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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

Expand Down
49 changes: 39 additions & 10 deletions emrg/server/llm.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand DownExpand Up@@ -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]
Expand DownExpand Up@@ -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
Expand Down
31 changes: 31 additions & 0 deletions tests/test_llm.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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"
Loading