design-playbook 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/LICENSE +28 -0
  2. package/NOTICE +37 -0
  3. package/README.md +143 -0
  4. package/commands/design-io.md +8 -0
  5. package/commands/ui-review.md +8 -0
  6. package/commands/ux-spec.md +8 -0
  7. package/mcp/__init__.py +0 -0
  8. package/mcp/_transport.py +242 -0
  9. package/mcp/evidence/README.md +40 -0
  10. package/mcp/evidence/__init__.py +0 -0
  11. package/mcp/evidence/server.py +450 -0
  12. package/mcp/evidence/test_server_stdio.py +645 -0
  13. package/mcp/preview/__init__.py +0 -0
  14. package/mcp/preview/browser.py +661 -0
  15. package/mcp/preview/confirm.py +255 -0
  16. package/mcp/preview/control.py +1293 -0
  17. package/mcp/preview/i18n.py +162 -0
  18. package/mcp/preview/server.py +126 -0
  19. package/mcp/preview/test_browser_control.py +663 -0
  20. package/mcp/preview/test_server_stdio.py +630 -0
  21. package/mcp/preview/test_transaction.py +436 -0
  22. package/mcp/preview/transaction.py +536 -0
  23. package/mcp/preview/util.py +19 -0
  24. package/mcp/test_transport.py +39 -0
  25. package/package.json +42 -0
  26. package/skills/craft-guard/SKILL.md +59 -0
  27. package/skills/craft-guard/references/craft.md +29 -0
  28. package/skills/craft-guard/references/detectors.md +124 -0
  29. package/skills/design-baseline/SKILL.md +134 -0
  30. package/skills/design-baseline/agents/openai.yaml +4 -0
  31. package/skills/design-baseline/references/design-template.md +73 -0
  32. package/skills/design-baseline/references/extraction-guidance.md +39 -0
  33. package/skills/design-baseline/scripts/design_baseline.py +780 -0
  34. package/skills/design-playbook/SKILL.md +219 -0
  35. package/skills/native-craft/SKILL.md +59 -0
  36. package/skills/native-craft/references/native-feel.md +79 -0
  37. package/skills/reference-intake/SKILL.md +86 -0
  38. package/skills/reference-intake/references/contract-template.md +82 -0
  39. package/skills/ui-evaluator/SKILL.md +110 -0
  40. package/skills/ui-evaluator/references/rubric.md +45 -0
  41. package/skills/ui-picker/SKILL.md +63 -0
  42. package/skills/ui-picker/references/components.md +31 -0
  43. package/skills/ui-picker/references/design.md +21 -0
  44. package/skills/ui-picker/references/domain.md +26 -0
  45. package/skills/ui-picker/references/template.md +24 -0
  46. package/skills/ux-spec/SKILL.md +51 -0
  47. package/skills/ux-spec/references/spec-template.md +43 -0
