diff --git a/README.md b/README.md index db5f468..5a4e4e6 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,8 @@ > **中文版文档见 [README_zh.md](README_zh.md).** +For unexplained Windows process exits, use the [independent process monitor](docs/external-process-monitor.md) to collect uv/Python exit codes, lifecycle, memory, health, and available Windows events without restarting the proxy. + ## ✨ Core Features - **Protocol conversion** - Supports three standard formats: OpenAI Chat Completions, Anthropic Messages API, and Responses diff --git a/README_zh.md b/README_zh.md index 2c66451..0f2162e 100644 --- a/README_zh.md +++ b/README_zh.md @@ -4,6 +4,8 @@ > **English version: see [README.md](README.md).** +Windows 代理无异常记录却自动退出时,使用[独立进程监控](docs/external-process-monitor.md),在不重启代理的情况下采集 uv/Python 生命周期、退出码、内存、探活和可读取的系统事件。 + ## ✨ 核心特性 - **协议转换** - 支持 OpenAI Chat Completions、Anthropic Messages API 和 Responses 三种标准格式 diff --git a/docs/HANDOFF_WORKBUDDY2API_UNEXPECTED_PROCESS_EXIT_2026-10-23.md b/docs/HANDOFF_WORKBUDDY2API_UNEXPECTED_PROCESS_EXIT_2026-10-23.md new file mode 100644 index 0000000..414c973 --- /dev/null +++ b/docs/HANDOFF_WORKBUDDY2API_UNEXPECTED_PROCESS_EXIT_2026-10-23.md @@ -0,0 +1,310 @@ +# workbuddy2api 异常退出排查 Handoff + +> 创建日期:2026-10-23(Windows 本地时区 UTC+8) +> +> 工作目录:`D:\workspace\workbuddy2api` +> +> 状态:**根因尚未确认;进程已多次无退出记录地消失。不要把“没有 Python 异常”直接判定为“确定由外部 kill”。** +> +> 本文是交接记录,不代表已有修复 PR、已部署守护进程或已确认罪魁祸首。 + +> 续接更新:2026-10-23 09:28 UTC+8 已实现并启动独立只读等待监控,见第九节。前八节保留当时证据;不代表已确认历史退出根因。 + +## 一、当前任务与目标 + +用户反馈 workbuddy2api 多次“运行一段时间后自动退出”,希望获得根因和可稳定复现、观察进程死亡原因的方案。当前阶段已完成业务日志、Windows 事件和 Wave Terminal 宿主的只读检查;已开发 Python 进程内诊断能力,但一次新故障发生后仍未捕获退出码。 + +**下一位 Agent 的核心任务:** + +1. 先确认当前代理是否运行,并保全最新现场;如已重启,区分新旧 PID / run_id。 +2. 通过**独立于代理本身的父进程监控/进程审计**确定谁先退出、真实退出码是什么、是否收到控制台事件或被 TerminateProcess。 +3. 根据新证据再做最小化代码/启动方式修复;不要先入为主改动协议、SSE、鉴权或发版。 +4. 如果需要实施新方案,优先独立分支 + 回归测试 + PR,不直接扰动运行中的服务。 + +## 二、明确的既有证据 + +所有时间均为 **2026-10-23 UTC+8(Windows 本地)**。 + +| 时间 | 已观察事件 | 证据/意义 | +| --- | --- | --- | +| 00:26:28 | 旧版代理启动 | `codebuddy-proxy.jsonl` 的 startup | +| 02:35:35 | 最后一次记录的流式请求成功,HTTP 200 | 直到 07:38 没有后续业务事件;此时仅能确认日志中断,无法证明具体退出时刻 | +| 07:38:01 | 手动重启,运行包版本 2.0.6 | `codebuddy-proxy.jsonl` startup | +| 07:56:36 | 旧代理最后一次正常响应 HTTP 200 | 之后没有 Python traceback、stream_failed 等异常 | +| 08:18:30 | 手动重启,已经使用分支代码 **2.0.7** | `process-events.jsonl` 记录 `process_start`,PID **41968**,run_id **71315ab3318246198a16c42f7c296227** | +| 08:52:46 | 收到最后一批客户端请求 | `codebuddy-proxy.jsonl` | +| **08:53:10** | 最后一条业务事件:`stream_completed`,HTTP **200** | request_id **8bd493259c0b436f910a873993a515c4** | +| 08:55 左右 | 检查时 workbuddy2api 的 uv/Python 进程均不存在、`/health` 不可达 | 确认服务已不可用;并非推断自无请求 | +| 09:04 左右 | 再次只读检查,没有发现代理进程;三个诊断文件仍未更新 | **以实际后续检查为准,用户可能随后手动重启** | + +重要:02:35、07:56、08:53 是 **最后一次有日志的时间**,不一定是进程死亡的真实时刻;除 08:55 主动检查外,缺少持续健康探测来标定准确时间。 + +### 08:18:30 这次运行的详细诊断 + +目录:`C:\Users\marcus\.workbuddy2api\` + +- `process-events.jsonl`:文件约 165 字节,**只有一条** `process_start`(pid=41968)。无 `process_exit`、`process_signal_received`、`process_fatal_exception`、`asyncio_unhandled_exception`、`diagnostic_log_failure`。 +- `console.log`:约 200 字节,仅记录正常启动文字(Local Models、监听 0.0.0.0:8787、Endpoint 列表),**无 traceback / Uvicorn 异常**。 +- `crash.log`:**0 字节**。Python faulthandler 未记录致命错误;不能据此排除所有原生层异常或强制终止。 +- `codebuddy-proxy.jsonl`:08:18:30 startup 标示 `app_version=2.0.7`;之后最后一次 `stream_completed` 是 08:53:10、状态 200,无对应失败事件。 +- `proxy.log`:需保留完整原始文件以供授权本机进一步比对;先前只读检查未发现对应异常。 +- `process-events.jsonl` 未出现退出事件,意味着代码未能执行相应钩子或诊断链路未完成写入。**仅凭此不能断言被外部 kill。** + +### 操作系统与启动链路 + +运行方式(使用 Wave Terminal 中的 PowerShell): + +```powershell +uv run python -m codebuddy_proxy --desensitize --host 0.0.0.0 +``` + +观察到的树形关系(事故之前,数值可能随重启变化): + +```text +Wave.exe + └─ wavesrv.x64.exe + └─ powershell.exe (PID 209484; 2026-10-15 创建) + └─ uv.exe + └─ python.exe (.venv) + └─ python.exe (实际运行解释器) +``` + +- 08:53 故障后,`uv.exe` 及其 Python 子进程均已不存在。 +- Wave、wavesrv、对应 PowerShell 宿主**仍存在**,不支持“整个 Wave Terminal 已退出”的简单解释。 +- Windows 最近开机时间为 2026-09-23 19:51:33,事故期间未重新开机。 +- Windows Application/System 事件日志在 08:40~08:58 未见匹配的 Python/uv APPCRASH、WER 崩溃转储、内存耗尽或整机重启事件。 +- 08:43 有 Windows Update/SideBySide 等系统事件,**无因果证据**,不要误判成元凶。 +- `C:\ProgramData\Microsoft\Windows\WER\ReportArchive`、`ReportQueue` 和用户 `CrashDumps` 对该时段未发现对应记录。 +- Wave 的 `waveapp.log` 在 08:53 前后仍有正常 flush、窗口焦点等事件,没有直接的 Python 退出记录。 +- 读取 Security 事件日志 4688/4689 遇到无权限,**不能以此声称 Windows 没有进程终止审计记录**。 +- 未找到项目生产源代码里直接调用 `os._exit`、`process.kill` 的明显路径;此为有限文本搜索,不等于完全排除第三方依赖或外部进程。 +- 先前曾检查磁盘未耗尽;本事故没有进程退出前的内存趋势或实时采样,**不能排除瞬时资源问题**。 + +## 三、代码 / PR 状态 + +仓库:`hawklithm/workbuddy2api` + +- 已合入 `main`:PR #15(国内 developer 角色兼容)、PR #16(请求构建/非流式协议格式化重构)。 +- 当时远端 `main`:`eca6f70a1af75788949fd397e100c612a1c8e440`。 +- 当前本地分支:`fix/process-diagnostics-2.0.7`。 +- 当前本地 / 分支提交:`1f4ee45e114408c9d3326b04ddd8e43faa60b235`。 +- **PR #17(进程诊断 + 版本 2.0.7):** https://github.com/hawklithm/workbuddy2api/pull/17 +- PR #17 以前确认处于 open、mergeable 状态,**未执行合并 / 2.0.7 Tag / Release**;接手时务必刷新远端状态。 +- `pyproject.toml` 已从 2.0.6 更新到 `2.0.7`;FastAPI 的 version 也已设为 `2.0.7`。用户运行的 08:18 进程确认日志版本为 2.0.7,但不能由此断言已合并发布。 + +PR #17 主要改动: + +- `src/codebuddy_proxy/process_diagnostics.py`:ProcessDiagnostics、ConsoleSink/TeeStream、EventJournal、faulthandler、退出原因/内存快照/asyncio/thread/unraisable 异常记录、缺失退出记录检测; +- `src/codebuddy_proxy/__main__.py`:主入口包裹诊断、asyncio 与信号观察、文件日志写入错误报告、版本元数据; +- `test_process_diagnostics.py`:22 项诊断测试; +- `docs/process-diagnostics-2.0.7.md`:诊断能力、路径、轮转、局限; +- `README.md`、`README_zh.md` 和 `pyproject.toml`。 + +既有测试:**254 passed, 1 skipped, 1 deselected**。排除项是已知 Windows/NTFS 文件权限 `0600` 断言,与这轮异常退出排查不是同一问题。**这些测试不等于已在生产环境复现或解决真实退出问题。** + +### 现有诊断的盲区 + +1. Python 的 `try/finally` / `sys.excepthook` / 信号 handler 在 Windows `TerminateProcess`、`taskkill /F`、解释器直接退出、严重本地层故障等场景可能完全无法执行。 +2. `TeeStream` 捕捉的是 Python 层 `sys.stdout/sys.stderr`;C 原生 fd 写入、子进程直接继承 OS handle 的输出可能绕过 Tee。 +3. `faulthandler` 不能保证捕获所有 native faults。 +4. `previous_run_exit_unobserved` 只有**下一次启动**才有机会生成。当前 08:18 那一轮仍没看到下一次启动事件,因此此事件目前还未出现;启动后即使出现,也只是“未观察到匹配退出”的证据,不会自动定位外部终止者。 +5. 当前没有独立的子进程退出码记录、Windows 进程审计或持续端口探测。因此仍不知道 `uv` 和 Python **谁先退出、实际退出码是什么、退出时间是多少**。 +6. 诊断日志可能包含客户请求/凭证等敏感数据;不要在公开 Issue 或 PR 中贴原始 `console.log`、`proxy.log`、完整 JSONL。 + +## 四、对根因的判断(分层,不要过度推断) + +**确认:** 三次运行的最后业务请求均成功,至少 08:18~08:53 那轮的代理进程在 Python 内部**没有留下可识别的正常/异常退出事件**,随后确实失去服务。 + +**倾向排查、尚未确认:** + +- 交互式终端或外部进程对子进程发送控制事件 / `TerminateProcess`; +- `uv run` 进程链路先退出,Python 随之被终止,或反之; +- Python/native 运行时意外终止、底层扩展故障; +- Windows 安全软件、资源保护或其他本地自动化脚本介入。 + +**目前没有证据:** 将原因归因于最后的 HTTP 200、角色兼容重构、PR #16、Windows 更新、Wave 窗口失焦、服务器主动关闭或上游 CodeBuddy 故障。 + +## 五、推荐后续实施顺序 + +### P0:留存现场和独立监控(最重要) + +在**不重启、不 kill 现存代理**的前提下,建立与代理不同进程的轻量监控器,记录时间戳、每秒或数秒采样的 `uv` / Python PID 与 PPID、创建时间、工作集/私有内存、健康检查状态。以独立文件写入,避免伴随目标一起丢失日志。 + +如果需要捕获**真实退出码**,最好由独立父进程启动并 `WaitForExit` / 等待句柄,或使用受控服务管理器。仅通过外部枚举 PID 不能获取已经结束的任意进程退出码;请明确设计这一点。 + +**请先保存已有日志副本再切换启动方式**;用户目前使用 `uv run python -m codebuddy_proxy --desensitize --host 0.0.0.0`,切换启动方式属于行为变更,必须单独验证和说明。 + +### P1:Windows 退出链路取证 + +- 有管理员权限时检查并按需开启 Windows Security 4688 / 4689 进程创建/退出审计;确认是否已启用、是否能拿到退出状态。 +- 评估 Sysmon Event ID 1(创建)/ 5(终止)等事件能否满足要求;**Sysmon 的事件不保证直接给出“是谁 kill 了谁”**,如需终止调用方证据,考虑 EDR、ETW、调试器或 Process Monitor 的适用事件/栈,并权衡采样开销。 +- 检查 Wave Terminal 对子进程退出和 Windows 控制台信号传递的管理行为;不要直接将 Wave 的 flush/focus 日志当作进程退出证据。 +- 评估 Windows Error Reporting / LocalDumps 或 ProcDump 的崩溃转储方案,注意保密、磁盘空间和性能。 + +### P2:诊断方法质量与服务稳定性 + +- 评审 PR #17 的 SIGTERM / Uvicorn 回放信号兼容路径,避免监控代码本身修改原有退出语义; +- 对新的外部监控器做人工隔离测试:正常退出、异常退出、`taskkill /F`、单独终止 `uv`、单独终止 Python、关闭终端;**仅对临时测试进程操作,绝不对用户运行中的服务进行 kill**; +- 只有拿到退出码/关键事件后才设计真正的修复;自动重启若要加,应在可靠采集异常后再启用,避免覆盖现场; +- 独立增加探活告警可以缩小服务失效时间窗口,但不能替代根因证据。 + +## 六、接手 Agent 的第一轮只读核验 + +在项目根目录查看 Git 状态: + +```bash +git status --short --branch +git log -3 --oneline +``` + +Windows PowerShell 可先查看服务、进程、文件时间: + +```powershell +Get-CimInstance Win32_Process | + Where-Object { $_.CommandLine -match 'codebuddy_proxy' } | + Select-Object ProcessId, ParentProcessId, CreationDate, Name, CommandLine + +Test-Path "$HOME\.workbuddy2api\process-events.jsonl" +Get-Content "$HOME\.workbuddy2api\process-events.jsonl" -Tail 30 +Get-Content "$HOME\.workbuddy2api\console.log" -Tail 80 +Get-Item "$HOME\.workbuddy2api\crash.log" +``` + +如服务存在: + +```powershell +Invoke-RestMethod "http://127.0.0.1:8787/health" +``` + +如重新启动过,应检查新的 `process_start` 后有无上一轮的 `previous_run_exit_unobserved`,并关联该条目的 previous_pid / previous_run_id;保留新旧 PID 记录。 + +**注意:本机默认服务监听 `0.0.0.0:8787`,对不可信网络暴露有安全风险;本次排查不修改服务监听范围。** + +## 七、验收标准与交付期望 + +交接后续不是要求简单复述“外部 kill 的可能性”,而是产出: + +1. **进程级事件时间线**:进程 A/进程 B 谁先终止,准确时间,退出码(如果可取得); +2. **与业务日志关联**:最后一个 HTTP 请求何时完成,有无仍在处理中的请求,进程是否在空闲阶段退出; +3. **复现结果**:在受控临时进程上模拟可疑退出路径,说明能否由监控器观测; +4. **根因结论**:分“已证实 / 高概率 / 未排除”,并附对应日志证据; +5. **如需要代码修改**:新分支、独立测试、PR 和操作/恢复文档;不得静默重启用户真实服务。 + +## 八、当前交接结论 + +> 2.0.7 进程内诊断已在实际运行中生效,但最新一轮故障的 `process-events.jsonl` 仅有启动记录,`console.log` 没有 traceback,`crash.log` 为空;进程却已不可用。现有证据不能确认外部 kill。**下一步核心是用独立的进程监督和 Windows 级取证拿到终止过程与退出码。** + +文档创建不会提交、合并、发布、停止或重启真实 workbuddy2api 服务。 + +## 九、2026-10-23 外部监控实施续接 + +### 最新现场与保全 + +- 09:13 左右只读核验:没有运行中的 uv/Python 代理,原日志仍止于 08:53:10;未取得新的历史退出码。没有启动、停止、重启或强制终止真实代理。 +- 原始五个当前诊断文件已复制至本机 `C:\Users\marcus\.workbuddy2api\evidence\20261023-091513`,计算 SHA256;不上传原始日志。原文件未改。 +- 刷新远端后 main 仍为 `eca6f70a1af75788949fd397e100c612a1c8e440`,PR #17 仍 open;本变更直接基于最新 main,不引入 #17 代码或版本升级。 +- 原 checkout 保留 `fix/process-diagnostics-2.0.7`;独立 worktree 为 `D:\workspace\workbuddy2api-external-monitor`,分支 `fix/external-process-monitor`。最初创建的 `codex/external-process-monitor` 因远端已有 `codex` 分支而发生 Git directory/file 命名冲突,仅重命名本轮分支,未修改已有远端分支。 + +### 已实现 + +- 提交 PR:[#18 — independently capture Windows uv/Python process exit evidence](https://github.com/hawklithm/workbuddy2api/pull/18),目标 main;尚未合并或发布。本次仅部署只读等待监控,未切换真实代理启动方式。 + +- `scripts/watch_processes.py`:独立进程、标准库 Win32 句柄取证;支持附加 PID、等待已有/将来的 `-m codebuddy_proxy`、显式启动新进程三种模式。 +- 保留 uv/Python/后代句柄,以 `WaitForSingleObject` 确认终止,再读真实 DWORD 退出码和 `GetProcessTimes` 的创建/退出时间。记录 PID/PPID、创建身份、内存、探活、业务日志白名单尾部摘要;不根据写入顺序猜死亡顺序。 +- `scripts/collect_windows_events.ps1`:后台查询 System/Application 错误/警告,以及可读 Security 4688/4689、Sysmon 1/5;按目标 PID 过滤审计事件,60 秒重叠,频道/record_id 可去重,记录截断、无权限、频道不存在和查询失败。不变更系统审计设置。 +- 所有采样和退出记录独立写到 `.workbuddy2api\monitor\独立运行目录`,JSONL 轮转 8 MiB × 5;显式启动模式将原生 stdout/stderr 重定向到独立文件(这两份不轮转,须管理磁盘)。不自动重启,不申请目标终止权限,不安装服务/计划任务。 +- 操作、恢复和边界详见 [external-process-monitor.md](external-process-monitor.md),README 已补充入口。 + +### 当前已运行的仅观察实例 + +- 09:27:25 UTC+8 用无控制台 `pythonw.exe --watch-proxy` 启动,venv launcher PID **64468**,实际解释器/monitor_pid **246352**。这是监控器,不是代理。 +- 输出:`C:\Users\marcus\.workbuddy2api\monitor\20261023-092726-fa6a27ab\events.jsonl`。 +- 已核对 `monitor_start`、连续 `sample`/`health` 和 `windows_events`;当前目标列表为空、探活失败,与代理未运行一致。等待用户以后自行启动代理后附加,未切换其启动命令。 +- 本机 System/Application 可读,Security 无权限,Sysmon Operational 不存在,已写入明确 unavailable,不能视作无退出事件。无新增审计策略/软件安装。 +- 该实例非开机自启;不要删除其脚本 worktree。后续关机、注销、Job Object 或监控自身异常可能终止它,应检查心跳。未实测 Wave 关窗部署边界。 + +### 回归与 Review + +- 新增 **20 项**测试全部通过(包含在全量回归中):正常退出、未捕获异常、`os._exit`、立即退出、259、指定高位状态、强制终止、uv/Python 分别终止及退出顺序、停止监控后目标继续运行、等待/自动发现、身份校验、轮转、字段脱敏、系统频道状态。 +- 所有强制终止只针对测试创建的精确对象/句柄;没有按名称批量 kill,也没有针对真实服务。高位状态由测试显式 ExitProcess 指定,不能称为真实 native crash 复现。 +- 完整回归首次:**251 passed、1 skipped、1 failed**,唯一失败是原 `test_session_profiles_are_isolated_and_saved_with_metadata` 的 Windows 0600 权限断言。之后新增立即退出测试,并在最终代码上运行:**252 passed、1 skipped、1 deselected**;排除的仍是该原有断言,未修改它。 +- 本轮实际 diff Review 已完成:检查只读句柄权限、259 判定、PID 重用、父子创建/退出时间、句柄释放、后台查询不阻塞、事件窗口/过滤/截断、白名单脱敏、轮转和监控退出语义。Review 后补上创建时间二次校验、父进程退出时间边界、事件查询重叠及小批量 XPath。 +- 未改变代理内部的信号处理、协议、SSE、认证、版本或监听地址;不宣称已完成真实 Wave 关窗、断电/注销、原生崩溃或生产异常退出验收。 + +### 下一次故障的行动 + +先保留监控目录(含 `.1`~`.4`)、完整业务/进程内日志,再按 `(pid, created_ticks)` 关联并用 `exit_ticks` 比较 uv/Python 谁先退出。用 exit_code_hex、Windows 事件、探活边界和业务 request_id 交叉核对;业务尾部摘要不是完整在途请求清单。 + +等待模式有约 5 秒加 CIM 延迟的发现窗口,短命进程可能漏采;手动附加也不能恢复先前已死亡进程退出码。需要更强根进程覆盖时,由维护者在下次受控启动选择 `--launch`,不要为切换监控而现在重启真实服务。 + +**根因分层:历史服务消失且缺失内部退出事件已证实;真正退出机制、终止调用者、是否 native/资源问题仍未排除。退出码可缩小范围,不足以单独证明外部 kill。** + +## 十、19:41:31 再次退出:已取得真实状态及隔离复现 + +用户 19:27 重新启动代理,确认故障时没有操作终端。监控记录:venv launcher 84896 于 **19:41:31.066082** 先退出、状态 **FFFFFFFF**;实际解释器 236188 于 **19:41:31.070364** 退出、状态 **0**;uv 189208 于 **19:41:31.070937** 退出、状态 **FFFFFFFF**。以上为 UTC+8,按 Windows exit_ticks 排序,不按事件行顺序。 + +本轮业务日志 5 个 stream_started 均有 HTTP 200 完成记录,最后 19:41:29;19:41:30.533 最近健康探测仍成功。Python 内部仍无本轮退出事件或 traceback,crash.log 空。原 PowerShell/Wave/wavesrv 仍在。System/Application 无已取得的匹配崩溃警告/错误,Security/Sysmon 缺口不变。内存采样未见持续增长,不能排除采样间隔内瞬时问题。 + +用相同 uv/venv 运行独立临时等待任务,以 readiness 文件验证工作解释器及其父 launcher 后,只强制终止该临时 launcher,复现 **launcher FFFFFFFF → interpreter 0 → uv FFFFFFFF**;直接强制终止临时解释器则三层均 FFFFFFFF,组合不同。未操作真实服务。最初探索性枚举误收 uv 探测进程,已改为 readiness 精确识别,并仅使用最终复现结果。 + +强线索是 launcher 层异常终止后连带实际解释器,尚无原事故终止调用方或 Job/控制台事件证据;不能认定 Wave、安全软件或外部 kill 已证实。code=0 也不能解释成实际解释器正常收尾。下一步重点取得 launcher 终止来源,而非先改业务协议。 + +已保全六份代理/监控日志、Wave 日志、SHA256、完整流式请求配对和复现脚本/结果于本机 `C:\Users\marcus\.workbuddy2api\evidence\20261023-194131`,详见该目录 `ANALYSIS.md`;敏感原日志不上传。本次没有启动、停止或重启真实代理。监控器继续等待。 + +## 十一、Silent Process Exit 已验证及部署(20:25:58 UTC+8) + +用户授权实施下一层取证后,经 Windows UAC 运行了隔离验证。为避免全机 python.exe 影响,生产配置仅针对原 venv 内新增的 **workbuddy2api-evidence.exe**;它与原 python.exe 字节及 SHA256 完全相同,沿用同一 pyvenv.cfg 和 site-packages。原 python.exe 的 IFEO/SilentProcessExit 均未配置。完整路径过滤试验未拿到报告,未用于生产;临时设置已清理。 + +最终验证拿到 self-exit 3000、cross-process 3001,后者明确指向本次测试 PowerShell,且生成临时目标与发起者两方 mini dump(含线程栈/上下文)。所有 Kill 仅对测试创建的精确 Process 对象;没有启动、停止或重启真实代理。原独立监控继续运行,采集助手已补充 ProcessExitMonitor 信息级事件并核实可读。 + +- 管理/回滚脚本:`scripts/manage_silent_exit.ps1`,有每值原始状态备份、冲突保护和失败回滚;现有静默退出配置会拒绝覆盖。 +- 成功状态:`C:\Users\marcus\.workbuddy2api\silent-exit\20261023-causal-exit-04\result.json`,status=enabled_for_future_starts。转储在同目录 dumps 的事故子目录;仅当前用户/SYSTEM/Administrators 访问。 +- 参数:GlobalFlag 0x200、ReportingMode 2、IgnoreSelfExits 0、DumpType 0(mini)、MaxNumberOfDumpFiles 8;没有 Debugger、MonitorProcess 回调、自启或自动重启设置。 +- 启动前检查已返回 ready;用专用映像运行的临时 import 测试确认 sys.prefix 为原 `.venv`、FastAPI 来自原 site-packages。此正常退出测试可能留下自退出报告/转储,不能当作真实代理事故。 +- 前期试验暴露了 PowerShell 原子备份、JSON 数组反序列化和 Windows 转储子目录检查问题,已修正;失败状态目录保留但不作为已启用实例。当前临时测试 IFEO/SPE 键均不存在。 + +**下次运行需使用新入口:** + +```powershell +& 'D:\workspace\workbuddy2api-external-monitor\scripts\start_proxy_with_evidence.ps1' +``` + +它先校验配置和哈希,拒绝重复启动现有代理,然后由外部监控父进程启动专用 launcher。原生 stdout/stderr 保存在显示的取证目录,不实时回显。此方式不经过 uv、无交互 stdin;更名/父进程/控制台都是隔离变量,不能据稳定性直接认定根因。 + +旧 `uv run python -m codebuddy_proxy ...` 仍可运行,但只有上一层退出码监控,**不会启用专用映像的静默退出来源取证**。完整操作/回滚/边界见 [silent-process-exit.md](silent-process-exit.md)。此机制不覆盖所有内核/Job/最后线程退出路径,也不证明过去故障的发起者。 + +最终回归:**257 passed、1 skipped、1 deselected**,排除的仍是原 Windows 0600 断言。新增 5 个配置/回滚单元测试只用随机 HKCU 测试键;管理员实机验证另行记录。原自动发现测试只有 3 秒观察窗口,短于 CIM 查询 10 秒超时,负载下先结束;将该临时观察窗口修正为 12 秒后回归通过,没有改变真实代理或监控器的运行逻辑。代码 Review 完成,并核实生产两处 python.exe 注册表键未创建、临时 probe 配置已清理、专用映像配置可读、启动前检查 ready。 + +## 十二、20:38:18 再次退出:已取证,实际解释器来源仍有缺口 + +用户确认使用新入口后再次自动退出,对当时是否有其他 AI/清理脚本运行表示不确定。本轮只读调查进程及事件、离线解析保存的 dump;没有启动、停止、重启代理,也没有强制终止测试或更改系统取证配置。 + +持久 pythonw 监控保存的 Windows exit_ticks 表明:实际代理 Python PID 33180 在 20:38:18.253396 最先结束;启动监控实际 Python PID 39968 在 .290291 结束;其 venv launcher PID 123156 在 .297212 结束;专用代理 launcher PID 124092 在 .854554 结束。四者退出码均为 FFFFFFFF。原 pythonw 监控仍存活;新启动监控自身未能写出退出事件。 + +Windows ProcessExitMonitor 3000(RecordId 146658)及 16601 字节、单线程的转储只对应专用 launcher PID 124092。离线栈包含 RtlExitUserProcess,支持它随后自行退出;不对应最先结束的实际解释器,不能识别后者的终止来源。没有本轮 3001。20:25 的跨进程 3001 属于隔离测试,不能当成本轮证据。 + +上一版配置只覆盖专用 launcher,实际运行 Python 仍是共享 Anaconda python.exe,未启用退出来源取证。这是已确认的覆盖缺口。多 Python 进程在约 37 ms 内结束而 pythonw 存活,是排查外部清理/终止行为的线索,不足以认定按名批量终止或归因某个客户端。19:41 的 launcher 先退出顺序也不能套用于本轮。 + +完整本机证据及分析:C:\Users\marcus\.workbuddy2api\evidence\20261023-203818\ANALYSIS.md。原始日志和 dump 仅保留本机,不上传公开 PR。下一阶段需设计实际解释器及监控进程的来源取证,明确共享 python.exe 配置的影响、限量和回滚后实施;保留独立 pythonw 观察器。历史及本轮根因均未确认。 + +## 十三、21:02 再次退出及实际解释器取证补齐 + +21:02:24.833883 启动监控实际解释器 PID 170704 最先退出,venv 父 PID 201900 于 .840553 退出;代理实际解释器 PID 258404 于 .912141 退出;专用 launcher PID 159076 于 21:02:25.333252 退出,均 FFFFFFFF。原独立 pythonw 保存了时间线,Application 3000 RecordId 146661 仍只对应旧 launcher。其他项目原先存在的 Python 进程在后续快照中也不见,但缺少它们的精确退出时间/来源,不能据此认定同一原因。本轮证据保存在 C:\Users\marcus\.workbuddy2api\evidence\20261023-210225。 + +已改为专用真实 CPython 映像运行,覆盖代理 runtime 和无控制台 observer,保留原 pythonw。bootstrap 使用原依赖和源码;sys.prefix 改为诊断 runtime,映像名称/父进程/控制台也是变量。没有修改全机共享 python.exe/pythonw.exe 的 IFEO/SPE。旧启动命令保持,但入口现在拒绝 launcher-only 状态,并自动选择已验证的 native runtime。 + +有效 state:C:\Users\marcus\.workbuddy2api\silent-exit\20261023-native-runtime-04,21:11:10 enabled。临时 normal PID 136100、abrupt self PID 233756、cross termination PID 19876;已知临时发起者是管理员测试 PowerShell PID 246860。已取得对应 3000/3001、双方 mini dump,生产两映像的临时 import/identity PID 142044、193460 均验证同进程运行且取得 3000。所有强制终止只针对独立创建的测试 Process 对象;未启动、停止或重启真实代理。 + +额外常驻观察器 PID 36748 于 21:12:24 启动,不启动代理;日志 C:\Users\marcus\.workbuddy2api\monitor\20261023-211224-5ed62f15。原 pythonw 64468/246352 保持运行;没有开机自启或自动重启。状态应现场重查。 + +完整回归 267 passed、1 skipped、1 deselected,排除原 Windows 0600 权限断言。Review 修正了最小 PATH 下缺少 Anaconda zlib 的初始化失败、跨 PowerShell 版本 Get-FileHash 模块解析、旧监控命令被误识别为代理、源运行时变化拒绝及已有 IFEO filter/debugger 冲突保护。退出测试事件按实际映像路径和退出码筛选,转储检查目标/发起者精确 PID;不得混入真实事故。配置、启动、回滚与限制详见 docs/silent-process-exit.md。 + +结论仍是根因未知;这轮交付补齐实际解释器取证,不宣称业务故障已修复,也不保证覆盖所有内核/Job 退出路径。原始日志/转储仅保留本机,PR 不包含原始内存或业务日志。 + +## 十四、用户手动启动验收与输出修正(21:18–21:22) + +用户于 21:18:39 通过入口启动,本轮 observer PID 92096、实际 runtime PID 120080,健康日志至 21:22 连续 HTTP 200,说明新入口已实际启动代理且探活成功。日志目录 C:\Users\marcus\.workbuddy2api\monitor\20261023-211839-daa868e0。启动结果 health_verified=false 仅表示启动脚本本身未等待健康验收。 + +PowerShell 5.1 的 Get-Content 字符串包含 PSPath/PSDrive 等扩展属性,直接送进 ConvertTo-Json 导致 monitor_output 变成庞大对象。已在启动输出处转换并 Trim 成普通字符串,使用当前打开的重定向日志实测序列化只含目录。保持 Get-Content 的共享读取方式;不使用可能与重定向写入冲突的 File.ReadAllText。此次未重启任何进程。 + +21:21 快照中此前 64468/246352 的 pythonw 及额外 observer 36748 已不在,36748 最后日志为 21:18:17,未取得其确切退出来源。不能继续声称原两套常驻观察器仍存活;当前确认存活的是用户此次启动的 observer 92096 与代理 120080。此状态变动需保留为后续独立调查项。 diff --git a/docs/external-process-monitor.md b/docs/external-process-monitor.md new file mode 100644 index 0000000..3c78751 --- /dev/null +++ b/docs/external-process-monitor.md @@ -0,0 +1,110 @@ +# Windows 独立进程取证监控 + +此工具用于回答「uv 和 Python 谁先退出、Windows 记录的退出码/退出时间是什么」。它不判断根因、不自动重启、不导入代理代码;兼容 main 2.0.6 和 PR #17 的 2.0.7。仅依赖 Python 3.10+ 标准库和 Windows PowerShell。 + +需要进一步捕获终止发起者时,参见[Silent Process Exit 取证和诊断启动命令](silent-process-exit.md)。本机已验证并配置专用诊断映像;旧 `uv run python ...` 不会触发这层 Windows 取证。 + +## 三种模式 + +| 模式 | 行为 | +| --- | --- | +| `--watch-proxy` | 每 5 秒后台发现以 `-m codebuddy_proxy` 运行的 uv/Python,附加并发现后代;消失后继续等待下一轮,不启动代理 | +| `--pid PID` | 立即附加指定活进程和后代;所有已捕获进程退出后结束 | +| `--launch EXE ARGS...` | 明确启动新进程并保留句柄;所有已捕获进程退出后结束,供以后手动切换 | + +监控句柄只请求查询/同步权限,没有终止/重启目标的接口。`--duration SECONDS` 或 Ctrl+C 只结束监控,目标继续运行。等待模式匹配本机可访问的同名模块,不能按工作目录区分实例;console-script 启动、改名解释器等用 `--pid` 精确附加。 + +## 先保全,再观察 + +先确认实际进程和 `/health`,不能因业务日志没更新就推断进程死亡。已有活进程应立即附加,而不是等待扫描。 + +把 `.workbuddy2api` 下当前 `process-events.jsonl`、`console.log`、`crash.log`、`codebuddy-proxy.jsonl`、`proxy.log` 复制到时间戳目录,保存 SHA256,不改原件。证据可能包含凭证和请求,只留本机,不上传 PR。 + +## 独立启动监控器(不启动代理) + +本次 worktree 的示例(合并后可将 `$monitorRepo` 改成主目录)。解释器借用原项目环境,不安装/修改依赖。 + +```powershell +$monitorRepo = 'D:\workspace\workbuddy2api-external-monitor' +$monitorPython = 'D:\workspace\workbuddy2api\.venv\Scripts\pythonw.exe' +$monitorArgs = '"' + $monitorRepo + '\scripts\watch_processes.py" --watch-proxy' +$monitor = Start-Process -FilePath $monitorPython -ArgumentList $monitorArgs ` + -WorkingDirectory $monitorRepo -WindowStyle Hidden -PassThru +$monitor | Select-Object Id, StartTime +``` + +`pythonw.exe` 无交互式控制台,监控器和代理分别运行。不能把监控工作放进代理线程,也不要在要测试关闭的代理控制台里运行它。本工具不安装服务/计划任务、不设置开机自启。如果宿主用 Job Object 连带终止后代,独立控制台仍不保证逃离 Job;持久部署可由维护者在独立计划任务/服务中启动相同命令。尚未在 Wave 关窗场景验证此边界。 + +默认目录 `$HOME\.workbuddy2api\monitor\YYYYMMDD-HHMMSS-随机标识\`。先确认 `events.jsonl` 有 `monitor_start`,随后 `sample` / `health` 持续更新。等待时 `sample.processes` 可为空。无日志或出现 `monitor_error` 表示监控未生效。 + +```powershell +$latest = Get-ChildItem "$HOME\.workbuddy2api\monitor" -Directory | + Sort-Object LastWriteTime -Descending | Select-Object -First 1 +Get-Content (Join-Path $latest.FullName 'events.jsonl') -Tail 12 +``` + +若代理已运行,以 `Get-CimInstance Win32_Process` 找到 uv 根 PID,把启动参数换成 `--pid 实际PID`,不要复用历史 PID。不存在/无权限会明确失败。避免重复监控相同范围。 + +## 将来的启动模式 + +**本次实施不执行下面的真实代理命令,也不停止现有代理。** 将来由维护者选择切换时,在主项目目录执行: + +```powershell +Set-Location 'D:\workspace\workbuddy2api' +.\.venv\Scripts\python.exe ..\workbuddy2api-external-monitor\scripts\watch_processes.py ` + --launch uv run python -m codebuddy_proxy --desensitize --host 0.0.0.0 +``` + +`--launch` 之后全是目标参数;监控选项放在前面。目标继承工作目录、环境和原参数,以 `CREATE_NO_WINDOW` 运行,stdin=DEVNULL,原生 stdout/stderr 写入该次目录 `target-stdout.log` / `target-stderr.log`,覆盖绕过 Python Tee 的输出。控制台和交互输入行为已改变,先完成认证;已有服务占用端口时不要再启动一份。 + +OS 输出文件不轮转,避免目标持有句柄时截断证据,长时间启动模式需另行管理磁盘空间。监控 JSONL 轮转 8 MiB × 当前文件加 4 个备份;多个运行目录另行归档。取证事件 flush+fsync,普通 sample 仅 flush。磁盘满会使监控失败,不能保证断电或磁盘故障仍有完整证据。 + +## 记录与判读 + +默认每 250 ms 枚举后代/检查退出,每 2 秒记录内存/心跳和 localhost:8787/health(超时 1 秒),每 30 秒查询 Windows 事件(后台执行,总超时 15 秒)。UTC 时间、monotonic_ns、seq 用于关联;**seq 是发现顺序,退出顺序须看 exit_ticks**。 + +| 事件 | 内容 | +| --- | --- | +| `process_observed` | pid、ppid、image、created_ticks / created_at;句柄已保留,不代表此刻才启动 | +| `process_exit` | Windows 无符号 exit_code / exit_code_hex、GetProcessTimes 的 exit_ticks / exited_at、最后存活采样、cause=unknown | +| `sample` | PID+创建时间、working_set_bytes / private_bytes;权限不足写 memory_error | +| `health` | HTTP 状态/是否成功/耗时;不读取正文、不走环境代理、不跟随重定向 | +| `business_log_tail` | 文件长度/更新时间,末尾最多 64 KiB / 32 条中的 timestamp/event/request_id/status/run_id/pid 白名单;不复制 headers/body | +| `windows_event_query` / `windows_events` | 查询窗口、System/Application 警告/错误/严重事件,以及可读 Security 4688/4689、Sysmon 1/5,后两者按目标 PID 过滤 | +| `*_failed` / `process_open_failed` / `monitor_error` | 采集失败,不能解释成“没有发生事件” | +| `monitor_stop` | 停止原因、still_tracked、targets_stopped=false;心跳中断且无此记录,监控器本身也可能消失 | + +先用 `WaitForSingleObject(handle, 0)` 判断终止再读 `GetExitCodeProcess`,可区分仍运行与真正退出码 259。原始 exit_ticks 保留 Windows 100ns 值,格式化时间精度为微秒。句柄和创建时间用于防止 PID 重用误关联。 + +事件查询重叠 60 秒,按 channel+record_id 去重,每频道最多 200 条,超出标 truncated。延迟超过窗口、日志被清空仍可能漏采。超时/无权限/频道不存在明确记录;不修改审计策略、不安装 Sysmon。频道可读但无匹配事件,不代表已开启进程审计;4688/4689 需管理员核实策略。4689 的 Subject 账户不是终止调用方进程 ID。 + +复盘步骤: + +1. 按 `(pid, created_ticks)` 分组,把 uv、venv Python、解释器的 `process_exit` 按 `exit_ticks` 排序。相等时不能确定先后;只捕获一层时不能推断其他层。 +2. 对齐最后成功/首次失败的探活,以及业务 request_id 完成事件。尾部摘要不完整,**不能证明没有在途请求或处于空闲**;完整原日志按 request_id 配对,并统一数字/字符串 timestamp 的时区。 +3. 检查系统事件、truncated 和采集失败;不能把时间邻近的更新/SideBySide 错误直接视作原因。 +4. 保留运行目录、轮转文件、原业务日志,再根据状态选择 WER/ProcDump/ETW/EDR。退出码只是状态证据,程序/外部调用者都可指定相同值;本工具不识别 TerminateProcess 调用方。 + +## 观测边界 + +不能恢复已死亡且未留句柄的历史进程退出码。等待模式有约 5 秒加 CIM 查询延迟的窗口,后代枚举有 250 ms 窗口,短命进程可能漏采;启动模式持有根进程,但不保证捕获所有短命后代。不能从新监控倒推 08:53 故障根因。 + +记录监控器自己的可处理 SIGINT/SIGTERM,不拦截/注入目标信号,不能证明目标收到 Ctrl+C/CTRL_CLOSE。监控器/系统一起退出、断电可能同时丢证据。终端关闭、Job Object、真实 native crash 和资源耗尽仍待现场验证。 + +## 测试和 Review + +```powershell +$env:PYTHONUTF8 = '1' +$env:PYTHONPATH = 'src;.' +D:\workspace\workbuddy2api\.venv\Scripts\python.exe -m pytest test_external_process_monitor.py -q +D:\workspace\workbuddy2api\.venv\Scripts\python.exe -m pytest -q --tb=short ` + -k 'not test_session_profiles_are_isolated_and_saved_with_metadata' +``` + +测试仅对独立创建的临时进程精确 Popen 对象/句柄强制终止,绝不按名称批量结束。覆盖正常/异常/绕过 Python 钩子的退出、259、高位状态、强制终止、uv/Python 分别退出和顺序、监控停止后目标存活、发现、轮转、脱敏、PID 身份及频道权限。高位状态测试调用 ExitProcess 指定值,不是真实访问冲突。原 Windows 0600 权限断言失败单独报告,未改原测试。 + +Review 检查最小权限、无终止/重启接口、句柄释放、PID 重用、259、非阻塞采样、事件过滤/上限/失败、采集延迟和隐私。真实 Wave 关窗、注销/断电、终止者归因尚未验收,不能以隔离测试代替生产复现。 + +参考:[进程句柄/ID](https://learn.microsoft.com/en-us/windows/win32/procthread/process-handles-and-identifiers)、[WaitForSingleObject](https://learn.microsoft.com/en-us/windows/win32/api/synchapi/nf-synchapi-waitforsingleobject)、[4689](https://learn.microsoft.com/en-us/previous-versions/windows/it-pro/windows-10/security/threat-protection/auditing/event-4689)、[Sysmon](https://learn.microsoft.com/en-us/windows/security/operating-system-security/sysmon/sysmon-events)。 + +当前 Windows 实际解释器取证入口及回滚以 [silent-process-exit.md](silent-process-exit.md) 为准。2026-10-23 20:38/21:02 的再次退出证实 venv launcher 取证不足;新入口使用两个专用真实解释器映像,原 pythonw 观察器与额外独立 observer 保留。最新回归 267 passed、1 skipped、1 deselected。 diff --git a/docs/silent-process-exit.md b/docs/silent-process-exit.md new file mode 100644 index 0000000..35e71f3 --- /dev/null +++ b/docs/silent-process-exit.md @@ -0,0 +1,65 @@ +# Windows 实际解释器退出取证 + +2026-10-23 20:38 现场证实旧配置只覆盖 venv launcher,真正的 Anaconda python.exe 先退出,转储无法说明它的来源。21:02 再次出现启动监控解释器与代理解释器相近退出,顺序与此前不同。尚未确认根因。 + +## 当前方案 + +`scripts/prepare_native_runtime.py` 从 `sys.base_prefix` 复制真实 CPython console/windowed 映像及 Python、VC runtime、Anaconda zlib DLL,生成独立 runtime。不是复制 venv redirector。专用映像名为 `workbuddy2api-runtime.exe` 和 `workbuddy2api-observer.exe`,只对这两个名称配置 Silent Process Exit,不修改共享 python.exe/pythonw.exe 的设置,也不改原 venv 文件。 + +bootstrap 保留 DLL 搜索目录句柄,通过 site.addsitedir 使用原 venv 依赖及 editable 源码。sys.prefix 指向诊断 runtime,sys.base_prefix 保持原解释器;这是一项明确的环境差异。原 venv 及基础 Python 仍须保留。复制文件、基础映像/DLL 和原 pyvenv.cfg 的 SHA256 在启动前校验;升级 Python 后需重新验证。 + +`scripts/manage_native_runtime.ps1` 的管理员验证要求: + +- OS 映像、sys.executable 和实际 workload PID 相符,拒绝再次落入 redirector 子进程;确认 FastAPI、SSL、SQLite 和原代理源码可导入。 +- 临时真实解释器正常退出 23、自行 os._exit(29),分别取得对应 3000。 +- 只终止本脚本创建且 readiness PID 匹配的临时 Process 对象,取得 3001、已知 PowerShell 发起者和目标/发起者两方 mini dump。 +- 临时 probe 配置回滚后,为两个生产映像设置 0x200、ReportingMode=2、IgnoreSelfExits=0、DumpType=0、MaxNumberOfDumpFiles=8;再对两个生产映像执行临时 import/identity 检查,分别确认 3000。这些测试事件不能当作真实事故。 +- 任何失败均尝试按每值 journal 回滚并保留错误。拒绝覆盖已有 SPE 设置或已启用 IFEO filter/debugger;不递归删除注册表,不停止真实代理。 + +日志/转储保留在仅当前用户、SYSTEM、Administrators 可访问的 state 目录;mini dump 仍可能包含敏感信息,不上传公开 PR。数量限制不等于总字节配额;启动监控的 stdout/stderr 也没有字节轮转。 + +## 已部署与下次启动 + +本机有效状态:`C:\Users\marcus\.workbuddy2api\silent-exit\20261023-native-runtime-04\result.json`,2026-10-23 21:11:10 UTC+8,status=native_runtime_enabled。01/02 为已回滚失败尝试(缺少 Anaconda zlib 导致进程初始化失败);03 验证成功后已回滚,由含源文件哈希及双生产映像验证的 04 取代。 + +原 pythonw 观察器保持运行。另有专用观察器 PID 36748 于 21:12:24 启动,只观察,不启动代理,日志在 `C:\Users\marcus\.workbuddy2api\monitor\20261023-211224-5ed62f15`。PID/存活状态应现场核对;没有开机自启或自动重启设置。 + +下次手动启动(命令保持一致): + +```powershell +& 'D:\workspace\workbuddy2api-external-monitor\scripts\start_proxy_with_evidence.ps1' +``` + +只检查配置,不启动: + +```powershell +& 'D:\workspace\workbuddy2api-external-monitor\scripts\start_proxy_with_evidence.ps1' -CheckOnly +``` + +入口只接受 native_runtime_enabled,拒绝旧 launcher-only 状态;检查哈希及两套 Windows 配置,拒绝已有代理。以无控制台 observer 映像启动独立监控,再启动真实 runtime 映像,不经过 uv 或 venv redirector。入口返回 observer PID、监控目录及启动日志;这只是进程创建确认,健康状态需查看日志。关闭启动终端不会主动执行清理代理,但 Windows Job/会话策略仍可能影响进程。 + +取证配置供未来启动生效。此次未由维护者启动/停止/重启真实代理,也没有把仍在运行的进程热切换到新解释器。 + +## 配置与回滚 + +在 64 位管理员 Windows PowerShell 中,新机器/重新验证使用全新 state 目录: + +```powershell +& 'D:\workspace\workbuddy2api-external-monitor\scripts\manage_native_runtime.ps1' -Action ValidateAndEnable -StateDirectory 'C:\Users\marcus\.workbuddy2api\silent-exit\NEW-UNUSED-DIRECTORY' +``` + +已有本工具配置应先使用其 state 回滚,不覆盖。当前配置回滚: + +```powershell +& 'D:\workspace\workbuddy2api-external-monitor\scripts\manage_native_runtime.ps1' -Action Rollback -StateDirectory 'C:\Users\marcus\.workbuddy2api\silent-exit\20261023-native-runtime-04' +``` + +回滚只恢复设置,保留文件及证据,不停止任何代理/观察器。旧 launcher-only 配置仍作为历史独立 state 保留;需要撤销时用旧 manage_silent_exit.ps1 配合原 state,不能拿新脚本混用旧 journal。 + +## 判断边界及验证 + +3000 表示受支持路径上的自行退出;3001 可提供跨进程终止发起者,结合目标创建时间、句柄时间线及双方转储分析。未捕获事件不能排除内核终止、部分 Job 清理、最后线程退出、断电等未覆盖路径。改变映像名称可能改变按名称清理行为,即使不再复现也不能单凭稳定性确认根因。 + +回归:267 passed、1 skipped、1 deselected;排除项仍是原 Windows POSIX 0600 权限断言。新增测试验证真实 OS 映像/PID、最小 PATH 下依赖解析、无控制台 observer/临时子进程的完整退出记录、参数转义、文件变更拒绝、HKCU 私有测试键配置/回滚与冲突保护。原生管理员事件/转储验收另行完成;未做真实代理启动验收。Review 重点为范围、进程所有权、可回滚性、依赖一致性及证据关联。 + +参考:[Microsoft Silent Process Exit](https://learn.microsoft.com/en-us/windows-hardware/drivers/debugger/registry-entries-for-silent-process-exit/)、[Python DLL 搜索目录](https://docs.python.org/3.10/library/os.html#os.add_dll_directory)。 diff --git a/scripts/collect_windows_events.ps1 b/scripts/collect_windows_events.ps1 new file mode 100644 index 0000000..b9eecf6 --- /dev/null +++ b/scripts/collect_windows_events.ps1 @@ -0,0 +1,98 @@ +param([Parameter(Mandatory=$true)][string]$Since, + [Parameter(Mandatory=$true)][string]$Until, + [string]$ProcessIds = '') +$ErrorActionPreference = 'Stop' +[Console]::OutputEncoding = New-Object System.Text.UTF8Encoding($false) +$start = [DateTimeOffset]::Parse($Since).LocalDateTime +$end = [DateTimeOffset]::Parse($Until).LocalDateTime +$pidsToMatch = @($ProcessIds.Split(',') | Where-Object { $_ } | ForEach-Object { [long]$_ }) +$channels = @('System', 'Application', 'Security', 'Microsoft-Windows-Sysmon/Operational') +$results = @() +function Read-WindowEvents($arguments) { + try { Get-WinEvent @arguments -MaxEvents 201 -ErrorAction Stop } + catch { + if ($_.FullyQualifiedErrorId -notlike 'NoMatchingEventsFound*') { throw } + } +} +foreach ($channel in $channels) { + try { + $null = Get-WinEvent -ListLog $channel -ErrorAction Stop + $filter = @{LogName=$channel; StartTime=$start; EndTime=$end} + if ($channel -eq 'Security') { $filter.Id = @(4688,4689) } + elseif ($channel -like '*Sysmon*') { $filter.Id = @(1,5) } + else { $filter.Level = @(1,2,3) } + # Filter target IDs BEFORE the cap; busy audit logs must not crowd out our processes. + $items = @() + if ($channel -eq 'Security' -or $channel -like '*Sysmon*') { + if ($pidsToMatch.Count -eq 0) { + $results += @{channel=$channel; status='readable'; truncated=$false; events=@()} + continue + } + $idFilter = if ($channel -eq 'Security') { '(EventID=4688 or EventID=4689)' } else { '(EventID=1 or EventID=5)' } + $from = $start.ToUniversalTime().ToString('o') + $to = $end.ToUniversalTime().ToString('o') + # Small batches stay under Windows Event Log XPath expression limits. + for ($offset = 0; $offset -lt $pidsToMatch.Count; $offset += 4) { + $pidFilters = @() + $last = [Math]::Min($offset + 3, $pidsToMatch.Count - 1) + foreach ($targetId in $pidsToMatch[$offset..$last]) { + $fields = if ($channel -eq 'Security') { @('ProcessId','NewProcessId') } else { @('ProcessId') } + $text = if ($channel -eq 'Security') { '0x{0:x}' -f $targetId } else { [string]$targetId } + foreach ($field in $fields) { + $pidFilters += "Data[@Name='$field']='$text'" + } + } + $query = "*[System[$idFilter and TimeCreated[@SystemTime >= '$from' and @SystemTime <= '$to']]] and *[EventData[$($pidFilters -join ' or ')]]" + $items += @(Read-WindowEvents @{LogName=$channel; FilterXPath=$query}) + } + $items = @($items | Sort-Object RecordId -Descending -Unique) + } else { + $items = @(Read-WindowEvents @{FilterHashtable=$filter}) + if ($channel -eq 'Application') { + # Silent Process Exit reports are informational/classic events, not errors. + $exitFilter = @{LogName=$channel; ProviderName='Microsoft-Windows-ProcessExitMonitor'; StartTime=$start; EndTime=$end} + $items += @(Read-WindowEvents @{FilterHashtable=$exitFilter}) + $items = @($items | Sort-Object RecordId -Descending -Unique) + } + } + $rows = @() + foreach ($item in ($items | Select-Object -First 200)) { + $xml = [xml]$item.ToXml() + $data = @{} + foreach ($node in $xml.Event.EventData.Data) { + if ($node.Name -in @('ProcessId','ProcessID','NewProcessId','CreatorProcessId', + 'ParentProcessId','ExitStatus','Status','Image','ParentImage','ProcessName', + 'NewProcessName','SourceProcessId','TargetProcessId','ProcessGuid','ParentProcessGuid', + 'AppName','AppVersion','ModuleName','ModuleVersion','ExceptionCode','FaultingOffset', + 'AppPath','ModulePath','ReportId')) { + $data[$node.Name] = [string]$node.'#text' + } + } + if ($item.ProviderName -eq 'Microsoft-Windows-ProcessExitMonitor') { + # Only this provider's documented image/status/creation-time parameters. + $values = @($item.Properties | ForEach-Object { [string]$_.Value }) + $data['report_type'] = if (($item.Id -band 65535) -eq 3001) { 'cross_process' } else { 'self_exit' } + $data['parameters'] = $values + } + if ($channel -eq 'Security' -or $channel -like '*Sysmon*') { + $matchesTarget = $false + foreach ($key in @('ProcessId','ProcessID','NewProcessId')) { + if ($data.ContainsKey($key)) { + try { + $value = $data[$key] + $number = if ($value.StartsWith('0x')) { [Convert]::ToInt64($value.Substring(2),16) } else { [long]$value } + if ($number -in $pidsToMatch) { $matchesTarget = $true } + } catch { } + } + } + if (-not $matchesTarget) { continue } + } + $rows += @{record_id=$item.RecordId; id=$item.Id; provider=$item.ProviderName; + timestamp=$item.TimeCreated.ToUniversalTime().ToString('o'); level=$item.Level; data=$data} + } + $results += @{channel=$channel; status='readable'; truncated=($items.Count -gt 200); events=$rows} + } catch { + $results += @{channel=$channel; status='unavailable'; error=$_.Exception.Message; events=@()} + } +} +@{since=$Since; until=$Until; channels=$results; audit_settings_changed=$false} | ConvertTo-Json -Depth 8 -Compress diff --git a/scripts/manage_native_runtime.ps1 b/scripts/manage_native_runtime.ps1 new file mode 100644 index 0000000..ec49727 --- /dev/null +++ b/scripts/manage_native_runtime.ps1 @@ -0,0 +1,183 @@ +#requires -Version 5.1 +[CmdletBinding()] +param( + [ValidateSet('ValidateAndEnable','Rollback','Status')][string]$Action = 'Status', + [Parameter(Mandatory=$true)][string]$StateDirectory, + [string]$Venv = 'D:\workspace\workbuddy2api\.venv' +) +$ErrorActionPreference = 'Stop' +$statePath = [IO.Path]::GetFullPath($StateDirectory) +$journalPath = Join-Path $statePath 'registry-journal.json' +$resultPath = Join-Path $statePath 'result.json' +$ifeoRoot = 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Image File Execution Options' +$speRoot = 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\SilentProcessExit' +$filterName = 'Workbuddy2apiExitEvidence' +$provider = 'Microsoft-Windows-ProcessExitMonitor' +# Reuse the reviewed journal/rollback functions without executing the legacy setup. +$tokens=$null; $errors=$null +$ast=[Management.Automation.Language.Parser]::ParseFile((Join-Path $PSScriptRoot 'manage_silent_exit.ps1'),[ref]$tokens,[ref]$errors) +if ($errors.Count) { throw ($errors | Out-String) } +foreach ($definition in $ast.FindAll({param($n) $n -is [Management.Automation.Language.FunctionDefinitionAst]},$true)) { + . ([scriptblock]::Create($definition.Extent.Text)) +} +if ($Action -eq 'Status') { + if (Test-Path (Join-Path $statePath 'rollback-result.json')) { Get-Content (Join-Path $statePath 'rollback-result.json') -Raw } + elseif (Test-Path $resultPath) { Get-Content $resultPath -Raw } + else { Write-Output 'No validated setup.' } + exit 0 +} +$principal = [Security.Principal.WindowsPrincipal]::new([Security.Principal.WindowsIdentity]::GetCurrent()) +if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) -or -not [Environment]::Is64BitProcess) { + throw '64-bit administrator PowerShell required for the dedicated image configuration' +} +$testJournal = Join-Path $statePath 'test-registry-journal.json' +if ($Action -eq 'Rollback') { + Restore-RecordedValues $journalPath + Restore-RecordedValues $testJournal + Save-Json (Join-Path $statePath 'rollback-result.json') @{status='rolled_back';timestamp=[DateTimeOffset]::Now.ToString('o')} + exit 0 +} +if (Test-Path -LiteralPath $statePath) { throw 'Use a fresh state directory; refusing to overwrite prior evidence' } +$null = New-Item -ItemType Directory -Path $statePath +Protect-StateDirectory +Start-Transcript -LiteralPath (Join-Path $statePath 'setup-transcript.txt') | Out-Null +$owned = New-Object System.Collections.Generic.List[System.Diagnostics.Process] +$testChanges = New-Object System.Collections.Generic.List[object] +$productionChanges = New-Object System.Collections.Generic.List[object] + +function Start-NativeTest($image, $mode, $payload) { + if ($payload.Contains('"')) { throw 'Test payload must use single quotes internally' } + $info = New-Object Diagnostics.ProcessStartInfo + $info.FileName = $image + $info.Arguments = '"' + $manifest.bootstrap + '" ' + $mode + ' "' + $payload + '"' + $info.UseShellExecute = $false + $info.CreateNoWindow = $true + $info.RedirectStandardOutput = $true + $info.RedirectStandardError = $true + $process = [Diagnostics.Process]::Start($info) + $owned.Add($process) + return $process +} +function Wait-NativeEvent($since, $id, $image, $expectedCode) { + $deadline = [DateTime]::UtcNow.AddSeconds(25) + do { + $rows = @() + try { + $rows = @(Get-WinEvent -FilterHashtable @{LogName='Application';ProviderName=$provider;StartTime=$since} -MaxEvents 100 -ErrorAction Stop | + Where-Object { ($_.Id -band 65535) -eq $id -and + $_.Properties[0].Value -ieq $image -and + [string]$_.Properties[$(if ($id -eq 3000) {1} else {2})].Value -eq [string]$expectedCode }) + } catch { if ($_.FullyQualifiedErrorId -notlike 'NoMatchingEventsFound*') { throw } } + if ($rows.Count) { return $rows } + Start-Sleep -Milliseconds 250 + } while ([DateTime]::UtcNow -lt $deadline) + throw "No native event $id for the exact temporary image" +} +try { + $venvPath = (Get-Item -LiteralPath $Venv).FullName + $original = Join-Path $venvPath 'Scripts\python.exe' + $runtimeRoot = Join-Path $statePath 'runtime' + & $original (Join-Path $PSScriptRoot 'prepare_native_runtime.py') $runtimeRoot $venvPath + if ($LASTEXITCODE -ne 0) { throw 'Dedicated runtime preparation failed' } + $manifest = Get-Content (Join-Path $runtimeRoot 'manifest.json') -Raw | ConvertFrom-Json + # Both executables must prove that OS PID and image are the actual workload. + $identities = @() + foreach ($image in @($manifest.runtime,$manifest.observer)) { + $identityPath = Join-Path $statePath (([IO.Path]::GetFileName($image)) + '.identity.json') + $process = Start-NativeTest $image '--identity' $identityPath + $completed = $process.WaitForExit(15000) + if (-not $completed -or $process.ExitCode -ne 0 -or -not (Test-Path $identityPath)) { + $diagnostic = @{image=$image;pid=$process.Id;completed=$completed;identity_exists=(Test-Path $identityPath)} + if ($completed) { + $diagnostic.exit_code=$process.ExitCode + $diagnostic.stderr=$process.StandardError.ReadToEnd() + $diagnostic.stdout=$process.StandardOutput.ReadToEnd() + } + Save-Json (Join-Path $statePath 'identity-failure.json') $diagnostic + throw 'Dedicated interpreter import/identity check failed; see identity-failure.json' + } + $identity = Get-Content $identityPath -Raw | ConvertFrom-Json + if ($identity.pid -ne $process.Id -or $identity.image -ine $image -or $identity.executable -ine $image) { + throw 'Workload is running in a redirector child; refusing configuration' + } + $expectedProxy = Join-Path (Split-Path -Parent $venvPath) 'src\codebuddy_proxy\__main__.py' + if ($identity.proxy -ine $expectedProxy -or $identity.base_prefix -ine $manifest.base) { + throw 'Dedicated interpreter resolved a different project or base Python' + } + $identities += $identity + } + Set-Scope $manifest.probe (Join-Path $statePath 'test-dumps') $testChanges $testJournal + $reports = @() + foreach ($test in @(@{name='normal';code='raise SystemExit(23)';exit=23}, + @{name='abrupt_self';code='import os; os._exit(29)';exit=29})) { + $since = Get-Date + $process = Start-NativeTest $manifest.probe '-c' $test.code + if (-not $process.WaitForExit(15000) -or $process.ExitCode -ne $test.exit) { throw 'Temporary self-exit test failed' } + $events = @(Wait-NativeEvent $since 3000 $manifest.probe $test.exit) + $reports += @{case=$test.name;pid=$process.Id;events=@($events | ForEach-Object { @{id=$_.Id;record_id=$_.RecordId;xml=$_.ToXml();message=$_.Message} })} + } + $ready = Join-Path $statePath 'temporary-ready.json' + if ($ready.Contains("'")) { throw 'Test state path cannot contain single quotes' } + $code = "import os,time,json; from pathlib import Path; Path(r'$ready').write_text(json.dumps(dict(pid=os.getpid()))); time.sleep(45)" + $since = Get-Date + $process = Start-NativeTest $manifest.probe '-c' $code + $deadline = [DateTime]::UtcNow.AddSeconds(15) + while (-not (Test-Path $ready) -and [DateTime]::UtcNow -lt $deadline) { Start-Sleep -Milliseconds 100 } + if (-not (Test-Path $ready) -or (Get-Content $ready -Raw | ConvertFrom-Json).pid -ne $process.Id) { + throw 'Temporary workload PID does not match the exact owned process' + } + $killedPid = $process.Id + $process.Kill() # Only this test Process object; never search or kill by name/PID. + if (-not $process.WaitForExit(15000)) { throw 'Temporary termination timed out' } + $events = @(Wait-NativeEvent $since 3001 $manifest.probe -1) + if (-not (@($events | ForEach-Object {$_.Message}) -join ' ').Contains('powershell.exe')) { + throw 'Cross-process report did not identify the known test PowerShell initiator' + } + $reports += @{case='external_termination';pid=$killedPid;initiator_pid=$PID; + events=@($events | ForEach-Object { @{id=$_.Id;record_id=$_.RecordId;xml=$_.ToXml();message=$_.Message} })} + Save-Json (Join-Path $statePath 'validation-events.json') $reports + $deadline = [DateTime]::UtcNow.AddSeconds(25) + do { + $dumps = @(Get-ChildItem (Join-Path $statePath 'test-dumps') -Recurse -File -Filter '*.dmp') + $victim = @($dumps | Where-Object {$_.Name -like "*probe.exe-(PID-$killedPid).*"}) + $initiator = @($dumps | Where-Object {$_.Name -like "*powershell.exe-(PID-$PID).*"}) + if ($victim.Count -and $initiator.Count) { break } + Start-Sleep -Milliseconds 250 + } while ([DateTime]::UtcNow -lt $deadline) + if (-not $victim.Count -or -not $initiator.Count) { throw 'Exact temporary victim/initiator dumps missing' } + Restore-RecordedValues $testJournal + foreach ($image in @($manifest.runtime,$manifest.observer)) { + Set-Scope $image (Join-Path $statePath 'dumps') $productionChanges $journalPath + } + $productionReports = @() + foreach ($image in @($manifest.runtime,$manifest.observer)) { + $since = Get-Date + $identityPath = Join-Path $statePath (([IO.Path]::GetFileName($image)) + '.monitored-identity.json') + $process = Start-NativeTest $image '--identity' $identityPath + if (-not $process.WaitForExit(15000) -or $process.ExitCode -ne 0) { throw 'Configured image import test failed' } + if ((Get-Content $identityPath -Raw | ConvertFrom-Json).pid -ne $process.Id) { throw 'Configured workload PID mismatch' } + $events = @(Wait-NativeEvent $since 3000 $image 0) + $productionReports += @{image=$image;temporary_pid=$process.Id; + events=@($events | ForEach-Object { @{id=$_.Id;record_id=$_.RecordId;xml=$_.ToXml();message=$_.Message} })} + } + Save-Json (Join-Path $statePath 'production-image-validation.json') $productionReports + Save-Json $resultPath @{status='native_runtime_enabled';timestamp=[DateTimeOffset]::Now.ToString('o'); + runtime_root=$runtimeRoot;venv=$venvPath;project=(Split-Path -Parent $venvPath); + identities=$identities;dump_folder=(Join-Path $statePath 'dumps'); + normal_exit_verified=$true;abrupt_self_exit_verified=$true;external_initiator_verified=$true; + terminated_temporary_pid=$killedPid;test_initiator_pid=$PID;running_proxy_changed=$false; + shared_python_configured=$false;both_configured_images_verified=$true} +} catch { + $failure = $_.Exception.Message + $rollbackErrors = @() + try { Restore-RecordedValues $journalPath } catch { $rollbackErrors += $_.Exception.Message } + try { Restore-RecordedValues $testJournal } catch { $rollbackErrors += $_.Exception.Message } + Save-Json $resultPath @{status='failed';error=$failure;rollback_errors=$rollbackErrors} + throw +} finally { + foreach ($process in $owned) { + if (-not $process.HasExited) { $process.Kill(); $null=$process.WaitForExit(10000) } + $process.Dispose() + } + Stop-Transcript | Out-Null +} diff --git a/scripts/manage_silent_exit.ps1 b/scripts/manage_silent_exit.ps1 new file mode 100644 index 0000000..831c82e --- /dev/null +++ b/scripts/manage_silent_exit.ps1 @@ -0,0 +1,298 @@ +#requires -Version 5.1 +[CmdletBinding()] +param( + [ValidateSet('ValidateAndEnable','Rollback','Status')][string]$Action = 'Status', + [Parameter(Mandatory=$true)][string]$StateDirectory, + [string]$TargetImage = 'D:\workspace\workbuddy2api\.venv\Scripts\python.exe', + [string]$VenvConfig = 'D:\workspace\workbuddy2api\.venv\pyvenv.cfg' +) +$ErrorActionPreference = 'Stop' +$ifeoRoot = 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Image File Execution Options' +$speRoot = 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\SilentProcessExit' +$filterName = 'Workbuddy2apiExitEvidence' +$provider = 'Microsoft-Windows-ProcessExitMonitor' +$statePath = [IO.Path]::GetFullPath($StateDirectory) +$journalPath = Join-Path $statePath 'production-registry-journal.json' +$resultPath = Join-Path $statePath 'result.json' +$script:ownedProcesses = New-Object System.Collections.Generic.List[System.Diagnostics.Process] + +function Save-Json($path, $value) { + $text = ConvertTo-Json -InputObject $value -Depth 12 + # Write replacement atomically so a partial write cannot destroy rollback state. + $tempPath = $path + '.new' + [IO.File]::WriteAllText($tempPath, $text, (New-Object Text.UTF8Encoding($false))) + if (Test-Path -LiteralPath $path) { [IO.File]::Replace($tempPath, $path, ($path + '.previous')) } + else { [IO.File]::Move($tempPath, $path) } +} + +function Set-RecordedValue($path, $name, $value, $type, $changes, $backupPath) { + $exists = Test-Path -LiteralPath $path + $hadValue = $false + $oldValue = $null + $oldKind = $null + if ($exists) { + $key = Get-Item -LiteralPath $path + $hadValue = $key.GetValueNames() -contains $name + if ($hadValue) { + $oldValue = $key.GetValue($name, $null, [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames) + $oldKind = [string]$key.GetValueKind($name) + } + } + $changes.Add([pscustomobject]@{Path=$path; Name=$name; KeyExisted=$exists; HadValue=$hadValue; + OldValue=$oldValue; OldKind=$oldKind; NewValue=$value; NewKind=$type}) + Save-Json $backupPath @($changes.ToArray()) + if (-not (Test-Path -LiteralPath $path)) { $null = New-Item -Path $path -Force } + $null = New-ItemProperty -LiteralPath $path -Name $name -Value $value -PropertyType $type -Force +} + +function Restore-RecordedValues($backupPath) { + if (-not (Test-Path -LiteralPath $backupPath)) { return } + # Windows PowerShell 5.1 emits a JSON array as one pipeline item. Assignment + # avoids accidentally wrapping it in a second array and enumerating .Name. + [object[]]$changes = Get-Content -LiteralPath $backupPath -Raw | ConvertFrom-Json + $emptyCandidates = @() + for ($index = $changes.Count - 1; $index -ge 0; $index--) { + $entry = $changes[$index] + # Restore only journaled values under the expected image-option registry roots. + $allowed = $entry.Path.StartsWith($ifeoRoot + '\', [StringComparison]::OrdinalIgnoreCase) -or + $entry.Path.StartsWith($speRoot + '\', [StringComparison]::OrdinalIgnoreCase) + if (-not $allowed) { throw "Rollback path outside expected registry roots: $($entry.Path)" } + if (Test-Path -LiteralPath $entry.Path) { + $key = Get-Item -LiteralPath $entry.Path + $present = $key.GetValueNames() -contains $entry.Name + if ($present) { + $current = $key.GetValue($entry.Name, $null, [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames) + if ([string]$current -ne [string]$entry.NewValue) { + throw "Rollback conflict; value changed after setup: $($entry.Path) / $($entry.Name)" + } + } + if ($entry.HadValue) { + $null = New-ItemProperty -LiteralPath $entry.Path -Name $entry.Name -Value $entry.OldValue -PropertyType $entry.OldKind -Force + } elseif ($present) { + Remove-ItemProperty -LiteralPath $entry.Path -Name $entry.Name + } + } elseif ($entry.HadValue) { + throw "Rollback conflict; original key was removed: $($entry.Path)" + } + if (-not $entry.KeyExisted) { $emptyCandidates += $entry.Path } + } + foreach ($path in ($emptyCandidates | Select-Object -Unique | Sort-Object { $_.Length } -Descending)) { + if (Test-Path -LiteralPath $path) { + $key = Get-Item -LiteralPath $path + if ($key.GetValueNames().Count -eq 0 -and $key.GetSubKeyNames().Count -eq 0) { + # No recursive registry deletion; never remove another tool's values/subkeys. + Remove-Item -LiteralPath $path + } + } + } +} + +function Set-Scope($imagePath, $dumpFolder, $changes, $backupPath) { + $imageName = [IO.Path]::GetFileName($imagePath) + if ($imageName -notin @('workbuddy-spe-probe.exe','workbuddy2api-evidence.exe', + 'workbuddy-native-probe.exe','workbuddy2api-runtime.exe','workbuddy2api-observer.exe')) { + throw 'Settings are restricted to our dedicated diagnostic image names; python.exe is never configured' + } + $baseKey = Join-Path $ifeoRoot $imageName + $filterKey = Join-Path $baseKey $filterName + $speKey = Join-Path $speRoot $imageName + if (Test-Path -LiteralPath $filterKey) { throw "Named filter already exists; refuse overwrite: $filterKey" } + if (Test-Path -LiteralPath $speKey) { + $old = Get-Item -LiteralPath $speKey + if ($old.GetValueNames().Count -or $old.GetSubKeyNames().Count) { + throw "Existing silent-exit configuration requires manual review: $speKey" + } + } + $globalFlags = 0 + if (Test-Path -LiteralPath $baseKey) { + $key = Get-Item -LiteralPath $baseKey + if (($key.GetValueNames() -contains 'Debugger') -or $key.GetValue('UseFilter', 0)) { + throw 'Existing debugger or enabled path filters require manual review' + } + if ($key.GetValueNames() -contains 'GlobalFlag') { + if ($key.GetValueKind('GlobalFlag') -ne [Microsoft.Win32.RegistryValueKind]::DWord) { + throw 'Existing non-DWORD GlobalFlag requires manual review' + } + $globalFlags = [int]$key.GetValue('GlobalFlag') + if ($globalFlags -band 0x200) { throw 'Image already has global silent-exit monitoring; refuse broadened scope' } + } + } + $null = New-Item -ItemType Directory -Path $dumpFolder -Force + Set-RecordedValue $baseKey 'GlobalFlag' ($globalFlags -bor 0x200) 'DWord' $changes $backupPath + Set-RecordedValue $speKey 'ReportingMode' 2 'DWord' $changes $backupPath + Set-RecordedValue $speKey 'IgnoreSelfExits' 0 'DWord' $changes $backupPath + Set-RecordedValue $speKey 'LocalDumpFolder' $dumpFolder 'String' $changes $backupPath + Set-RecordedValue $speKey 'DumpType' 0 'DWord' $changes $backupPath + Set-RecordedValue $speKey 'MaxNumberOfDumpFiles' 8 'DWord' $changes $backupPath +} + +function New-Probe($executable, $code) { + $info = New-Object Diagnostics.ProcessStartInfo + $info.FileName = $executable + $info.Arguments = '-c "' + $code + '"' + $info.UseShellExecute = $false + $info.CreateNoWindow = $true + $info.WorkingDirectory = Split-Path -Parent $executable + $process = [Diagnostics.Process]::Start($info) + $script:ownedProcesses.Add($process) + return $process +} + +function Read-ProbeEvents($since) { + try { + return @(Get-WinEvent -FilterHashtable @{LogName='Application';ProviderName=$provider;StartTime=$since} -MaxEvents 100 -ErrorAction Stop | + Where-Object { (@($_.Properties | ForEach-Object { [string]$_.Value }) -join ' ') -match 'workbuddy-spe-(probe|control)\.exe' }) + } catch { + if ($_.FullyQualifiedErrorId -like 'NoMatchingEventsFound*') { return @() } + throw + } +} + +function Wait-ProbeEvent($since, $eventId) { + $deadline = [DateTime]::UtcNow.AddSeconds(25) + while ([DateTime]::UtcNow -lt $deadline) { + $rows = @(Read-ProbeEvents $since | Where-Object { ($_.Id -band 65535) -eq $eventId }) + if ($rows.Count) { return $rows } + Start-Sleep -Milliseconds 250 + } + throw "No expected silent-exit event $eventId; validation failed" +} + +function Protect-StateDirectory { + $sid = [Security.Principal.WindowsIdentity]::GetCurrent().User + $acl = New-Object Security.AccessControl.DirectorySecurity + $acl.SetAccessRuleProtection($true, $false) + foreach ($identity in @($sid, [Security.Principal.SecurityIdentifier]'S-1-5-18', [Security.Principal.SecurityIdentifier]'S-1-5-32-544')) { + $rule = New-Object Security.AccessControl.FileSystemAccessRule($identity, 'FullControl', 'ContainerInherit,ObjectInherit', 'None', 'Allow') + $acl.AddAccessRule($rule) + } + Set-Acl -LiteralPath $statePath -AclObject $acl +} + +if ($Action -eq 'Status') { + $rollbackResult = Join-Path $statePath 'rollback-result.json' + if (Test-Path -LiteralPath $rollbackResult) { Get-Content -LiteralPath $rollbackResult -Raw } + elseif (Test-Path -LiteralPath $resultPath) { Get-Content -LiteralPath $resultPath -Raw } + else { Write-Output 'No result file; configuration has not been confirmed.' } + exit 0 +} + +$principal = [Security.Principal.WindowsPrincipal]::new([Security.Principal.WindowsIdentity]::GetCurrent()) +if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { + throw 'Administrator token required. Run this script through an explicit Windows UAC prompt.' +} +if (-not [Environment]::Is64BitProcess) { throw 'Use 64-bit Windows PowerShell for the 64-bit registry view' } + +if ($Action -eq 'Rollback') { + Restore-RecordedValues $journalPath + Restore-RecordedValues (Join-Path $statePath 'test-registry-journal.json') + Save-Json (Join-Path $statePath 'rollback-result.json') @{status='rolled_back'; timestamp=[DateTimeOffset]::Now.ToString('o')} + exit 0 +} +if (Test-Path -LiteralPath $statePath) { throw 'Use a fresh state directory; refusing to overwrite prior rollback evidence' } +$null = New-Item -ItemType Directory -Path $statePath +Protect-StateDirectory +Start-Transcript -LiteralPath (Join-Path $statePath 'setup-transcript.txt') | Out-Null +$testBackup = Join-Path $statePath 'test-registry-journal.json' +$testChanges = New-Object System.Collections.Generic.List[object] +$productionChanges = New-Object System.Collections.Generic.List[object] +$testRestored = $false +try { + $target = (Get-Item -LiteralPath $TargetImage).FullName + if ([IO.Path]::GetFileName($target) -ine 'python.exe') { throw 'Target must be the explicitly selected venv python.exe' } + $config = (Get-Item -LiteralPath $VenvConfig).FullName + $testDirectory = Join-Path $statePath 'temporary-test' + $monitoredDirectory = Join-Path $testDirectory 'monitored' + $controlDirectory = Join-Path $testDirectory 'control' + foreach ($directory in @($monitoredDirectory,$controlDirectory)) { + $null = New-Item -ItemType Directory -Path $directory -Force + Copy-Item -LiteralPath $target -Destination (Join-Path $directory 'workbuddy-spe-probe.exe') + Copy-Item -LiteralPath $config -Destination (Join-Path $directory 'pyvenv.cfg') + } + $probe = Join-Path $monitoredDirectory 'workbuddy-spe-probe.exe' + $control = Join-Path $controlDirectory 'workbuddy-spe-control.exe' + Move-Item -LiteralPath (Join-Path $controlDirectory 'workbuddy-spe-probe.exe') -Destination $control + Set-Scope $probe (Join-Path $statePath 'test-dumps') $testChanges $testBackup + + # Only our unique diagnostic image name is configured; original python.exe is untouched. + $controlStart = Get-Date + $process = New-Probe $control 'raise SystemExit(17)' + if (-not $process.WaitForExit(15000)) { throw 'Temporary control process timed out' } + if ($process.ExitCode -ne 17) { throw 'Renamed temporary launcher did not execute the control workload' } + Start-Sleep -Seconds 3 + if (@(Read-ProbeEvents $controlStart).Count) { throw 'Unexpected diagnostic image event during control workload' } + + $selfStart = Get-Date + $process = New-Probe $probe 'raise SystemExit(23)' + if (-not $process.WaitForExit(15000)) { throw 'Temporary self-exit process timed out' } + $selfEvents = @(Wait-ProbeEvent $selfStart 3000) + Save-Json (Join-Path $statePath 'self-exit-events.json') @($selfEvents | ForEach-Object { + @{id=$_.Id;record_id=$_.RecordId;timestamp=$_.TimeCreated.ToString('o');message=$_.Message;xml=$_.ToXml()} + }) + + $ready = Join-Path $testDirectory 'ready.txt' + # Use a Python raw string; path contains no user-controlled shell substitutions. + if ($ready.Contains("'")) { throw 'State directory cannot contain a single quote for the isolated readiness test' } + $code = "import os,time; from pathlib import Path; Path(r'$ready').write_text(str(os.getpid())); time.sleep(20)" + $killStart = Get-Date + $process = New-Probe $probe $code + $readyDeadline = [DateTime]::UtcNow.AddSeconds(10) + while (-not (Test-Path -LiteralPath $ready) -and [DateTime]::UtcNow -lt $readyDeadline) { Start-Sleep -Milliseconds 100 } + if (-not (Test-Path -LiteralPath $ready)) { throw 'Temporary cross-process workload did not become ready' } + $killedTestPid = $process.Id + $process.Kill() # ONLY this exact Process object created by New-Probe, never any proxy. + if (-not $process.WaitForExit(15000)) { throw 'Temporary termination did not complete' } + $crossEvents = @(Wait-ProbeEvent $killStart 3001) + if (-not (@($crossEvents | ForEach-Object { $_.Message }) -join ' ').Contains('powershell.exe')) { + throw 'Cross-process event did not identify the known PowerShell initiator' + } + Save-Json (Join-Path $statePath 'cross-process-events.json') @($crossEvents | ForEach-Object { + @{id=$_.Id;record_id=$_.RecordId;timestamp=$_.TimeCreated.ToString('o');message=$_.Message;xml=$_.ToXml()} + }) + $dumpDeadline = [DateTime]::UtcNow.AddSeconds(25) + do { + # Windows groups each exit's victim/initiator dumps in a per-incident subfolder. + $dumps = @(Get-ChildItem -LiteralPath (Join-Path $statePath 'test-dumps') -Recurse -Filter '*.dmp' -File) + $initiatorDumps = @($dumps | Where-Object { $_.Name -like '*powershell*' }) + if ($initiatorDumps.Count) { break } + Start-Sleep -Milliseconds 250 + } while ([DateTime]::UtcNow -lt $dumpDeadline) + if (-not $dumps.Count) { throw 'No test dumps were produced; refusing production configuration' } + if (-not @($dumps | Where-Object { $_.Name -like '*powershell*' }).Count) { + throw 'No initiator dump found; refusing production configuration' + } + Restore-RecordedValues $testBackup + $testRestored = $true + $diagnosticImage = Join-Path (Split-Path -Parent $target) 'workbuddy2api-evidence.exe' + if (-not (Test-Path -LiteralPath $diagnosticImage)) { + Copy-Item -LiteralPath $target -Destination $diagnosticImage + } + $sourceHash = (Get-FileHash -LiteralPath $target -Algorithm SHA256).Hash + if ((Get-FileHash -LiteralPath $diagnosticImage -Algorithm SHA256).Hash -ne $sourceHash) { + throw 'Existing diagnostic image differs from the original venv launcher; refuse overwrite' + } + # Keep the copy adjacent to the original so the same pyvenv.cfg and site-packages apply. + Set-Scope $diagnosticImage (Join-Path $statePath 'dumps') $productionChanges $journalPath + Save-Json $resultPath @{status='enabled_for_future_starts';timestamp=[DateTimeOffset]::Now.ToString('o'); + target=$diagnosticImage;original_target=$target;launcher_sha256=$sourceHash; + scope='dedicated diagnostic image name; original python.exe not configured';dump_folder=(Join-Path $statePath 'dumps'); + control_image_ignored=$true;self_exit_verified=$true;external_initiator_verified=$true; + test_initiator_pid=$PID;terminated_temporary_launcher_pid=$killedTestPid; + test_dumps=@($dumps | Select-Object Name,Length);target_processes_stopped=$false; + current_running_processes_reconfigured=$false;reporting_mode=2;dump_type=0;max_dump_files=8} +} catch { + $failure = $_.Exception.Message + $restoreErrors = @() + try { Restore-RecordedValues $journalPath } catch { $restoreErrors += $_.Exception.Message } + if (-not $testRestored) { + try { Restore-RecordedValues $testBackup } catch { $restoreErrors += $_.Exception.Message } + } + Save-Json $resultPath @{status='failed';error=$failure;rollback_errors=$restoreErrors;timestamp=[DateTimeOffset]::Now.ToString('o')} + throw +} finally { + foreach ($process in $script:ownedProcesses) { + if (-not $process.HasExited) { $process.Kill(); $process.WaitForExit(10000) | Out-Null } + $process.Dispose() + } + Stop-Transcript | Out-Null +} diff --git a/scripts/native_runtime_bootstrap.py b/scripts/native_runtime_bootstrap.py new file mode 100644 index 0000000..9780477 --- /dev/null +++ b/scripts/native_runtime_bootstrap.py @@ -0,0 +1,48 @@ +"""Run with the dedicated, real CPython image (never a venv redirector).""" +import json +import os +from pathlib import Path +import runpy +import site +import sys + + +def main(): + root = Path(__file__).resolve().parent + config = json.loads((root / "runtime.json").read_text(encoding="utf-8")) + # Anaconda's extensions can depend on DLLs outside CPython's DLLs directory. + # Retain handles for the whole workload; closing them removes the search paths. + handles = [os.add_dll_directory(str(path)) for path in + (Path(config["base"]), Path(config["base"]) / "DLLs", + Path(config["base"]) / "Library" / "bin") if path.is_dir()] + site.addsitedir(config["site_packages"]) + args = sys.argv[1:] + if len(args) < 2 or args[0] not in ("-m", "--script", "-c", "--identity"): + raise SystemExit("Expected -m MODULE, --script PATH, -c CODE, or --identity FILE") + mode, target, *rest = args + sys.argv = [target, *rest] + if mode == "--identity": + import ctypes + import importlib + import ssl + import sqlite3 + import fastapi + proxy = importlib.import_module("codebuddy_proxy.__main__") + image = ctypes.create_unicode_buffer(32768) + if not ctypes.windll.kernel32.GetModuleFileNameW(None, image, len(image)): + raise OSError("Cannot identify actual process image") + Path(target).write_text(json.dumps(dict(pid=os.getpid(), image=image.value, + executable=sys.executable, prefix=sys.prefix, base_prefix=sys.base_prefix, + fastapi=fastapi.__file__, proxy=proxy.__file__)), encoding="utf-8") + elif mode == "-m": + runpy.run_module(target, run_name="__main__", alter_sys=True) + elif mode == "--script": + sys.path.insert(0, str(Path(target).resolve().parent)) + runpy.run_path(target, run_name="__main__") + else: + exec(compile(target, "", "exec"), {"__name__": "__main__"}) + # 'handles' deliberately remains referenced until workload completion. + + +if __name__ == "__main__": + main() diff --git a/scripts/prepare_native_runtime.py b/scripts/prepare_native_runtime.py new file mode 100644 index 0000000..e2b62f9 --- /dev/null +++ b/scripts/prepare_native_runtime.py @@ -0,0 +1,54 @@ +"""Build an isolated CPython image using the existing venv's dependencies.""" +import argparse +import hashlib +import json +from pathlib import Path +import shutil +import sys + + +def prepare(destination, venv): + destination, venv = Path(destination), Path(venv).resolve() + base = Path(sys.base_prefix).resolve() + if destination.exists(): + raise ValueError("Runtime directory already exists; use a fresh state directory") + scripts = destination / "Scripts" + scripts.mkdir(parents=True) + (destination / "Lib" / "site-packages").mkdir(parents=True) + # Copy the actual executable, NOT .venv/Scripts/python.exe (a redirector). + for name, source in (("workbuddy2api-runtime.exe", "python.exe"), + ("workbuddy2api-observer.exe", "pythonw.exe"), + ("workbuddy-native-probe.exe", "python.exe")): + shutil.copy2(base / source, scripts / name) + for source in base.iterdir(): + if source.is_file() and source.suffix.lower() == ".dll" and ( + source.name.lower().startswith("python") or + source.name.lower().startswith("vcruntime") or + source.name.lower().startswith("zlib")): + shutil.copy2(source, scripts / source.name) + (destination / "pyvenv.cfg").write_text( + f"home = {base}\ninclude-system-site-packages = false\n", encoding="utf-8") + config = dict(base=str(base), venv=str(venv), site_packages=str(venv / "Lib" / "site-packages")) + (destination / "runtime.json").write_text(json.dumps(config), encoding="utf-8") + shutil.copy2(Path(__file__).with_name("native_runtime_bootstrap.py"), + destination / "bootstrap.py") + files = {str(p.relative_to(destination)): hashlib.sha256(p.read_bytes()).hexdigest() + for p in destination.rglob("*") if p.is_file()} + sources = {str(p): hashlib.sha256(p.read_bytes()).hexdigest() for p in + [base / "python.exe", base / "pythonw.exe", venv / "pyvenv.cfg", + *[base / p.name for p in scripts.glob('*.dll')]]} + manifest = dict(config, files=files, sources=sources, python_version=sys.version, + runtime=str(scripts / "workbuddy2api-runtime.exe"), + observer=str(scripts / "workbuddy2api-observer.exe"), + probe=str(scripts / "workbuddy-native-probe.exe"), + bootstrap=str(destination / "bootstrap.py")) + (destination / "manifest.json").write_text(json.dumps(manifest, indent=2), encoding="utf-8") + return manifest + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("destination", type=Path) + parser.add_argument("venv", type=Path) + args = parser.parse_args() + prepare(args.destination, args.venv) diff --git a/scripts/start_proxy_with_evidence.ps1 b/scripts/start_proxy_with_evidence.ps1 new file mode 100644 index 0000000..657fe09 --- /dev/null +++ b/scripts/start_proxy_with_evidence.ps1 @@ -0,0 +1,82 @@ +#requires -Version 5.1 +[CmdletBinding()] +param( + [string]$StateDirectory, + [string[]]$ProxyArguments = @('--desensitize','--host','0.0.0.0'), + [switch]$CheckOnly +) +$ErrorActionPreference = 'Stop' +# Python subprocesses can inherit PowerShell 7's PSModulePath when invoking 5.1. +# Load the host's own utility module rather than resolving a different edition. +Import-Module (Join-Path $PSHOME 'Modules\Microsoft.PowerShell.Utility\Microsoft.PowerShell.Utility.psd1') -ErrorAction Stop +if (-not $StateDirectory) { + $stateRoot = Join-Path $HOME '.workbuddy2api\silent-exit' + foreach ($candidate in @(Get-ChildItem -LiteralPath $stateRoot -Directory -ErrorAction SilentlyContinue | Sort-Object LastWriteTime -Descending)) { + $resultPath = Join-Path $candidate.FullName 'result.json' + if ((Test-Path $resultPath) -and -not (Test-Path (Join-Path $candidate.FullName 'rollback-result.json'))) { + $record = Get-Content $resultPath -Raw | ConvertFrom-Json + if ($record.status -eq 'native_runtime_enabled') { $StateDirectory = $candidate.FullName; break } + } + } +} +if (-not $StateDirectory) { throw 'No validated native runtime setup. Run manage_native_runtime.ps1 first; launcher-only setups are insufficient.' } +$record = Get-Content (Join-Path $StateDirectory 'result.json') -Raw | ConvertFrom-Json +if ($record.status -ne 'native_runtime_enabled' -or (Test-Path (Join-Path $StateDirectory 'rollback-result.json'))) { + throw 'The selected native runtime setup is not enabled' +} +$runtimeRoot = (Get-Item -LiteralPath $record.runtime_root).FullName +$manifest = Get-Content (Join-Path $runtimeRoot 'manifest.json') -Raw | ConvertFrom-Json +if (-not $manifest.sources) { throw 'Missing source-runtime validation; revalidate the native setup' } +foreach ($source in $manifest.sources.PSObject.Properties) { + if ((Get-FileHash -LiteralPath $source.Name -Algorithm SHA256).Hash -ine $source.Value) { + throw 'Base Python or venv configuration changed; revalidate before starting' + } +} +foreach ($entry in $manifest.files.PSObject.Properties) { + $file = [IO.Path]::GetFullPath((Join-Path $runtimeRoot $entry.Name)) + if (-not $file.StartsWith($runtimeRoot + '\', [StringComparison]::OrdinalIgnoreCase) -or + (Get-FileHash -LiteralPath $file -Algorithm SHA256).Hash -ine $entry.Value) { + throw 'Runtime content changed since validation; revalidate before starting' + } +} +foreach ($image in @($manifest.runtime,$manifest.observer)) { + $name = [IO.Path]::GetFileName($image) + if ($name -notin @('workbuddy2api-runtime.exe','workbuddy2api-observer.exe') -or + [IO.Path]::GetDirectoryName($image) -ine (Join-Path $runtimeRoot 'Scripts')) { throw 'Invalid dedicated image path' } + $ifeo = 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Image File Execution Options\' + $name + $spe = 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\SilentProcessExit\' + $name + $flags = Get-ItemProperty -LiteralPath $ifeo + $settings = Get-ItemProperty -LiteralPath $spe + if (-not ($flags.GlobalFlag -band 0x200) -or $settings.ReportingMode -ne 2 -or + $settings.IgnoreSelfExits -ne 0 -or $settings.LocalDumpFolder -ine $record.dump_folder -or + $settings.DumpType -ne 0 -or $settings.MaxNumberOfDumpFiles -ne 8) { + throw 'Windows settings differ from the validated native setup' + } +} +$monitorScript = Join-Path $PSScriptRoot 'watch_processes.py' +if ($CheckOnly) { + @{status='ready';state_directory=$StateDirectory;project=$record.project; + target=$manifest.runtime;observer=$manifest.observer;dump_folder=$record.dump_folder; + actual_interpreter_verified=$true;proxy_started=$false} | ConvertTo-Json + exit 0 +} +$existing = @(Get-CimInstance Win32_Process -Filter "Name='python.exe' OR Name='uv.exe' OR Name='workbuddy2api-evidence.exe' OR Name='workbuddy2api-runtime.exe'" | + Where-Object { $_.CommandLine -match '(?:^|\s)-m\s+codebuddy_proxy(?:\s|$)' }) +if ($existing.Count) { throw 'A proxy module process already exists; refusing to start another or stop it.' } +function Quote-NativeArgument([string]$value) { + return '"' + (($value -replace '(\\*)"', '$1$1\"') -replace '(\\+)$', '$1$1') + '"' +} +$launchRoot = Join-Path $StateDirectory ('launch-' + [DateTime]::Now.ToString('yyyyMMdd-HHmmss-') + [guid]::NewGuid().ToString('N').Substring(0,8)) +$null = New-Item -ItemType Directory -Path $launchRoot +$arguments = @($manifest.bootstrap,'--script',$monitorScript,'--output',(Join-Path $HOME '.workbuddy2api\monitor'), + '--launch',$manifest.runtime,$manifest.bootstrap,'-m','codebuddy_proxy') + $ProxyArguments +$argumentLine = ($arguments | ForEach-Object { Quote-NativeArgument $_ }) -join ' ' +$process = Start-Process -FilePath $manifest.observer -ArgumentList $argumentLine -WorkingDirectory $record.project ` + -WindowStyle Hidden -RedirectStandardOutput (Join-Path $launchRoot 'observer-stdout.log') ` + -RedirectStandardError (Join-Path $launchRoot 'observer-stderr.log') -PassThru +# Start is intentionally detached. This check confirms process creation, not health. +Start-Sleep -Seconds 2 +if ($process.HasExited) { throw "Observer exited during startup; inspect $launchRoot" } +@{status='observer_started';observer_pid=$process.Id;launch_logs=$launchRoot; + monitor_output=([string](Get-Content -LiteralPath (Join-Path $launchRoot 'observer-stdout.log') -Raw)).Trim(); + health_verified=$false;automatic_restart=$false} | ConvertTo-Json diff --git a/scripts/watch_processes.py b/scripts/watch_processes.py new file mode 100644 index 0000000..b44dfb1 --- /dev/null +++ b/scripts/watch_processes.py @@ -0,0 +1,507 @@ +"""Independent Windows process evidence collector. No proxy imports or stop/restart API. + +Run with a separate console/hidden Start-Process; see docs/external-process-monitor.md. +""" +from __future__ import annotations + +import argparse +import ctypes as C +from ctypes import wintypes as W +from datetime import datetime, timedelta, timezone +import json +import os +from pathlib import Path +import queue +import signal +import subprocess +import threading +import time +import urllib.request +import uuid + + +def utc(): + return datetime.now(timezone.utc).isoformat(timespec="microseconds") + + +def filetime(value): + ticks = (value.dwHighDateTime << 32) | value.dwLowDateTime + return ticks + + +def ticks_utc(ticks): + return datetime.fromtimestamp((ticks - 116444736000000000) / 10_000_000, + timezone.utc).isoformat(timespec="microseconds") + + +class Journal: + def __init__(self, path, max_bytes=8 * 1024 * 1024, backups=4): + self.path, self.max_bytes, self.backups = Path(path), max_bytes, backups + self.seq = 0 + self.stream = self.path.open("a", encoding="utf-8") + + def emit(self, event, **fields): + self.seq += 1 + line = json.dumps(dict(timestamp=utc(), monotonic_ns=time.monotonic_ns(), + seq=self.seq, event=event, **fields), ensure_ascii=True) + "\n" + if self.stream.tell() + len(line.encode()) > self.max_bytes: + self.stream.close() + for index in range(self.backups, 0, -1): + source = self.path if index == 1 else Path(f"{self.path}.{index - 1}") + if source.exists(): + source.replace(Path(f"{self.path}.{index}")) + self.stream = self.path.open("a", encoding="utf-8") + self.stream.write(line) + self.stream.flush() + if event != "sample": + os.fsync(self.stream.fileno()) + + def close(self): + self.stream.close() + + +class PROCESSENTRY32W(C.Structure): + _fields_ = [("dwSize", W.DWORD), ("cntUsage", W.DWORD), ("th32ProcessID", W.DWORD), + ("th32DefaultHeapID", C.c_size_t), ("th32ModuleID", W.DWORD), + ("cntThreads", W.DWORD), ("th32ParentProcessID", W.DWORD), + ("pcPriClassBase", W.LONG), ("dwFlags", W.DWORD), ("szExeFile", W.WCHAR * 260)] + + +class MEMORY(C.Structure): + _fields_ = [("cb", W.DWORD), ("PageFaultCount", W.DWORD)] + [ + (name, C.c_size_t) for name in ("PeakWorkingSetSize", "WorkingSetSize", + "QuotaPeakPagedPoolUsage", "QuotaPagedPoolUsage", "QuotaPeakNonPagedPoolUsage", + "QuotaNonPagedPoolUsage", "PagefileUsage", "PeakPagefileUsage", "PrivateUsage")] + + +class Windows: + def __init__(self): + self.k = C.WinDLL("kernel32", use_last_error=True) + self.p = C.WinDLL("psapi", use_last_error=True) + for dll, name, restype, args in [ + (self.k, "OpenProcess", W.HANDLE, [W.DWORD, W.BOOL, W.DWORD]), + (self.k, "CloseHandle", W.BOOL, [W.HANDLE]), + (self.k, "CreateToolhelp32Snapshot", W.HANDLE, [W.DWORD, W.DWORD]), + (self.k, "Process32FirstW", W.BOOL, [W.HANDLE, C.POINTER(PROCESSENTRY32W)]), + (self.k, "Process32NextW", W.BOOL, [W.HANDLE, C.POINTER(PROCESSENTRY32W)]), + (self.k, "WaitForSingleObject", W.DWORD, [W.HANDLE, W.DWORD]), + (self.k, "GetExitCodeProcess", W.BOOL, [W.HANDLE, C.POINTER(W.DWORD)]), + (self.k, "GetProcessTimes", W.BOOL, [W.HANDLE] + [C.POINTER(W.FILETIME)] * 4), + (self.k, "QueryFullProcessImageNameW", W.BOOL, + [W.HANDLE, W.DWORD, W.LPWSTR, C.POINTER(W.DWORD)]), + (self.p, "GetProcessMemoryInfo", W.BOOL, [W.HANDLE, C.POINTER(MEMORY), W.DWORD]), + ]: + fn = getattr(dll, name) + fn.restype, fn.argtypes = restype, args + + def check(self, ok): + if not ok: + raise C.WinError(C.get_last_error()) + + def snapshot(self): + handle = self.k.CreateToolhelp32Snapshot(2, 0) + if handle == C.c_void_p(-1).value: + raise C.WinError(C.get_last_error()) + result = {} + try: + entry = PROCESSENTRY32W() + entry.dwSize = C.sizeof(entry) + ok = self.k.Process32FirstW(handle, C.byref(entry)) + while ok: + result[entry.th32ProcessID] = (entry.th32ParentProcessID, entry.szExeFile) + ok = self.k.Process32NextW(handle, C.byref(entry)) + if C.get_last_error() != 18: # ERROR_NO_MORE_FILES + raise C.WinError(C.get_last_error()) + return result + finally: + self.k.CloseHandle(handle) + + def open(self, pid): + # Query + synchronize only: deliberately no PROCESS_TERMINATE permission. + handle = self.k.OpenProcess(0x1000 | 0x100000, False, pid) + self.check(handle) + return handle + + def times(self, handle): + values = [W.FILETIME() for _ in range(4)] + self.check(self.k.GetProcessTimes(handle, *(C.byref(v) for v in values))) + return filetime(values[0]), filetime(values[1]) + + def exited(self, handle): + status = self.k.WaitForSingleObject(handle, 0) + if status == 0xFFFFFFFF: + raise C.WinError(C.get_last_error()) + return status == 0 + + def exit_code(self, handle): + value = W.DWORD() + self.check(self.k.GetExitCodeProcess(handle, C.byref(value))) + return value.value + + def image(self, handle): + buf, size = C.create_unicode_buffer(32768), W.DWORD(32768) + self.check(self.k.QueryFullProcessImageNameW(handle, 0, buf, C.byref(size))) + return buf.value + + def memory(self, handle): + value = MEMORY() + value.cb = C.sizeof(value) + self.check(self.p.GetProcessMemoryInfo(handle, C.byref(value), C.sizeof(value))) + return dict(working_set_bytes=value.WorkingSetSize, private_bytes=value.PrivateUsage) + + +class Tracker: + def __init__(self, api, journal): + self.api, self.journal = api, journal + self.active = {} + self.identities = set() + self.pids = set() + + def add(self, pid, ppid, name, parent=None): + if pid in self.active: + return + handle = self.api.open(pid) + try: + created, _ = self.api.times(handle) + identity = (pid, created) + if identity in self.identities: + return + if parent and created < parent["created_ticks"]: + return # Stale PPID, not a child of this incarnation. + if parent and parent.get("handle"): + _, parent_ended = self.api.times(parent["handle"]) + if parent_ended and created > parent_ended: + return + fields = dict(pid=pid, ppid=ppid, name=name, created_ticks=created, + created_at=ticks_utc(created)) + try: + fields["image"] = self.api.image(handle) + except OSError as exc: + fields["image_error"] = str(exc) + observed_at = utc() + self.active[pid] = dict(handle=handle, + last_alive=None if self.api.exited(handle) else observed_at, **fields) + self.identities.add(identity) + self.pids.add(pid) + self.journal.emit("process_observed", **fields) + handle = None + finally: + if handle is not None: + self.api.k.CloseHandle(handle) + + def discover(self, snapshot): + pending = dict(snapshot) + changed = True + while changed: + changed = False + for pid, (ppid, name) in list(pending.items()): + if pid not in self.active and ppid in self.active: + try: + self.add(pid, ppid, name, self.active[ppid]) + except OSError as exc: + self.journal.emit("process_open_failed", pid=pid, ppid=ppid, error=str(exc)) + del pending[pid] + changed = pid in self.active or changed + + def sample(self, record=True): + rows = [] + for pid, process in list(self.active.items()): + h = process["handle"] + observed_at = utc() + if self.api.exited(h): + code = self.api.exit_code(h) + _, ended = self.api.times(h) + self.journal.emit("process_exit", pid=pid, ppid=process["ppid"], + created_ticks=process["created_ticks"], exit_code=code, + exit_code_hex=f"0x{code:08X}", exit_ticks=ended, + exited_at=ticks_utc(ended) if ended else None, + last_observed_alive=process["last_alive"], + cause="unknown", source="retained_process_handle") + self.api.k.CloseHandle(h) + del self.active[pid] + else: + process["last_alive"] = observed_at + if not record: + continue + row = dict(pid=pid, created_ticks=process["created_ticks"]) + try: + row.update(self.api.memory(h)) + except OSError as exc: + row["memory_error"] = str(exc) + rows.append(row) + if record: + self.journal.emit("sample", processes=rows) + + def close(self): + for process in self.active.values(): + self.api.k.CloseHandle(process["handle"]) + + +def health(url): + started = time.monotonic() + try: + # Do not send local probes through environment proxy servers; never read response bodies. + opener = urllib.request.build_opener(urllib.request.ProxyHandler({}), + NoRedirect()) + with opener.open(url, timeout=1) as response: + return dict(ok=response.status == 200, status=response.status, + duration_ms=round((time.monotonic() - started) * 1000)) + except Exception as exc: + return dict(ok=False, error=type(exc).__name__, + duration_ms=round((time.monotonic() - started) * 1000)) + + +class NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, req, fp, code, msg, headers, newurl): + return None + + +class BusinessTail: + """Bounded, allowlisted correlation hints; deliberately not an in-flight request census.""" + def __init__(self, path): + self.path, self.previous = Path(path), None + + def read(self): + stat = self.path.stat() + identity = (stat.st_ino, stat.st_size, stat.st_mtime_ns) + if identity == self.previous: + return None + with self.path.open("rb") as stream: + offset = max(0, stat.st_size - 65536) + stream.seek(offset) + data = stream.read(65536) + lines = data.splitlines() + if offset: + lines = lines[1:] # First line may start in the middle of a JSON record. + rows = [] + for line in lines[-32:]: + try: + obj = json.loads(line) + rows.append({key: obj[key] for key in ( + "timestamp", "event", "request_id", "status", "run_id", "pid") if key in obj}) + except (ValueError, TypeError): + continue + self.previous = identity + return dict(size=stat.st_size, modified_ns=stat.st_mtime_ns, tail=rows, + complete_history=False) + + +def collect_events(start, end, pids): + result = subprocess.run(["powershell.exe", "-NoProfile", "-NonInteractive", "-File", + str(Path(__file__).with_name("collect_windows_events.ps1")), + "-Since", start, "-Until", end, "-ProcessIds", ",".join(map(str, sorted(pids)))], + capture_output=True, encoding="utf-8", errors="replace", timeout=15, + creationflags=subprocess.CREATE_NO_WINDOW) + if result.returncode: + raise RuntimeError(f"event collector failed ({result.returncode})") + return json.loads(result.stdout.lstrip("\ufeff")) + + +def find_proxies(): + # Command lines stay inside the helper; never store credentials or request content. + command = """ + [Console]::OutputEncoding = New-Object System.Text.UTF8Encoding($false) + $ErrorActionPreference = 'Stop' + $rows = @(Get-CimInstance Win32_Process -Filter "Name='python.exe' OR Name='uv.exe' OR Name='workbuddy2api-evidence.exe' OR Name='workbuddy2api-runtime.exe'" | + Where-Object { $_.CommandLine -match '(?:^|\\s)-m\\s+codebuddy_proxy(?:\\s|$)' -and + $_.CommandLine -notmatch 'watch_processes\\.py' } | + ForEach-Object { @{pid=$_.ProcessId; ppid=$_.ParentProcessId; name=$_.Name; + created_ticks=$_.CreationDate.ToUniversalTime().ToFileTimeUtc()} }) + ConvertTo-Json -InputObject $rows -Compress + """ + result = subprocess.run(["powershell.exe", "-NoProfile", "-NonInteractive", "-Command", command], + capture_output=True, encoding="utf-8", errors="replace", timeout=10, + creationflags=subprocess.CREATE_NO_WINDOW) + if result.returncode: + raise RuntimeError(f"proxy discovery failed ({result.returncode})") + return json.loads(result.stdout.lstrip("\ufeff")) + + +class Background: + """Bounded single worker: slow health/event APIs must not delay handle sampling.""" + def __init__(self): + self.results = queue.Queue() + self.thread = None + + def submit(self, event, fn, *args): + if self.thread and self.thread.is_alive(): + return False + def work(): + try: + value = fn(*args) + self.results.put((event, dict(result=value))) + except Exception as exc: + self.results.put((event + "_failed", dict(error=str(exc)))) + self.thread = threading.Thread(target=work, daemon=True) + self.thread.start() + return True + + def drain(self, journal): + while True: + try: + event, fields = self.results.get_nowait() + except queue.Empty: + break + journal.emit(event, **fields) + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + mode = parser.add_mutually_exclusive_group(required=True) + mode.add_argument("--pid", type=int, help="Attach to this live root PID and discover descendants") + mode.add_argument("--watch-proxy", action="store_true", help="Wait for existing/future proxy module processes") + mode.add_argument("--launch", nargs=argparse.REMAINDER, help="Explicitly launch a NEW process") + parser.add_argument("--output", type=Path, default=Path.home() / ".workbuddy2api" / "monitor") + parser.add_argument("--interval", type=float, default=0.25) + parser.add_argument("--duration", type=float, help="Stop monitoring only; targets remain running") + parser.add_argument("--health-url", default="http://127.0.0.1:8787/health") + parser.add_argument("--no-health", action="store_true") + parser.add_argument("--no-system-events", action="store_true") + parser.add_argument("--business-log", type=Path, + default=Path.home() / ".workbuddy2api" / "codebuddy-proxy.jsonl") + parser.add_argument("--no-business-log", action="store_true") + args = parser.parse_args(argv) + if os.name != "nt": + parser.error("Windows only") + if args.interval < 0.05 or (args.duration is not None and args.duration <= 0): + parser.error("interval must be >= 0.05 and duration > 0") + if args.launch == []: + parser.error("--launch requires an executable and arguments") + if args.pid is not None and args.pid <= 0: + parser.error("PID must be positive") + if not args.no_health and args.health_url not in ( + "http://127.0.0.1:8787/health", "http://localhost:8787/health"): + parser.error("health probes are restricted to localhost:8787/health") + run_dir = args.output / (datetime.now().strftime("%Y%m%d-%H%M%S-") + uuid.uuid4().hex[:8]) + run_dir.mkdir(parents=True, exist_ok=False) + journal = Journal(run_dir / "events.jsonl") + api, tracker = Windows(), None + stopped = threading.Event() + def stop(signum, frame): + journal.emit("monitor_signal", signal=signum) + stopped.set() + signal.signal(signal.SIGINT, stop) + signal.signal(signal.SIGTERM, stop) + tracker = Tracker(api, journal) + events, probes, discovery = Background(), Background(), Background() + business = BusinessTail(args.business_log) + child = None + reason = "duration" + status = 0 + started = time.monotonic() + event_since = (datetime.now(timezone.utc) - timedelta(seconds=60)).isoformat() + next_event = next_health = next_discovery = next_sample = 0 + journal.emit("monitor_start", monitor_pid=os.getpid(), + mode="watch" if args.watch_proxy else "launch" if args.launch else "attach", + interval=args.interval, health_enabled=not args.no_health, + system_events_enabled=not args.no_system_events, + limitations=["discovery can miss short-lived descendants", "exit code does not identify killer", + "system events depend on existing audit settings and access"]) + print(str(run_dir), flush=True) + try: + if args.launch: + # Files capture native and descendant OS writes too. No pipes to deadlock. + with (run_dir / "target-stdout.log").open("ab", buffering=0) as out, \ + (run_dir / "target-stderr.log").open("ab", buffering=0) as err: + child = subprocess.Popen(args.launch, stdin=subprocess.DEVNULL, stdout=out, stderr=err, + creationflags=subprocess.CREATE_NO_WINDOW, close_fds=True) + pid = child.pid + journal.emit("target_launched", pid=pid, executable=args.launch[0]) + else: + pid = args.pid + if not args.watch_proxy: + snapshot = api.snapshot() + ppid, name = snapshot.get(pid, (os.getpid() if child else None, "unknown")) + tracker.add(pid, ppid, name) + while not stopped.is_set(): + now = time.monotonic() + if args.watch_proxy: + if now >= next_discovery and discovery.submit("proxy_discovery", find_proxies): + next_discovery = now + 5 + while not discovery.results.empty(): + event, fields = discovery.results.get_nowait() + if event.endswith("_failed"): + journal.emit(event, **fields) + else: + for row in fields["result"]: + if row["pid"] in tracker.active: + continue + # Verify CIM's creation time against the opened handle to reject PID reuse. + try: + handle = api.open(row["pid"]) + try: + created, _ = api.times(handle) + if abs(created - row["created_ticks"]) > 10: + raise OSError("PID identity changed since discovery") + tracker.add(row["pid"], row["ppid"], row["name"]) + finally: + api.k.CloseHandle(handle) + except OSError as exc: + journal.emit("process_open_failed", pid=row["pid"], error=str(exc)) + # Discover before removing dead parents so surviving descendants are included. + tracker.discover(api.snapshot()) + tracker.sample(record=now >= next_sample) + if now >= next_sample: + next_sample = now + 2 + if not args.no_business_log: + try: + tail = business.read() + if tail is not None: + journal.emit("business_log_tail", **tail) + except OSError as exc: + journal.emit("business_log_unavailable", error=str(exc)) + events.drain(journal) + probes.drain(journal) + if not args.no_system_events and now >= next_event: + until = utc() + if events.submit("windows_events", collect_events, event_since, until, set(tracker.pids)): + journal.emit("windows_event_query", since=event_since, until=until, + pids=sorted(tracker.pids)) + # Overlap for delayed event-log delivery. Consumers dedupe channel + record_id. + event_since = (datetime.fromisoformat(until) - timedelta(seconds=60)).isoformat() + next_event = now + 30 + if not args.no_health and now >= next_health: + if probes.submit("health", health, args.health_url): + next_health = now + 2 + if not tracker.active and not args.watch_proxy: + reason = "all_tracked_processes_exited" + break + if args.duration and now - started >= args.duration: + break + stopped.wait(args.interval) + if stopped.is_set(): + reason = "signal" + except Exception as exc: + journal.emit("monitor_error", error=repr(exc)) + reason, status = "error", 1 + finally: + # A final event query includes the termination window; never stop the target. + if events.thread: + events.thread.join(timeout=17) + events.drain(journal) + if not args.no_system_events: + try: + journal.emit("windows_events", result=collect_events(event_since, utc(), tracker.pids)) + except Exception as exc: + journal.emit("windows_events_failed", error=str(exc)) + if probes.thread: + probes.thread.join(timeout=2) + probes.drain(journal) + if not args.no_business_log: + try: + tail = business.read() + if tail is not None: + journal.emit("business_log_tail", **tail) + except OSError as exc: + journal.emit("business_log_unavailable", error=str(exc)) + if child is not None: + journal.emit("launched_root_status", pid=child.pid, exit_code=child.poll()) + journal.emit("monitor_stop", reason=reason, still_tracked=list(tracker.active), + targets_stopped=False) + tracker.close() + journal.close() + return status + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/test_external_process_monitor.py b/test_external_process_monitor.py new file mode 100644 index 0000000..564eef0 --- /dev/null +++ b/test_external_process_monitor.py @@ -0,0 +1,266 @@ +"""Real Windows tests only terminate processes created by these tests.""" +import importlib.util +import json +import os +from pathlib import Path +import shutil +import subprocess +import sys +import time + +import pytest + +SCRIPT = Path(__file__).parent / "scripts" / "watch_processes.py" +spec = importlib.util.spec_from_file_location("watch_processes", SCRIPT) +watch = importlib.util.module_from_spec(spec) +spec.loader.exec_module(watch) + + +def records(path): + result = [] + for file in path.glob("*/events.jsonl"): + for line in file.read_text(encoding="utf-8").splitlines(): + try: + result.append(json.loads(line)) + except json.JSONDecodeError: + pass # Reader may race an append; the next read retries. + return result + + +def wait_for(path, predicate): + deadline = time.monotonic() + 12 + while time.monotonic() < deadline: + found = [row for row in records(path) if predicate(row)] + if found: + return found[0] + time.sleep(0.05) + pytest.fail(f"Missing monitor event; events={records(path)}") + + +def test_journal_rotation(tmp_path): + journal = watch.Journal(tmp_path / "events.jsonl", max_bytes=350, backups=2) + for number in range(8): + journal.emit("test", number=number) + journal.close() + assert len(list(tmp_path.iterdir())) == 3 + last = json.loads((tmp_path / "events.jsonl").read_text().splitlines()[-1]) + assert last["seq"] == 8 + assert last["number"] == 7 + + +def test_background_does_not_block_or_overlap(): + import threading + gate = threading.Event() + worker = watch.Background() + try: + assert worker.submit("test", gate.wait, 2) + assert not worker.submit("other", lambda: 1) + finally: + gate.set() + worker.thread.join(3) + assert worker.results.get()[0] == "test" + + +def test_business_tail_redacts_payload_and_handles_replacement(tmp_path): + path = tmp_path / "business.jsonl" + path.write_text(json.dumps(dict(event="stream_started", request_id="r1", headers="secret", + body="private")) + "\n", encoding="utf-8") + tail = watch.BusinessTail(path) + assert tail.read()["tail"] == [dict(event="stream_started", request_id="r1")] + assert tail.read() is None + path.unlink() + path.write_text('{"event":"stream_completed","status":200}\n', encoding="utf-8") + assert tail.read()["tail"] == [dict(event="stream_completed", status=200)] + + +windows = pytest.mark.skipif(os.name != "nt", reason="Windows process handles required") + + +@pytest.fixture +def owned(): + processes = [] + def create(command): + process = subprocess.Popen(command, stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + processes.append(process) + return process + yield create + for process in reversed(processes): + if process.poll() is None: + process.terminate() # Only exact Popen objects created by this fixture. + process.wait(timeout=15) + + +def monitor(owned, path, *mode, duration="4", events=False): + args = [sys.executable, str(SCRIPT), "--output", str(path), "--interval", "0.05", + "--duration", duration, "--no-health", "--no-business-log"] + if not events: + args += ["--no-system-events"] + return owned(args + list(mode)) + + +@windows +@pytest.mark.parametrize("code,expected", [ + ("raise SystemExit(0)", 0), ("raise RuntimeError('test failure')", 1), + ("import os; os._exit(37)", 37), ("import os; os._exit(259)", 259), + ("import ctypes; ctypes.windll.kernel32.ExitProcess(0xC0000005)", 0xC0000005), +]) +def test_launch_exit_codes(tmp_path, owned, code, expected): + proc = monitor(owned, tmp_path, "--launch", sys.executable, "-c", + "import time; time.sleep(0.2); " + code) + assert proc.wait(timeout=20) == 0 + row = wait_for(tmp_path, lambda r: r["event"] == "process_exit") + assert row["exit_code"] == expected + assert row["exit_code_hex"] == f"0x{expected:08X}" + assert row["exited_at"] >= row["last_observed_alive"] + assert row["cause"] == "unknown" + + +@windows +def test_immediate_launch_exit_retains_root_handle(tmp_path, owned): + proc = monitor(owned, tmp_path, "--launch", sys.executable, "-c", "raise SystemExit(23)") + assert proc.wait(timeout=20) == 0 + assert wait_for(tmp_path, lambda r: r["event"] == "process_exit")["exit_code"] == 23 + + +@windows +def test_attach_forced_termination_and_memory(tmp_path, owned): + target = owned([sys.executable, "-c", "import time; time.sleep(30)"]) + observer = monitor(owned, tmp_path, "--pid", str(target.pid)) + wait_for(tmp_path, lambda r: r["event"] == "process_observed" and r["pid"] == target.pid) + sample = wait_for(tmp_path, lambda r: r["event"] == "sample" and r["processes"]) + assert sample["processes"][0]["working_set_bytes"] > 0 + target.terminate() + assert observer.wait(timeout=20) == 0 + row = wait_for(tmp_path, lambda r: r["event"] == "process_exit") + assert row["exit_code"] == target.wait(timeout=3) + + +@windows +def test_monitor_duration_leaves_target_running(tmp_path, owned): + target = owned([sys.executable, "-c", "import time; time.sleep(30)"]) + observer = monitor(owned, tmp_path, "--pid", str(target.pid), duration="0.3") + assert observer.wait(timeout=20) == 0 + assert target.poll() is None + assert records(tmp_path)[-1]["targets_stopped"] is False + + +@windows +def test_killing_attach_monitor_leaves_target_running(tmp_path, owned): + target = owned([sys.executable, "-c", "import time; time.sleep(30)"]) + observer = monitor(owned, tmp_path, "--pid", str(target.pid)) + wait_for(tmp_path, lambda r: r["event"] == "process_observed") + observer.terminate() + observer.wait(timeout=5) + assert target.poll() is None + assert not any(r["event"] == "monitor_stop" for r in records(tmp_path)) + + +@windows +@pytest.mark.parametrize("kill_root", [True, False]) +def test_uv_python_exit_order(tmp_path, owned, kill_root): + uv = shutil.which("uv") + if not uv: + pytest.skip("uv unavailable") + observer = monitor(owned, tmp_path, "--launch", uv, "run", "--no-project", + "--python", sys.executable, "python", "-c", "import time; time.sleep(2)", duration="5") + root = wait_for(tmp_path, lambda r: r["event"] == "target_launched")["pid"] + child = wait_for(tmp_path, lambda r: r["event"] == "process_observed" and r["pid"] != root) + # Open an exact test process handle with termination rights. No name-based taskkill. + import ctypes + api = watch.Windows() + handle = api.k.OpenProcess(0x100001, False, root if kill_root else child["pid"]) + assert handle + try: + api.k.TerminateProcess.argtypes = [watch.W.HANDLE, watch.W.UINT] + api.check(api.k.TerminateProcess(handle, 71)) + finally: + api.k.CloseHandle(handle) + assert observer.wait(timeout=20) == 0 + exits = [r for r in records(tmp_path) if r["event"] == "process_exit"] + by_pid = {r["pid"]: r for r in exits} + assert {root, child["pid"]} <= by_pid.keys() + assert by_pid[root if kill_root else child["pid"]]["exit_code"] == 71 + if kill_root: + assert by_pid[root]["exited_at"] < by_pid[child["pid"]]["exited_at"] + else: + assert by_pid[child["pid"]]["exited_at"] <= by_pid[root]["exited_at"] + + +@windows +def test_event_collection_reports_channel_access(): + now = watch.utc() + result = watch.collect_events(now, watch.utc(), {os.getpid()}) + assert result["audit_settings_changed"] is False + assert {r["channel"] for r in result["channels"]} == { + "System", "Application", "Security", "Microsoft-Windows-Sysmon/Operational"} + assert all(r["status"] in ("readable", "unavailable") for r in result["channels"]) + + +@windows +def test_missing_pid_is_explicit_failure(tmp_path, owned): + proc = monitor(owned, tmp_path, "--pid", "4294967295") + assert proc.wait(timeout=20) == 1 + assert any(r["event"] == "monitor_error" for r in records(tmp_path)) + + +@windows +def test_pid_identity_rejects_stale_parent(tmp_path, owned): + target = owned([sys.executable, "-c", "import time; time.sleep(30)"]) + journal = watch.Journal(tmp_path / "events.jsonl") + tracker = watch.Tracker(watch.Windows(), journal) + try: + tracker.add(target.pid, 0, "python", parent={"created_ticks": 2**63}) + assert not tracker.active + finally: + tracker.close() + journal.close() + + +@windows +def test_watch_mode_waits_without_starting_proxy(tmp_path, owned): + proc = monitor(owned, tmp_path, "--watch-proxy", duration="0.5") + assert proc.wait(timeout=20) == 0 + rows = records(tmp_path) + assert rows[0]["mode"] == "watch" + assert not any(row["event"] == "target_launched" for row in rows) + + +@windows +def test_watch_discovers_real_temporary_module(tmp_path, owned): + # This isolated module shares only the invocation name, never imports or starts the proxy. + module = tmp_path / "codebuddy_proxy.py" + module.write_text("import time; time.sleep(30)\n", encoding="utf-8") + env = os.environ.copy() + env["PYTHONPATH"] = str(tmp_path) + target = subprocess.Popen([sys.executable, "-m", "codebuddy_proxy"], cwd=tmp_path, env=env, + stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + try: + # Discovery invokes CIM in a worker with a 10-second timeout. A 3-second + # observer lifetime can end before a healthy query returns on a busy host. + proc = monitor(owned, tmp_path / "monitor", "--watch-proxy", duration="12") + row = wait_for(tmp_path / "monitor", lambda r: r["event"] == "process_observed" and r["pid"] == target.pid) + assert row["name"].lower() == "python.exe" + target.terminate() + target.wait(timeout=5) + assert proc.wait(timeout=20) == 0 + wait_for(tmp_path / "monitor", lambda r: r["event"] == "process_exit" and r["pid"] == target.pid) + finally: + if target.poll() is None: + target.terminate() + target.wait(timeout=5) + + +@windows +def test_launch_duration_leaves_exact_test_process_alive(tmp_path, owned): + # Target self-expires even if this test fails. Only the emitted exact PID is opened. + proc = monitor(owned, tmp_path, "--launch", sys.executable, "-c", + "import time; time.sleep(4)", duration="0.4") + assert proc.wait(timeout=20) == 0 + pid = wait_for(tmp_path, lambda r: r["event"] == "target_launched")["pid"] + api = watch.Windows() + handle = api.open(pid) + try: + assert not api.exited(handle) + finally: + api.k.CloseHandle(handle) diff --git a/test_native_runtime.py b/test_native_runtime.py new file mode 100644 index 0000000..bf203ee --- /dev/null +++ b/test_native_runtime.py @@ -0,0 +1,116 @@ +"""Real isolated interpreter checks; no proxy server or forced termination.""" +import importlib.util +import json +import os +from pathlib import Path +import subprocess +import sys + +import pytest + +pytestmark = pytest.mark.skipif(os.name != "nt", reason="Native Windows runtime") +ROOT = Path(__file__).parent +spec = importlib.util.spec_from_file_location("prepare_native_runtime", ROOT / "scripts" / "prepare_native_runtime.py") +prepare_module = importlib.util.module_from_spec(spec) +spec.loader.exec_module(prepare_module) + + +@pytest.fixture +def runtime(tmp_path): + return prepare_module.prepare(tmp_path / "runtime with spaces", sys.prefix) + + +@pytest.mark.parametrize("kind", ["runtime", "observer"]) +def test_actual_image_and_pid_run_original_dependencies(runtime, tmp_path, kind): + target = tmp_path / "identity.json" + env = dict(os.environ, PATH=str(Path(os.environ['SystemRoot']) / 'System32')) + process = subprocess.Popen([runtime[kind], runtime["bootstrap"], "--identity", str(target)], env=env) + assert process.wait(timeout=15) == 0 + identity = json.loads(target.read_text(encoding="utf-8")) + assert identity["pid"] == process.pid # A redirector would report a different PID. + assert Path(identity["image"]) == Path(runtime[kind]) + assert Path(identity["executable"]) == Path(runtime[kind]) + assert Path(identity["fastapi"]).is_relative_to(Path(sys.prefix)) + import codebuddy_proxy.__main__ as proxy + assert Path(identity["proxy"]) == Path(proxy.__file__) + + +def test_dedicated_runtime_does_not_overwrite_existing_directory(runtime): + with pytest.raises(ValueError, match="already exists"): + prepare_module.prepare(Path(runtime["bootstrap"]).parent, sys.prefix) + + +def test_script_arguments_and_explicit_exit_are_preserved(runtime, tmp_path): + script = tmp_path / "argv script.py" + output = tmp_path / "argv.json" + script.write_text("import sys,json; from pathlib import Path\n" + "Path(sys.argv[1]).write_text(json.dumps(sys.argv[2:]))\n" + "raise SystemExit(31)\n", encoding="utf-8") + args = ["space value", 'literal"quote', "trailing\\", ""] + result = subprocess.run([runtime["runtime"], runtime["bootstrap"], "--script", + str(script), str(output), *args], timeout=15) + assert result.returncode == 31 + assert json.loads(output.read_text()) == args + + +def test_powershell_argument_quoting_roundtrips(runtime, tmp_path): + output = tmp_path / "quoted args.json" + script = tmp_path / "print args.py" + script.write_text("import sys,json; from pathlib import Path\n" + "Path(sys.argv[1]).write_text(json.dumps(sys.argv[2:]))\n", encoding="utf-8") + def quote(value): + return "'" + str(value).replace("'", "''") + "'" + args = [runtime["bootstrap"], "--script", str(script), str(output), + "space value", 'literal"quote', "trailing\\", ""] + code = """ +$ErrorActionPreference='Stop' +$tokens=$null;$errors=$null +$ast=[Management.Automation.Language.Parser]::ParseFile(SCRIPT,[ref]$tokens,[ref]$errors) +if($errors.Count){throw ($errors|Out-String)} +$fn=$ast.FindAll({param($n) $n -is [Management.Automation.Language.FunctionDefinitionAst] -and $n.Name -eq 'Quote-NativeArgument'},$true)[0] +. ([scriptblock]::Create($fn.Extent.Text)) +$arguments=@(ARGUMENTS) +$info=New-Object Diagnostics.ProcessStartInfo +$info.FileName=IMAGE +$info.Arguments=($arguments|ForEach-Object {Quote-NativeArgument $_}) -join ' ' +$info.UseShellExecute=$false +$info.CreateNoWindow=$true +$process=[Diagnostics.Process]::Start($info) +if(-not $process.WaitForExit(15000) -or $process.ExitCode -ne 0){throw 'argument test failed'} +""".replace("SCRIPT", quote(ROOT / "scripts" / "start_proxy_with_evidence.ps1")).replace( + "ARGUMENTS", ",".join(quote(a) for a in args)).replace("IMAGE", quote(runtime["runtime"])) + result = subprocess.run(["powershell.exe", "-NoProfile", "-NonInteractive", "-Command", code], + capture_output=True, text=True, timeout=25) + assert result.returncode == 0, result.stdout + result.stderr + assert json.loads(output.read_text()) == args[4:] + + +def test_console_free_observer_records_only_temporary_workload(runtime, tmp_path): + stdout, stderr = tmp_path / 'observer.out', tmp_path / 'observer.err' + with stdout.open('w') as out, stderr.open('w') as err: + process = subprocess.Popen([runtime['observer'], runtime['bootstrap'], '--script', + str(ROOT / 'scripts' / 'watch_processes.py'), '--output', str(tmp_path / 'monitor'), + '--no-health', '--no-system-events', '--no-business-log', '--launch', + runtime['runtime'], runtime['bootstrap'], '-c', 'import time; time.sleep(2)'], + stdout=out, stderr=err, creationflags=subprocess.CREATE_NO_WINDOW) + assert process.wait(timeout=20) == 0 + run_dir = Path(stdout.read_text().strip()) + rows = [json.loads(line) for line in (run_dir / 'events.jsonl').read_text().splitlines()] + launched = next(row for row in rows if row['event'] == 'target_launched') + exited = next(row for row in rows if row['event'] == 'process_exit' and row['pid'] == launched['pid']) + assert exited['exit_code'] == 0 + assert stderr.read_text() == '' + + +def test_start_refuses_tampered_runtime_before_launching(runtime, tmp_path): + state = tmp_path / 'state' + state.mkdir() + (state / 'result.json').write_text(json.dumps(dict(status='native_runtime_enabled', + runtime_root=str(Path(runtime['bootstrap']).parent)))) + with Path(runtime['bootstrap']).open('a') as output: + output.write('\n# tampered\n') + result = subprocess.run(['powershell.exe', '-NoProfile', '-NonInteractive', '-File', + str(ROOT / 'scripts' / 'start_proxy_with_evidence.ps1'), '-StateDirectory', str(state), + '-CheckOnly'], capture_output=True, text=True, timeout=15) + assert result.returncode != 0 + assert 'Runtime content changed' in result.stderr diff --git a/test_silent_exit_configuration.py b/test_silent_exit_configuration.py new file mode 100644 index 0000000..25e5fa5 --- /dev/null +++ b/test_silent_exit_configuration.py @@ -0,0 +1,137 @@ +"""Exercise the actual setup functions against private HKCU scratch keys, never HKLM.""" +import json +import os +from pathlib import Path +import subprocess +import uuid + +import pytest + +pytestmark = pytest.mark.skipif(os.name != "nt", reason="Windows PowerShell and registry required") +SCRIPT = Path(__file__).parent / "scripts" / "manage_silent_exit.ps1" + + +def quote(value): + return "'" + str(value).replace("'", "''") + "'" + + +def run_functions(tmp_path, body): + root = "HKCU:\\Software\\Workbuddy2apiSilentExitTest_" + uuid.uuid4().hex + setup = """ +$ErrorActionPreference = 'Stop' +$tokens=$null; $errors=$null +$ast=[Management.Automation.Language.Parser]::ParseFile(SCRIPT,[ref]$tokens,[ref]$errors) +if ($errors.Count) { throw ($errors | Out-String) } +$definitions=$ast.FindAll({param($node) $node -is [Management.Automation.Language.FunctionDefinitionAst]},$true) +foreach ($definition in $definitions) { . ([scriptblock]::Create($definition.Extent.Text)) } +$testRoot=TESTROOT +$ifeoRoot=$testRoot+'\\IFEO' +$speRoot=$testRoot+'\\SPE' +$filterName='Workbuddy2apiExitEvidence' +$backupPath=BACKUP +$changes=New-Object System.Collections.Generic.List[object] +try { +BODY +} finally { + # Fixed generated HKCU test key; never remove any actual IFEO configuration. + if ($testRoot -notlike 'HKCU:\\Software\\Workbuddy2apiSilentExitTest_*') { throw 'Bad cleanup root' } + if (Test-Path -LiteralPath $testRoot) { Remove-Item -LiteralPath $testRoot -Recurse } +} +""" + code = setup.replace("SCRIPT", quote(SCRIPT)).replace("TESTROOT", quote(root)).replace( + "BACKUP", quote(tmp_path / "journal.json")).replace("BODY", body) + result = subprocess.run(["powershell.exe", "-NoProfile", "-NonInteractive", "-Command", code], + capture_output=True, text=True, timeout=30) + assert result.returncode == 0, result.stdout + result.stderr + return result.stdout + + +def test_atomic_journal_retains_previous_revision(tmp_path): + output = run_functions(tmp_path, """ +Save-Json $backupPath @{value=1} +Save-Json $backupPath @{value=2} +Save-Json $backupPath @{value=3} +@{current=(Get-Content $backupPath -Raw|ConvertFrom-Json).value; + previous=(Get-Content ($backupPath+'.previous') -Raw|ConvertFrom-Json).value}|ConvertTo-Json +""") + assert json.loads(output) == dict(current=3, previous=2) + + +def test_scope_preserves_existing_values_and_rolls_back(tmp_path): + output = run_functions(tmp_path, """ +$base=$ifeoRoot+'\\workbuddy2api-evidence.exe' +$null=New-Item -Path $base -Force +$null=New-ItemProperty -Path $base -Name UseFilter -Value 0 -PropertyType DWord +$null=New-ItemProperty -Path $base -Name GlobalFlag -Value 2 -PropertyType DWord +$null=New-ItemProperty -Path $base -Name Sentinel -Value 'keep' -PropertyType String +Set-Scope 'C:\\isolated-test\\workbuddy2api-evidence.exe' (Split-Path $backupPath -Parent) $changes $backupPath +$configured=(Get-ItemProperty -LiteralPath $base).GlobalFlag +Restore-RecordedValues $backupPath +$restored=Get-ItemProperty -LiteralPath $base +@{configured=$configured;use_filter=$restored.UseFilter;global_flag=$restored.GlobalFlag; + sentinel=$restored.Sentinel;filter_exists=(Test-Path ($base+'\\'+$filterName)); + spe_exists=(Test-Path ($speRoot+'\\workbuddy2api-evidence.exe'))}|ConvertTo-Json +""") + assert json.loads(output) == dict(configured=514, use_filter=0, global_flag=2, + sentinel="keep", filter_exists=False, spe_exists=False) + + +def test_existing_configuration_is_not_overwritten(tmp_path): + output = run_functions(tmp_path, """ +$existing=$speRoot+'\\workbuddy2api-evidence.exe' +$null=New-Item -Path $existing -Force +$null=New-ItemProperty -Path $existing -Name ReportingMode -Value 4 -PropertyType DWord +$rejected=$false +try { Set-Scope 'C:\\isolated-test\\workbuddy2api-evidence.exe' (Split-Path $backupPath -Parent) $changes $backupPath } +catch { $rejected=$true } +@{rejected=$rejected;reporting_mode=(Get-ItemProperty $existing).ReportingMode; + journal_written=(Test-Path $backupPath)}|ConvertTo-Json +""") + assert json.loads(output) == dict(rejected=True, reporting_mode=4, journal_written=False) + + +def test_rollback_does_not_overwrite_later_external_change(tmp_path): + output = run_functions(tmp_path, """ +Set-Scope 'C:\\isolated-test\\workbuddy2api-evidence.exe' (Split-Path $backupPath -Parent) $changes $backupPath +$path=$speRoot+'\\workbuddy2api-evidence.exe' +Set-ItemProperty -LiteralPath $path -Name ReportingMode -Value 4 +$conflict=$false +try { Restore-RecordedValues $backupPath } catch { $conflict=$_.Exception.Message -like '*Rollback conflict*' } +@{conflict=$conflict;external_value=(Get-ItemProperty $path).ReportingMode}|ConvertTo-Json +""") + assert json.loads(output) == dict(conflict=True, external_value=4) + + +def test_shared_python_image_is_refused_before_registry_changes(tmp_path): + output = run_functions(tmp_path, """ +$refused=$false +try { Set-Scope 'C:\\some-venv\\python.exe' (Split-Path $backupPath -Parent) $changes $backupPath } +catch { $refused=$_.Exception.Message -like '*dedicated diagnostic image*' } +@{refused=$refused;journal_written=(Test-Path $backupPath);registry_written=(Test-Path $testRoot)}|ConvertTo-Json +""") + assert json.loads(output) == dict(refused=True, journal_written=False, registry_written=False) + + +@pytest.mark.parametrize('name', ['workbuddy2api-runtime.exe', 'workbuddy2api-observer.exe']) +def test_real_runtime_images_configure_and_rollback_without_shared_python(tmp_path, name): + output = run_functions(tmp_path, """ +Set-Scope 'C:\\isolated-test\\IMAGENAME' (Split-Path $backupPath -Parent) $changes $backupPath +$flags=(Get-ItemProperty ($ifeoRoot+'\\IMAGENAME')).GlobalFlag +$shared=Test-Path ($ifeoRoot+'\\python.exe') +Restore-RecordedValues $backupPath +@{flags=$flags;shared=$shared;removed=(-not(Test-Path ($ifeoRoot+'\\IMAGENAME')))}|ConvertTo-Json +""".replace('IMAGENAME', name)) + assert json.loads(output) == dict(flags=512, shared=False, removed=True) + + +def test_enabled_ifeo_filter_is_refused_before_changes(tmp_path): + output = run_functions(tmp_path, """ +$base=$ifeoRoot+'\\workbuddy2api-runtime.exe' +$null=New-Item -Path $base -Force +$null=New-ItemProperty -Path $base -Name UseFilter -Value 1 -PropertyType DWord +$refused=$false +try { Set-Scope 'C:\\isolated-test\\workbuddy2api-runtime.exe' (Split-Path $backupPath -Parent) $changes $backupPath } +catch { $refused=$_.Exception.Message -like '*path filters*' } +@{refused=$refused;unchanged=((Get-ItemProperty $base).UseFilter -eq 1);journal_written=(Test-Path $backupPath)}|ConvertTo-Json +""") + assert json.loads(output) == dict(refused=True, unchanged=True, journal_written=False)