Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/EVIDENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ R1 固定总出口的源码为 `ce9b24d`,早期 `2dc8d6d` / `9b16acc` 仍分
| 工作区恢复的命令编辑前缀(#61) | [BS 因果反例及复验](../experiments/shell/results/2026-10-26-workspace-restore-bs/validation.json)、[运输完成合同](research/workspace-restore-transport-oracle.md) | 原失败中的 PowerShell 命令重绘 BS 污染当前记录;真实 LF 后仍须完整输出与新提示符,回显/拼接/非法封装拒绝保留。仅修完成 oracle,原 30/40 秒与产品断言不变。 |
| 工作区保存的空记录重绘(#72) | [原 122 B 因果回归与三配置结果](../experiments/shell/results/2026-10-27-workspace-empty-cr/validation.json)、[运输完成合同](research/workspace-restore-transport-oracle.md) | 两次 CI 失败记录逐字节相同;空且未编辑的记录在输出前允许裸 CR,仍须完整独立标记与新提示符。默认/system、候选/system、候选/官方各两轮保存/恢复/钳制共 18 次通过;最终完整回归、跳过和未闭合项独立记录。 |
| WSL 原生尺寸程序返回边界(#80) | [机制、反例与复验](research/wsl-size-return.md)、[各阶段固定记录](../experiments/shell/results/2026-10-27-wsl-size-return/validation.json) | 尺寸输出后提前发送 exit 的受控 stage6 红例;实际事件握手修前红、修后与原两轮通过。默认/system 完整 46/46,12 个内部跳过单列;全 shell 提示符方案的 cmd stage5 失败保留。原 hosted 样本同因未知,其他配置、实际呈现及 #81 准备超时未由此验收。 |
| hosted 准备子命令边界(#81) | [白名单与观测合同](research/hosted-shell-command-boundaries.md)、[原失败及回归身份](../experiments/shell/results/2026-10-27-hosted-command-boundaries/validation.json) | 固定命令的调用/返回/超时和 worker 期限后的部分记录;写记录失败不替代实际命令结果。23 项准备/清理回归通过,主要过程使用模拟,真实 Job 用隔离 Python 子树;原 hosted 具体超时命令与根因仍未知。 |
| hosted 准备子命令边界(#81) | [白名单与观测合同](research/hosted-shell-command-boundaries.md)、[原边界身份](../experiments/shell/results/2026-10-27-hosted-command-boundaries/validation.json)、[#158输出后验身份](../experiments/shell/results/2026-10-29-hosted-output-boundary/validation.json) | 调用/返回/超时与worker部分记录保留;新增worker/Job回收后有界输出计数和完整marker布尔,未知不当0,原命令/预算/oracle不变。历史23项含真实隔离Job,新切片普通/-O各32项仅纯/mock,身份分列;旧输出未留存及旧超时根因仍未知,不重跑取样。 |
| 选择复制夹具错误边界(#85) | [观测、控制与保护合同](research/selection-clipboard-boundaries.md)、[原失败及固定回归](../experiments/shell/results/2026-10-27-selection-clipboard-boundaries/validation.json) | 失败后记录固定阶段、读写目的、原错误及打开窗口/数据所有者类别,明确非原子;清理异常不覆盖主错误。默认/system 本地 46/46,13 个内部跳过;真实 CF 控制需 hosted 验证,原 OpenClipboard 5 根因仍未知,不作修复宣称。 |
| 候选文字观测与回显端点(#38 夹具修正) | [同源码前后对照及反事实](../experiments/shell/results/2026-10-26-native-observation/validation.json)、[标准文本投影](../experiments/core/text_projection.hpp) | 观测改用标准文本投影,回显完成后才全选;原断言和负例保留。修正夹具时序/投影,不宣称产品选择策略已变或 R2 全绿。 |
| 拒绝负例的读取前停止(#46 夹具修正) | [原记录、反事实及 fresh 验证](../experiments/shell/results/2026-10-26-receiver-stop/validation.json)、[公开空数据夹具](../experiments/shell/fixtures/rejected-order-before-read.json) | 旧 oracle 错误拒绝“首读前已停止”;同一空数据/零读原件红→绿,正常读取约束保留。限定夹具修正,不解释 #44 的退出。 |
Expand Down
24 changes: 23 additions & 1 deletion docs/research/hosted-shell-command-boundaries.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

[#81](https://github.com/JavaZeroo/termforge/issues/81) 保存 [PR #76 的 preflight 失败](https://github.com/JavaZeroo/termforge/actions/runs/40366292708/job/121724790152):`command_deadline`,产品测试未运行。原摘要只知道 worker 退出 1、Job active0/total6/terminated0,不能识别 cmd、PowerShell 5.1、PowerShell 7 或 wsl-list 中哪条命令超时,也不能从进程总数推定。

准备程序现在在实际 `run_command` 调用处写一个有界原子记录,父进程确认 worker 已回收后才读取,并把通过严格白名单验证的字段放入公开摘要。原命令参数、工作目录、stdout/stderr、环境和异常文本仍保留在私有目录,不进入记录。
准备程序现在在实际 `run_command` 调用处写一个有界原子记录,父进程确认 worker 已回收后才读取,并把通过严格白名单验证的字段放入公开摘要。原命令参数、工作目录、stdout/stderr原文、环境和异常文本仍保留在私有目录,不进入记录。

| 阶段 | 能证明什么 | 不能证明什么 |
|---|---|---|
Expand All @@ -21,3 +21,25 @@ python -m unittest tools.test_prepare_hosted_shells tools.test_owned_job_cleanup
```

原 artifact 与本次代码/验证身份见[限定记录](../../experiments/shell/results/2026-10-27-hosted-command-boundaries/validation.json)。后续 hosted 运行继续沿用原 workflow;即使不再超时,也只证明该次运行通过,#81 原根因仍待定位。#80 的四 shell 交接与此准备阶段没有同因证据。

## 收尾后的输出事实

[#158](https://github.com/JavaZeroo/termforge/issues/158)增加可选的 `command_output_observation`,保留原schema及 `command_observation`。它在worker已reap、Job审计/membership稳定且最终active/PID计数均明确为0后,按已验证的invoking命令前缀读取固定私有文件。条件不足时为 `writers_unverified`;未知step或trace不能选择任意文件。无命令的download为 `not_applicable`。

每流最多读取65537B,只在完整、稳定、普通文件且不超过65536B时发布可信字节数。缺失、超限、读错和读取期间变化分别报告 `not_observed/oversize/unreadable/unstable`,字节数为null;不存在的文件不等于空输出。

stdout还可有固定marker的 `marker_status` 和 `marker_complete_line_seen`:

| 观测 | 字段含义 |
|---|---|
| `observed`、true/false | 有界完整流中是否包含固定ASCII marker的完整LF/CRLF行;仅去除流开头UTF8 BOM,不接受子串或未结束片段 |
| `unknown`、null | 缺失、不稳定、超限或读取失败,不能判定 |
| `not_applicable`、null | list/import等命令没有固定marker定义,不猜测标记 |

其他输出不解码/公开,stderr仅有状态和数量。即使流含marker和额外私密文字,也只导出计数/布尔;原严格marker oracle仍拒绝额外内容。读/解析/报告异常只能降低该诊断的完整性,不能覆盖原成功、非0、command/worker期限或cleanup失败。新增读取在既有worker/Job收尾后进行,不延长原命令或worker等待预算;没有据此建立整程硬上限。

完整marker只证明最终捕获流里存在该行,不证明它在10秒截止前写出;未见marker也不能证明进程尚未创建或PowerShell引擎未启动。`subprocess.run`的超时覆盖调用/等待及超时收尾边界,不能把整段耗时直接称“启动耗时”。

首次真实文件seam红例为 `39921c1`:fake subprocess向传入stdout写完整PS51 marker再抛TimeoutExpired,父进程原判定保持失败但旧实现无输出事实。`d63be02`补齐观测;`87deb48`的普通/优化解释器相关纯/mock回归均32/32、0skip,覆盖10项新文件/父进程边界。未运行真实OwnJobApi进程测试、hosted setup、PS/WSL或其他目标;[脱敏身份与原件守卫](../../experiments/shell/results/2026-10-29-hosted-output-boundary/validation.json)保留来源。

PR150/153旧CI的8份summary/ZIP/job日志前后未变,原artifact没有私有输出,marker仍未知。本切片没有新真实deadline观测,不定位或关闭#81旧根因,也不重跑旧job。后续实际hosted观测由既有CI自然产生,并在责任issue独立记录。
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
{
"schema": "termforge-hosted-output-boundary-validation-v1",
"issue": 158,
"base_commit": "7e896cb75283fd0d984c936500ba8d1de4ceaf46",
"red_test_commit": "39921c1",
"implementation_commit": "d63be02",
"tested_source_commit": "87deb48fd0849dc8a65d627e4e0e47fb9c71289e",
"read_limit_bytes_per_stream": 65537,
"maximum_reported_bytes": 65536,
"related_tests": {
"ordinary": {
"tests_passed": 32,
"seconds": 0.58,
"skipped": 0,
"log_bytes": 6450,
"log_sha256": "8725ee467ffcd72e4d7b8a970c7cb8be53cd990304a8dfbed9ba8d2a849876b6"
},
"optimized": {
"tests_passed": 32,
"seconds": 0.583,
"skipped": 0,
"log_bytes": 6450,
"log_sha256": "1c481ebca2a49667c876e052297d3e23b7e83ab011a8b17a413bd9d04683d8d7"
}
},
"new_output_boundary_tests": 10,
"real_hosted_setup_runs": 0,
"real_target_processes": 0,
"real_owned_job_api_test_executed": false,
"new_real_deadline_observations": 0,
"old_timeout_root_cause": null,
"original_marker_presence": null,
"stdout_or_stderr_plaintext_published": false,
"source_sha256": {
"tools/prepare_hosted_shells.py": "f7389da9b7a727fbf8a3d541c0cd08dada03ecc1f451d6554159691784a0b97a",
"tools/test_prepare_hosted_shells.py": "7004cfff39d9da411286dd962bb36acece271f835d43c2b8006aefceff3c806e",
"tools/owned_job_cleanup.py": "e9ced3f89ef13e236ad91d0c94594c6a4c95ce3df86fd4121da12a327718dd0a",
"tools/test_owned_job_cleanup.py": "b962a46d257f4a2907e158951a55de807ef8be595f2bfff9c7598a3600666f07",
".github/workflows/build.yml": "b1bf26d1335ed8a98b4b54e0e8006bcc5eb8260fb66ccf13316171fd47669ae3"
},
"unchanged_protected_functions_sha256": {
"run_command": "494a462071dd37192ce51762f6ef8bef49426b5d5d8e600cdf70478dadd8fcd5",
"worker": "7d7ccd6d906b35b986f80252b0d5e5cbc3e845d2a783d1beec30c26847fe57fc",
"validate_marker": "96e675a7cb17b5fc89ed7b4e48f4742ba3ed740d519f53d617cde2a001b07bcc",
"require_hosted": "d91ca75927000a78265cb3f124df81330b5f6b5f563add3618a4ad96ce97335b",
"require_paths": "5c9e56f35088a5a3ae20f772f6997875c303e2677d116364029ec1b3cb9d29b6",
"require_runtime": "b1b6d159782f48d041f95f2f5ea20026c08a4c36e3bf59329a1fd49f2fbb4d00",
"require_worker_job": "ee259b2c030900361de8975e6431f3abfcf08b4fea7bf61aed2f35b4d5463778",
"write_command_event": "f01bb648c7dc7b0ca913619e5d75252616242ed4cf8b8f3ed8949952a3264848",
"public_command_observation": "91b88a05b6734354ddacc3cce39117874db93f99879ad2baad37f14ce0c7f091"
},
"protected_schema_commands_oracles_and_budgets_unchanged": true,
"original_ci_artifacts": {
"pr150-preparation.zip": {
"bytes": 794,
"sha256": "31a931f0aa9a5cf3132bdebbcdafa4dd8bc6a61062060487520bcd02a0d3040f"
},
"pr150-failure.zip": {
"bytes": 20847,
"sha256": "9fa15920d95b690cf6fe6c8b47832a6c886db18c704dc21fc9ea6e64d1b5c830"
},
"pr150-job.log": {
"bytes": 53914,
"sha256": "1d8a788cf33d2d0aeb72333da7406056a14d197292739e7b3b6dd5ceb3124674"
},
"pr150-preparation-summary.json": {
"bytes": 2215,
"sha256": "6ad0029fa3e77c9e54fe23ba2de132db98638ddab16a754437c40794d7441216"
},
"pr153-preparation.zip": {
"bytes": 793,
"sha256": "09eb0d74b0c6294236e8f1f7862c6ae4e78ca3ccb4fb0eedf8d089200a130a0b"
},
"pr153-failure.zip": {
"bytes": 20847,
"sha256": "097c7eabc88c122153bd2d9a02395186b0e4c5068c4d9c0af49a111d65db9a4c"
},
"pr153-job.log": {
"bytes": 53844,
"sha256": "5520b7a224a3fe5785fe5d3666c13de16e7def9c7019f468fd59756862a5a18c"
},
"pr153-preparation-summary.json": {
"bytes": 2215,
"sha256": "6818a1af4a70f5336a2b4d41ed91366b4660ca5fd5ed461cc2a70f334aec051d"
}
},
"preflight_sha256": "555cbac4d66878fc7a1650f50ee741cfaa3d38a7b5b195220ade94ffa79448da",
"postflight_sha256": "8ff8dcabd40389c350cbd802b3653d95bb86e79c9b539507fa9528aed0e0f3be",
"red_log": {
"bytes": 1074,
"sha256": "7945ff347c4c60613d58dd9e3060724152f6a22666ce93e5343758773c486d2d"
},
"green_log": {
"bytes": 324,
"sha256": "29c4f6bb245d1f68c81748c7bed6bc2b7da7c4d87d1f91a9b9033995d67c9211"
},
"synthetic_boundaries_verified": [
"run_command writes actual supplied stdout file then TimeoutExpired; parent marker=true while command_deadline remains",
"complete LF/CRLF line and leading UTF8 BOM; empty/partial/substring false",
"private extra output is never published and strict startup oracle still rejects it",
"missing/oversize/read-error/changed-during-read output is unknown, not zero",
"65536B observed; 65537B overflow unknown for both stream types",
"non-reaped worker or nonempty owned Job cannot claim stable output",
"original success/nonzero/command deadline/worker deadline/cleanup failure preserved",
"unknown command trace cannot construct a private read path",
"commands without marker explicitly not_applicable",
"reporter exception cannot replace success or failure"
],
"limitations": [
"synthetic mocked process/Job boundaries only; no real hosted run or PowerShell execution",
"captured complete marker is a post-cleanup fact, not a pre-deadline timestamp",
"no marker does not prove process creation or engine startup failed",
"old CI artifacts do not contain original private streams; old marker remains unknown",
"existing command and worker budgets remain; no new whole-lifecycle hard bound claimed"
]
}
71 changes: 71 additions & 0 deletions tools/prepare_hosted_shells.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from pathlib import Path, PureWindowsPath
import re
import shlex
import stat
import subprocess
import sys
import time
Expand All @@ -34,6 +35,9 @@
"check-before": ("list", "startup"), "check-after": ("list", "startup")}
COMMAND_DEADLINES = {"cmd": 10, "ps51": 10, "ps7": 10, "list": 10,
"list-before-import": 10, "import": 80, "startup": 20}
OUTPUT_LIMIT = 65536
COMMAND_MARKERS = {"cmd": b"TERMFORGE_HOSTED_CMD_READY", "ps51": b"TERMFORGE_HOSTED_PS51_READY",
"ps7": b"TERMFORGE_HOSTED_PS7_READY", "startup": b"TERMFORGE_HOSTED_WSL_READY"}


class PreparationError(Exception):
Expand Down Expand Up @@ -237,6 +241,67 @@ def public_command_observation(directory, step):
return {"status": "invalid", "events": []}


def _output_identity(value):
return value.st_dev, value.st_ino, value.st_size, value.st_mtime_ns


def _output_stream(path):
# Regular, closed worker files only. Raw bytes never enter the public map.
try:
with path.open("rb") as stream:
before = os.fstat(stream.fileno())
if not stat.S_ISREG(before.st_mode):
return {"status": "unreadable", "bytes": None}, None
raw = stream.read(OUTPUT_LIMIT + 1)
after = os.fstat(stream.fileno())
if _output_identity(before) != _output_identity(after) or _output_identity(after) != _output_identity(path.stat()):
return {"status": "unstable", "bytes": None}, None
if len(raw) > OUTPUT_LIMIT:
return {"status": "oversize", "bytes": None}, None
if len(raw) != after.st_size:
return {"status": "unstable", "bytes": None}, None
return {"status": "observed", "bytes": len(raw)}, raw
except FileNotFoundError:
return {"status": "not_observed", "bytes": None}, None
except (OSError, ValueError):
return {"status": "unreadable", "bytes": None}, None


def public_command_output(directory, step, cleanup, observation):
"""Post-cleanup facts, independent of command and preparation acceptance."""
if step not in COMMANDS_BY_STEP:
return {"status": "invalid", "commands": []}
if not COMMANDS_BY_STEP[step]:
return {"status": "not_applicable", "commands": []}
after = cleanup.get("job_after")
if (cleanup.get("root_reaped") is not True or cleanup.get("audit_status") != "observed" or
cleanup.get("membership_stable") is not True or type(after) is not dict or
type(after.get("active")) is not int or after["active"] != 0 or
type(cleanup.get("job_after_pid_count")) is not int or cleanup["job_after_pid_count"] != 0):
return {"status": "writers_unverified", "commands": []}
if observation.get("status") != "observed":
return {"status": "not_observed", "commands": []}
events = validate_command_events({"events": observation["events"]}, step)
commands = []
for event in events:
if event["phase"] != "invoking":
continue
name = event["command"]
stdout, raw = _output_stream(directory / (name + ".stdout"))
stderr, _ = _output_stream(directory / (name + ".stderr"))
marker = COMMAND_MARKERS.get(name)
stdout.update(marker_status="not_applicable" if marker is None else "unknown",
marker_complete_line_seen=None)
if marker is not None and raw is not None:
# A final unterminated fragment is not a completed WriteLine. Other
# output may be private and is neither decoded nor published.
lines = raw.removeprefix(b"\xef\xbb\xbf").split(b"\n")[:-1]
stdout.update(marker_status="observed",
marker_complete_line_seen=any(line.removesuffix(b"\r") == marker for line in lines))
commands.append({"command": name, "stdout": stdout, "stderr": stderr})
return {"status": "observed" if commands else "not_observed", "commands": commands}


def run_command(command, directory, name, *, cwd, timeout=10, data=None):
# This function only runs inside a GO-gated worker already in the parent's
# exact Job. No shell string construction or global-process cleanup.
Expand Down Expand Up @@ -462,6 +527,12 @@ def terminate_job():
(private / "worker.stderr").write_bytes(stderr)
record["command_observation"] = (public_command_observation(private, step) if cleanup["root_reaped"] else
{"status": "worker_not_reaped", "events": []})
try:
record["command_output_observation"] = public_command_output(private, step, cleanup, record["command_observation"])
except Exception:
# Diagnostic I/O, malformed metadata or a reporter failure must never
# replace the command result, its deadline, or a cleanup failure.
record["command_output_observation"] = {"status": "unavailable", "commands": []}
return record


Expand Down
Loading