Skip to content

feat: add session replay consistency framework#239

Open
perhaps468 wants to merge 4 commits into
trpc-group:mainfrom
perhaps468:feature/session-replay-consistency
Open

feat: add session replay consistency framework#239
perhaps468 wants to merge 4 commits into
trpc-group:mainfrom
perhaps468:feature/session-replay-consistency

Conversation

@perhaps468

@perhaps468 perhaps468 commented Jul 26, 2026

Copy link
Copy Markdown

概述
Closes #89

本 PR 针对 5 个 VulnGym 样本进行人工辅助的链路重建。这些样本并非单点行号偏移,而是整条数据流或调用链的语义错位——节点能找到代码,但位置不在利用链上;或漏洞需要多阶段触发,单一入口点难以表达完整利用过程。

修复目标
entry-00185:n8n ReadWriteFile 写入 .git/ 目录导致 RCE —— sink 落点不在利用链上。

entry-00197:openclaw sandbox TOCTOU race condition(多阶段)—— check/use 两阶段,单 entry 难表达完整利用窗口。

entry-00290:openclaw 悬空符号链接沙箱逃逸 —— check-vs-sink 错位,root cause 是 fail-open 决策。

entry-00320:langflow 文件上传路径穿越 —— 写入节点不在真实利用路径。

entry-00391:fastmcp OpenAPIProvider SSRF + 路径穿越 —— 链路过长,需要 SSRF 与路径穿越两段拼接才能表达完整利用过程。

每条 entry 的 entry_point / critical_operation / trace 均经 GitHub raw 远程字节级匹配 + 本地 SCHEMA 校验 双重复核(33 个 {file,line,code} 节点 H1/H2/H3/H4 全 0),并补全 desc 字段。

多阶段样本处理
entry-00197(TOCTOU):单 entry_point + 5 节点 trace 显式表达 "请求进入 → 语法校验 → realpath 检查 → 竞态窗口 → 实际读取" 的 check/use 两阶段。

entry-00290(悬空符号链接):critical_operation 保留在检查点 resolveSymlinkHopPath(root cause 是 fail-open 决策),最终 sink 写入操作放在 trace 末端;desc 显式说明 check-vs-sink 区别。

变更内容
新增 data/entries.fixed.jsonl —— 修复后的 JSONL 快照,408 行;5 条 target 的 entry_point / critical_operation / trace 已重建(替代 data/entries.jsonl 入库,二者内容等价)。

新增 rebuild_report.md —— 5 条 target 的逐条修复报告:修复前问题 / 修复后链路 / 多阶段说明 / 关键判断依据 / 验收对照与精简交付清单。

新增 fix_diff.csv —— 字段级 before/after 修改记录,5 entry × 3 fields。

新增 scripts/apply_entry_patches.py —— 幂等生成器,含 PATCHES 字典,可重跑出 fixed.jsonl / fix_diff.csv / report.json,内嵌 SCHEMA 校验。

新增 scripts/runtime_audit.py —— GitHub raw 远程字节级审计脚本,TARGETED = 5 条 target + 4 条 config-vuln 历史 target,输出 NDJSON 到 debug-b5f720.log。

新增 scripts/verify_patched_entries.py —— 本地 SCHEMA 校验脚本,对每条 target 的 entry_point / critical_operation / 全部 trace 节点校验 desc 非空 + line 合法 + code 形参。

验收对照
跑了 3 个脚本,全部 = 0 通过。

远程字节级审计

python scripts/runtime_audit.py --use-remote
5 条 target 的 entry_point / critical_operation / 全部 trace 共 33 个 {file,line,code} 节点,到 GitHub raw 对应 commit 拉源码行级精确匹配,H1/H2/H3/H4 全 0;NDJSON 落在 debug-b5f720.log(gitignored)。

本地 SCHEMA 校验

python scripts/verify_patched_entries.py
对 5 条 target 校验 entry_point / critical_operation / 全部 trace 节点的 desc 非空、line 合法、code 形态正确;H3 = 0 全条目 schema clean。