@@ -0,0 +1,630 @@
1
+ #!/usr/bin/env python3
2
+ """Process-boundary tests for the preview MCP stdio transport."""
3
+ from __future__ import annotations
4
+
5
+ import importlib.util
6
+ import json
7
+ import re
8
+ import socket
9
+ import subprocess
10
+ import sys
11
+ import tempfile
12
+ import threading
13
+ import time
14
+ import unittest
15
+ from pathlib import Path
16
+ from unittest import mock
17
+
18
+ # Sibling modules live next to this file. pytest's default prepend mode only
19
+ # puts this dir on sys.path[0] when it has no __init__.py; mcp/preview/ is now
20
+ # a package (see __init__.py) so the two same-named test_server_stdio.py files
21
+ # in preview/ and evidence/ collect under package-qualified names without an
22
+ # import-mismatch — so make the sibling dir importable explicitly here.
23
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
24
+ # Browser behavior is tested through its owning adapter, not server re-exports.
25
+ import browser # noqa: E402
26
+ import transaction # noqa: E402
27
+
28
+
29
+ SERVER = Path(__file__).with_name("server.py")
30
+
31
+
32
+ # G5: the parent page embeds a one-time dpb_token + dpb_round as hidden fields.
33
+ # Tests that POST /decide must GET / first and lift them — the same path a real
34
+ # human submit takes through the trusted control form (not a forged fetch).
35
+ _TOKEN_RE = re.compile(r'name="dpb_token"\s+value="([^"]*)"')
36
+ _ROUND_RE = re.compile(r'name="dpb_round"\s+value="([^"]*)"')
37
+
38
+
39
+ def _fetch_decision_token(port: int, timeout: float = 3.0) -> tuple[str, int]:
40
+ """GET / and extract the one-time dpb_token + dpb_round from the parent form."""
41
+ deadline = time.time() + 5
42
+ while time.time() < deadline:
43
+ try:
44
+ with socket.create_connection(("127.0.0.1", port), timeout=timeout) as sock:
45
+ sock.sendall(
46
+ f"GET / HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\n"
47
+ "Connection: close\r\n\r\n".encode("ascii")
48
+ )
49
+ chunks: list[bytes] = []
50
+ while True:
51
+ data = sock.recv(65536)
52
+ if not data:
53
+ break
54
+ chunks.append(data)
55
+ body = b"".join(chunks).decode("utf-8", errors="replace")
56
+ m_tok = _TOKEN_RE.search(body)
57
+ m_round = _ROUND_RE.search(body)
58
+ if m_tok and m_round:
59
+ return m_tok.group(1), int(m_round.group(1))
60
+ except OSError:
61
+ pass
62
+ time.sleep(0.02)
63
+ raise AssertionError("could not fetch dpb_token/dpb_round from parent page")
64
+
65
+
66
+ def _load_server_module():
67
+ spec = importlib.util.spec_from_file_location("dpb_preview_server", SERVER)
68
+ assert spec is not None and spec.loader is not None
69
+ mod = importlib.util.module_from_spec(spec)
70
+ spec.loader.exec_module(mod)
71
+ return mod
72
+
73
+
74
+ class PreviewMcpStdioTests(unittest.TestCase):
75
+ def test_claude_code_newline_json_can_initialize_and_list_tools(self) -> None:
76
+ requests = [
77
+ {
78
+ "jsonrpc": "2.0",
79
+ "id": 1,
80
+ "method": "initialize",
81
+ "params": {"protocolVersion": "2025-06-18"},
82
+ },
83
+ {"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {}},
84
+ ]
85
+ wire_input = "".join(
86
+ json.dumps(request, ensure_ascii=False) + "\n" for request in requests
87
+ )
88
+
89
+ completed = subprocess.run(
90
+ [sys.executable, str(SERVER)],
91
+ input=wire_input,
92
+ text=True,
93
+ encoding="utf-8",
94
+ capture_output=True,
95
+ timeout=5,
96
+ check=False,
97
+ )
98
+
99
+ self.assertEqual(completed.returncode, 0, completed.stderr)
100
+ responses = [
101
+ json.loads(line)
102
+ for line in completed.stdout.splitlines()
103
+ if line.strip()
104
+ ]
105
+ self.assertEqual([response["id"] for response in responses], [1, 2])
106
+ self.assertEqual(
107
+ responses[0]["result"]["serverInfo"]["name"],
108
+ "design-playbook-preview",
109
+ )
110
+ self.assertEqual(
111
+ [tool["name"] for tool in responses[1]["result"]["tools"]],
112
+ ["preview_prototype"],
113
+ )
114
+
115
+ def test_active_lock_returns_structured_error_over_stdio(self) -> None:
116
+ html = "<html><body>locked</body></html>"
117
+ options = ["确认通过", "需要修改"]
118
+ binding = transaction._binding(
119
+ round_n=1,
120
+ prototype_hash=transaction.prototype_html_digest(html.encode("utf-8")),
121
+ report_ref="report.md", summary="review", options=options,
122
+ )
123
+ with tempfile.TemporaryDirectory() as tmp:
124
+ preview = Path(tmp) / ".scratch" / "preview-adapter" / "preview"
125
+ preview.mkdir(parents=True)
126
+ lock = preview / "decision-round-1.lock"
127
+ lock.write_text(json.dumps({
128
+ "owner_id": "active", "decision_id": "existing-id",
129
+ "binding_digest": binding["digest"], "heartbeat": time.time(),
130
+ }), encoding="utf-8")
131
+ request = {
132
+ "jsonrpc": "2.0", "id": 1, "method": "tools/call",
133
+ "params": {
134
+ "name": "preview_prototype",
135
+ "arguments": {
136
+ "html": html, "summary": "review", "round": 1,
137
+ "report_ref": "report.md", "options": options,
138
+ },
139
+ },
140
+ }
141
+ completed = subprocess.run(
142
+ [sys.executable, str(SERVER)],
143
+ input=json.dumps(request, ensure_ascii=False) + "\n",
144
+ text=True, encoding="utf-8", capture_output=True,
145
+ cwd=tmp, timeout=5, check=False,
146
+ )
147
+
148
+ self.assertEqual(completed.returncode, 0, completed.stderr)
149
+ result = json.loads(completed.stdout)["result"]
150
+ self.assertTrue(result["isError"])
151
+ self.assertIn("already active", result["content"][0]["text"])
152
+ self.assertEqual(result["structuredContent"]["round"], 1)
153
+ self.assertEqual(result["structuredContent"]["decision_id"], "existing-id")
154
+ self.assertTrue(result["structuredContent"]["retryable"])
155
+
156
+
157
+ class PreviewStructuredErrorTests(unittest.TestCase):
158
+ def test_handler_maps_recovery_error_to_structured_tool_error(self) -> None:
159
+ server_mod = _load_server_module()
160
+ domain_error = transaction.PreviewTransactionError(
161
+ "repair required", retryable=True, round_n=2,
162
+ decision_id="abc", artifact="decision-round-2.json",
163
+ )
164
+ with mock.patch.object(
165
+ server_mod, "run_preview_transaction", side_effect=domain_error
166
+ ):
167
+ with self.assertRaises(server_mod.ToolError) as caught:
168
+ server_mod.handle_preview_prototype({
169
+ "html": "<html></html>", "summary": "review", "round": 2,
170
+ "report_ref": "report.md",
171
+ })
172
+
173
+ self.assertEqual(str(caught.exception), "repair required")
174
+ self.assertEqual(caught.exception.structured_content, domain_error.details)
175
+
176
+
177
+ class PreviewWindowTests(unittest.TestCase):
178
+ def test_open_preview_window_uses_centered_app_window_not_fullscreen(self) -> None:
179
+ fake_proc = mock.Mock(pid=4242)
180
+ with mock.patch.object(browser, "_screen_size", return_value=(1920, 1080)), mock.patch.object(
181
+ browser, "_browser_candidates", return_value=["browser.exe"]
182
+ ), mock.patch.object(browser.tempfile, "mkdtemp", return_value="profile-dir"), mock.patch.object(
183
+ browser.subprocess, "Popen", return_value=fake_proc
184
+ ) as popen:
185
+ proc, profile = browser._open_preview_window(
186
+ "http://127.0.0.1:4321/", width=1100, height=780
187
+ )
188
+
189
+ self.assertIs(proc, fake_proc)
190
+ self.assertEqual(profile, "profile-dir")
191
+ command = popen.call_args.args[0]
192
+ self.assertIn("--app=http://127.0.0.1:4321/", command)
193
+ self.assertIn("--window-size=1100,780", command)
194
+ self.assertIn("--window-position=410,150", command)
195
+ self.assertNotIn("--start-maximized", command)
196
+ self.assertNotIn("--start-fullscreen", command)
197
+ self.assertNotIn("--kiosk", command)
198
+
199
+
200
+ class PreviewCollectShutdownTests(unittest.TestCase):
201
+ def test_collect_returns_when_client_keeps_connection_open(self) -> None:
202
+ """POST /decide must not hang MCP on HTTP keep-alive (dogfood 006 hang)."""
203
+ port_box: dict[str, int] = {}
204
+ sticky_done = threading.Event()
205
+
206
+ def sticky_client() -> None:
207
+ deadline = time.time() + 5
208
+ port = None
209
+ while time.time() < deadline:
210
+ port = port_box.get("port")
211
+ if port:
212
+ break
213
+ time.sleep(0.02)
214
+ if not port:
215
+ sticky_done.set()
216
+ return
217
+ from urllib.parse import quote
218
+
219
+ token, round_n = _fetch_decision_token(port)
220
+ body = (
221
+ "choice=%E7%A1%AE%E8%AE%A4%E9%80%9A%E8%BF%87"
222
+ "&feedback="
223
+ "&anchors_json=%5B%5D"
224
+ + f"&dpb_token={quote(token)}"
225
+ + f"&dpb_round={round_n}"
226
+ )
227
+ payload = body.encode("ascii")
228
+ req = (
229
+ f"POST /decide HTTP/1.1\r\n"
230
+ f"Host: 127.0.0.1:{port}\r\n"
231
+ f"Content-Type: application/x-www-form-urlencoded\r\n"
232
+ f"Content-Length: {len(payload)}\r\n"
233
+ f"Connection: keep-alive\r\n"
234
+ f"\r\n"
235
+ ).encode("ascii") + payload
236
+ sock = socket.create_connection(("127.0.0.1", port), timeout=3)
237
+ try:
238
+ sock.sendall(req)
239
+ sock.settimeout(3)
240
+ try:
241
+ data = sock.recv(65536)
242
+ # Response must request close so Chromium cannot pin serve_forever.
243
+ self.assertIn(b"Connection: close", data)
244
+ except socket.timeout:
245
+ pass
246
+ time.sleep(3)
247
+ finally:
248
+ try:
249
+ sock.close()
250
+ except OSError:
251
+ pass
252
+ sticky_done.set()
253
+
254
+ real_http = browser.HTTPServer
255
+
256
+ class StashingHTTPServer(real_http): # type: ignore[misc, valid-type]
257
+ def __init__(self, *args, **kwargs):
258
+ super().__init__(*args, **kwargs)
259
+ port_box["port"] = self.server_address[1]
260
+
261
+ client_thread = threading.Thread(target=sticky_client, daemon=True)
262
+ client_thread.start()
263
+
264
+ with tempfile.TemporaryDirectory() as tmp:
265
+ proto = Path(tmp) / "proto.html"
266
+ proto.write_text(
267
+ "<html><body><h1>proto</h1></body></html>",
268
+ encoding="utf-8",
269
+ )
270
+ with mock.patch.object(browser, "HTTPServer", StashingHTTPServer), mock.patch.object(
271
+ browser, "_open_preview_window", return_value=(None, None)
272
+ ), mock.patch.object(browser, "_request_browser_window_close") as close_window, mock.patch.object(
273
+ browser, "_kill_browser_proc"
274
+ ) as kill_browser:
275
+ started = time.monotonic()
276
+ decision = browser._collect_via_browser(
277
+ proto,
278
+ "summary for test",
279
+ ["\u786e\u8ba4\u901a\u8fc7", "\u9700\u8981\u4fee\u6539"],
280
+ 1,
281
+ )
282
+ elapsed = time.monotonic() - started
283
+
284
+ close_window.assert_called_once_with(None)
285
+ kill_browser.assert_called_once_with(None, None)
286
+ sticky_done.wait(timeout=5)
287
+ self.assertLess(
288
+ elapsed,
289
+ 2.5,
290
+ f"collect hung under keep-alive client: {elapsed:.2f}s",
291
+ )
292
+ self.assertEqual(decision["choice"], "确认通过")
293
+ self.assertEqual(decision["feedback"], "")
294
+ self.assertFalse(decision["aborted"])
295
+
296
+ def test_modify_submission_returns_dom_anchor_and_closes_owned_window(self) -> None:
297
+ port_box: dict[str, int] = {}
298
+ anchor = {"selector": "#submit", "comment": "\u6309\u94ae\u5c42\u7ea7\u4e0d\u6e05\u6670", "label": "Retry", "tag": ""}
299
+
300
+ def submit_modify() -> None:
301
+ from urllib.parse import quote
302
+
303
+ deadline = time.time() + 5
304
+ while time.time() < deadline and not port_box.get("port"):
305
+ time.sleep(0.02)
306
+ port = port_box["port"]
307
+ token, round_n = _fetch_decision_token(port)
308
+ body = (
309
+ "choice=%E9%9C%80%E8%A6%81%E4%BF%AE%E6%94%B9"
310
+ "&feedback=%E8%AF%B7%E8%B0%83%E6%95%B4"
311
+ "&anchors_json="
312
+ + quote(json.dumps([anchor], ensure_ascii=False))
313
+ + f"&dpb_token={quote(token)}"
314
+ + f"&dpb_round={round_n}"
315
+ )
316
+ payload = body.encode("ascii")
317
+ request = (
318
+ f"POST /decide HTTP/1.1\r\n"
319
+ f"Host: 127.0.0.1:{port}\r\n"
320
+ "Content-Type: application/x-www-form-urlencoded\r\n"
321
+ f"Content-Length: {len(payload)}\r\n"
322
+ "Connection: close\r\n\r\n"
323
+ ).encode("ascii") + payload
324
+ with socket.create_connection(("127.0.0.1", port), timeout=3) as sock:
325
+ sock.sendall(request)
326
+ sock.recv(65536)
327
+
328
+ real_http = browser.HTTPServer
329
+
330
+ class StashingHTTPServer(real_http): # type: ignore[misc, valid-type]
331
+ def __init__(self, *args, **kwargs):
332
+ super().__init__(*args, **kwargs)
333
+ port_box["port"] = self.server_address[1]
334
+
335
+ client_thread = threading.Thread(target=submit_modify, daemon=True)
336
+ client_thread.start()
337
+ with tempfile.TemporaryDirectory() as tmp:
338
+ proto = Path(tmp) / "proto.html"
339
+ proto.write_text(
340
+ "<html><body><button id='submit'>Retry</button></body></html>",
341
+ encoding="utf-8",
342
+ )
343
+ with mock.patch.object(browser, "HTTPServer", StashingHTTPServer), mock.patch.object(
344
+ browser, "_open_preview_window", return_value=(mock.sentinel.proc, "profile")
345
+ ), mock.patch.object(browser, "_request_browser_window_close") as close_window, mock.patch.object(
346
+ browser, "_kill_browser_proc"
347
+ ) as kill_browser, mock.patch.object(browser, "_rm_tree"):
348
+ decision = browser._collect_via_browser(
349
+ proto, "summary", ["\u786e\u8ba4\u901a\u8fc7", "\u9700\u8981\u4fee\u6539"], 1
350
+ )
351
+
352
+ client_thread.join(timeout=3)
353
+ self.assertFalse(client_thread.is_alive())
354
+ self.assertEqual(decision["choice"], "\u9700\u8981\u4fee\u6539")
355
+ self.assertEqual(decision["feedback"], "\u8bf7\u8c03\u6574")
356
+ self.assertEqual(decision["anchors"], [anchor])
357
+ self.assertFalse(decision["aborted"])
358
+ close_window.assert_called_once_with(mock.sentinel.proc)
359
+ kill_browser.assert_called_once_with(mock.sentinel.proc, "profile")
360
+
361
+ def test_post_with_bogus_token_is_rejected(self) -> None:
362
+ """G5 stdio e2e: a forged POST carrying an arbitrary dpb_token (which
363
+ an attacker controls in the POST body) must NOT abort the session -
364
+ only a genuinely validated decision ends it. The bogus POST is fail
365
+ closed (confirmed=False), then the real user's valid-token POST still
366
+ confirms, proving the session was not hijacked.
367
+ """
368
+ port_box: dict[str, int] = {}
369
+
370
+ def bogus_then_valid_client() -> None:
371
+ deadline = time.time() + 5
372
+ port = None
373
+ while time.time() < deadline:
374
+ port = port_box.get("port")
375
+ if port:
376
+ break
377
+ time.sleep(0.02)
378
+ if not port:
379
+ return
380
+ from urllib.parse import quote
381
+
382
+ # Lift the real token + round from the parent page.
383
+ token, round_n = _fetch_decision_token(port)
384
+ # 1) Forged POST: attacker-controlled arbitrary dpb_token. Must be
385
+ # fail closed AND must not end the session.
386
+ forged_body = (
387
+ "choice=%E7%A1%AE%E8%AE%A4%E9%80%9A%E8%BF%87"
388
+ "&feedback=forged"
389
+ "&anchors_json=%5B%5D"
390
+ "&dpb_token=bogus-not-the-real-token"
391
+ + f"&dpb_round={round_n}"
392
+ )
393
+ for body in (forged_body,):
394
+ payload = body.encode("ascii")
395
+ req = (
396
+ f"POST /decide HTTP/1.1\r\n"
397
+ f"Host: 127.0.0.1:{port}\r\n"
398
+ "Content-Type: application/x-www-form-urlencoded\r\n"
399
+ f"Content-Length: {len(payload)}\r\n"
400
+ "Connection: close\r\n\r\n"
401
+ ).encode("ascii") + payload
402
+ with socket.create_connection(("127.0.0.1", port), timeout=3) as sock:
403
+ sock.sendall(req)
404
+ sock.settimeout(3)
405
+ try:
406
+ sock.recv(65536)
407
+ except socket.timeout:
408
+ pass
409
+ # 2) Real user's valid-token POST: must still confirm (session was
410
+ # not hijacked by the forged POST).
411
+ valid_body = (
412
+ "choice=%E7%A1%AE%E8%AE%A4%E9%80%9A%E8%BF%87"
413
+ "&feedback=ok"
414
+ "&anchors_json=%5B%5D"
415
+ + f"&dpb_token={quote(token)}"
416
+ + f"&dpb_round={round_n}"
417
+ )
418
+ payload = valid_body.encode("ascii")
419
+ req = (
420
+ f"POST /decide HTTP/1.1\r\n"
421
+ f"Host: 127.0.0.1:{port}\r\n"
422
+ "Content-Type: application/x-www-form-urlencoded\r\n"
423
+ f"Content-Length: {len(payload)}\r\n"
424
+ "Connection: close\r\n\r\n"
425
+ ).encode("ascii") + payload
426
+ with socket.create_connection(("127.0.0.1", port), timeout=3) as sock:
427
+ sock.sendall(req)
428
+ sock.settimeout(3)
429
+ try:
430
+ sock.recv(65536)
431
+ except socket.timeout:
432
+ pass
433
+
434
+ real_http = browser.HTTPServer
435
+
436
+ class StashingHTTPServer(real_http): # type: ignore[misc, valid-type]
437
+ def __init__(self, *args, **kwargs):
438
+ super().__init__(*args, **kwargs)
439
+ port_box["port"] = self.server_address[1]
440
+
441
+ client_thread = threading.Thread(target=bogus_then_valid_client, daemon=True)
442
+ client_thread.start()
443
+ with tempfile.TemporaryDirectory() as tmp:
444
+ proto = Path(tmp) / "proto.html"
445
+ proto.write_text(
446
+ "<html><body><h1>proto</h1></body></html>",
447
+ encoding="utf-8",
448
+ )
449
+ with mock.patch.object(browser, "HTTPServer", StashingHTTPServer), mock.patch.object(
450
+ browser, "_open_preview_window", return_value=(None, None)
451
+ ), mock.patch.object(browser, "_request_browser_window_close"), mock.patch.object(
452
+ browser, "_kill_browser_proc"
453
+ ):
454
+ decision = browser._collect_via_browser(
455
+ proto,
456
+ "summary",
457
+ ["确认通过", "需要修改"],
458
+ 1,
459
+ )
460
+
461
+ client_thread.join(timeout=3)
462
+ self.assertFalse(client_thread.is_alive())
463
+ # The forged POST did not hijack: the real user's valid POST wins.
464
+ self.assertEqual(decision["choice"], "确认通过")
465
+ self.assertEqual(decision["feedback"], "ok")
466
+ self.assertFalse(decision["aborted"])
467
+ def test_replay_same_token_is_rejected(self) -> None:
468
+ """G5 stdio e2e: first-decision-wins. The first valid POST locks the
469
+ session and sets the confirmed result; a replayed second POST with
470
+ the same token must NOT overwrite that result - the legitimate
471
+ confirmed decision survives.
472
+ """
473
+ port_box: dict[str, int] = {}
474
+
475
+ def replay_client() -> None:
476
+ deadline = time.time() + 5
477
+ port = None
478
+ while time.time() < deadline:
479
+ port = port_box.get("port")
480
+ if port:
481
+ break
482
+ time.sleep(0.02)
483
+ if not port:
484
+ return
485
+ from urllib.parse import quote
486
+
487
+ token, round_n = _fetch_decision_token(port)
488
+ body = (
489
+ "choice=%E7%A1%AE%E8%AE%A4%E9%80%9A%E8%BF%87"
490
+ "&feedback=ok"
491
+ "&anchors_json=%5B%5D"
492
+ + f"&dpb_token={quote(token)}"
493
+ + f"&dpb_round={round_n}"
494
+ )
495
+ payload = body.encode("ascii")
496
+ req = (
497
+ f"POST /decide HTTP/1.1\r\n"
498
+ f"Host: 127.0.0.1:{port}\r\n"
499
+ "Content-Type: application/x-www-form-urlencoded\r\n"
500
+ f"Content-Length: {len(payload)}\r\n"
501
+ "Connection: close\r\n\r\n"
502
+ ).encode("ascii") + payload
503
+ # POST 1: first valid use of the token - locks the session and
504
+ # ends it (done.set()). result is confirmed=True.
505
+ with socket.create_connection(("127.0.0.1", port), timeout=3) as sock:
506
+ sock.sendall(req)
507
+ sock.settimeout(3)
508
+ try:
509
+ sock.recv(65536)
510
+ except socket.timeout:
511
+ pass
512
+ # POST 2: same token - rejected as reuse by the session. It must
513
+ # not change the confirmed result already set by POST 1. (This
514
+ # POST may or may not be processed before shutdown; the assertion
515
+ # is that result stays confirmed regardless.)
516
+ try:
517
+ with socket.create_connection(("127.0.0.1", port), timeout=1) as sock:
518
+ sock.sendall(req)
519
+ sock.settimeout(1)
520
+ try:
521
+ sock.recv(65536)
522
+ except socket.timeout:
523
+ pass
524
+ except OSError:
525
+ # Server already shut down after POST 1 - acceptable; the
526
+ # replay never reached the handler, result is intact.
527
+ pass
528
+
529
+ real_http = browser.HTTPServer
530
+
531
+ class StashingHTTPServer(real_http): # type: ignore[misc, valid-type]
532
+ def __init__(self, *args, **kwargs):
533
+ super().__init__(*args, **kwargs)
534
+ port_box["port"] = self.server_address[1]
535
+
536
+ client_thread = threading.Thread(target=replay_client, daemon=True)
537
+ client_thread.start()
538
+ with tempfile.TemporaryDirectory() as tmp:
539
+ proto = Path(tmp) / "proto.html"
540
+ proto.write_text(
541
+ "<html><body><h1>proto</h1></body></html>",
542
+ encoding="utf-8",
543
+ )
544
+ with mock.patch.object(browser, "HTTPServer", StashingHTTPServer), mock.patch.object(
545
+ browser, "_open_preview_window", return_value=(None, None)
546
+ ), mock.patch.object(browser, "_request_browser_window_close"), mock.patch.object(
547
+ browser, "_kill_browser_proc"
548
+ ):
549
+ decision = browser._collect_via_browser(
550
+ proto,
551
+ "summary",
552
+ ["确认通过", "需要修改"],
553
+ 1,
554
+ )
555
+
556
+ client_thread.join(timeout=3)
557
+ self.assertFalse(client_thread.is_alive())
558
+ # First-decision-wins: legitimate submission survives replay.
559
+ self.assertEqual(decision["choice"], "确认通过")
560
+ self.assertEqual(decision["feedback"], "ok")
561
+ self.assertFalse(decision["aborted"])
562
+
563
+ def test_stop_http_server_joins_serve_thread(self) -> None:
564
+ http = browser.HTTPServer(("127.0.0.1", 0), browser.BaseHTTPRequestHandler)
565
+ serve_thread = threading.Thread(target=http.serve_forever, daemon=True)
566
+ serve_thread.start()
567
+
568
+ browser._stop_http_server(http, serve_thread, timeout_s=0.4)
569
+
570
+ self.assertFalse(serve_thread.is_alive())
571
+
572
+
573
+ class PreviewLogRejectionTests(unittest.TestCase):
574
+ """LOW-4 (secure-ship-0.4.4): a rejected decision's rejection reason
575
+ must persist to preview_dir/log.md so a fail-closed G5 event (forged
576
+ token / replay / round mismatch) is auditable on disk, not just in the
577
+ in-memory MCP payload that vanishes when the call returns.
578
+ """
579
+
580
+ def _run_handle(self, server_mod: object, tmp: str, decision: dict) -> dict:
581
+ proto = Path(tmp) / "proto.html"
582
+ proto.write_text("<html></html>", encoding="utf-8")
583
+ with mock.patch.object(browser, "_collect_via_browser",
584
+ return_value=decision):
585
+ return server_mod.handle_preview_prototype(
586
+ {
587
+ "path": str(proto),
588
+ "summary": "summary",
589
+ "round": 1,
590
+ "report_ref": "report-1",
591
+ }
592
+ )
593
+
594
+ def test_rejected_decision_writes_rejection_line_to_log(self) -> None:
595
+ server_mod = _load_server_module()
596
+ rejected_decision = {
597
+ "choice": "",
598
+ "feedback": "forged",
599
+ "aborted": True,
600
+ "anchors": [],
601
+ "rejected": True,
602
+ "rejection": "invalid_token",
603
+ }
604
+ with tempfile.TemporaryDirectory() as tmp:
605
+ payload = self._run_handle(server_mod, tmp, rejected_decision)
606
+ log_text = (Path(tmp) / "log.md").read_text(encoding="utf-8")
607
+
608
+ self.assertFalse(payload["confirmed"])
609
+ self.assertIn("- rejected: true", log_text)
610
+ self.assertIn("- rejection: invalid_token", log_text)
611
+
612
+ def test_confirmed_decision_does_not_write_rejection_line(self) -> None:
613
+ server_mod = _load_server_module()
614
+ confirmed_decision = {
615
+ "choice": "确认通过",
616
+ "feedback": "ok",
617
+ "aborted": False,
618
+ "anchors": [],
619
+ }
620
+ with tempfile.TemporaryDirectory() as tmp:
621
+ payload = self._run_handle(server_mod, tmp, confirmed_decision)
622
+ log_text = (Path(tmp) / "log.md").read_text(encoding="utf-8")
623
+
624
+ self.assertTrue(payload["confirmed"])
625
+ self.assertNotIn("rejected:", log_text)
626
+ self.assertNotIn("rejection:", log_text)
627
+
628
+
629
+ if __name__ == "__main__":
630
+ unittest.main()