diff --git a/docs/EVIDENCE.md b/docs/EVIDENCE.md index df4f080f..6d1d1685 100644 --- a/docs/EVIDENCE.md +++ b/docs/EVIDENCE.md @@ -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 的退出。 | diff --git a/docs/research/hosted-shell-command-boundaries.md b/docs/research/hosted-shell-command-boundaries.md index 1dd74a95..1c997ab0 100644 --- a/docs/research/hosted-shell-command-boundaries.md +++ b/docs/research/hosted-shell-command-boundaries.md @@ -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原文、环境和异常文本仍保留在私有目录,不进入记录。 | 阶段 | 能证明什么 | 不能证明什么 | |---|---|---| @@ -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独立记录。 diff --git a/experiments/shell/results/2026-10-29-hosted-output-boundary/validation.json b/experiments/shell/results/2026-10-29-hosted-output-boundary/validation.json new file mode 100644 index 00000000..82f19f37 --- /dev/null +++ b/experiments/shell/results/2026-10-29-hosted-output-boundary/validation.json @@ -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" + ] +} diff --git a/tools/prepare_hosted_shells.py b/tools/prepare_hosted_shells.py index c2f497d2..30fc9517 100644 --- a/tools/prepare_hosted_shells.py +++ b/tools/prepare_hosted_shells.py @@ -13,6 +13,7 @@ from pathlib import Path, PureWindowsPath import re import shlex +import stat import subprocess import sys import time @@ -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): @@ -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. @@ -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 diff --git a/tools/test_prepare_hosted_shells.py b/tools/test_prepare_hosted_shells.py index 9036d951..9b281642 100644 --- a/tools/test_prepare_hosted_shells.py +++ b/tools/test_prepare_hosted_shells.py @@ -316,5 +316,204 @@ def communicate(*args, **kwargs): self.assertNotIn("private-worker-error", json.dumps(result)) +class CommandOutputBoundaryTests(unittest.TestCase): + MARKER = b"TERMFORGE_HOSTED_PS51_READY\r\n" + + def parent_result(self, stdout=MARKER, stderr=b"private-error-never-publish", *, + timeout=True, code=0, worker_timeout=False, active=0, reaped=True, mutate=None): + with tempfile.TemporaryDirectory() as temporary: + out = Path(temporary) + process = mock.Mock(pid=123, returncode=None) + process.poll.side_effect = lambda: process.returncode if reaped else None + captured, first_call = [], True + + def command_run(command, **kwargs): + if command == ["synthetic-cmd"]: + kwargs["stdout"].write(b"TERMFORGE_HOSTED_CMD_READY\r\n") + return prep.subprocess.CompletedProcess(command, 0) + kwargs["stdout"].write(stdout) + kwargs["stderr"].write(stderr) + if timeout: + raise prep.subprocess.TimeoutExpired(command, 10) + return prep.subprocess.CompletedProcess(command, code) + + def communicate(*args, **kwargs): + nonlocal first_call + if not first_call: + process.returncode = 99 + return b"", b"" + first_call = False + private = out / "_private/preflight" + verdict = {"result": "ubuntu-absent"} + try: + prep.run_command(["synthetic-cmd"], private, "cmd", cwd=private) + result_code, result_output = prep.run_command(["synthetic-ps51"], private, "ps51", cwd=private) + prep.validate_marker(result_output, result_code, "TERMFORGE_HOSTED_PS51_READY") + except prep.PreparationError as error: + verdict = {"failure_code": str(error)} + captured.append((private / "ps51.stdout").read_bytes()) + if mutate: + mutate(private) + if worker_timeout: + raise prep.subprocess.TimeoutExpired(["synthetic-worker"], 45) + process.returncode = 1 if "failure_code" in verdict else 0 + return json.dumps(verdict).encode(), b"private-worker-error" + + process.communicate.side_effect = communicate + job = mock.Mock() + job.drain.return_value = {"active": active, "total": 6, "terminated": 0} + audit = mock.Mock(stable=True, members=[], private=[]) + audit.process_ids.return_value = [] + with mock.patch.object(prep, "OwnedJob", return_value=job), \ + mock.patch.object(prep, "JobMembers", return_value=audit), \ + mock.patch.object(prep.subprocess, "Popen", return_value=process), \ + mock.patch.object(prep.subprocess, "run", side_effect=command_run), \ + mock.patch.object(prep.subprocess, "CREATE_NO_WINDOW", 0, create=True): + result = prep.run_step("preflight", out) + return result, captured[0] + + def test_parent_reads_written_marker_after_command_timeout_and_verified_cleanup(self): + result, raw = self.parent_result() + self.assertEqual(raw, self.MARKER) + self.assertEqual((result["status"], result["failure_code"]), ("failed", "command_deadline")) + self.assertTrue(result["cleanup"]["root_reaped"]) + output = result["command_output_observation"]["commands"][1] + self.assertEqual(output["command"], "ps51") + self.assertEqual(output["stdout"], {"status": "observed", "bytes": 29, + "marker_status": "observed", "marker_complete_line_seen": True}) + self.assertEqual(output["stderr"], {"status": "observed", "bytes": 27}) + self.assertNotIn("private-error", json.dumps(result)) + + def test_marker_requires_a_complete_line_but_never_publishes_other_output(self): + cases = ((b"", False), (self.MARKER[:-1], False), (b"prefix" + self.MARKER, False), + (b"\xef\xbb\xbf" + self.MARKER, True), + (b"synthetic-private-\xff\n" + self.MARKER + b"other-private-line\n", True)) + for raw, seen in cases: + with self.subTest(raw=raw): + result, _ = self.parent_result(stdout=raw) + stream = result["command_output_observation"]["commands"][1]["stdout"] + self.assertEqual((stream["status"], stream["bytes"], stream["marker_complete_line_seen"]), + ("observed", len(raw), seen)) + self.assertEqual(result["failure_code"], "command_deadline") + self.assertNotIn("private-line", json.dumps(result)) + + def test_missing_and_oversized_files_stay_unknown(self): + def missing(directory): + (directory / "ps51.stdout").unlink() + def oversized(directory): + (directory / "ps51.stdout").write_bytes(b"x" * 65537) + for change, status in ((missing, "not_observed"), (oversized, "oversize")): + with self.subTest(status=status): + result, _ = self.parent_result(mutate=change) + self.assertEqual(result["command_output_observation"]["commands"][1]["stdout"], + {"status": status, "bytes": None, "marker_status": "unknown", + "marker_complete_line_seen": None}) + self.assertEqual(result["failure_code"], "command_deadline") + result, _ = self.parent_result(stdout=b"x" * 65537, timeout=False) + self.assertEqual((result["status"], result["failure_code"]), ("failed", "command_report_size")) + self.assertEqual(result["command_output_observation"]["commands"][1]["stdout"]["status"], "oversize") + + def test_read_errors_and_changed_file_during_read_stay_unknown(self): + original_open = Path.open + for fault, status in (("unreadable", "unreadable"), ("changed", "unstable")): + with self.subTest(fault=fault): + ready = [False] + def guarded_open(path, mode="r", *args, **kwargs): + if ready[0] and path.name == "ps51.stdout" and mode == "rb": + if fault == "unreadable": + raise OSError("synthetic-private-read-error") + stream = original_open(path, mode, *args, **kwargs) + class ChangingRead: + def __enter__(self): + return self + def __exit__(self, *arguments): + stream.close() + def fileno(self): + return stream.fileno() + def read(self, size=-1): + raw = stream.read(size) + with original_open(path, "ab") as writer: + writer.write(b"x") + return raw + return ChangingRead() + return original_open(path, mode, *args, **kwargs) + def worker_finished(_): + ready[0] = True + with mock.patch.object(Path, "open", new=guarded_open): + result, _ = self.parent_result(mutate=worker_finished) + self.assertEqual(result["command_output_observation"]["commands"][1]["stdout"], + {"status": status, "bytes": None, "marker_status": "unknown", + "marker_complete_line_seen": None}) + self.assertEqual(result["failure_code"], "command_deadline") + self.assertNotIn("synthetic-private", json.dumps(result)) + + def test_exact_limit_is_observed_and_stderr_oversize_is_not_zero(self): + raw = b"x" * (65536 - len(self.MARKER) - 1) + b"\n" + self.MARKER + result, _ = self.parent_result(stdout=raw, stderr=b"x" * 65537) + output = result["command_output_observation"]["commands"][1] + self.assertEqual(output["stdout"]["bytes"], 65536) + self.assertTrue(output["stdout"]["marker_complete_line_seen"]) + self.assertEqual(output["stderr"], {"status": "oversize", "bytes": None}) + + def test_invalid_command_trace_cannot_select_private_files(self): + def invalid(directory): + prep.write_json(directory / "command-events.json", {"events": [ + {"command": "../../synthetic-private-file", "phase": "invoking", + "deadline_ms": 10000, "elapsed_ms": 0, "returncode": None}]}) + result, _ = self.parent_result(mutate=invalid) + self.assertEqual(result["command_observation"], {"status": "invalid", "events": []}) + self.assertEqual(result["command_output_observation"], {"status": "not_observed", "commands": []}) + self.assertEqual(result["failure_code"], "command_deadline") + self.assertNotIn("synthetic-private", json.dumps(result)) + + def test_commands_without_marker_are_explicitly_not_applicable(self): + cleanup = {"root_reaped": True, "audit_status": "observed", "membership_stable": True, + "job_after": {"active": 0}, "job_after_pid_count": 0} + with tempfile.TemporaryDirectory() as temporary: + directory = Path(temporary) + (directory / "list.stdout").write_bytes(b"synthetic-private-distro\n") + events = [] + for name in ("cmd", "ps51", "ps7", "list"): + events.extend([CommandBoundaryTests.event(command=name), + CommandBoundaryTests.event(command=name, phase="returned", elapsed=1, code=0)]) + value = prep.public_command_output(directory, "preflight", cleanup, {"status": "observed", "events": events}) + stream = value["commands"][-1]["stdout"] + self.assertEqual(stream, {"status": "observed", "bytes": 25, "marker_status": "not_applicable", + "marker_complete_line_seen": None}) + self.assertNotIn("synthetic-private", json.dumps(value)) + self.assertEqual(prep.public_command_output(directory, "download", {}, {}), + {"status": "not_applicable", "commands": []}) + self.assertEqual(prep.public_command_output(directory, "../../synthetic-private-step", {}, {}), + {"status": "invalid", "commands": []}) + + def test_success_nonzero_and_worker_deadline_keep_their_original_verdict(self): + for options, status, failure in (({"timeout": False}, "passed", "none"), + ({"timeout": False, "code": 7}, "failed", "shell_startup"), + ({"timeout": False, "stdout": b"private-extra\n" + self.MARKER}, "failed", "shell_startup"), + ({"worker_timeout": True}, "failed", "worker_deadline")): + with self.subTest(options=options): + result, _ = self.parent_result(**options) + self.assertEqual((result["status"], result["failure_code"]), (status, failure)) + self.assertTrue(result["command_output_observation"]["commands"][1]["stdout"]["marker_complete_line_seen"]) + self.assertNotIn("private-extra", json.dumps(result)) + + def test_unreaped_or_nonempty_job_cannot_claim_stable_output(self): + for options in ({"reaped": False}, {"active": 1}): + with self.subTest(options=options): + result, _ = self.parent_result(**options) + self.assertEqual(result["command_output_observation"], {"status": "writers_unverified", "commands": []}) + self.assertEqual(result["status"], "failed") + + def test_reporter_failure_cannot_replace_success_or_timeout(self): + with mock.patch.object(prep, "public_command_output", side_effect=RuntimeError("synthetic-private-diagnostic-error")): + for timeout in (False, True): + with self.subTest(timeout=timeout): + result, _ = self.parent_result(timeout=timeout) + self.assertEqual((result["status"], result["failure_code"]), + ("failed", "command_deadline") if timeout else ("passed", "none")) + self.assertEqual(result["command_output_observation"], {"status": "unavailable", "commands": []}) + self.assertNotIn("synthetic-private", json.dumps(result)) + + if __name__ == "__main__": unittest.main()