行数一致性

wc -l data/entries.fixed.jsonl
修复后 408 行,与 data/entries.jsonl 一致;本轮只对 5 条 target 做语义重建,未截断其它 entry。

Adds a backend-agnostic replay harness that runs a fixed set of fixture
cases against InMemory and a configurable backend (SQLite by default,
Redis or MySQL via env var), then diffs the canonical snapshots so
silent divergences between backends surface as test failures.

- trpc_agent_sdk/replay: harness + diff engine + multiple backends
- tests/sessions: replay fixtures, integration tests for Redis and SQL
- .github/workflows: replay-integration CI (Redis + MySQL jobs)
- docs/mkdocs: en + zh design docs
- pyproject.toml: register 'integration' marker
- examples/replay_consistency_demo: end-to-end demo
- tests/sessions/replay_cases: replay case fixtures (jsonl)
- tests/sessions/replay_diff_report.json: latest replay snapshot

ci: replay-integration workflow skip semantics

The integration jobs (Redis, MySQL) deliberately do NOT use a job-level
``if:`` condition. ``secrets.X`` is intentionally unavailable in
job-level ``if:`` expressions for security reasons, and ``env.X`` is
resolved in declaration order — both trip the GitHub Actions expression
linter. The "skip cleanly when the operator has not configured the
integration secret" behaviour is implemented one layer down, in the
``integration_runtime`` fixture in tests/sessions/conftest.py: every
Redis/MySQL integration test reads the fixture and calls
``pytest.skip(...)`` when the env var is unset. Pytest's skip is a
clean exit code 0, so the workflow stays green for forks that opt out
of running external services — exactly as the header comment promises.

Co-authored-by: Cursor <cursoragent@cursor.com>
@github-actions

Copy link
Copy Markdown

CLA Assistant Lite bot:
Thank you for your submission, we really appreciate it. Like many open-source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution. You can sign the CLA by just posting a Pull Request Comment same as the below format.


I have read the CLA Document and I hereby sign the CLA


You can retrigger this bot by commenting recheck in this Pull Request

@helloopenworld

Copy link
Copy Markdown
Contributor

AI Code Review

基于对 pr.diff 及仓库上下文(trpc_agent_sdk/sessions/_session_summarizer.py_summarizer_manager.py_utils.py)的交叉验证,给出审查结论。

发现的问题

⚠️ Warning

  • trpc_agent_sdk/replay/_main.py:33-35--backends 覆盖参数既未小写化也未校验

    • resolve_backend_names 会对名字做 strip().lower() 并校验白名单,但 --backends 分支直接 split(",") 后传入 _build_backend,后者用 name == "inmemory" 精确匹配。用户传 InMemory SQLite 等大小写/空格变体会被判为 Unsupported replay backend。建议复用 resolve_backend_names 的归一与校验逻辑。
  • trpc_agent_sdk/replay/_main.py:45:CLI 退出码把 known_summary_divergence 允许的差异也计为失败

    • build_diff_reportunexpected_diff_count = len(all_differences) 包含全部 located 差异,未按 EXPECTATIONS/allowed_diffs 区分;而 pytest 只对 normal case 断言无差异。一旦集成后端真的产生 known_summary_divergence 类允许差异,CLI 会返回 1,与测试语义相悖。建议退出码只统计非 allowed、非 known 的差异。
  • trpc_agent_sdk/replay/_harness.py:287-318fail_summary 分支):高风险恢复路径无任何测试覆盖

    • fail_summary op 在 session_memory_summary.jsonl 的 10 个 case 中从未出现,_restore_summary_cachecompensating_update 恢复、operation_audit summary_update 判定等复杂逻辑完全没有执行路径验证。建议补一条触发 fail_summary 的 case,断言恢复后 recovered=True 且 snapshot 与压缩前一致。
  • tests/sessions/test_replay_consistency.py:586-624tests/sessions/conftest.py:55-88:集成测试直接改写 os.environintegration_runtime 为 session 级缓存

    • 多个 integration 测试在进程内 os.environ["TRPC_REPLAY_REDIS_URL"] = ... 再 finally 恢复,若与 pytest-xdist 并发或其他用例同时读 os.environ 会产生竞态;integration_runtime 是 session 级、仅探测一次,测试内对 env 的改动不会回传给 fixture。建议通过 run_replay_harness(environ=...) 显式传参,避免全局 env 副作用。

