From 0582a02bbfd332d03761b1831445754eebd77549 Mon Sep 17 00:00:00 2001 From: JavaZeroo <71128095+JavaZeroo@users.noreply.github.com> Date: Wed, 28 Oct 2026 21:50:07 +0800 Subject: [PATCH 1/3] test: automate owned WindTerm cmd correctness diagnostic (#119) --- benchmarks/tools/README.md | 1 + benchmarks/tools/WINDTERM-CMD.md | 26 ++ .../results/2026-10-28-windterm-cmd/README.md | 24 ++ .../2026-10-28-windterm-cmd/validation.json | 73 ++++ benchmarks/tools/test_windterm_cmd.py | 179 +++++++++ benchmarks/tools/windterm_cmd.py | 369 ++++++++++++++++++ research/ui/qt_cmd_diagnostic.js | 88 +++++ 7 files changed, 760 insertions(+) create mode 100644 benchmarks/tools/WINDTERM-CMD.md create mode 100644 benchmarks/tools/results/2026-10-28-windterm-cmd/README.md create mode 100644 benchmarks/tools/results/2026-10-28-windterm-cmd/validation.json create mode 100644 benchmarks/tools/test_windterm_cmd.py create mode 100644 benchmarks/tools/windterm_cmd.py create mode 100644 research/ui/qt_cmd_diagnostic.js diff --git a/benchmarks/tools/README.md b/benchmarks/tools/README.md index 9fb1eace..d8dd1cb2 100644 --- a/benchmarks/tools/README.md +++ b/benchmarks/tools/README.md @@ -72,5 +72,6 @@ JSONL 有 metadata、sample、schedule_gap、end 类型。采样包含 root、de - 已实现:确定性数据、逻辑文本 oracle、字节播放器、Windows 进程树资源 JSONL、合成负载校准。 - 已实现视觉诊断:[自建窗口捕获、序列标记匹配、F13 注入、时钟往返校准](VISUAL.md),另有实验性的固定像素模板录制/等待工具。 - 已实现资源汇总与语义校验:观察到的分组 CPU、同时采样内存峰值、缺失与调度质量、来源哈希。 +- 已实现限定的[原版本地 cmd 自动正确性诊断](WINDTERM-CMD.md):独立非输入桌面/Job、合成 profile、14 行全文 oracle 和生产事件绑定;[固定脱敏结果](results/2026-10-28-windterm-cmd/README.md)不包含真实呈现或性能结论。 - 待完成:WindTerm 实际文本标记/输入驱动验证、T01 捕获/采样开销校准、完整运行记录 JSON Schema、节流/交互驱动。 - 剩余任务统一见[路线图](../../docs/PLAN.md);任何工具校准均不代表 WindTerm 基线或性能达标。 diff --git a/benchmarks/tools/WINDTERM-CMD.md b/benchmarks/tools/WINDTERM-CMD.md new file mode 100644 index 00000000..bf4c8a04 --- /dev/null +++ b/benchmarks/tools/WINDTERM-CMD.md @@ -0,0 +1,26 @@ +# 原版本地 cmd 自动正确性诊断 + +本工具对应 #119,是 #3 的一个有界切片。它启动固定官方 2.7.0 的独立副本、全新合成 profile 和本地 cmd;cmd 运行一个 14 行 ASCII producer。它不操作用户输入桌面、剪贴板或显示设置,不连接远程服务。 + +```powershell +# 使用已有 REA 6.7.0 公共 CLI,不安装/更新 provider。 +D:/Code/termforge/.venv/Scripts/python.exe -B -X utf8 benchmarks/tools/windterm_cmd.py run ` + --source D:/Code/termforge/local/windterm-official/WindTerm_2.7.0 ` + --rea D:/Code/termforge/research/ui/r1-tools/node_modules/.bin/rea.cmd ` + --out local/windterm-cmd/new-run --seconds 30 +``` + +输出目录必须不存在且位于当前仓库 `local/` 下。复用 `research/ui/launch.py` 的发行物内容清单、禁首启导入的合成配置、home 环境隔离和 Owned Job;挂起创建后先加入 Job,再执行。独立非输入桌面不会切换到前台。所有外部 CLI 及目标子进程同样有界回收。隔离配置与桌面不是文件系统或网络沙箱。 + +producer 在计时事件外清屏,从本地 cmd 控制台写入合成数据,记录 run_id、payload SHA、原始生产事件及控制台窗口/缓冲尺寸,然后保持存活供文本读取。结束/退出不代表消费或呈现完成。 + +独立探针通过已调查的固定 Qt5 API 打开唯一合成会话,并在其 GUI 线程读取 `activeText.visibleLineCount` 和每一条 `lineText(i)`;最多 128 行。仅末尾 marker 用于决定何时获取完整文本,Python 校验器对照独立生成的所有 14 条逻辑行逐行比较,剩余行只能为空。不会 trim 空格、忽略前缀、只比 marker 或从配置文件值推定当前实际设置。未知 API、缺失行、超时、错误、截断或身份不符均不能通过。 + +`diagnostic.json` 是供审查的白名单摘要,原始 `run.json`、producer、REA evidence、复制发行物和 profile 全部留在忽略目录,不提交。`actual_settings` 只记录实际可读的控制台尺寸、文档容量、控件几何和 QWidget 字体;该字体不代表全部 QPainter/glyph fallback,其他 C0 配置仍未知。用同一执行源码可离线重算: + +```powershell +python benchmarks/tools/windterm_cmd.py summarize --out local/windterm-cmd/new-run +python -m unittest benchmarks.tools.test_windterm_cmd -v +``` + +**这是 instrumented correctness diagnostic。** 它没有原生输入或画面捕获,`consumer_done`、`presented_done` 固定为 null,`performance_gate=not_evaluated`。生产计数器仅原始本地事件,不发布吞吐/时延。它没有执行正式 T01、观测开销校准、历史/Unicode或完整 WindTerm 基线,也不续跑旧手工 5/12 批次。 diff --git a/benchmarks/tools/results/2026-10-28-windterm-cmd/README.md b/benchmarks/tools/results/2026-10-28-windterm-cmd/README.md new file mode 100644 index 00000000..430cb8e3 --- /dev/null +++ b/benchmarks/tools/results/2026-10-28-windterm-cmd/README.md @@ -0,0 +1,24 @@ +# 原版 WindTerm 本地 cmd:有界自动正确性诊断 + +#119 的首个自动切片,基于 main `2903290`。工具及其运行边界见 [WINDTERM-CMD.md](../../WINDTERM-CMD.md)。本页只记录固定结果;#3/#4 的实际呈现、低扰动校准和正式基线仍未完成。 + +最终执行源码 `windterm_cmd.py` SHA-256 为 `bd4a67f5df26c32a13c5d9f0296bf2347719a3b9024d0607afe81f4655e00f61`。在 Windows 11 上使用既有 REA 6.7.0 公共 CLI,启动固定官方 2.7.0 EXE 和经完整内容清单校验的发行物副本。没有改动发行物、用户配置、输入桌面、剪贴板或显示设置。 + +| 检查点 | 最终实际结果 | +|---|---| +| 合成数据 | 新 run_id 的 14 行 ASCII,536 B,字节 SHA 与独立 manifest 一致 | +| 本地链路 | 原版合成 Shell 会话 → 本地 cmd → Python producer;本地控制台 VT 启用成功 | +| 文本正确性 | `visibleLineCount=15`,逐条 `lineText(i)` 恰为全部 14 条独立预期及 1 条空白尾行;不是只检查 marker | +| 配置可读项 | producer 控制台窗口80×24、缓冲80×3000;JS `screenLineCapacity=23`;控件矩形 `[0,0,905,428]`、QWidget 字体 Roboto Mono 9pt / pixel_size -1 | +| 身份与回收 | run_id/target_pid 绑定 producer、探针和 cleanup;自有 Job active=0;profile 仍只有一个合成会话 | +| 未观测端点 | consumer_done=null;presented_done=null;performance_gate=not_evaluated | + +控制台尺寸、JS 文档容量与 QWidget 几何属于各自读取点,原样分列;没有将它们解释为同一网格、完整 C0 验证或真实像素呈现。控件字体也不证明 QPainter、实际 glyph fallback 或字体文件身份。 + +首次工具源码曾完成同样的小负载诊断,其原件与执行源码保存在 `local/windterm-cmd/initial-20261028`,没有覆盖。随后离线审查修正异步 TerminateProcess 的等待,以及 cleanup 身份和摘要白名单/原始记录哈希绑定;只在新源码、新 run_id 和新目录 `local/windterm-cmd/final-20261028` 执行了一次已预声明的最终验证。两次均为独立正确性诊断,没有合并成性能样本或重跑旧手工批次。 + +新工具10项边界测试及既有隔离准备3项通过,独立 JS 语法检查通过。测试覆盖 marker-only、缺失中间/首行、多余内容、空格改变、错误运行/源码/PID、截断、probe错误、重复快照、缺失 producer、配置白名单、异常终止仍回收以及非零 Job/profile 变化拒绝。独立离线脚本重新生成 oracle、核对 payload/生产事件/完整15行/身份/六份原始记录哈希和清理等式,全部通过;没有再次启动目标。 + +[validation.json](validation.json) 仅公开人工审查过的白名单摘要、设置和来源哈希,不含 PID、宿主路径、原始生产计数器、画面、复制发行物或 profile。原始 REA evidence 和本地数据均保留在忽略目录。 + +这是有插桩的小 ASCII 文本读取链路,尚不覆盖滚动历史、Unicode、ANSI样式、输入响应、真实呈现、观测扰动或正式 T01。没有计算吞吐/时延、提出数值容差或声明原版性能等价。 diff --git a/benchmarks/tools/results/2026-10-28-windterm-cmd/validation.json b/benchmarks/tools/results/2026-10-28-windterm-cmd/validation.json new file mode 100644 index 00000000..756f28cc --- /dev/null +++ b/benchmarks/tools/results/2026-10-28-windterm-cmd/validation.json @@ -0,0 +1,73 @@ +{ + "schema": "termforge.windterm-cmd-diagnostic.v1", + "run_id": "dc5fe5779e3e4e2985505af394365865", + "kind": "correctness-diagnostic", + "target_sha256": "7b10531dccc7cd614ae559f36f1ef518e0c96335637431b4646354823b2804d0", + "distribution_sha256": "e459adf2f12adb6a1b7f55e66a9da1c22794185e66e848057d21e0c60d669203", + "runner_sha256": "bd4a67f5df26c32a13c5d9f0296bf2347719a3b9024d0607afe81f4655e00f61", + "probe_sha256": "421cf8e238fa7362f3f557ac772127f79796753918a9b025afb6e0c09da4c296", + "payload_sha256": "c3c5ee519e60e2bff5c1498e1aaef599d5cff7dfe54c40a66ba01b4d23b2bb23", + "payload_bytes": 536, + "producer_status": "complete", + "correctness": { + "status": "passed", + "method": "all-bounded-logical-lines-exact", + "payload_lines": 14, + "observed_lines": 15, + "terminal_text_sha256": "44b8badd8084200fb0895b4d65aa7fce8c3430a690d86f5289c02e40700e203a" + }, + "actual_settings": { + "producer_console": { + "vt_enable_succeeded": true, + "original_mode": 3, + "columns": 80, + "rows": 24, + "buffer_columns": 80, + "buffer_rows": 3000 + }, + "terminal": { + "screen_line_capacity": 23, + "widget_geometry": [ + 0, + 0, + 905, + 428 + ], + "widget_font": { + "family": "Roboto Mono", + "point_size": 9, + "pixel_size": -1 + } + } + }, + "consumer_done": null, + "presented_done": null, + "performance_gate": "not_evaluated", + "cleanup": { + "owned_active": 0, + "only_synthetic_session": true + }, + "limitations": [ + "Instrumented local cmd diagnostic on a non-input desktop; no native input or presentation timing.", + "Small ASCII payload only; no T01 baseline, retained-history, Unicode or performance acceptance.", + "Widget font and console dimensions are readable settings, not complete C0 verification." + ], + "raw_records_sha256": { + "run.json": "850d7077f6066e93d8ca9b352b892fc7354460d84c6082e96b7abd16fc1eaabb", + "workload.json": "7df03dc2324d20b5a2dd3948aab01cd464cd0e0b9b6e59187434eb808473b506", + "producer.json": "7fc89ad3561407c066eb7a4c15d1ae269a55844024fb7a0d82d11eb397dac262", + "evidence.json": "ab3bfd1bdeb87ca904a172b222e63fd3944283322240f622f8d15469d0fa9a73", + "cleanup.json": "c5c5c0699ff0e15119b92a6c0ff931a07a419443daca740da4035ed0961725a6", + "profile-check.json": "5e522a64e98197722ccd084639b1ce50258b238e03be2f016e81c8af023b5ee1" + }, + "source_base": "2903290", + "issue": 119, + "validation": { + "public_boundary_tests": 10, + "existing_isolation_preparation_tests": 3, + "js_syntax_check": "passed", + "independent_offline_audit": "passed", + "raw_record_hashes_checked": 6, + "target_launches_for_final_source": 1 + } +} diff --git a/benchmarks/tools/test_windterm_cmd.py b/benchmarks/tools/test_windterm_cmd.py new file mode 100644 index 00000000..8af7bd6c --- /dev/null +++ b/benchmarks/tools/test_windterm_cmd.py @@ -0,0 +1,179 @@ +"""Public diagnostic-boundary tests. Never attach to or launch WindTerm.""" +import copy +import hashlib +import json +from pathlib import Path +import tempfile +import unittest +from unittest.mock import Mock, patch + +from benchmarks.tools import windterm_cmd as cmd + + +RUN_ID = "0123456789abcdef0123456789abcdef" + + +class WorkloadTests(unittest.TestCase): + def test_full_deterministic_ascii_oracle(self): + lines, data = cmd.workload(RUN_ID) + self.assertEqual(cmd.workload(RUN_ID), (lines, data)) + self.assertEqual(data.decode("ascii"), "\r\n".join(lines) + "\r\n") + self.assertEqual(len(lines), 14) + self.assertLess(max(map(len, lines)), 80) + self.assertNotEqual(cmd.workload("f" * 32)[1], data) + for value in (None, "", "../secret", "a" * 33, "A" * 32): + with self.subTest(value=value), self.assertRaises(ValueError): + cmd.workload(value) + + def test_missing_middle_prefix_extra_content_and_spaces_rejected(self): + expected, _ = cmd.workload(RUN_ID) + self.assertEqual(cmd.check_text({"lines": expected + ["", ""], "line_count": 16}, expected)["status"], "passed") + for rows in ([expected[-1]], expected[1:], expected[:5] + expected[6:], + ["unexpected"] + expected, expected + ["unexpected"], + [expected[0] + " "] + expected[1:]): + with self.subTest(rows=rows), self.assertRaises(ValueError): + cmd.check_text({"lines": rows, "line_count": len(rows)}, expected) + for value in (len(expected) - 1, True, None): + with self.assertRaises(ValueError): + cmd.check_text({"lines": expected, "line_count": value}, expected) + + def test_prepare_rejects_cmd_expansion_before_creating_target(self): + with patch.object(cmd, "prepare") as prepare: + with self.assertRaisesRegex(ValueError, "cmd path"): + cmd.prepare_run(Path("source"), Path("local/%USERNAME%"), RUN_ID) + prepare.assert_not_called() + + def test_runtime_bound_rejected_before_preparation(self): + with patch.object(cmd.sys, "platform", "win32"), patch.object(cmd, "prepare_run") as prepare: + for value in (0, 9, 61, True): + with self.assertRaisesRegex(ValueError, "10..60"): + cmd.run(Path("source"), Path("out"), Path("rea"), value) + prepare.assert_not_called() + + +class EvidenceTests(unittest.TestCase): + def fixture(self, out): + lines, data = cmd.workload(RUN_ID) + script = b"synthetic original probe" + sha = hashlib.sha256(script).hexdigest() + identity = {"schema": cmd.SCHEMA, "run_id": RUN_ID, "pid": 12345, + "sha256": cmd.EXPECTED, "files_sha256": cmd.EXPECTED_FILES, + "profile": "synthetic-local-only", "desktop": "owned-non-input", + "script_sha256": sha, "runner_sha256": cmd.digest(Path(cmd.__file__))} + text = {"kind": "text_complete", "run_id": RUN_ID, "line_count": 14, "lines": lines, + "screen_line_capacity": 24, "widget_font": {"family": "synthetic", "point_size": 9, "pixel_size": -1}, "widget_geometry": [0, 0, 99, 99]} + evidence = {"ok": True, "data": {"evidence": {"operation": "instrument_with_frida", + "normalized_result": {"source_sha256": sha, "pid": 12345, "messages_truncated": False, + "messages": [{"type": "send", "payload": text}]}}}} + records = {"run.json": identity, "workload.json": {"run_id": RUN_ID, "lines": lines, + "payload_bytes": len(data), "sha256": hashlib.sha256(data).hexdigest()}, + "cleanup.json": {"run_id": RUN_ID, "target_pid": 12345, "active": 0, "total": 5}, + "profile-check.json": {"only_synthetic_session": True, "session_count": 1}, + "evidence.json": evidence, + "producer.json": {"run_id": RUN_ID, "sha256": hashlib.sha256(data).hexdigest(), + "payload_bytes": len(data), "status": "complete", "error": None, + "t0_counter_ns": 10, "producer_done_counter_ns": 20, + "consumer_done": None, "presented_done": None, "console": {"vt_enable_succeeded": True, "columns": 80, "rows": 24}}} + for name, value in records.items(): + cmd.write_json(out / name, value) + (out / "payload.bin").write_bytes(data) + (out / "probe.js").write_bytes(script) + return records + + def test_complete_text_passes_without_terminal_or_perf_endpoints(self): + with tempfile.TemporaryDirectory() as folder: + out = Path(folder) + self.fixture(out) + result = cmd.summarize(out) + self.assertEqual(result["correctness"]["status"], "passed") + self.assertEqual(result["performance_gate"], "not_evaluated") + self.assertIsNone(result["consumer_done"]) + self.assertIsNone(result["presented_done"]) + public = json.dumps(result) + self.assertNotIn('"pid":', public) + self.assertNotIn(folder, public) + self.assertNotIn("counter_ns", public) + + def test_missing_producer_or_text_stays_unknown(self): + for missing in ("producer.json", "evidence.json"): + with self.subTest(missing=missing), tempfile.TemporaryDirectory() as folder: + out = Path(folder) + self.fixture(out) + (out / missing).unlink() + result = cmd.summarize(out) + self.assertEqual(result["correctness"]["status"], "unknown") + + def test_identity_truncation_errors_wrong_run_and_marker_only_fail(self): + with tempfile.TemporaryDirectory() as folder: + out = Path(folder) + records = self.fixture(out) + for mutation in ("pid", "hash", "run", "truncated", "script-error", "marker", "duplicate"): + evidence = copy.deepcopy(records["evidence.json"]) + result = evidence["data"]["evidence"]["normalized_result"] + if mutation == "pid": result["pid"] += 1 + elif mutation == "hash": result["source_sha256"] = "a" * 64 + elif mutation == "run": result["messages"][0]["payload"]["run_id"] = "f" * 32 + elif mutation == "truncated": result["messages_truncated"] = True + elif mutation == "script-error": result["messages"].append({"type": "error"}) + elif mutation == "marker": + result["messages"][0]["payload"]["lines"] = ["TF-CMD-END " + RUN_ID] + result["messages"][0]["payload"]["line_count"] = 1 + elif mutation == "duplicate": result["messages"] *= 2 + (out / "evidence.json").write_text(json.dumps(evidence), encoding="utf-8") + with self.subTest(mutation=mutation): + self.assertEqual(cmd.summarize(out)["correctness"]["status"], "unknown") + + def test_active_job_profile_scope_changed_or_source_drift_rejected(self): + for record, value in (("cleanup.json", {"run_id": RUN_ID, "target_pid": 12345, "active": 1}), + ("cleanup.json", {"run_id": "f" * 32, "target_pid": 12345, "active": 0}), + ("profile-check.json", {"only_synthetic_session": False, "session_count": 2})): + with tempfile.TemporaryDirectory() as folder: + out = Path(folder) + self.fixture(out) + (out / record).write_text(json.dumps(value), encoding="utf-8") + with self.assertRaises(ValueError): cmd.summarize(out) + with tempfile.TemporaryDirectory() as folder: + out = Path(folder) + records = self.fixture(out) + records["run.json"]["runner_sha256"] = "f" * 64 + (out / "run.json").write_text(json.dumps(records["run.json"]), encoding="utf-8") + with self.assertRaisesRegex(ValueError, "source changed"): cmd.summarize(out) + + def test_public_settings_drop_unlisted_fields_and_reject_path_or_nan(self): + snapshot = {"screen_line_capacity": 24, "widget_geometry": [0, 0, 99, 99], + "widget_font": {"family": "Synthetic", "point_size": 9, "pixel_size": -1, + "private_path": "should not be exported"}, "secret": "not public"} + self.assertNotIn("private_path", json.dumps(cmd.terminal_settings(snapshot))) + self.assertNotIn("secret", json.dumps(cmd.terminal_settings(snapshot))) + for family, point in (("C:/private/font", 9), ("Synthetic", float("nan"))): + altered = copy.deepcopy(snapshot) + altered["widget_font"].update(family=family, point_size=point) + with self.assertRaises(ValueError): cmd.terminal_settings(altered) + + +class CleanupTests(unittest.TestCase): + def test_failed_target_stop_still_terminates_and_records_job(self): + for signaled in (0, 258): + with self.subTest(signaled=signaled), tempfile.TemporaryDirectory() as folder: + out = Path(folder) + kernel, job = Mock(), Mock() + kernel.WaitForSingleObject.side_effect = [258, signaled] + kernel.TerminateProcess.return_value = False + job.drain.return_value = {"active": 0, "total": 1} + info = Mock(process=123, pid=12345) + with patch.object(cmd, "check_profile_scope") as check: + if signaled == 0: + cmd.cleanup_owned(kernel, info, job, out, RUN_ID) + else: + with self.assertRaisesRegex(OSError, "termination failed"): + cmd.cleanup_owned(kernel, info, job, out, RUN_ID) + check.assert_called_once_with(out) + job.terminate.assert_called_once() + row = json.loads((out / "cleanup.json").read_text()) + self.assertEqual(row["run_id"], RUN_ID) + self.assertEqual(row["target_pid"], 12345) + self.assertEqual(row["active"], 0) + + +if __name__ == "__main__": + unittest.main() diff --git a/benchmarks/tools/windterm_cmd.py b/benchmarks/tools/windterm_cmd.py new file mode 100644 index 00000000..ebaf3d48 --- /dev/null +++ b/benchmarks/tools/windterm_cmd.py @@ -0,0 +1,369 @@ +"""Owned WindTerm local-cmd correctness diagnostic; never a performance run. + +The target runs on a new non-input desktop. No clipboard, global input or +display settings are touched. Raw target/evidence/profile files stay in local/. +""" +from __future__ import annotations + +import argparse +import ctypes as c +from ctypes import wintypes as w +import hashlib +import json +import math +import os +from pathlib import Path +import platform +import re +import subprocess +import sys +import time +import uuid + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) +from research.ui.launch import (EXPECTED, EXPECTED_FILES, StartupInfo, ProcessInfo, + OwnedJob, prepare, isolated_environment, + check_profile_scope, digest) +from research.ui.r1_launch import cli_command, run_cli +from benchmarks.tools.play import write_all + +SCHEMA = "termforge.windterm-cmd-diagnostic.v1" +PROBE = ROOT / "research/ui/qt_cmd_diagnostic.js" +MAX_LINES = 128 + + +def require(condition, message): + if not condition: + raise ValueError(message) + + +def write_json(path, value): + with Path(path).open("x", encoding="utf-8", newline="\n") as stream: + json.dump(value, stream, ensure_ascii=True, indent=2, allow_nan=False) + stream.write("\n") + + +def workload(run_id): + require(isinstance(run_id, str) and re.fullmatch(r"[0-9a-f]{32}", run_id), "Invalid run_id") + lines = ["TF-CMD-BEGIN " + run_id] + for index in range(12): + token = hashlib.sha256(f"{run_id}:{index}".encode("ascii")).hexdigest()[:24] + lines.append(f"TF-CMD-{index:03d} {token}") + lines.append("TF-CMD-END " + run_id) + return lines, ("\r\n".join(lines) + "\r\n").encode("ascii") + + +def console_state(): + class Coord(c.Structure): + _fields_ = [("x", c.c_short), ("y", c.c_short)] + class Rect(c.Structure): + _fields_ = [("left", c.c_short), ("top", c.c_short), ("right", c.c_short), ("bottom", c.c_short)] + class Info(c.Structure): + _fields_ = [("size", Coord), ("cursor", Coord), ("attributes", w.WORD), ("window", Rect), ("max_size", Coord)] + kernel = c.WinDLL("kernel32", use_last_error=True) + kernel.GetStdHandle.argtypes, kernel.GetStdHandle.restype = [w.DWORD], w.HANDLE + kernel.GetConsoleMode.argtypes = [w.HANDLE, c.POINTER(w.DWORD)] + kernel.SetConsoleMode.argtypes = [w.HANDLE, w.DWORD] + kernel.GetConsoleScreenBufferInfo.argtypes = [w.HANDLE, c.POINTER(Info)] + handle, mode, info = kernel.GetStdHandle(-11), w.DWORD(), Info() + readable = bool(kernel.GetConsoleMode(handle, c.byref(mode))) + enabled = bool(readable and kernel.SetConsoleMode(handle, mode.value | 4)) + sized = bool(kernel.GetConsoleScreenBufferInfo(handle, c.byref(info))) + return {"vt_enable_succeeded": enabled, "original_mode": mode.value if readable else None, + "columns": info.window.right - info.window.left + 1 if sized else None, + "rows": info.window.bottom - info.window.top + 1 if sized else None, + "buffer_columns": info.size.x if sized else None, + "buffer_rows": info.size.y if sized else None} + + +def producer(out, run_id): + """Write bounded synthetic data to inherited cmd console, then await cleanup.""" + import msvcrt + out = Path(out).resolve() + expected, data = workload(run_id) + manifest = json.loads((out / "workload.json").read_text(encoding="utf-8")) + require(manifest == {"run_id": run_id, "lines": expected, "payload_bytes": len(data), + "sha256": hashlib.sha256(data).hexdigest()}, "Producer workload differs") + fd = sys.stdout.fileno() + msvcrt.setmode(fd, os.O_BINARY) + state = console_state() + require(state["vt_enable_succeeded"], "Local console VT setup failed") + # Clear startup cmd/banner contents before the independently checked payload. + write_all(fd, b"\x1b[2J\x1b[H") + start, status, error = time.perf_counter_ns(), "complete", None + try: + write_all(fd, data) + except OSError as exc: + status, error = "failed", type(exc).__name__ + end = time.perf_counter_ns() + write_json(out / "producer.json", {"run_id": run_id, "status": status, + "pid": os.getpid(), "sha256": manifest["sha256"], "payload_bytes": len(data), + "clock": "perf_counter_ns", "t0_counter_ns": start, + "producer_done_counter_ns": end if not error else None, + "attempt_ended_counter_ns": end, "consumer_done": None, "presented_done": None, + "console": state, "error": error}) + # Exit is not used as completion: keep the terminal document available. + deadline = time.monotonic() + 90 + while time.monotonic() < deadline and not (out / "STOP_PRODUCER").exists(): + time.sleep(.05) + return 0 if status == "complete" else 1 + + +def prepare_run(source, out, run_id): + lines, data = workload(run_id) + for path in (Path(out).resolve(), Path(__file__).resolve(), Path(sys.executable).resolve()): + require(not any(char in str(path) for char in '%!"\r\n'), "Unsupported cmd path characters") + app = prepare(Path(source), Path(out)) + out = Path(out).resolve() + manifest = {"run_id": run_id, "lines": lines, "payload_bytes": len(data), + "sha256": hashlib.sha256(data).hexdigest()} + write_json(out / "workload.json", manifest) + (out / "payload.bin").write_bytes(data) + # Explicitly synthetic cmd invocation; no shell text is accepted from input. + session = out / "profile/.wind/profiles/default.v10/terminal/user.sessions" + rows = json.loads(session.read_text(encoding="utf-8")) + invocation = subprocess.list2cmdline([sys.executable, "-B", "-X", "utf8", str(Path(__file__).resolve()), + "producer", "--out", str(out), "--run-id", run_id]) + rows[0]["process.arguments"] = '/d /q /c "' + invocation + '"' + session.write_text(json.dumps(rows, indent=2), encoding="utf-8") + return app, manifest + + +def evidence_payloads(identity, evidence, script_digest): + require(evidence.get("ok") is True, "REA invocation failed") + record = evidence["data"]["evidence"] + require(record.get("operation") == "instrument_with_frida", "Wrong observation operation") + result = record["normalized_result"] + require(result.get("source_sha256") == identity.get("script_sha256") == script_digest, + "Probe source mismatch") + require(result.get("pid") == identity.get("pid"), "Wrong owned target") + require(result.get("messages_truncated") is False, "Truncated or unspecified observation") + require(result.get("cleanup_error") is None and evidence["data"].get("cleanup_error") is None, + "Frida cleanup failed") + messages = result.get("messages", []) + require(not any(m.get("type") == "error" for m in messages), "Frida script error") + payloads = [m["payload"] for m in messages if m.get("type") == "send"] + require(all(isinstance(p, dict) and p.get("run_id") == identity["run_id"] for p in payloads), + "Probe run_id mismatch") + require(not any(p.get("error") for p in payloads), "Probe reported an error") + return payloads + + +def check_text(snapshot, expected): + """Compare every logical line, permitting only empty unused terminal rows.""" + rows = snapshot.get("lines") + require(isinstance(rows, list) and len(rows) <= MAX_LINES and + all(isinstance(line, str) for line in rows), "Missing bounded full text") + require(type(snapshot.get("line_count")) is int and snapshot["line_count"] == len(rows), + "Incomplete text enumeration") + # lineText is observed to exclude the logical newline. Do not trim spaces, + # normalise characters, skip the beginning or accept a marker substring. + require(rows[:len(expected)] == expected and all(line == "" for line in rows[len(expected):]), + "Full text differs from oracle") + require(len(rows) >= len(expected), "Missing payload lines") + return {"status": "passed", "method": "all-bounded-logical-lines-exact", + "payload_lines": len(expected), "observed_lines": len(rows), + "terminal_text_sha256": hashlib.sha256(json.dumps(rows, separators=(",", ":")).encode()).hexdigest()} + + +def terminal_settings(snapshot): + """Whitelist only validated scalar settings, never arbitrary probe objects.""" + capacity, geometry, font = (snapshot.get(k) for k in ("screen_line_capacity", "widget_geometry", "widget_font")) + require(capacity is None or type(capacity) is int and 1 <= capacity <= 10000, "Invalid screen capacity") + require(isinstance(geometry, list) and len(geometry) == 4 and + all(type(value) is int and abs(value) <= 100000 for value in geometry), "Invalid widget geometry") + require(isinstance(font, dict) and isinstance(font.get("family"), str) and + len(font["family"]) <= 128 and not any(char in font["family"] for char in "\\/\r\n"), "Invalid font family") + for key in ("point_size", "pixel_size"): + value = font.get(key) + require(type(value) in (int, float) and math.isfinite(value) and -1 <= value <= 10000, "Invalid font size") + return {"screen_line_capacity": capacity, "widget_geometry": geometry, + "widget_font": {key: font[key] for key in ("family", "point_size", "pixel_size")}} + + +def summarize(out): + """Strict local replay and a deliberately small, publication-reviewable report.""" + out = Path(out) + read = lambda name: json.loads((out / name).read_text(encoding="utf-8")) + identity, manifest, cleanup, profile = (read(name) for name in + ("run.json", "workload.json", "cleanup.json", "profile-check.json")) + require(identity.get("schema") == SCHEMA and identity.get("sha256") == EXPECTED and + identity.get("files_sha256") == EXPECTED_FILES, "Unpinned target identity") + require(identity.get("profile") == "synthetic-local-only" and identity.get("desktop") == "owned-non-input", + "Unknown target isolation") + require(cleanup.get("run_id") == identity["run_id"] and cleanup.get("target_pid") == identity["pid"], + "Cleanup run identity mismatch") + require(cleanup.get("active") == 0, "Owned processes remain") + require(profile == {"only_synthetic_session": True, "session_count": 1}, "Profile scope changed") + lines, data = workload(identity["run_id"]) + require(manifest == {"run_id": identity["run_id"], "lines": lines, "payload_bytes": len(data), + "sha256": hashlib.sha256(data).hexdigest()}, "Oracle identity mismatch") + require((out / "payload.bin").read_bytes() == data, "Payload identity mismatch") + require(identity.get("runner_sha256") == digest(Path(__file__)), "Runner source changed since execution") + result = {"schema": SCHEMA, "run_id": identity["run_id"], "kind": "correctness-diagnostic", + "target_sha256": EXPECTED, "distribution_sha256": EXPECTED_FILES, + "runner_sha256": identity["runner_sha256"], "probe_sha256": digest(out / "probe.js"), + "payload_sha256": manifest["sha256"], "payload_bytes": len(data), + "producer_status": "unknown", "correctness": {"status": "unknown"}, + "actual_settings": {}, "consumer_done": None, "presented_done": None, + "performance_gate": "not_evaluated", "cleanup": {"owned_active": 0, "only_synthetic_session": True}, + "limitations": ["Instrumented local cmd diagnostic on a non-input desktop; no native input or presentation timing.", + "Small ASCII payload only; no T01 baseline, retained-history, Unicode or performance acceptance.", + "Widget font and console dimensions are readable settings, not complete C0 verification."]} + problems = [] + if (out / "launcher-failure.json").exists(): + problems.append("launcher-failed") + if (out / "producer.json").exists(): + produced = read("producer.json") + require(produced.get("run_id") == identity["run_id"] and produced.get("sha256") == manifest["sha256"] and + produced.get("payload_bytes") == len(data), "Producer identity mismatch") + require(produced.get("consumer_done") is None and produced.get("presented_done") is None, + "Producer cannot provide terminal endpoints") + require(produced.get("status") == "complete" and produced.get("error") is None and + type(produced.get("t0_counter_ns")) is int and type(produced.get("producer_done_counter_ns")) is int and + produced["producer_done_counter_ns"] >= produced["t0_counter_ns"], "Producer did not complete") + result["producer_status"] = "complete" + console = produced["console"] + require(isinstance(console, dict) and console.get("vt_enable_succeeded") is True, "Producer VT setup missing") + selected = {"vt_enable_succeeded": True} + for key in ("original_mode", "columns", "rows", "buffer_columns", "buffer_rows"): + value = console.get(key) + require(value is None or type(value) is int and 0 <= value <= 0xffffffff, "Invalid console setting") + selected[key] = value + result["actual_settings"]["producer_console"] = selected + else: + problems.append("producer-not-observed") + if (out / "evidence.json").exists(): + try: + payloads = evidence_payloads(identity, read("evidence.json"), digest(out / "probe.js")) + finished = [p for p in payloads if p.get("kind") == "text_complete"] + require(len(finished) == 1, "Full text snapshot not observed exactly once") + require(result["producer_status"] == "complete", "Missing producer completion") + result["correctness"] = check_text(finished[0], lines) + result["actual_settings"]["terminal"] = terminal_settings(finished[0]) + except (ValueError, KeyError, TypeError, json.JSONDecodeError) as exc: + problems.append(type(exc).__name__ + ": " + str(exc)) + else: + problems.append("probe-not-observed") + if problems: + result["correctness"] = {"status": "unknown", "reasons": problems} + result["raw_records_sha256"] = {name: digest(out / name) for name in + ("run.json", "workload.json", "producer.json", "evidence.json", "cleanup.json", "profile-check.json") + if (out / name).exists()} + return result + + +def cleanup_owned(kernel, info, job, out, run_id): + """Record Job cleanup even if stopping an unassigned suspended target fails.""" + try: + if info.process and kernel.WaitForSingleObject(info.process, 0) == 258: + terminated = kernel.TerminateProcess(info.process, 99) + error = c.get_last_error() if sys.platform == "win32" else 0 + # TerminateProcess is asynchronous; the owned handle must signal. + if kernel.WaitForSingleObject(info.process, 5000) != 0: + if not terminated: + raise OSError(error, "Owned target termination failed") + raise RuntimeError("Owned target did not terminate") + finally: + if job: + job.terminate() + remaining = job.drain() + write_json(out / "cleanup.json", {"run_id": run_id, "target_pid": info.pid, **remaining}) + require(remaining["active"] == 0, "Owned Job did not drain") + check_profile_scope(out) + + +def run(source, out, rea, seconds=30): + require(sys.platform == "win32", "Windows is required") + require(type(seconds) is int and 10 <= seconds <= 60, "Diagnostic bound must be 10..60 seconds") + # Resolve/validate the existing public CLI before creating target artifacts. + cli = cli_command(Path(rea)) + require(Path(cli[0]).is_file(), "Existing REA CLI unavailable") + out, run_id = Path(out).resolve(), uuid.uuid4().hex + app, _ = prepare_run(source, out, run_id) + environment = isolated_environment(out) + block = c.create_unicode_buffer("\0".join(f"{k}={v}" for k, v in sorted(environment.items())) + "\0") + user, kernel = c.WinDLL("user32", use_last_error=True), c.WinDLL("kernel32", use_last_error=True) + user.CreateDesktopW.argtypes, user.CreateDesktopW.restype = [w.LPCWSTR, w.LPCWSTR, c.c_void_p, w.DWORD, w.DWORD, c.c_void_p], w.HANDLE + user.CloseDesktop.argtypes = [w.HANDLE] + kernel.CreateProcessW.argtypes = [w.LPCWSTR, w.LPWSTR, c.c_void_p, c.c_void_p, w.BOOL, w.DWORD, + c.c_void_p, w.LPCWSTR, c.POINTER(StartupInfo), c.POINTER(ProcessInfo)] + kernel.ResumeThread.argtypes, kernel.ResumeThread.restype = [w.HANDLE], w.DWORD + kernel.WaitForSingleObject.argtypes, kernel.WaitForSingleObject.restype = [w.HANDLE, w.DWORD], w.DWORD + kernel.TerminateProcess.argtypes, kernel.CloseHandle.argtypes = [w.HANDLE, w.UINT], [w.HANDLE] + check, info, job = OwnedJob.check, ProcessInfo(), None + desktop_name = "TermForgeCmd-" + run_id + desktop = user.CreateDesktopW(desktop_name, None, None, 0, 0x1ff, None) + check(desktop) + try: + job = OwnedJob() + start = StartupInfo() + start.cb, start.desktop = c.sizeof(start), desktop_name + binary = app / "WindTerm.exe" + command = c.create_unicode_buffer('"' + str(binary) + '"') + check(kernel.CreateProcessW(str(binary), command, None, None, False, 4 | 0x400, block, + str(app), c.byref(start), c.byref(info))) + job.assign(info.pid) + script = PROBE.read_text(encoding="utf-8") + for key, value in {"__UI_THREAD_ID__": str(info.tid), "__RUN_ID_JSON__": json.dumps(run_id), + "__RUN_DIRECTORY_JSON__": json.dumps(out.as_posix()), + "__DESKTOP_JSON__": json.dumps(desktop_name), + "__TIMEOUT_MS__": str((seconds - 3) * 1000)}.items(): + script = script.replace(key, value) + (out / "probe.js").write_text(script, encoding="utf-8") + write_json(out / "run.json", {"schema": SCHEMA, "run_id": run_id, "pid": info.pid, "tid": info.tid, + "sha256": digest(binary), "files_sha256": EXPECTED_FILES, + "script_sha256": digest(out / "probe.js"), "runner_sha256": digest(Path(__file__)), + "probe_template_sha256": digest(PROBE), "profile": "synthetic-local-only", + "desktop": "owned-non-input", "seconds": seconds, + "environment": {"os": platform.platform(), "python": platform.python_version()}, + "consumer_done": None, "presented_done": None}) + if kernel.ResumeThread(info.thread) == 0xffffffff: + raise c.WinError(c.get_last_error()) + time.sleep(2) + code = run_cli(cli + ["instrument-with-frida", "attach", str(info.pid), "--script-file", str(out / "probe.js"), + "--duration-ms", str(seconds * 1000), "--format", "json", "--full-output"], + out, job, seconds + 15) + if code: + raise RuntimeError("REA invocation failed") + except BaseException as exc: + if not (out / "launcher-failure.json").exists(): + write_json(out / "launcher-failure.json", {"reason": "diagnostic-" + type(exc).__name__}) + raise + finally: + try: + cleanup_owned(kernel, info, job, out, run_id) + finally: + if job: job.close() + if info.thread: kernel.CloseHandle(info.thread) + if info.process: kernel.CloseHandle(info.process) + check(user.CloseDesktop(desktop)) + report = summarize(out) + write_json(out / "diagnostic.json", report) + return report + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + commands = parser.add_subparsers(dest="command", required=True) + launch = commands.add_parser("run") + launch.add_argument("--source", required=True, type=Path) + launch.add_argument("--out", required=True, type=Path) + launch.add_argument("--rea", required=True, type=Path) + launch.add_argument("--seconds", type=int, default=30) + peer = commands.add_parser("producer") + peer.add_argument("--out", required=True, type=Path) + peer.add_argument("--run-id", required=True) + replay = commands.add_parser("summarize") + replay.add_argument("--out", required=True, type=Path) + args = parser.parse_args() + if args.command == "producer": + return producer(args.out, args.run_id) + result = run(args.source, args.out, args.rea, args.seconds) if args.command == "run" else summarize(args.out) + print(json.dumps(result, ensure_ascii=True, allow_nan=False)) + return 0 if result["correctness"]["status"] == "passed" else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/research/ui/qt_cmd_diagnostic.js b/research/ui/qt_cmd_diagnostic.js new file mode 100644 index 00000000..c6a44116 --- /dev/null +++ b/research/ui/qt_cmd_diagnostic.js @@ -0,0 +1,88 @@ +// Original fixed-Qt5/MSVC probe. Reads only the new synthetic local document. +// Controlled tree-key replay and bounded text reads are correctness diagnostics. +'use strict'; +const uiThread = __UI_THREAD_ID__, runId = __RUN_ID_JSON__, directory = __RUN_DIRECTORY_JSON__; +const desktopName = __DESKTOP_JSON__, timeoutMs = __TIMEOUT_MS__; +const widgets = Process.getModuleByName('Qt5Widgets.dll'), gui = Process.getModuleByName('Qt5Gui.dll'); +const core = Process.getModuleByName('Qt5Core.dll'), qml = Process.getModuleByName('Qt5Qml.dll'); +const wind = Process.getModuleByName('WindTerm.exe'), user = Process.getModuleByName('user32.dll'); +const fn = (m,n,r,a) => new NativeFunction(m.getExportByName(n),r,a); +const currentDesktop = fn(user,'GetThreadDesktop','pointer',['uint32'])(uiThread); +const desktopBuffer = Memory.alloc(512), needed = Memory.alloc(4); +if(!fn(user,'GetUserObjectInformationW','bool',['pointer','int','pointer','uint32','pointer'])(currentDesktop,2,desktopBuffer,512,needed) || desktopBuffer.readUtf16String()!==desktopName) + throw Error('owned non-input desktop identity mismatch'); +const all = fn(widgets,'?allWidgets@QApplication@@SA?AV?$QList@PEAVQWidget@@@@XZ','pointer',['pointer']); +const geometry = fn(widgets,'?geometry@QWidget@@QEBAAEBVQRect@@XZ','pointer',['pointer']); +const parent = fn(widgets,'?parentWidget@QWidget@@QEBAPEAV1@XZ','pointer',['pointer']); +const visible = fn(widgets,'?isVisible@QWidget@@QEBA_NXZ','bool',['pointer']); +const className = fn(core,'?className@QMetaObject@@QEBAPEBDXZ','pointer',['pointer']); +const constData = fn(core,'?constData@QString@@QEBAPEBVQChar@@XZ','pointer',['pointer']); +const destroyString = fn(core,'??1QString@@QEAA@XZ','void',['pointer']); +const stringCtor = fn(core,'??0QString@@QEAA@PEBVQChar@@H@Z','pointer',['pointer','pointer','int']); +const resize = fn(widgets,'?resize@QWidget@@QEAAXHH@Z','void',['pointer','int','int']); +const expandAll = fn(widgets,'?expandAll@QTreeView@@QEAAXXZ','void',['pointer']); +const focus = fn(widgets,'?setFocus@QWidget@@QEAAXW4FocusReason@Qt@@@Z','void',['pointer','int']); +const keyCtor = fn(gui,'??0QKeyEvent@@QEAA@W4Type@QEvent@@HV?$QFlags@W4KeyboardModifier@Qt@@@@AEBVQString@@_NG@Z','pointer',['pointer','int','int','int','pointer','bool','uint16']); +const keyDestroy = fn(gui,'??1QKeyEvent@@UEAA@XZ','void',['pointer']); +const sendEvent = fn(core,'?sendEvent@QCoreApplication@@SA_NPEAVQObject@@PEAVQEvent@@@Z','bool',['pointer','pointer']); +const treeModel = fn(widgets,'?model@QAbstractItemView@@QEBAPEAVQAbstractItemModel@@XZ','pointer',['pointer']); +const indexCtor = fn(core,'??0QModelIndex@@QEAA@XZ','pointer',['pointer']); +const hasIndex = fn(core,'?hasIndex@QAbstractItemModel@@QEBA_NHHAEBVQModelIndex@@@Z','bool',['pointer','int','int','pointer']); +const font = fn(widgets,'?font@QWidget@@QEBAAEBVQFont@@XZ','pointer',['pointer']); +const fontFamily = fn(gui,'?family@QFont@@QEBA?AVQString@@XZ','pointer',['pointer','pointer']); +const pointSize = fn(gui,'?pointSizeF@QFont@@QEBANXZ','double',['pointer']); +const pixelSize = fn(gui,'?pixelSize@QFont@@QEBAHXZ','int',['pointer']); +const jsEngine = fn(qml,'?engine@QJSValue@@QEBAPEAVQJSEngine@@XZ','pointer',['pointer']); +const jsEvaluate = fn(qml,'?evaluate@QJSEngine@@QEAA?AVQJSValue@@AEBVQString@@0H@Z','pointer',['pointer','pointer','pointer','pointer','int']); +const jsText = fn(qml,'?toString@QJSValue@@QEBA?AVQString@@XZ','pointer',['pointer','pointer']); +const jsDestroy = fn(qml,'??1QJSValue@@QEAA@XZ','void',['pointer']); +const emit = value => send({run_id:runId,...value}); +function str(call,self,...args){const s=Memory.alloc(8);call(self,s,...args);try{const n=s.readPointer().add(4).readS32();if(n<0||n>32768)throw Error('QString bound');return constData(s).readUtf16String(n);}finally{destroyString(s);}} +function withString(value,call){const s=Memory.alloc(8);stringCtor(s,Memory.allocUtf16String(value),value.length);try{return call(s);}finally{destroyString(s);}} +let engine=null,evaluating=false; +for(const ex of qml.enumerateExports().filter(e=>/^\?call.*@QJSValue@@/.test(e.name)))Interceptor.attach(ex.address,{onEnter(args){if(!evaluating&&Process.getCurrentThreadId()===uiThread){const e=jsEngine(args[0]);if(!e.isNull())engine=e;}}}); +Interceptor.attach(qml.getExportByName('?evaluate@QJSEngine@@QEAA?AVQJSValue@@AEBVQString@@0H@Z'),{onEnter(args){if(!evaluating&&Process.getCurrentThreadId()===uiThread)engine=args[0];}}); +function evaluate(source){if(engine===null)return null;evaluating=true;try{return withString(source,s=>withString('TermForgeCmdDiagnostic',file=>{const value=Memory.alloc(16);jsEvaluate(engine,value,s,file,1);try{return str(jsText,value);}finally{jsDestroy(value);}}));}finally{evaluating=false;}} +let main=null,tree=null,editor=null,scanCount=0; +function scan(){ + // A bounded number of Qt QList return allocations live until Job teardown. + if(++scanCount>8)throw Error('widget scan bound'); + const result=Memory.alloc(8);all(result);const d=result.readPointer(),b=d.add(8).readS32(),e=d.add(12).readS32(); + if(b<0||e2500)throw Error('widget list bound'); + const rows=[]; + for(let i=b;ix.class==='MainWindow'&&x.parent==='0x0');if(roots.length!==1)throw Error('one main window required');main=roots[0].p; + const panes=rows.filter(x=>x.class==='SessionPane');const trees=rows.filter(x=>x.class==='TreeView'&&panes.some(p=>p.p.toString()===x.parent));if(trees.length!==1)throw Error('one synthetic tree required');tree=trees[0].p; + const editors=rows.filter(x=>x.class==='ScintillaEditBase');if(editors.length>1||rows.some(x=>x.class==='MessageBar'))throw Error('unexpected terminal state');editor=editors.length===1?editors[0].p:null; +} +function key(p,k){withString('',s=>{for(const t of [6,7]){const event=Memory.alloc(256);keyCtor(event,t,k,0,s,0,1);try{sendEvent(p,event);}finally{keyDestroy(event);}}});} +function state(){ + const value=evaluate(`JSON.stringify((function(){var t=window.activeText;if(!t)return null;var n=t.visibleLineCount;if(typeof n==='function')n=n.call(t);if(typeof n!=='number'||n<0||n>128)throw Error('logical line count bound');var rows=[];for(var i=0;i{if(!finished)postThreadMessage(uiThread,0,0,0);},100); +emit({kind:'probe_ready',desktop_verified:true,clipboard_access:false}); +Interceptor.attach(user.getExportByName('DispatchMessageW'),{onEnter(){ + if(busy||finished||Process.getCurrentThreadId()!==uiThread||Date.now()timeoutMs)throw Error('bounded diagnostic timeout'); + if(stage===0){scan();resize(main,1360,860);stage=1;} + else if(stage===1){const invalid=Memory.alloc(32);indexCtor(invalid);const ready=engine!==null&&!treeModel(tree).isNull()&&hasIndex(treeModel(tree),0,0,invalid);modelReady=ready?modelReady+1:0;if(modelReady>=2){expandAll(tree);focus(tree,7);key(tree,0x1000011);stage=2;}} + else if(stage===2){key(tree,0x1000004);emit({kind:'local_session_open_requested'});stage=3;due=Date.now()+1500;return;} + else if(stage===3){scan();if(editor!==null)stage=4;} + else if(stage===4){ + let producer=null;try{producer=JSON.parse(File.readAllText(directory+'/producer.json'));}catch(e){due=Date.now()+200;return;} + if(producer.run_id!==runId||producer.status!=='complete')throw Error('producer identity or completion'); + const snapshot=state();if(snapshot===null){due=Date.now()+200;return;} + if(!Array.isArray(snapshot.lines)||!snapshot.lines.includes('TF-CMD-END '+runId)){due=Date.now()+200;return;} + const r=geometry(editor),f=font(editor); + emit({kind:'text_complete',...snapshot,widget_geometry:[r.readS32(),r.add(4).readS32(),r.add(8).readS32(),r.add(12).readS32()],widget_font:{family:str(fontFamily,f),point_size:pointSize(f),pixel_size:pixelSize(f)}}); + finished=true; + } + due=Date.now()+250; + }catch(error){finished=true;emit({error:String(error),stage});}finally{busy=false;} +}}); From 64c2de4487688ffed1b6df1407a0d031c5fcd663 Mon Sep 17 00:00:00 2001 From: JavaZeroo <71128095+JavaZeroo@users.noreply.github.com> Date: Wed, 28 Oct 2026 22:00:52 +0800 Subject: [PATCH 2/3] fix: reject cmd metacharacters before diagnostic preparation (#119) --- benchmarks/tools/WINDTERM-CMD.md | 4 + .../results/2026-10-28-windterm-cmd/README.md | 14 ++- .../path-guard-validation.json | 87 +++++++++++++++++++ benchmarks/tools/test_windterm_cmd.py | 32 +++++++ benchmarks/tools/windterm_cmd.py | 12 ++- 5 files changed, 145 insertions(+), 4 deletions(-) create mode 100644 benchmarks/tools/results/2026-10-28-windterm-cmd/path-guard-validation.json diff --git a/benchmarks/tools/WINDTERM-CMD.md b/benchmarks/tools/WINDTERM-CMD.md index bf4c8a04..c1c9233d 100644 --- a/benchmarks/tools/WINDTERM-CMD.md +++ b/benchmarks/tools/WINDTERM-CMD.md @@ -12,8 +12,12 @@ D:/Code/termforge/.venv/Scripts/python.exe -B -X utf8 benchmarks/tools/windterm_ 输出目录必须不存在且位于当前仓库 `local/` 下。复用 `research/ui/launch.py` 的发行物内容清单、禁首启导入的合成配置、home 环境隔离和 Owned Job;挂起创建后先加入 Job,再执行。独立非输入桌面不会切换到前台。所有外部 CLI 及目标子进程同样有界回收。隔离配置与桌面不是文件系统或网络沙箱。 +producer 通过 `cmd /c` 启动,C argv 的 `list2cmdline` 不能视为 cmd 引用器。因此在读取/复制发行物或创建输出前,显式拒绝 out、Python 解释器和 runner 路径(传入及解析后的拼写)中的 `& | < > ^ ( ) % ! "` 与控制字符。具有这些字符的正常文件路径也不支持。`--seconds 30` 是 Frida 观察时长,公共 CLI 另有45秒等待上限;准备、两秒启动等待及有界清理另外计入整个生命周期。 + producer 在计时事件外清屏,从本地 cmd 控制台写入合成数据,记录 run_id、payload SHA、原始生产事件及控制台窗口/缓冲尺寸,然后保持存活供文本读取。结束/退出不代表消费或呈现完成。 +producer 记录通过 run_id 和负载身份关联;probe 与 cleanup 通过原版 target PID 关联。不据此声称已经核验 producer 的父子进程链。 + 独立探针通过已调查的固定 Qt5 API 打开唯一合成会话,并在其 GUI 线程读取 `activeText.visibleLineCount` 和每一条 `lineText(i)`;最多 128 行。仅末尾 marker 用于决定何时获取完整文本,Python 校验器对照独立生成的所有 14 条逻辑行逐行比较,剩余行只能为空。不会 trim 空格、忽略前缀、只比 marker 或从配置文件值推定当前实际设置。未知 API、缺失行、超时、错误、截断或身份不符均不能通过。 `diagnostic.json` 是供审查的白名单摘要,原始 `run.json`、producer、REA evidence、复制发行物和 profile 全部留在忽略目录,不提交。`actual_settings` 只记录实际可读的控制台尺寸、文档容量、控件几何和 QWidget 字体;该字体不代表全部 QPainter/glyph fallback,其他 C0 配置仍未知。用同一执行源码可离线重算: diff --git a/benchmarks/tools/results/2026-10-28-windterm-cmd/README.md b/benchmarks/tools/results/2026-10-28-windterm-cmd/README.md index 430cb8e3..64f3917c 100644 --- a/benchmarks/tools/results/2026-10-28-windterm-cmd/README.md +++ b/benchmarks/tools/results/2026-10-28-windterm-cmd/README.md @@ -2,7 +2,7 @@ #119 的首个自动切片,基于 main `2903290`。工具及其运行边界见 [WINDTERM-CMD.md](../../WINDTERM-CMD.md)。本页只记录固定结果;#3/#4 的实际呈现、低扰动校准和正式基线仍未完成。 -最终执行源码 `windterm_cmd.py` SHA-256 为 `bd4a67f5df26c32a13c5d9f0296bf2347719a3b9024d0607afe81f4655e00f61`。在 Windows 11 上使用既有 REA 6.7.0 公共 CLI,启动固定官方 2.7.0 EXE 和经完整内容清单校验的发行物副本。没有改动发行物、用户配置、输入桌面、剪贴板或显示设置。 +首次提交的最终执行源码 `windterm_cmd.py` SHA-256 为 `bd4a67f5df26c32a13c5d9f0296bf2347719a3b9024d0607afe81f4655e00f61`;后续路径守卫修订及其独立运行见下文。在 Windows 11 上使用既有 REA 6.7.0 公共 CLI,启动固定官方 2.7.0 EXE 和经完整内容清单校验的发行物副本。没有改动发行物、用户配置、输入桌面、剪贴板或显示设置。 | 检查点 | 最终实际结果 | |---|---| @@ -10,7 +10,7 @@ | 本地链路 | 原版合成 Shell 会话 → 本地 cmd → Python producer;本地控制台 VT 启用成功 | | 文本正确性 | `visibleLineCount=15`,逐条 `lineText(i)` 恰为全部 14 条独立预期及 1 条空白尾行;不是只检查 marker | | 配置可读项 | producer 控制台窗口80×24、缓冲80×3000;JS `screenLineCapacity=23`;控件矩形 `[0,0,905,428]`、QWidget 字体 Roboto Mono 9pt / pixel_size -1 | -| 身份与回收 | run_id/target_pid 绑定 producer、探针和 cleanup;自有 Job active=0;profile 仍只有一个合成会话 | +| 身份与回收 | producer 按 run_id/负载身份关联;probe 与 cleanup 按原版 target PID 关联;自有 Job active=0;profile 仍只有一个合成会话。未核验 producer 的父子进程身份 | | 未观测端点 | consumer_done=null;presented_done=null;performance_gate=not_evaluated | 控制台尺寸、JS 文档容量与 QWidget 几何属于各自读取点,原样分列;没有将它们解释为同一网格、完整 C0 验证或真实像素呈现。控件字体也不证明 QPainter、实际 glyph fallback 或字体文件身份。 @@ -21,4 +21,14 @@ [validation.json](validation.json) 仅公开人工审查过的白名单摘要、设置和来源哈希,不含 PID、宿主路径、原始生产计数器、画面、复制发行物或 profile。原始 REA evidence 和本地数据均保留在忽略目录。 +## cmd 路径守卫修订 + +Standards 审查指出 `list2cmdline` 只处理 C argv 引用,原守卫遗漏的无空格 `&` 路径会进入 `cmd /c` 语法。修复前先增加不启动原版的反例:`& | < > ^ ( )` 各在 out、runner 和 Python 解释器路径上的21个子例到达了发行物准备,公共 CLI 的 `out&echo.TF_CMD_REVIEW_MARKER` 也未被提前拒绝,两项测试失败。测试只使用 mock 或 Python 公共 CLI 的缺失发行物入口,没有启动 cmd/原版试注入。 + +修订在任何发行物读取/复制或输出目录创建前,检查三个路径的传入及解析后拼写,拒绝上述字符以及原来的 `% ! "` 和控制字符。修复后12项工具边界测试与3项既有隔离准备测试全部通过;独立 JS 语法检查通过。README 中的身份描述也已精确区分:producer 用 run_id/负载,probe/cleanup 用原版 target PID,不声称已有 producer 父子进程核验。 + +预声明的 `local/windterm-cmd/final-path-guard-20261028` 仅执行一次正常14行验证,run_id 与前两轮不同。运行源码 SHA-256 为 `e517f8cab36d55e5d4bdda3fc14d4641e5ce6256991af04762dbf43c25961297`;30秒为 Frida 观察时长,CLI等待上限45秒,准备、启动和清理另外计入生命周期。536 B 全文逐行匹配并有1空白尾行,producer complete;可读配置与上表一致,自有 Job active=0、合成 profile 一项,未知端点及所有性能限制不变。 + +[path-guard-validation.json](path-guard-validation.json) 保留此新源码的独立白名单和六份原件哈希。离线独立审计重新核对全部文本、身份与清理;前 `final-20261028` 六份原件哈希仍与原公开摘要完全一致,其执行源码副本另行保留。新 summarizer 仍拒绝旧 runner source,未放宽绑定;没有改写前两次数据或把三次独立诊断合并为性能样本。 + 这是有插桩的小 ASCII 文本读取链路,尚不覆盖滚动历史、Unicode、ANSI样式、输入响应、真实呈现、观测扰动或正式 T01。没有计算吞吐/时延、提出数值容差或声明原版性能等价。 diff --git a/benchmarks/tools/results/2026-10-28-windterm-cmd/path-guard-validation.json b/benchmarks/tools/results/2026-10-28-windterm-cmd/path-guard-validation.json new file mode 100644 index 00000000..fc2b724b --- /dev/null +++ b/benchmarks/tools/results/2026-10-28-windterm-cmd/path-guard-validation.json @@ -0,0 +1,87 @@ +{ + "schema": "termforge.windterm-cmd-diagnostic.v1", + "run_id": "2cb6f43c4e384983a1c1ef8104e6ca64", + "kind": "correctness-diagnostic", + "target_sha256": "7b10531dccc7cd614ae559f36f1ef518e0c96335637431b4646354823b2804d0", + "distribution_sha256": "e459adf2f12adb6a1b7f55e66a9da1c22794185e66e848057d21e0c60d669203", + "runner_sha256": "e517f8cab36d55e5d4bdda3fc14d4641e5ce6256991af04762dbf43c25961297", + "probe_sha256": "648b00e683f9544fc305aef1186084ad5fb57934e78b1dce46b5a819deccab3f", + "payload_sha256": "61c6afe24b3642e6491e2382f30c5a5993819d7b23d702dbc59e2b25daa71bf9", + "payload_bytes": 536, + "producer_status": "complete", + "correctness": { + "status": "passed", + "method": "all-bounded-logical-lines-exact", + "payload_lines": 14, + "observed_lines": 15, + "terminal_text_sha256": "5ff220f3626d945002a7b2cd2d4468e91c3132e1509e700ccdb2f9e2d0cfeb80" + }, + "actual_settings": { + "producer_console": { + "vt_enable_succeeded": true, + "original_mode": 3, + "columns": 80, + "rows": 24, + "buffer_columns": 80, + "buffer_rows": 3000 + }, + "terminal": { + "screen_line_capacity": 23, + "widget_geometry": [ + 0, + 0, + 905, + 428 + ], + "widget_font": { + "family": "Roboto Mono", + "point_size": 9, + "pixel_size": -1 + } + } + }, + "consumer_done": null, + "presented_done": null, + "performance_gate": "not_evaluated", + "cleanup": { + "owned_active": 0, + "only_synthetic_session": true + }, + "limitations": [ + "Instrumented local cmd diagnostic on a non-input desktop; no native input or presentation timing.", + "Small ASCII payload only; no T01 baseline, retained-history, Unicode or performance acceptance.", + "Widget font and console dimensions are readable settings, not complete C0 verification." + ], + "raw_records_sha256": { + "run.json": "b4c74552b0bf150ab4dcca6e22f5a08060fb8ee51a108547849d32154d3db353", + "workload.json": "eb488ec88b339aac6f3ff75ebcbb40220d8cf88888fd0275fa8aa9a23f6308ad", + "producer.json": "0ad5292ce921653c9730dcdfbd625098d32626e0964485d19b169c3ae0143645", + "evidence.json": "03bb9a6910ba856dd93a6418c5b42941594f2b195468bfe9aa0afda607cad5c2", + "cleanup.json": "cc83e085935e3cc581b8e39c693b536718d30840e2fe56b6121d7e57a76d2bf4", + "profile-check.json": "5e522a64e98197722ccd084639b1ce50258b238e03be2f016e81c8af023b5ee1" + }, + "source_base": "0582a02bbfd332d03761b1831445754eebd77549", + "issue": 119, + "validation": { + "public_boundary_tests": 12, + "existing_isolation_preparation_tests": 3, + "js_syntax_check": "passed", + "independent_offline_audit": "passed", + "raw_record_hashes_checked": 6, + "target_launches_for_final_source": 1, + "prior_six_record_hashes_unchanged": true, + "prior_source_rejected_by_current_summarizer": true + }, + "path_guard_red_green": { + "guarded_paths": [ + "out", + "runner", + "sys.executable" + ], + "explicit_metacharacters": "&|<>^()%!\"", + "also_rejects_control_characters": true, + "before_fix_boundary_failures": 21, + "before_fix_public_cli_failures": 1, + "injection_target_launches": 0 + } +} diff --git a/benchmarks/tools/test_windterm_cmd.py b/benchmarks/tools/test_windterm_cmd.py index 8af7bd6c..e55d82bd 100644 --- a/benchmarks/tools/test_windterm_cmd.py +++ b/benchmarks/tools/test_windterm_cmd.py @@ -3,6 +3,8 @@ import hashlib import json from pathlib import Path +import subprocess +import sys import tempfile import unittest from unittest.mock import Mock, patch @@ -43,6 +45,36 @@ def test_prepare_rejects_cmd_expansion_before_creating_target(self): cmd.prepare_run(Path("source"), Path("local/%USERNAME%"), RUN_ID) prepare.assert_not_called() + def test_all_cmd_metacharacters_rejected_in_each_interpreted_path(self): + for char in '&|<>^()%!"\r\n': + for field in ("out", "runner", "executable"): + with self.subTest(char=repr(char), field=field): + out = Path("local/out" + char + "echo.TF_CMD_REVIEW_MARKER") if field == "out" else Path("local/safe") + runner = str(Path("runner" + char + "probe.py")) if field == "runner" else cmd.__file__ + executable = str(Path("python" + char + "probe.exe")) if field == "executable" else sys.executable + with patch.object(cmd, "__file__", runner), patch.object(cmd.sys, "executable", executable), \ + patch.object(cmd, "prepare", side_effect=AssertionError("distribution preparation reached")) as prepare: + with self.assertRaisesRegex(ValueError, "cmd path"): + cmd.prepare_run(Path("source"), out, RUN_ID) + prepare.assert_not_called() + + @unittest.skipUnless(sys.platform == "win32", "Public CLI uses Windows-only run entry") + def test_public_cli_rejects_ampersand_before_missing_source_or_output(self): + with tempfile.TemporaryDirectory() as folder: + root = Path(folder) + out = root / "out&echo.TF_CMD_REVIEW_MARKER" + # Existing Python stands in only for CLI path validation; no REA CLI, + # cmd, WindTerm, desktop or provider may be launched after rejection. + result = subprocess.run([sys.executable, "-B", "-X", "utf8", str(Path(cmd.__file__).resolve()), + "run", "--source", str(root / "missing-distribution"), "--rea", sys.executable, + "--out", str(out), "--seconds", "10"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, + timeout=10, check=False) + self.assertNotEqual(result.returncode, 0) + self.assertIn(b"Unsupported cmd path characters", result.stderr) + self.assertNotIn(b"FileNotFoundError", result.stderr) + self.assertFalse(out.exists()) + self.assertEqual(list(root.iterdir()), []) + def test_runtime_bound_rejected_before_preparation(self): with patch.object(cmd.sys, "platform", "win32"), patch.object(cmd, "prepare_run") as prepare: for value in (0, 9, 61, True): diff --git a/benchmarks/tools/windterm_cmd.py b/benchmarks/tools/windterm_cmd.py index ebaf3d48..f4e13c2b 100644 --- a/benchmarks/tools/windterm_cmd.py +++ b/benchmarks/tools/windterm_cmd.py @@ -31,6 +31,8 @@ SCHEMA = "termforge.windterm-cmd-diagnostic.v1" PROBE = ROOT / "research/ui/qt_cmd_diagnostic.js" MAX_LINES = 128 +# subprocess.list2cmdline quotes for a C argv parser, not cmd.exe grammar. +CMD_PATH_META = frozenset('&|<>^()%!"') def require(condition, message): @@ -112,8 +114,14 @@ def producer(out, run_id): def prepare_run(source, out, run_id): lines, data = workload(run_id) - for path in (Path(out).resolve(), Path(__file__).resolve(), Path(sys.executable).resolve()): - require(not any(char in str(path) for char in '%!"\r\n'), "Unsupported cmd path characters") + # These three paths enter process.arguments, which WindTerm passes to cmd /c. + # Reject both supplied and resolved spellings before reading/copying the + # distribution or creating output; C argv quoting cannot neutralise cmd. + for original in (out, __file__, sys.executable): + path = Path(original) + for text in (str(path), str(path.resolve())): + require(not any(char in CMD_PATH_META or ord(char) < 32 for char in text), + "Unsupported cmd path characters") app = prepare(Path(source), Path(out)) out = Path(out).resolve() manifest = {"run_id": run_id, "lines": lines, "payload_bytes": len(data), From b3d3e70f19ff31cfae1ced1aa4cca5f6587eeaab Mon Sep 17 00:00:00 2001 From: JavaZeroo <71128095+JavaZeroo@users.noreply.github.com> Date: Wed, 28 Oct 2026 22:02:11 +0800 Subject: [PATCH 3/3] test: retain local cmd diagnostic contracts in Windows CTest (#119) --- CMakeLists.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/CMakeLists.txt b/CMakeLists.txt index 4b1e2cac..2d6ca66a 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -313,6 +313,7 @@ if(BUILD_TESTING) termforge_add_python_test(copy_error_frame -m unittest experiments.shell.test_copy_error_frame -v) if(WIN32) termforge_add_python_test(research_guards -m unittest discover -s research/tests -v) + termforge_add_python_test(windterm_cmd_contract -m unittest benchmarks.tools.test_windterm_cmd -v) termforge_add_python_test(receive -m unittest experiments.receive.test_receive -v) termforge_add_python_test(conpty -m unittest experiments.conpty.test_conpty -v) termforge_add_python_test(conpty_ctrl_c -m unittest experiments.conpty.test_ctrl_c -v)