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.
- package/LICENSE +28 -0
- package/NOTICE +37 -0
- package/README.md +143 -0
- package/commands/design-io.md +8 -0
- package/commands/ui-review.md +8 -0
- package/commands/ux-spec.md +8 -0
- package/mcp/__init__.py +0 -0
- package/mcp/_transport.py +242 -0
- package/mcp/evidence/README.md +40 -0
- package/mcp/evidence/__init__.py +0 -0
- package/mcp/evidence/server.py +450 -0
- package/mcp/evidence/test_server_stdio.py +645 -0
- package/mcp/preview/__init__.py +0 -0
- package/mcp/preview/browser.py +661 -0
- package/mcp/preview/confirm.py +255 -0
- package/mcp/preview/control.py +1293 -0
- package/mcp/preview/i18n.py +162 -0
- package/mcp/preview/server.py +126 -0
- package/mcp/preview/test_browser_control.py +663 -0
- package/mcp/preview/test_server_stdio.py +630 -0
- package/mcp/preview/test_transaction.py +436 -0
- package/mcp/preview/transaction.py +536 -0
- package/mcp/preview/util.py +19 -0
- package/mcp/test_transport.py +39 -0
- package/package.json +42 -0
- package/skills/craft-guard/SKILL.md +59 -0
- package/skills/craft-guard/references/craft.md +29 -0
- package/skills/craft-guard/references/detectors.md +124 -0
- package/skills/design-baseline/SKILL.md +134 -0
- package/skills/design-baseline/agents/openai.yaml +4 -0
- package/skills/design-baseline/references/design-template.md +73 -0
- package/skills/design-baseline/references/extraction-guidance.md +39 -0
- package/skills/design-baseline/scripts/design_baseline.py +780 -0
- package/skills/design-playbook/SKILL.md +219 -0
- package/skills/native-craft/SKILL.md +59 -0
- package/skills/native-craft/references/native-feel.md +79 -0
- package/skills/reference-intake/SKILL.md +86 -0
- package/skills/reference-intake/references/contract-template.md +82 -0
- package/skills/ui-evaluator/SKILL.md +110 -0
- package/skills/ui-evaluator/references/rubric.md +45 -0
- package/skills/ui-picker/SKILL.md +63 -0
- package/skills/ui-picker/references/components.md +31 -0
- package/skills/ui-picker/references/design.md +21 -0
- package/skills/ui-picker/references/domain.md +26 -0
- package/skills/ui-picker/references/template.md +24 -0
- package/skills/ux-spec/SKILL.md +51 -0
- package/skills/ux-spec/references/spec-template.md +43 -0
|
@@ -0,0 +1,661 @@
|
|
|
1
|
+
"""Owned-Chromium preview window + local HTTP decide form.
|
|
2
|
+
|
|
3
|
+
Opens a centered app window, serves the prototype and trusted control bar,
|
|
4
|
+
authenticates one raw submission, and tears down browser and server without
|
|
5
|
+
hanging on keep-alive sockets. Decision authority and floor logic belong to
|
|
6
|
+
transaction.py.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import ctypes
|
|
11
|
+
import html
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import re
|
|
15
|
+
import shutil
|
|
16
|
+
import subprocess
|
|
17
|
+
import sys
|
|
18
|
+
import tempfile
|
|
19
|
+
import threading
|
|
20
|
+
import webbrowser
|
|
21
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer as HTTPServer
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Any
|
|
24
|
+
from urllib.parse import parse_qs
|
|
25
|
+
|
|
26
|
+
from confirm import (
|
|
27
|
+
_DecisionSession,
|
|
28
|
+
_generate_decision_token,
|
|
29
|
+
prototype_html_digest,
|
|
30
|
+
)
|
|
31
|
+
from control import _build_control
|
|
32
|
+
from i18n import lang, t
|
|
33
|
+
from util import _log
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _screen_size() -> tuple[int, int]:
|
|
37
|
+
try:
|
|
38
|
+
import ctypes
|
|
39
|
+
user32 = ctypes.windll.user32 # type: ignore[attr-defined]
|
|
40
|
+
return int(user32.GetSystemMetrics(0)), int(user32.GetSystemMetrics(1))
|
|
41
|
+
except Exception: # noqa: BLE001
|
|
42
|
+
return 1440, 900
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _browser_candidates() -> list[str]:
|
|
47
|
+
found: list[str] = []
|
|
48
|
+
for env in ("DPB_PREVIEW_BROWSER", "CHROME_PATH", "EDGE_PATH"):
|
|
49
|
+
v = os.environ.get(env)
|
|
50
|
+
if v:
|
|
51
|
+
found.append(v)
|
|
52
|
+
for name in ("msedge", "chrome", "google-chrome", "chromium", "chromium-browser"):
|
|
53
|
+
w = shutil.which(name)
|
|
54
|
+
if w:
|
|
55
|
+
found.append(w)
|
|
56
|
+
roots = [
|
|
57
|
+
os.environ.get("ProgramFiles", r"C:\Program Files"),
|
|
58
|
+
os.environ.get("ProgramFiles(x86)", r"C:\Program Files (x86)"),
|
|
59
|
+
os.environ.get("LOCALAPPDATA", ""),
|
|
60
|
+
]
|
|
61
|
+
rels = [
|
|
62
|
+
("Microsoft", "Edge", "Application", "msedge.exe"),
|
|
63
|
+
("Google", "Chrome", "Application", "chrome.exe"),
|
|
64
|
+
("Microsoft", "Edge Beta", "Application", "msedge.exe"),
|
|
65
|
+
]
|
|
66
|
+
for root in roots:
|
|
67
|
+
if not root:
|
|
68
|
+
continue
|
|
69
|
+
for rel in rels:
|
|
70
|
+
found.append(str(Path(root).joinpath(*rel)))
|
|
71
|
+
out: list[str] = []
|
|
72
|
+
seen: set[str] = set()
|
|
73
|
+
for c in found:
|
|
74
|
+
key = c.lower()
|
|
75
|
+
if key in seen:
|
|
76
|
+
continue
|
|
77
|
+
seen.add(key)
|
|
78
|
+
if Path(c).is_file():
|
|
79
|
+
out.append(c)
|
|
80
|
+
return out
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _open_preview_window(url: str, *, width: int = 1100, height: int = 780):
|
|
85
|
+
"""Open a centered Chromium app window; fallback to default browser.
|
|
86
|
+
|
|
87
|
+
Returns (proc, profile_dir). profile_dir is a private user-data-dir so the
|
|
88
|
+
Chromium process stays owned by us and can be killed on submit (shared
|
|
89
|
+
profiles hand off to an existing browser and ignore terminate/window.close).
|
|
90
|
+
"""
|
|
91
|
+
sw, sh = _screen_size()
|
|
92
|
+
x = max(0, (sw - width) // 2)
|
|
93
|
+
y = max(0, (sh - height) // 2)
|
|
94
|
+
profile_dir = tempfile.mkdtemp(prefix="dpb-preview-")
|
|
95
|
+
args_tail = [
|
|
96
|
+
f"--app={url}",
|
|
97
|
+
f"--user-data-dir={profile_dir}",
|
|
98
|
+
f"--window-size={width},{height}",
|
|
99
|
+
f"--window-position={x},{y}",
|
|
100
|
+
"--new-window",
|
|
101
|
+
"--no-first-run",
|
|
102
|
+
"--no-default-browser-check",
|
|
103
|
+
"--disable-features=TranslateUI",
|
|
104
|
+
]
|
|
105
|
+
for exe in _browser_candidates():
|
|
106
|
+
try:
|
|
107
|
+
proc = subprocess.Popen(
|
|
108
|
+
[exe, *args_tail],
|
|
109
|
+
stdout=subprocess.DEVNULL,
|
|
110
|
+
stderr=subprocess.DEVNULL,
|
|
111
|
+
)
|
|
112
|
+
_log(
|
|
113
|
+
f"preview app window: {exe} pid={proc.pid} pos={x},{y} "
|
|
114
|
+
f"size={width}x{height} profile={profile_dir}"
|
|
115
|
+
)
|
|
116
|
+
return proc, profile_dir
|
|
117
|
+
except Exception as exc: # noqa: BLE001
|
|
118
|
+
_log(f"app window open failed ({exe}): {exc}")
|
|
119
|
+
try:
|
|
120
|
+
webbrowser.open(url)
|
|
121
|
+
_log("preview fallback: webbrowser.open")
|
|
122
|
+
except Exception as exc: # noqa: BLE001
|
|
123
|
+
_log(f"webbrowser.open failed: {exc}")
|
|
124
|
+
_rm_tree(profile_dir)
|
|
125
|
+
return None, None
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _kill_browser_proc(
|
|
130
|
+
proc: subprocess.Popen | None,
|
|
131
|
+
profile_dir: str | None = None,
|
|
132
|
+
) -> None:
|
|
133
|
+
"""Force-close the owned preview Chromium.
|
|
134
|
+
|
|
135
|
+
Chromium may exit the launcher PID and keep the app window under another
|
|
136
|
+
process that still holds --user-data-dir. Kill by PID tree first, then by
|
|
137
|
+
profile path in the command line.
|
|
138
|
+
"""
|
|
139
|
+
launcher_killed = False
|
|
140
|
+
if proc is not None and proc.poll() is None:
|
|
141
|
+
try:
|
|
142
|
+
if sys.platform == "win32":
|
|
143
|
+
completed = subprocess.run(
|
|
144
|
+
["taskkill", "/PID", str(proc.pid), "/T", "/F"],
|
|
145
|
+
stdout=subprocess.DEVNULL,
|
|
146
|
+
stderr=subprocess.DEVNULL,
|
|
147
|
+
check=False,
|
|
148
|
+
timeout=5,
|
|
149
|
+
)
|
|
150
|
+
launcher_killed = completed.returncode == 0
|
|
151
|
+
else:
|
|
152
|
+
proc.terminate()
|
|
153
|
+
try:
|
|
154
|
+
proc.wait(timeout=2)
|
|
155
|
+
except subprocess.TimeoutExpired:
|
|
156
|
+
proc.kill()
|
|
157
|
+
launcher_killed = True
|
|
158
|
+
except subprocess.TimeoutExpired:
|
|
159
|
+
_log("browser kill by pid timed out; trying profile fallback")
|
|
160
|
+
except Exception as exc: # noqa: BLE001
|
|
161
|
+
_log(f"browser kill by pid failed: {exc}")
|
|
162
|
+
|
|
163
|
+
if launcher_killed:
|
|
164
|
+
_log(f"browser kill by pid tree: {proc.pid}")
|
|
165
|
+
return
|
|
166
|
+
if not profile_dir:
|
|
167
|
+
return
|
|
168
|
+
try:
|
|
169
|
+
if sys.platform == "win32":
|
|
170
|
+
# Keep the marker out of PowerShell's own command line, otherwise
|
|
171
|
+
# the matcher can terminate itself before reaching Chromium.
|
|
172
|
+
ps = (
|
|
173
|
+
"$m=$env:DPB_PREVIEW_PROFILE;"
|
|
174
|
+
"Get-CimInstance Win32_Process | Where-Object {"
|
|
175
|
+
" $_.ProcessId -ne $PID -and $_.CommandLine -and "
|
|
176
|
+
" $_.CommandLine.IndexOf($m,[StringComparison]::OrdinalIgnoreCase) -ge 0"
|
|
177
|
+
"} | ForEach-Object {"
|
|
178
|
+
" Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue"
|
|
179
|
+
"}"
|
|
180
|
+
)
|
|
181
|
+
env = os.environ.copy()
|
|
182
|
+
# Match the unique leaf name because Chromium may expand an 8.3
|
|
183
|
+
# temp path (AMSTER~1) to its long form in the child command line.
|
|
184
|
+
env["DPB_PREVIEW_PROFILE"] = Path(profile_dir).name
|
|
185
|
+
subprocess.run(
|
|
186
|
+
[
|
|
187
|
+
"powershell",
|
|
188
|
+
"-NoProfile",
|
|
189
|
+
"-ExecutionPolicy",
|
|
190
|
+
"Bypass",
|
|
191
|
+
"-Command",
|
|
192
|
+
ps,
|
|
193
|
+
],
|
|
194
|
+
stdout=subprocess.DEVNULL,
|
|
195
|
+
stderr=subprocess.DEVNULL,
|
|
196
|
+
check=False,
|
|
197
|
+
env=env,
|
|
198
|
+
timeout=8,
|
|
199
|
+
)
|
|
200
|
+
else:
|
|
201
|
+
# pkill -f is common on mac/linux for matching cmdline
|
|
202
|
+
for pat in (str(Path(profile_dir).resolve()), profile_dir):
|
|
203
|
+
if not pat:
|
|
204
|
+
continue
|
|
205
|
+
subprocess.run(
|
|
206
|
+
["pkill", "-f", pat],
|
|
207
|
+
stdout=subprocess.DEVNULL,
|
|
208
|
+
stderr=subprocess.DEVNULL,
|
|
209
|
+
check=False,
|
|
210
|
+
)
|
|
211
|
+
_log(f"browser kill by profile: {profile_dir}")
|
|
212
|
+
except subprocess.TimeoutExpired:
|
|
213
|
+
_log(f"browser kill by profile timed out: {profile_dir}")
|
|
214
|
+
except Exception as exc: # noqa: BLE001
|
|
215
|
+
_log(f"browser kill by profile failed: {exc}")
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _request_browser_window_close(proc: subprocess.Popen | None) -> None:
|
|
220
|
+
"""Hide the owned app window synchronously before process cleanup."""
|
|
221
|
+
if proc is None or sys.platform != "win32":
|
|
222
|
+
return
|
|
223
|
+
try:
|
|
224
|
+
user32 = ctypes.windll.user32
|
|
225
|
+
target_pid = proc.pid
|
|
226
|
+
closed = 0
|
|
227
|
+
|
|
228
|
+
@ctypes.WINFUNCTYPE(ctypes.c_bool, ctypes.c_void_p, ctypes.c_void_p)
|
|
229
|
+
def close_if_owned(hwnd, _lparam):
|
|
230
|
+
nonlocal closed
|
|
231
|
+
pid = ctypes.c_ulong()
|
|
232
|
+
user32.GetWindowThreadProcessId(hwnd, ctypes.byref(pid))
|
|
233
|
+
if pid.value == target_pid and user32.IsWindowVisible(hwnd):
|
|
234
|
+
user32.ShowWindow(hwnd, 0) # SW_HIDE
|
|
235
|
+
closed += 1
|
|
236
|
+
return True
|
|
237
|
+
|
|
238
|
+
user32.EnumWindows(close_if_owned, 0)
|
|
239
|
+
if closed:
|
|
240
|
+
_log(f"browser window hidden: pid={target_pid} windows={closed}")
|
|
241
|
+
except Exception as exc: # noqa: BLE001
|
|
242
|
+
_log(f"browser window close failed: {exc}")
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _rm_tree(path: str | None) -> None:
|
|
247
|
+
if not path:
|
|
248
|
+
return
|
|
249
|
+
try:
|
|
250
|
+
shutil.rmtree(path, ignore_errors=True)
|
|
251
|
+
except Exception: # noqa: BLE001
|
|
252
|
+
pass
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _stop_http_server(
|
|
257
|
+
server: HTTPServer,
|
|
258
|
+
serve_thread: threading.Thread,
|
|
259
|
+
*,
|
|
260
|
+
timeout_s: float = 1.5,
|
|
261
|
+
) -> None:
|
|
262
|
+
"""Stop the threaded preview server and prove its serve loop exited."""
|
|
263
|
+
errors: list[str] = []
|
|
264
|
+
try:
|
|
265
|
+
server.shutdown()
|
|
266
|
+
except Exception as exc: # noqa: BLE001
|
|
267
|
+
errors.append(f"http shutdown failed: {exc}")
|
|
268
|
+
try:
|
|
269
|
+
server.server_close()
|
|
270
|
+
except Exception as exc: # noqa: BLE001
|
|
271
|
+
errors.append(f"http server_close failed: {exc}")
|
|
272
|
+
|
|
273
|
+
serve_thread.join(timeout=timeout_s)
|
|
274
|
+
if serve_thread.is_alive():
|
|
275
|
+
errors.append(f"http serve thread still alive after {timeout_s:.1f}s")
|
|
276
|
+
if errors:
|
|
277
|
+
message = "; ".join(errors)
|
|
278
|
+
_log(message)
|
|
279
|
+
raise RuntimeError(message)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _parse_anchors(raw: str) -> list[dict[str, Any]]:
|
|
284
|
+
if not raw or not raw.strip():
|
|
285
|
+
return []
|
|
286
|
+
try:
|
|
287
|
+
data = json.loads(raw)
|
|
288
|
+
except json.JSONDecodeError:
|
|
289
|
+
return []
|
|
290
|
+
if not isinstance(data, list):
|
|
291
|
+
return []
|
|
292
|
+
out: list[dict[str, Any]] = []
|
|
293
|
+
for item in data:
|
|
294
|
+
if not isinstance(item, dict):
|
|
295
|
+
continue
|
|
296
|
+
selector = str(item.get("selector") or "").strip()
|
|
297
|
+
if not selector:
|
|
298
|
+
continue
|
|
299
|
+
out.append({
|
|
300
|
+
"selector": selector,
|
|
301
|
+
"label": str(item.get("label") or "").strip()[:120],
|
|
302
|
+
"comment": str(item.get("comment") or "").strip()[:500],
|
|
303
|
+
"tag": str(item.get("tag") or "").strip()[:40],
|
|
304
|
+
})
|
|
305
|
+
return out[:40]
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _done_page_html() -> bytes:
|
|
310
|
+
# Owned Chromium is killed by the server after submit; JS is best-effort only.
|
|
311
|
+
# Use unique %markers + str.replace (not .format) so the CSS/JS braces don't
|
|
312
|
+
# need escaping.
|
|
313
|
+
html = """<!DOCTYPE html><html lang="%html_lang%"><head><meta charset="utf-8"/>
|
|
314
|
+
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
|
315
|
+
<title>%done_title%</title>
|
|
316
|
+
<style>
|
|
317
|
+
body{margin:0;min-height:100vh;display:grid;place-items:center;
|
|
318
|
+
font:14px/1.5 system-ui,-apple-system,"Segoe UI",sans-serif;
|
|
319
|
+
background:#0f1218;color:#e5e7eb}
|
|
320
|
+
.card{width:min(420px,92vw);padding:28px 24px;border-radius:14px;
|
|
321
|
+
background:#171b24;border:1px solid #2c3444;text-align:center}
|
|
322
|
+
h1{margin:0 0 8px;font-size:18px;font-weight:650;letter-spacing:-.02em}
|
|
323
|
+
p{margin:0;color:#9aa3b2;font-size:13px}
|
|
324
|
+
.ok{display:inline-flex;align-items:center;justify-content:center;
|
|
325
|
+
width:40px;height:40px;border-radius:999px;margin-bottom:14px;
|
|
326
|
+
background:rgba(20,184,166,.14);color:#5eead4;font-weight:700}
|
|
327
|
+
</style>
|
|
328
|
+
<script>
|
|
329
|
+
setTimeout(function () {
|
|
330
|
+
try { window.open("", "_self"); window.close(); } catch (e) {}
|
|
331
|
+
try { window.close(); } catch (e) {}
|
|
332
|
+
}, 200);
|
|
333
|
+
</script>
|
|
334
|
+
</head><body><div class="card">
|
|
335
|
+
<div class="ok" aria-hidden="true">OK</div>
|
|
336
|
+
<h1>%done_title%</h1><p>%done_body%</p>
|
|
337
|
+
</div></body></html>"""
|
|
338
|
+
return (html
|
|
339
|
+
.replace("%html_lang%", lang())
|
|
340
|
+
.replace("%done_title%", t("done_title"))
|
|
341
|
+
.replace("%done_body%", t("done_body"))).encode("utf-8")
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
# G5: the parent control form's opening tag — a stable hook for token
|
|
346
|
+
# injection. control.py owns the template; we only splice hidden fields in.
|
|
347
|
+
_FORM_MARKER = '<form method="POST" action="/decide" id="dpb-decide-form">'
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _inject_token_fields(control_html: str, token: str, round_n: int) -> str:
|
|
351
|
+
"""Insert hidden dpb_token + dpb_round fields into the control form (G5).
|
|
352
|
+
|
|
353
|
+
The token is the parent page's proof-of-origin; the round binds it to this
|
|
354
|
+
preview session. Spliced in post-template so control.py stays untouched
|
|
355
|
+
(sibling agents own its contents).
|
|
356
|
+
"""
|
|
357
|
+
safe_token = html.escape(token, quote=True)
|
|
358
|
+
fields = (
|
|
359
|
+
f'<input type="hidden" name="dpb_token" value="{safe_token}"/>'
|
|
360
|
+
f'<input type="hidden" name="dpb_round" value="{round_n}"/>'
|
|
361
|
+
)
|
|
362
|
+
if _FORM_MARKER in control_html:
|
|
363
|
+
return control_html.replace(_FORM_MARKER, _FORM_MARKER + fields, 1)
|
|
364
|
+
# Defensive fallback: anchor to any <form ...> open tag if the template
|
|
365
|
+
# marker ever moves. Lambda keeps the replacement literal (no backslash
|
|
366
|
+
# expansion of the HTML/JS payload).
|
|
367
|
+
return re.sub(
|
|
368
|
+
r"(<form\b[^>]*>)",
|
|
369
|
+
lambda m: m.group(1) + fields,
|
|
370
|
+
control_html,
|
|
371
|
+
count=1,
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
# pin-to-annotate postMessage bridge (G5 sandbox regression fix).
|
|
376
|
+
#
|
|
377
|
+
# G5 isolated the prototype inside <iframe sandbox="allow-scripts" srcdoc=...>
|
|
378
|
+
# with allow-same-origin DELIBERATELY omitted, so the iframe is an opaque
|
|
379
|
+
# origin and prototype scripts cannot reach the parent DOM (where the decision
|
|
380
|
+
# token lives). That broke pin-to-annotate: the parent's document.click +
|
|
381
|
+
# cssPath(e.target) can no longer see clicks inside the iframe or traverse the
|
|
382
|
+
# iframe DOM (cross-origin). This bridge runs INSIDE the iframe document and
|
|
383
|
+
# restores anchor collection by postMessaging {selector, tag} to the parent.
|
|
384
|
+
#
|
|
385
|
+
# G5 safety contract (verified by test_browser_control.PinAnnotationBridgeTests):
|
|
386
|
+
# - the bridge only postMessages anchor DATA ({selector, tag}) — it never
|
|
387
|
+
# reads parent.document, parent.location, the token, or storage, and it
|
|
388
|
+
# never fetches/XHRs. postMessage is its only outbound channel.
|
|
389
|
+
# - the parent records the anchor only while pin mode is on (control.py
|
|
390
|
+
# message listener filters on pinOn), so no pin-state sync is needed.
|
|
391
|
+
# - the iframe highlights the clicked element itself (dpb-pin-target) since
|
|
392
|
+
# the parent cannot reach into the iframe DOM to do it.
|
|
393
|
+
#
|
|
394
|
+
# Raw string + single braces: this is plain string concatenation (not .format),
|
|
395
|
+
# so JS braces stay literal (no {{ doubling). cssPath is a faithful copy of
|
|
396
|
+
# control.py's cssPath so selectors match the same-origin path.
|
|
397
|
+
BRIDGE_SCRIPT = r"""<script>
|
|
398
|
+
(function () {
|
|
399
|
+
// Inject the pin highlight CSS into the iframe document. The parent's
|
|
400
|
+
// control-bar stylesheet does not cross the iframe boundary, so the bridge
|
|
401
|
+
// brings its own copy of .dpb-pin-target / .dpb-pin-hover (the same rules
|
|
402
|
+
// control.py renders in the parent) to actually show the highlight in-frame.
|
|
403
|
+
var style = document.createElement("style");
|
|
404
|
+
style.textContent =
|
|
405
|
+
".dpb-pin-target{outline:1.5px solid rgba(20,184,166,.9)!important;" +
|
|
406
|
+
"outline-offset:1px!important;background-color:rgba(20,184,166,.06)!important;" +
|
|
407
|
+
"cursor:crosshair!important}" +
|
|
408
|
+
".dpb-pin-hover{outline:1px dashed rgba(20,184,166,.45)!important;" +
|
|
409
|
+
"outline-offset:1px!important}";
|
|
410
|
+
(document.head || document.documentElement).appendChild(style);
|
|
411
|
+
|
|
412
|
+
function cssPath(el) {
|
|
413
|
+
if (!el || el.nodeType !== 1) return "";
|
|
414
|
+
if (el.id) return "#" + CSS.escape(el.id);
|
|
415
|
+
var parts = [];
|
|
416
|
+
var cur = el;
|
|
417
|
+
var depth = 0;
|
|
418
|
+
while (cur && cur.nodeType === 1 && cur !== document.documentElement && depth < 8) {
|
|
419
|
+
if (cur.id === "dpb-preview-bar" || cur.id === "dpb-preview-spacer" || cur.id === "dpb-float-root") break;
|
|
420
|
+
var part = cur.tagName.toLowerCase();
|
|
421
|
+
if (cur.classList && cur.classList.length) {
|
|
422
|
+
var cls = Array.prototype.slice.call(cur.classList, 0, 2)
|
|
423
|
+
.filter(function (c) { return c && c.indexOf("dpb-") !== 0; })
|
|
424
|
+
.map(function (c) { return "." + CSS.escape(c); })
|
|
425
|
+
.join("");
|
|
426
|
+
part += cls;
|
|
427
|
+
}
|
|
428
|
+
var parent = cur.parentElement;
|
|
429
|
+
if (parent) {
|
|
430
|
+
var kids = parent.children;
|
|
431
|
+
var n = 0, idx = 0, i;
|
|
432
|
+
for (i = 0; i < kids.length; i++) {
|
|
433
|
+
if (kids[i].tagName === cur.tagName) {
|
|
434
|
+
n++;
|
|
435
|
+
if (kids[i] === cur) idx = n;
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
if (n > 1) part += ":nth-of-type(" + idx + ")";
|
|
439
|
+
}
|
|
440
|
+
parts.unshift(part);
|
|
441
|
+
if (cur.tagName === "BODY") break;
|
|
442
|
+
cur = parent;
|
|
443
|
+
depth++;
|
|
444
|
+
}
|
|
445
|
+
return parts.join(" > ");
|
|
446
|
+
}
|
|
447
|
+
var hoverEl = null;
|
|
448
|
+
function clearHover() {
|
|
449
|
+
if (hoverEl) {
|
|
450
|
+
hoverEl.classList.remove("dpb-pin-hover");
|
|
451
|
+
hoverEl = null;
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
document.addEventListener("mousemove", function (e) {
|
|
455
|
+
var el = e.target;
|
|
456
|
+
if (!el || el === document.body || el === document.documentElement) {
|
|
457
|
+
clearHover();
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
if (hoverEl !== el) {
|
|
461
|
+
clearHover();
|
|
462
|
+
hoverEl = el;
|
|
463
|
+
hoverEl.classList.add("dpb-pin-hover");
|
|
464
|
+
}
|
|
465
|
+
}, true);
|
|
466
|
+
document.addEventListener("click", function (e) {
|
|
467
|
+
var el = e.target;
|
|
468
|
+
if (!el || el === document.body || el === document.documentElement) return;
|
|
469
|
+
e.preventDefault();
|
|
470
|
+
e.stopPropagation();
|
|
471
|
+
var prev = document.querySelector(".dpb-pin-target");
|
|
472
|
+
if (prev && prev !== el) prev.classList.remove("dpb-pin-target");
|
|
473
|
+
el.classList.add("dpb-pin-target");
|
|
474
|
+
var selector = cssPath(el);
|
|
475
|
+
if (!selector) return;
|
|
476
|
+
parent.postMessage({ dpbPinAnchor: { selector: selector, tag: el.tagName.toLowerCase() } }, "*");
|
|
477
|
+
}, true);
|
|
478
|
+
})();
|
|
479
|
+
</script>"""
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def _build_parent_page(prototype_html: str, control_html: str) -> str:
|
|
483
|
+
"""Build the trusted parent document (G5 trust boundary).
|
|
484
|
+
|
|
485
|
+
The parent renders only the control bar; the prototype is isolated inside
|
|
486
|
+
``<iframe sandbox="allow-scripts" srcdoc="...">``. ``allow-same-origin`` is
|
|
487
|
+
deliberately omitted so the iframe is treated as a unique opaque origin and
|
|
488
|
+
prototype scripts cannot reach the parent DOM — where the one-time decision
|
|
489
|
+
token lives as a hidden form field.
|
|
490
|
+
|
|
491
|
+
The pin-to-annotate bridge (``BRIDGE_SCRIPT``) is appended to the prototype
|
|
492
|
+
BEFORE escaping so it executes inside the iframe document, where it can see
|
|
493
|
+
the prototype DOM. It captures clicks/hover, computes a cssPath selector
|
|
494
|
+
on its own side of the trust boundary, and postMessages ``{selector, tag}``
|
|
495
|
+
to the parent — restoring anchor collection that G5's cross-origin boundary
|
|
496
|
+
took away (the parent can no longer see iframe clicks or traverse iframe
|
|
497
|
+
DOM). The bridge never touches ``parent.document`` or the token; postMessage
|
|
498
|
+
is its only outbound channel (verified by test_browser_control).
|
|
499
|
+
"""
|
|
500
|
+
# html.escape neutralizes every </script> (and quote) in both the prototype
|
|
501
|
+
# and the bridge trailer to entity form inside the srcdoc ATTRIBUTE, so the
|
|
502
|
+
# prototype's own script boundaries cannot leak across and truncate the
|
|
503
|
+
# bridge. The browser decodes the entities when rendering the iframe
|
|
504
|
+
# document, restoring the original <script>...</script> blocks. This is the
|
|
505
|
+
# attribute-escaping context (safe); it is NOT the inline-<script> context
|
|
506
|
+
# where </script> would need splitting.
|
|
507
|
+
srcdoc = html.escape(prototype_html + BRIDGE_SCRIPT, quote=True)
|
|
508
|
+
# String concatenation (not .format): the CSS braces are literal here, and
|
|
509
|
+
# concatenation sidesteps the format()-on-HTML brace-escaping trap.
|
|
510
|
+
return (
|
|
511
|
+
'<!DOCTYPE html><html lang="' + lang() + '"><head>'
|
|
512
|
+
'<meta charset="utf-8"/>'
|
|
513
|
+
'<meta name="viewport" content="width=device-width, initial-scale=1"/>'
|
|
514
|
+
'<title>preview</title>'
|
|
515
|
+
"<style>"
|
|
516
|
+
"html,body{margin:0;padding:0;height:100%;background:#0f1218;}"
|
|
517
|
+
".dpb-proto-frame{position:fixed;inset:0;width:100%;height:100%;"
|
|
518
|
+
"border:0;background:#ffffff;}"
|
|
519
|
+
"</style></head><body>"
|
|
520
|
+
+ control_html
|
|
521
|
+
+ '<iframe class="dpb-proto-frame" sandbox="allow-scripts" srcdoc="'
|
|
522
|
+
+ srcdoc
|
|
523
|
+
+ '" title="prototype"></iframe>'
|
|
524
|
+
+ "</body></html>"
|
|
525
|
+
)
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def _collect_via_browser(
|
|
529
|
+
prototype: Path, summary: str, options: list[str],
|
|
530
|
+
round_n: int) -> dict[str, Any]:
|
|
531
|
+
"""Serve prototype + control form; block until user submits or aborts."""
|
|
532
|
+
result: dict[str, Any] = {
|
|
533
|
+
"choice": "",
|
|
534
|
+
"feedback": "",
|
|
535
|
+
"aborted": True,
|
|
536
|
+
"anchors": [],
|
|
537
|
+
}
|
|
538
|
+
done = threading.Event()
|
|
539
|
+
|
|
540
|
+
# TOCTOU fix: read bytes once, hash (LF-normalized), then decode for display
|
|
541
|
+
raw_bytes = prototype.read_bytes()
|
|
542
|
+
prototype_html_hash = prototype_html_digest(raw_bytes)
|
|
543
|
+
prototype_html = raw_bytes.decode("utf-8")
|
|
544
|
+
result["prototype_html_hash"] = prototype_html_hash
|
|
545
|
+
|
|
546
|
+
def with_prototype_hash(submission: dict[str, Any]) -> dict[str, Any]:
|
|
547
|
+
submission["prototype_html_hash"] = prototype_html_hash
|
|
548
|
+
return submission
|
|
549
|
+
|
|
550
|
+
control = _build_control(round_n, summary.strip(), options)
|
|
551
|
+
# G5 trust boundary: one-time token + first-decision-wins session. The
|
|
552
|
+
# token renders as a hidden field in the PARENT control form (trusted);
|
|
553
|
+
# the sandboxed prototype iframe cannot read it, so a forged
|
|
554
|
+
# fetch('/decide', ...) arrives without proof and fails closed.
|
|
555
|
+
token = _generate_decision_token()
|
|
556
|
+
control = _inject_token_fields(control, token, round_n)
|
|
557
|
+
page = _build_parent_page(prototype_html, control)
|
|
558
|
+
session = _DecisionSession(round_n, token)
|
|
559
|
+
|
|
560
|
+
class Handler(BaseHTTPRequestHandler):
|
|
561
|
+
def log_message(self, fmt: str, *args: Any) -> None: # noqa: A003
|
|
562
|
+
_log("http: " + (fmt % args))
|
|
563
|
+
|
|
564
|
+
def do_GET(self) -> None: # noqa: N802
|
|
565
|
+
if self.path not in ("/", "/index.html"):
|
|
566
|
+
self.send_error(404)
|
|
567
|
+
return
|
|
568
|
+
data = page.encode("utf-8")
|
|
569
|
+
self.close_connection = True
|
|
570
|
+
self.send_response(200)
|
|
571
|
+
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
572
|
+
self.send_header("Content-Length", str(len(data)))
|
|
573
|
+
self.send_header("Connection", "close")
|
|
574
|
+
self.end_headers()
|
|
575
|
+
self.wfile.write(data)
|
|
576
|
+
|
|
577
|
+
def do_POST(self) -> None: # noqa: N802
|
|
578
|
+
nonlocal result
|
|
579
|
+
length = int(self.headers.get("Content-Length", "0"))
|
|
580
|
+
body = self.rfile.read(length).decode("utf-8")
|
|
581
|
+
form = parse_qs(body)
|
|
582
|
+
choice = (form.get("choice") or ["__abort__"])[0]
|
|
583
|
+
feedback = (form.get("feedback") or [""])[0]
|
|
584
|
+
anchors = _parse_anchors((form.get("anchors_json") or ["[]"])[0])
|
|
585
|
+
# G5: validate the one-time decision token before trusting choice.
|
|
586
|
+
# A sandboxed prototype cannot read the hidden token, so a forged
|
|
587
|
+
# fetch('/decide', ...) arrives without it and fails closed.
|
|
588
|
+
try:
|
|
589
|
+
posted_round = int((form.get("dpb_round") or [""])[0])
|
|
590
|
+
except (ValueError, TypeError):
|
|
591
|
+
posted_round = -1
|
|
592
|
+
posted_token = (form.get("dpb_token") or [None])[0]
|
|
593
|
+
validated = session.validate(posted_round, posted_token)
|
|
594
|
+
if not validated:
|
|
595
|
+
# Fail closed: missing / reused / mismatched token -> NOT confirmed.
|
|
596
|
+
result = with_prototype_hash({
|
|
597
|
+
"choice": "",
|
|
598
|
+
"feedback": feedback,
|
|
599
|
+
"aborted": True,
|
|
600
|
+
"anchors": anchors,
|
|
601
|
+
"rejected": True,
|
|
602
|
+
"rejection": session.last_rejection,
|
|
603
|
+
})
|
|
604
|
+
else:
|
|
605
|
+
result = with_prototype_hash({
|
|
606
|
+
"choice": choice,
|
|
607
|
+
"feedback": feedback,
|
|
608
|
+
"aborted": choice == "__abort__",
|
|
609
|
+
"anchors": anchors,
|
|
610
|
+
})
|
|
611
|
+
reply = _done_page_html()
|
|
612
|
+
self.close_connection = True
|
|
613
|
+
self.send_response(200)
|
|
614
|
+
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
615
|
+
self.send_header("Content-Length", str(len(reply)))
|
|
616
|
+
self.send_header("Connection", "close")
|
|
617
|
+
self.end_headers()
|
|
618
|
+
self.wfile.write(reply)
|
|
619
|
+
self.wfile.flush()
|
|
620
|
+
# MEDIUM-1 (secure-ship-0.4.4) anti-DoS: only end the session when
|
|
621
|
+
# the POST proves trusted-form origin — validated (first valid
|
|
622
|
+
# decision) OR carried a dpb_token at all (real control-form
|
|
623
|
+
# submit, even on replay/mismatch). A forged cross-origin fetch
|
|
624
|
+
# arrives with no token (sandboxed iframe cannot read the hidden
|
|
625
|
+
# field); responding 200 keeps it quiet, but the server stays
|
|
626
|
+
# alive so the real user can still confirm. Unconditional
|
|
627
|
+
# done.set() here let one forged POST abort every preview before
|
|
628
|
+
# the user clicked anything. Fail-closed semantics above are
|
|
629
|
+
# unchanged — only session termination is now gated.
|
|
630
|
+
if validated:
|
|
631
|
+
done.set()
|
|
632
|
+
|
|
633
|
+
server = HTTPServer(("127.0.0.1", 0), Handler)
|
|
634
|
+
port = server.server_address[1]
|
|
635
|
+
thread = threading.Thread(
|
|
636
|
+
target=server.serve_forever, name="dpb-preview-http", daemon=True
|
|
637
|
+
)
|
|
638
|
+
thread.start()
|
|
639
|
+
url = f"http://127.0.0.1:{port}/"
|
|
640
|
+
_log(f"preview UI at {url}")
|
|
641
|
+
browser_proc, browser_profile = _open_preview_window(url)
|
|
642
|
+
try:
|
|
643
|
+
if not done.wait(timeout=1800):
|
|
644
|
+
result = with_prototype_hash({
|
|
645
|
+
"choice": "",
|
|
646
|
+
"feedback": "timeout waiting for user",
|
|
647
|
+
"aborted": True,
|
|
648
|
+
"anchors": [],
|
|
649
|
+
})
|
|
650
|
+
finally:
|
|
651
|
+
# Hide for immediate visual feedback. Kill the owned Chromium next so
|
|
652
|
+
# keep-alive sockets cannot block HTTPServer.shutdown; response is
|
|
653
|
+
# already flushed before done.set(). Bound HTTP stop so MCP returns.
|
|
654
|
+
_request_browser_window_close(browser_proc)
|
|
655
|
+
_kill_browser_proc(browser_proc, browser_profile)
|
|
656
|
+
try:
|
|
657
|
+
_stop_http_server(server, thread, timeout_s=1.5)
|
|
658
|
+
finally:
|
|
659
|
+
_rm_tree(browser_profile)
|
|
660
|
+
return result
|
|
661
|
+
|