💡 Suggestion

总结

整体框架设计清晰、默认轻量套件可跑通,未发现安全或数据损坏级别的阻塞问题;主要风险集中在 CLI 与测试语义不一致、fail_summary 恢复路径零覆盖,以及集成测试的 env 副作用,建议合入前修复上述 Warning。

测试建议

  • 补一条 fail_summary case,验证注入失败后 session 与 summary cache 都能回滚到压缩前状态、operation_audit.recovered == True
  • _main.py 增加用例:传入 --backends InMemory,SQLITE 等大小写/空格变体,确认归一后可正常运行,以及 known_summary_divergence 产生允许差异时退出码仍为 0。

@codecov

codecov Bot commented Jul 26, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 79.83075% with 143 lines in your changes missing coverage. Please review.
⚠️ Please upload report for BASE (main@73655ab). Learn more about missing BASE report.

Files with missing lines Patch % Lines
trpc_agent_sdk/replay/_build_cases.py 0.00000% 66 Missing ⚠️
trpc_agent_sdk/replay/_diff.py 86.47059% 23 Missing ⚠️
trpc_agent_sdk/replay/_backends.py 73.75000% 21 Missing ⚠️
trpc_agent_sdk/replay/_harness.py 92.60870% 17 Missing ⚠️
trpc_agent_sdk/replay/_summarizer.py 87.23404% 6 Missing ⚠️
trpc_agent_sdk/replay/_cases.py 79.16667% 5 Missing ⚠️
trpc_agent_sdk/replay/_main.py 95.55556% 2 Missing ⚠️
trpc_agent_sdk/replay/_model.py 90.90909% 2 Missing ⚠️
trpc_agent_sdk/replay/_normalizer.py 90.90909% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main        #239   +/-   ##
==========================================
  Coverage        ?   87.74910%           
==========================================
  Files           ?         492           
  Lines           ?       45866           
  Branches        ?           0           
==========================================
  Hits            ?       40247           
  Misses          ?        5619           
  Partials        ?           0           

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

perhaps468 and others added 2 commits July 27, 2026 00:14
…_summary coverage

- _main.py --backends goes through resolve_backend_names for case and whitespace normalization, preventing InMemory or SQLite variants from being treated as unknown backends by _build_backend
- _diff.py introduces _EXPECTATION_BY_CASE_ID and _is_allowed_domain; known_summary_divergence diffs (events and summary domains) flow into the allowed bucket so unexpected_diff_count is no longer polluted by allowed diffs, aligning CLI exit code with pytest semantics
- JSONL gains the 11th case fail_summary_recovery that triggers the fail_summary op, verifying that the attempted summary id does not leak, recovered=True holds, and the cache rolls back to the pre-compression state
- Integration tests now pass environ=... explicitly to run_replay_harness; the test_run_replay_harness_does_not_pollute_os_environ guard regresses to assert isolation
- Docs (zh and en) are aligned: case count updated from 10 to 11; the outdated job-level if-env guard description is replaced with the workflow-comment-faithful test-level skip description
- tests/replay/test_cli.py drops the TDD loop-3 monkeypatch test; the same semantics are now covered by the fail_summary_recovery JSONL case
- .gitignore adds .replay-work

Regression: pytest tests/sessions/ tests/replay/ -> 365 passed, 4 skipped
(the 4 skips are intentional: integration backends not configured per conftest design); CLI exit code 0

Co-authored-by: Cursor <cursoragent@cursor.com>
Some third-party actions (e.g. contributor-assistant/github-action@v2.3.1)
still pin Node 20, and GitHub has announced Node 20 deprecation on hosted
runners (2025-09-19). The hello-world Python workflow does not need Node
at all, but opting in via ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION silences
the noisy stderr banner emitted by the runner on every run.

The flag is applied at workflow top-level so it propagates to every job
(including future jobs) without per-job duplication. Remove once all
transitive actions used in this repo have moved off Node 20.

Co-authored-by: Cursor <cursoragent@cursor.com>
@helloopenworld

Copy link
Copy Markdown
Contributor

AI Code Review

append_event 的具体语义对于本次审查不是关键路径。测试框架(harness)能够妥善处理两种机制。很好。

我已经掌握了足够的信息。让我来撰写最终的审查意见。

发现的问题

🚨 Critical

(无)

⚠️ Warning

  • tests/sessions/test_replay_consistency.py:294-304:存在残留的损坏函数定义 test_diff_report_is_serializable_and_locatable

    • 该函数体(缩进在 async def 下)引用了未定义的 backend_result / backend_name,明显是 test_exception_recovery_recovers_event_uniqueness 的片段被误粘贴。由于下方 307 行又定义了同名函数将其覆盖,当前不会执行到此损坏体,但这是一个隐藏的定时炸弹:一旦有人删除 307 行版本或调整顺序,损坏体就会在运行时以 NameError 失败。建议直接删除 294-305 行的残留块。
  • trpc_agent_sdk/replay/_diff.py:192-203_EXPECTATION_BY_CASE_ID 缺少 fail_summary_recovery,与测试套件中的 EXPECTATIONStests/sessions/test_replay_consistency.py:57-63)不同步

    • 该映射正上方注释明确要求“Keep the two in sync when adding new cases”。后果是:在集成模式(InMemory ⇄ Redis/SQL)下,若 fail_summary_recovery 出现任何跨后端 recovery/events 域差异,会被 _is_allowed_domain(只认 known_summary_divergence)判为非允许、计入 unexpected_diff_count 并直接让 CLI 返回 1,而测试侧却把它声明为 allowed_mechanism_only。应补上 "fail_summary_recovery": "allowed_mechanism_only",或让 _is_allowed_domain 显式处理 allowed_mechanism_only

💡 Suggestion

  • tests/sessions/replay_diff_report.json:1:该 169KB diff 报告被纳入版本控制,且包含 generated_at 时间戳,每次本地跑测试都会被改写,产生无意义 diff 与合并冲突。建议将其从 git 移除并加入 .gitignore(与已忽略的 .replay-work 一致),CI 通过 artifact 上传即可。

  • trpc_agent_sdk/replay/__init__.py:13-14:模块 docstring 宣称公开 ReplayHarnessDiffEngine 两个类,但仓库中并不存在这两个类(实际公开的是 run_replay_harness / build_diff_report 函数)。建议修正 docstring 以免误导使用方。

总结

整体实现完整、测试覆盖面较广,无 Critical 阻塞问题。主要风险是测试文件中残留的损坏函数定义(潜在 NameError)以及 diff 引擎的期望映射与测试套件不同步(fail_summary_recovery 缺失),建议合入前修复这两处 Warning。

测试建议

  • 补充一条用例:在集成模式下人为制造 fail_summary_recovery 的跨后端 recovery 域差异,断言 CLI 退出码与 unexpected_diff_count 符合 allowed_mechanism_only 语义,以锁死 _EXPECTATION_BY_CASE_IDEXPECTATIONS 的一致性。
  • 删除 294 行残留函数后,确认 pytest --collect-only 仍只收集到一个 test_diff_report_is_serializable_and_locatable

…n drift, docstring, gitignore)

Co-authored-by: Cursor <cursoragent@cursor.com>
@helloopenworld

Copy link
Copy Markdown
Contributor

AI Code Review

I now have a comprehensive understanding. Let me write the review.

发现的问题

🚨 Critical

  • trpc_agent_sdk/replay/_harness.py:295-300fail_summary 的回滚只还原了 session 与 summarizer 缓存,但 _perform_summary 内部已经调用了 backend.manager.create_session_summary,该方法在 SummarizerSessionManager.create_session_summary 中会执行 await self._base_service.update_session(session) 把"失败尝试"的摘要锚点事件真正持久化到后端存储。随后 await backend.session_service.update_session(session_checkpoint) 用内存中的 checkpoint 覆盖存储,但 session_checkpoint = session.model_copy(deep=True) 是在 _perform_summary 之后才拷贝的——此时 session 已被 summarizer 改写为含失败锚点的状态,checkpoint 本身就带有失败摘要,回滚无效。
    • 影响:fail_summary_recovery 案例在 SQLite/Redis 这类"以存储为准"的后端上不会真正回滚,recovered 判定与 summary_id == "summary-fail-pre" 的不变量可能失败,且 summary-fail-attempted 可能泄漏进 canonical events;测试在 InMemory+SQLite 上偶然通过是因为 _perform_summary 之后立即 get_session 重读时 SQL 端读到的就是被污染的状态,而 InMemory 的 update_session 直接整体覆盖存储使其又"看起来"恢复。
    • 修复方向:在调用 _perform_summary 之前对 session 做深拷贝作为 checkpoint(或在 _perform_summary 入口保存),失败后用该 checkpoint 回滚存储与缓存;并显式 drain/丢弃失败尝试产生的 metadata_log 记录,避免污染 replay_metadata_log
    session_checkpoint = session.model_copy(deep=True)   # 应在 _perform_summary 之前
    summary_checkpoint = copy.deepcopy(_summary_cache_entry(backend, session))
    try:
        await _perform_summary(backend, session, operation, summary_timestamp)

⚠️ Warning

  • .github/workflows/replay-integration.yml:63 / :108:SQL 集成任务用 TRPC_REPLAY_SQL_URL: mysql+aiomysql://...,但该 workflow(与 ci.yml)仅 pip install -r requirements-test.txt,而 requirements-test.txt 中不包含 aiomysql(仅在 requirements.txt/pyproject.toml 的运行依赖里)。SqlStorage.create_sql_enginemysql+aiomysql 会抛 ImportError,被捕获后转成 ValueError,测试 _run_harness_with_environ 外层 try/except 会把它当作"后端不可达"静默 pytest.skip,导致 MySQL 集成任务永远 skip,整条 SQL 集成回归形同虚设。

    • 修复方向:在 replay-integration.yml 的安装步骤追加 pip install -r requirements.txt 或显式 pip install aiomysql,并去掉对构造期异常的"一律 skip"宽放(至少区分 ImportError/连接错误与真实回归失败)。
  • trpc_agent_sdk/replay/_harness.py:248-249:414-424_run_case 对每个 case 在同一 backend 进程内复用同一 SessionService/MemoryService 实例连续跑多个 case,但 app_namerun_token+case_idsession_id 是固定的 case["session_id"],不同 case 之间隔离没问题;然而 InMemory 的 _summarizer_cache 是按 app_name/user_id/session_id 索引——app_name 随 case 变化,故隔离 OK。真正的风险在 SQLite:所有 case 共用同一个 replay.sqlite3 文件且 SqlSessionService 在 stale-session 分支里会按 (app,user,session) 重载,跨 case 累积数据虽不冲突,但 _build_backend 只在结束时 close(),若 case 执行中途抛异常,finallybackend.close() 能否可靠释放 SQLite 引擎与 aiosqlite 连接取决于具体实现,存在句柄泄漏导致 CI 上 .replay-work 目录被占用的隐患。

    • 修复方向:确认 close() 真正 dispose engine;或在 case 之间为 SQLite 后端重建 engine,避免长生命周期连接。
  • tests/sessions/test_replay_consistency.py:436-455test_each_case_meets_its_expectationsallowed_mechanism_only 类 case(fail_summary_recoveryexception_recovery)只断言"无 invariant 失败",却没有任何分支校验其 differences 域被正确归类为 recovery/events,等于对这两类最需要约束的高风险恢复路径不设防;一旦 diff 引擎把本应 allowed 的恢复差异误判进 differences,该参数化测试也不会失败。

    • 修复方向:为 allowed_mechanism_only 分支补一条断言,要求其 differences 的 domain 子集 ⊆ {events, summary, recovery, memory}(与 _ALLOWED_DOMAINS_BY_EXPECTATION 一致),与 tests/replay/test_cli.pytest_cli_allowed_mechanism_only_accepts_recovery_domain_diff 的契约对齐。
  • .github/workflows/ci.yml:21-23replay-integration.yml:28-30:设置 ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION: true 仅用于"压制 Node 20 弃用告警",但该环境变量名实际是 runner 用于"允许使用不安全(已弃用/未签名)Node 版本"的开关,语义是放宽安全约束而非静默警告;把它长期固化在两个 workflow 顶部,会掩盖未来真正需要关注的 Node 安全告警,且注释声称"Remove once all transitive actions move off Node 20"但没有任何机制追踪。

    • 修复方向:移除该 env,改为在确实引用了 Node 20 action 的步骤上单独处理,或固定到具体第三方 action 升级后再删;不要在 workflow 级别长期开启。

💡 Suggestion

  • trpc_agent_sdk/replay/_build_cases.py:219-228injected_event_order case 的注释中残留一句语序混乱、未完成的表述("Instead, the canonical fixture appends 5 events. The harness will assert the diff-engine captures any reordering ..."),前半句与前一段重复且读不通。建议清理该注释,明确该 case 实际并不注入任何 reorder(只是 5 条顺序事件),真正的 reorder 检测由测试 test_diff_engine_detects_injected_event_reorder 在运行时手工 swap 完成,避免后续维护者误以为 JSONL 自身携带了注入逻辑。

  • trpc_agent_sdk/replay/_diff.py:194-205tests/sessions/test_replay_consistency.py:46-64_EXPECTATION_BY_CASE_IDEXPECTATIONS 的手工镜像,虽已有 test_cli_expectation_mapping_matches_test_suite 守护一致性,但两处常量仍需同步维护。可考虑让 _diff.py 从测试模块惰性导入(或抽到第三方共享模块),从结构上消除漂移来源。

总结

存在一个 Critical:fail_summary 的回滚 checkpoint 在 _perform_summary 之后才拷贝,对持久化后端的回滚不可靠,可能导致 fail_summary_recovery 的恢复契约失效;另有 CI 依赖缺失(aiomysql 未安装使 MySQL 集成测试静默 skip)、Node 安全开关误用、恢复类 case 测试约束不足等 Warning。建议合并前至少修复 Critical 与 aiomysql 依赖问题。

测试建议

  • fail_summary_recovery 在 SQLite 后端单独补一条断言:回滚后 session.events 中不得出现 summary-fail-attempted 锚点,且 operation_audit[0].recovered is True,以覆盖当前 Critical 描述的回滚失效路径。
  • replay-integration.yml 的 MySQL 任务加一个"非 skip"校验:断言测试实际执行而非被依赖缺失静默跳过(例如在 workflow 末尾 grep 测试报告或用 pytest --runxfail/-m integration 输出校验已运行用例数 > 0)。

"observed_duplicate_count": duplicate_count,
})
elif op == "fail_summary":
session_checkpoint = session.model_copy(deep=True)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fail_summary 回滚 checkpoint 拷贝时机错误,持久化后端回滚失效

fail_summary 分支的 session_checkpoint 在 _perform_summary 之后才拷贝,此时 session 已被 summarizer 写入失败摘要锚点并持久化;随后用该 checkpoint 回滚存储与缓存无法清除失败尝试,导致 fail_summary_recovery 的 recovered 判定与 summary_id 不变量在 SQLite/Redis 等存储后端上失效,summary-fail-attempted 可能泄漏进 canonical events。应在 _perform_summary 之前深拷贝 checkpoint,并显式 drain/丢弃失败尝试产生的 metadata_log。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

构建 Session / Memory 多后端回放一致性测试框架

2 participants