codebee 0.1.95 → 0.1.96

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 (138) hide show
  1. package/CHANGELOG.md +725 -720
  2. package/LICENSE +21 -21
  3. package/README.md +515 -509
  4. package/app/core/acceptance.py +100 -100
  5. package/app/core/agent_context.py +100 -0
  6. package/app/core/aiflavor.py +197 -197
  7. package/app/core/analytics.py +38 -38
  8. package/app/core/artifacts.py +148 -148
  9. package/app/core/assets.py +110 -110
  10. package/app/core/attachments.py +623 -623
  11. package/app/core/audit.py +30 -30
  12. package/app/core/backup.py +470 -470
  13. package/app/core/beekeeper.py +183 -183
  14. package/app/core/benchstore.py +102 -95
  15. package/app/core/board.py +209 -209
  16. package/app/core/bookmeta.py +1036 -1036
  17. package/app/core/bookmeta_catalog.py +180 -180
  18. package/app/core/branching.py +137 -137
  19. package/app/core/builtin_agent.py +1680 -1680
  20. package/app/core/capability.py +144 -144
  21. package/app/core/chaptersafe.py +32 -32
  22. package/app/core/checkpoints.py +143 -0
  23. package/app/core/cleanup.py +303 -303
  24. package/app/core/compaction.py +201 -201
  25. package/app/core/contracts.py +255 -255
  26. package/app/core/covergen.py +356 -356
  27. package/app/core/defectretro.py +43 -43
  28. package/app/core/diagnostics.py +114 -114
  29. package/app/core/dispatch.py +198 -191
  30. package/app/core/dispatch_log.py +128 -128
  31. package/app/core/env_scrub.py +85 -85
  32. package/app/core/errorlog.py +137 -137
  33. package/app/core/eval_matrix.py +82 -0
  34. package/app/core/evalbench.py +184 -91
  35. package/app/core/evaluation.py +174 -174
  36. package/app/core/failure_notify.py +290 -290
  37. package/app/core/flow_graph.py +33 -0
  38. package/app/core/goal_service.py +167 -167
  39. package/app/core/health.py +389 -389
  40. package/app/core/history.py +38 -38
  41. package/app/core/hooks.py +162 -162
  42. package/app/core/jobs.py +25 -4
  43. package/app/core/knowledge_pipeline.py +86 -0
  44. package/app/core/manager.py +2232 -2232
  45. package/app/core/mocks.py +64 -64
  46. package/app/core/notify.py +201 -201
  47. package/app/core/novel_quality.py +181 -189
  48. package/app/core/openapi.py +177 -154
  49. package/app/core/operations.py +228 -228
  50. package/app/core/paihang.py +145 -145
  51. package/app/core/pipeline.py +43 -19
  52. package/app/core/planner.py +17 -11
  53. package/app/core/policy.py +52 -0
  54. package/app/core/portguard.py +111 -111
  55. package/app/core/presence.py +77 -77
  56. package/app/core/preview.py +293 -293
  57. package/app/core/prompt_guard.py +78 -78
  58. package/app/core/publish/__init__.py +7 -7
  59. package/app/core/publish/auto.py +26 -3
  60. package/app/core/publish/browser.py +655 -655
  61. package/app/core/publish/chaprows.py +138 -0
  62. package/app/core/publish/fanqie.py +210 -165
  63. package/app/core/publish/flow.py +753 -640
  64. package/app/core/publish/flows-fanqie-calibrated.json +349 -336
  65. package/app/core/publish/ledger.py +13 -1
  66. package/app/core/publish/manager.py +1150 -1118
  67. package/app/core/publish/qimao.py +176 -176
  68. package/app/core/publish/volumes.py +108 -0
  69. package/app/core/publish/ws.py +139 -139
  70. package/app/core/quality_gate.py +194 -194
  71. package/app/core/redact.py +38 -38
  72. package/app/core/repeat_guard.py +129 -129
  73. package/app/core/retrieval.py +137 -137
  74. package/app/core/router.py +398 -398
  75. package/app/core/runner.py +2238 -2238
  76. package/app/core/selfupdate.py +384 -384
  77. package/app/core/session_log.py +161 -161
  78. package/app/core/sessions.py +312 -312
  79. package/app/core/settings.py +20 -0
  80. package/app/core/settings_schema.py +270 -270
  81. package/app/core/share_page.py +114 -114
  82. package/app/core/skill_scan.py +139 -139
  83. package/app/core/skillpacks/fanqie-novel.md +8 -6
  84. package/app/core/skillpacks/market/character-bible.md +66 -66
  85. package/app/core/skillpacks/market/code-risk-checklist.md +58 -58
  86. package/app/core/skillpacks/market/git-workflow.md +57 -57
  87. package/app/core/skillpacks/market/release-notes.md +72 -72
  88. package/app/core/skillpacks/market/weekly-report.md +71 -71
  89. package/app/core/skillpacks/market/worldview-consistency.md +70 -70
  90. package/app/core/skillpacks/qimao-signing.md +14 -11
  91. package/app/core/skills.py +32 -21
  92. package/app/core/step_runner.py +132 -132
  93. package/app/core/store.py +9 -0
  94. package/app/core/story_tracking.py +275 -275
  95. package/app/core/task_compile.py +199 -199
  96. package/app/core/telemetry.py +292 -292
  97. package/app/core/tlsctx.py +64 -64
  98. package/app/core/token_meter.py +147 -147
  99. package/app/core/tracing.py +207 -144
  100. package/app/core/upstream.py +41 -41
  101. package/app/core/usage.py +1038 -1038
  102. package/app/core/volumes.py +389 -389
  103. package/app/core/webhooks.py +89 -0
  104. package/app/core/wxdigest.py +858 -858
  105. package/app/core/zentao.py +2692 -2692
  106. package/app/main.py +104 -9
  107. package/app/mcp_server.py +244 -244
  108. package/app/pick_dialog.py +110 -110
  109. package/app/ui/app.js +286 -18
  110. package/app/ui/artdiff.js +74 -74
  111. package/app/ui/assets/community-qr.png +0 -0
  112. package/app/ui/assets/donate-alipay.jpg +0 -0
  113. package/app/ui/assets/donate-qr.jpg +0 -0
  114. package/app/ui/board.html +162 -162
  115. package/app/ui/board.js +305 -305
  116. package/app/ui/i18n.js +33 -0
  117. package/app/ui/icons/bee.svg +79 -79
  118. package/app/ui/index.html +32 -3
  119. package/app/ui/manifest.json +16 -16
  120. package/app/ui/qrcode.js +2297 -2297
  121. package/app/ui/style.css +196 -2
  122. package/bin/tutti.js +147 -147
  123. package/package.json +48 -48
  124. package/tools/wx_reader.py +261 -261
  125. package/app/.playwright-cli/console-2026-09-23T08-26-03-635Z.log +0 -88
  126. package/app/.playwright-cli/page-2026-09-23T08-26-03-845Z.yml +0 -126
  127. package/app/.playwright-cli/page-2026-09-23T08-29-44-203Z.png +0 -0
  128. package/app/.playwright-cli/page-2026-09-23T08-35-42-046Z.yml +0 -210
  129. package/app/.playwright-cli/page-2026-09-23T08-36-52-858Z.png +0 -0
  130. package/app/.playwright-cli/page-2026-09-23T08-37-33-938Z.png +0 -0
  131. package/app/.playwright-cli/run-detail-after.png +0 -0
  132. package/app/output/_createbook_body.json +0 -1
  133. package/app/output/_probe_fq_click.py +0 -50
  134. package/app/output/_probe_fq_form.py +0 -42
  135. package/app/output/_probe_fq_modal.py +0 -55
  136. package/app/output/_probe_fq_points.py +0 -71
  137. package/app/output/_probe_fq_submit.py +0 -34
  138. package/app/output/_probe_fq_watch.py +0 -62
@@ -1,293 +1,293 @@
1
- # -*- coding: utf-8 -*-
2
- """运行预览:把任务工作目录里的网页成品挂成「可实时查看」的页面。
3
-
4
- 借鉴对话式编程产品(右栏「运行 / index.html / style.css / script.js」页签):
5
- 代码任务跑完,用户要的不是翻文件,而是**直接看到东西跑起来**。这里提供两件事:
6
-
7
- 1. list_app(run_id):从本任务的成品文件里挑出可预览的应用——入口 HTML +
8
- 同目录的代码文件(按 html→css→js 排序),供前端渲染页签条。
9
- 2. serve(run_id, rel):只读挂载工作目录里的静态资源,供 iframe / 新窗口直接
10
- 打开(<img>/<link>/<script> 这些子资源请求带不了自定义请求头)。
11
-
12
- **访问令牌走路径段**(/preview/<run>/<令牌>/<文件>):子资源是浏览器按文档
13
- URL 相对解析的,查询串不会被继承——令牌只能放路径里才覆盖得到 style.css /
14
- script.js。鉴权本身直接复用 remote.request_authed(与 /api/* 同一把尺子,
15
- 含反向代理下"带转发头的 loopback 算远程"的判断),所以本地免令牌、公网必须
16
- 带令牌,两条路都不需要新写一套判断。
17
-
18
- 安全边界(这是把一个任意目录交给浏览器渲染,必须写清楚):
19
- - 只认静态扩展名白名单,单文件体积上限;路径 resolve 后必须落在工作目录内。
20
- - 工作目录下的服务端可执行物(.py/.php/.rb/.sh…)不在白名单,不会被预览。
21
- - HTML 响应注入 <base>(相对资源在子路径下也解析得对)+ 一段极小的补丁:
22
- 页面里的 fetch/XHR 指向 /api/ 时自动补上访问令牌,让「对话生成的示例应用」
23
- 能真的连通后端。补丁只加一个请求头;写接口该 423/401 的照旧。
24
- - 前端把预览 iframe 挂在同一来源下、不加 sandbox:预览要**像真的在跑**——生成的
25
- 示例应用大量用 localStorage,sandbox 去掉 same-origin 会让它当场抛 SecurityError,
26
- 而 allow-same-origin 的 sandbox 等于没加(页面可自行摘掉)。这与本地 dev server
27
- 的信任模型一致:跑的是你自己的代码、在你自己的机器上。代价是预览页能读到
28
- 本会话的 localStorage 令牌,故「预览」页签只在用户主动点开时才加载(不预跑)。
29
- """
30
- from __future__ import annotations
31
-
32
- import json
33
- import re
34
- from pathlib import Path
35
-
36
- from . import store
37
-
38
- # 静态资源白名单:只挂能安全交给浏览器渲染的扩展名。刻意不含 .py/.php/.rb/.sh
39
- # 等工作目录里可能出现的服务端可执行物——预览是只读静态托管,不是网站服务器。
40
- PREVIEW_EXT = {
41
- ".html", ".htm", ".css", ".js", ".mjs", ".cjs", ".json", ".map",
42
- ".svg", ".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".ico", ".avif",
43
- ".woff", ".woff2", ".ttf", ".otf", ".eot",
44
- ".mp3", ".wav", ".ogg", ".mp4", ".webm",
45
- ".txt", ".md", ".csv", ".xml", ".wasm", ".yml", ".yaml", ".toml",
46
- }
47
-
48
- # 页签条里列出的扩展名(比可挂载范围窄):图片/字体是资源不是「文件」,
49
- # 给它们开页签只是噪音。
50
- TAB_EXT = {".html", ".htm", ".css", ".js", ".mjs", ".cjs", ".json", ".svg",
51
- ".txt", ".md", ".csv", ".xml", ".yml", ".yaml", ".toml", ".map"}
52
-
53
- _HTML_EXT = {".html", ".htm"}
54
-
55
- # 页签排序权重:入口 → 样式 → 脚本 → 数据 → 其它
56
- _TAB_ORDER = {".html": 0, ".htm": 0, ".css": 1, ".js": 2, ".mjs": 2, ".cjs": 2,
57
- ".json": 3, ".map": 4, ".svg": 5, ".md": 6, ".txt": 6, ".csv": 6,
58
- ".xml": 6, ".yml": 6, ".yaml": 6, ".toml": 6}
59
-
60
- BASE = "/preview" # 路由前缀,与 main.py 的 do_GET 分发约定
61
- MAX_FILE = 4 << 20 # 单文件上限:超过不挂(大文件走「成果」页签下载)
62
- MAX_TABS = 12 # 页签上限:防病态目录撑爆页签条
63
- MAX_LIST = 400 # 参与挑选的成品文件上限
64
-
65
- _MIME = {
66
- ".html": "text/html; charset=utf-8", ".htm": "text/html; charset=utf-8",
67
- ".css": "text/css; charset=utf-8", ".js": "text/javascript; charset=utf-8",
68
- ".mjs": "text/javascript; charset=utf-8", ".cjs": "text/javascript; charset=utf-8",
69
- ".json": "application/json; charset=utf-8", ".map": "application/json; charset=utf-8",
70
- ".svg": "image/svg+xml", ".png": "image/png", ".jpg": "image/jpeg",
71
- ".jpeg": "image/jpeg", ".gif": "image/gif", ".webp": "image/webp",
72
- ".bmp": "image/bmp", ".ico": "image/x-icon", ".avif": "image/avif",
73
- ".woff": "font/woff", ".woff2": "font/woff2", ".ttf": "font/ttf",
74
- ".otf": "font/otf", ".eot": "application/vnd.ms-fontobject",
75
- ".mp3": "audio/mpeg", ".wav": "audio/wav", ".ogg": "audio/ogg",
76
- ".mp4": "video/mp4", ".webm": "video/webm",
77
- ".txt": "text/plain; charset=utf-8", ".md": "text/plain; charset=utf-8",
78
- ".csv": "text/plain; charset=utf-8", ".xml": "text/xml; charset=utf-8",
79
- ".wasm": "application/wasm",
80
- ".yml": "text/plain; charset=utf-8", ".yaml": "text/plain; charset=utf-8",
81
- ".toml": "text/plain; charset=utf-8",
82
- }
83
-
84
- _TXT = {".html", ".htm", ".css", ".js", ".mjs", ".cjs", ".json", ".map", ".svg",
85
- ".txt", ".md", ".csv", ".xml", ".yml", ".yaml", ".toml"}
86
-
87
- _HEAD_RE = re.compile(r"<head\b[^>]*>", re.I)
88
- _HTML_RE = re.compile(r"<html\b[^>]*>", re.I)
89
-
90
-
91
- def _ext(name):
92
- return Path(str(name or "")).suffix.lower()
93
-
94
-
95
- def safe_rel(rel):
96
- """归一化工作目录内相对路径。返回 None 表示越界/非法。"""
97
- rel = str(rel or "").replace("\\", "/").lstrip("/")
98
- if not rel:
99
- return None
100
- parts = [p for p in rel.split("/") if p not in ("", ".")]
101
- if not parts or any(p == ".." for p in parts):
102
- return None
103
- # 隐藏目录(.git/.zcode/.claude…)是工具过程文件,不是成品,一律不给挂
104
- if any(p.startswith(".") for p in parts):
105
- return None
106
- return "/".join(parts)
107
-
108
-
109
- def resolve(run_id, rel):
110
- """把 rel 解析到运行工作目录内的真实文件。返回 (Path|None, 错误文本)。
111
-
112
- 与 store.read_run_file 同一道守卫:resolve 之后必须仍在工作目录内,
113
- 顺带挡掉软链接指向工作目录之外的形态。
114
- """
115
- wd = store.run_workdir(run_id)
116
- if not wd:
117
- return None, "该运行没有关联的工作目录"
118
- safe = safe_rel(rel)
119
- if not safe:
120
- return None, "非法路径"
121
- if _ext(safe) not in PREVIEW_EXT:
122
- return None, "该类型不支持预览"
123
- root = Path(wd).resolve()
124
- try:
125
- p = (root / safe).resolve()
126
- except OSError:
127
- return None, "非法路径"
128
- if root != p and root not in p.parents:
129
- return None, "非法路径"
130
- if not p.is_file():
131
- return None, "文件不存在"
132
- return p, None
133
-
134
-
135
- def _base_href(run_id, tok, rel):
136
- """入口 HTML 的 <base>:挂在 /preview/<run>/<令牌>/<入口目录>/ 上。
137
-
138
- 令牌放在路径里是为了让 style.css / ./js/a.js / ../x.css 这些相对子资源
139
- 自动继承它(查询串不会被子资源继承);这个绝对路径也让相对资源在
140
- 子目录入口(web/index.html)下解析得对。
141
- """
142
- d = rel.rsplit("/", 1)[0] if "/" in rel else ""
143
- return "%s/%s/%s/%s" % (BASE, run_id, tok, (d + "/") if d else "")
144
-
145
-
146
- def _patch_token(tok):
147
- """注入页面脚本:预览页里的 fetch/XHR 打 /api/ 时自动补访问令牌。
148
-
149
- 生成的示例应用常直接 fetch("/api/..."),而子资源与脚本发起的请求带不了
150
- 自定义请求头——不补的话预览里一切接口调用都 401,用户会以为是自己代码写
151
- 错了。补丁只加一个请求头;写接口该 423/401 的照旧,不放行任何服务端能力。
152
- """
153
- tk = json.dumps(str(tok or ""))
154
- return (
155
- "<script>(function(){var TK=%s;if(!TK)return;try{"
156
- "var isApi=function(u){u=String(u||'');return u.indexOf('/api/')===0"
157
- "||u.indexOf('api/')===0;};"
158
- "var _f=window.fetch;if(_f){window.fetch=function(i,o){o=o||{};"
159
- "var u=(typeof i==='string')?i:((i&&i.url)||'');"
160
- "if(isApi(u)){try{o.headers=new Headers(o.headers||{});"
161
- "o.headers.set('X-CodeBee-Token',TK);}catch(e){}}"
162
- "return _f.call(this,i,o);};}"
163
- "var _o=XMLHttpRequest.prototype.open;"
164
- "XMLHttpRequest.prototype.open=function(m,u){"
165
- "this.__cbApi=isApi(u);return _o.apply(this,arguments);};"
166
- "var _s=XMLHttpRequest.prototype.send;"
167
- "XMLHttpRequest.prototype.send=function(b){"
168
- "if(this.__cbApi){try{this.setRequestHeader('X-CodeBee-Token',TK);}"
169
- "catch(e){}}return _s.apply(this,arguments);};"
170
- "}catch(e){}})();</script>" % tk
171
- )
172
-
173
-
174
- def _inject(html, run_id, tok, rel):
175
- """把 <base> + 令牌补丁插到入口 HTML 的最前面(head 内最优先)。"""
176
- inject = ('<base href="%s">' % _base_href(run_id, tok, rel)) + _patch_token(tok)
177
- m = _HEAD_RE.search(html)
178
- if m:
179
- return html[:m.end()] + inject + html[m.end():]
180
- m = _HTML_RE.search(html)
181
- if m:
182
- return html[:m.end()] + inject + html[m.end():]
183
- return inject + html
184
-
185
-
186
- def _decode(data):
187
- for enc in ("utf-8", "gbk"):
188
- try:
189
- return data.decode(enc)
190
- except UnicodeDecodeError:
191
- continue
192
- return data.decode("utf-8", "replace")
193
-
194
-
195
- def serve(run_id, rel, tok=""):
196
- """读取一个预览资源。返回 (bytes|None, content-type, 错误文本)。"""
197
- p, err = resolve(run_id, rel)
198
- if err:
199
- return None, "", err
200
- try:
201
- if p.stat().st_size > MAX_FILE:
202
- return None, "", "文件过大,不支持预览"
203
- data = p.read_bytes()
204
- except OSError as e:
205
- return None, "", "读取失败: %s" % e
206
- ext = p.suffix.lower()
207
- ctype = _MIME.get(ext, "application/octet-stream")
208
- if ext in _HTML_EXT:
209
- # 入口页:注入 <base> 与令牌补丁,并按 UTF-8 重编码(非 UTF-8 落 GBK
210
- # 回退——子代理在中文 Windows 上常把文件写成 GBK)
211
- data = _inject(_decode(data), run_id, tok, rel).encode("utf-8")
212
- elif ext in _TXT:
213
- data = _decode(data).encode("utf-8")
214
- return data, ctype, None
215
-
216
-
217
- def _pick_entry(avail):
218
- """挑入口 HTML:优先工作目录根的 index.html,其次最浅、最新。
219
-
220
- 先只看根目录(单页应用的标准形态),根目录没有 HTML 时才退到最浅的子目录
221
- 入口——`web/index.html` 这类布局也该能预览。
222
- """
223
- htmls = [f for f in avail if _ext(f["name"]) in _HTML_EXT]
224
- if not htmls:
225
- return ""
226
- roots = [f for f in htmls if "/" not in f["name"]]
227
- cands = roots or htmls
228
- best = None
229
- for f in cands:
230
- name = f["name"]
231
- base = name.rsplit("/", 1)[-1].lower()
232
- key = (0 if base in ("index.html", "index.htm") else 1,
233
- name.count("/"), -int(f.get("mtime") or 0), name)
234
- if best is None or key < best[0]:
235
- best = (key, name)
236
- return best[1] if best else ""
237
-
238
-
239
- def _tab_key(f):
240
- name = f["name"]
241
- return (_TAB_ORDER.get(_ext(name), 9), name.count("/"),
242
- -int(f.get("mtime") or 0), name)
243
-
244
-
245
- def preview_path(run_id, tok):
246
- """预览根路径(带路径令牌):前端据此拼 iframe src 与新窗口地址。
247
-
248
- 令牌为空时落「-」占位段:远程开着时它会自然 401,本机(remote 未配令牌
249
- 或 loopback 豁免)照常放行——路径里永远有一段,前端不用做两种拼接。
250
- """
251
- return "%s/%s/%s/" % (BASE, run_id, tok or "-")
252
-
253
-
254
- def list_app(run_id, tok=""):
255
- """本任务可预览的网页应用。返回前端渲染页签条所需的全部信息。
256
-
257
- {"ok": False, "reason": "no_run"|"no_workdir"|"no_steps"|"no_html"} 表示没有
258
- 可预览的东西(前端据此把「预览」页签整体隐藏,不打扰非前端任务)。
259
- """
260
- if not store.get_run(run_id):
261
- return {"ok": False, "reason": "no_run"}
262
- wd, files = store.run_artifacts(run_id, limit=MAX_LIST)
263
- if not wd or not Path(wd).is_dir():
264
- return {"ok": False, "reason": "no_workdir"}
265
- # 与「成果」同一口径:一步没跑出来过的任务没有成品可言(历次都在检出等
266
- # 前置环节失败时,工作目录里的变动是并行活动的噪音)
267
- run = store.get_run(run_id) or {}
268
- if not store.task_step_count(run.get("task_id") or ""):
269
- return {"ok": False, "reason": "no_steps"}
270
- avail = [f for f in (files or [])
271
- if _ext(f["name"]) in PREVIEW_EXT
272
- and int(f.get("size") or 0) <= MAX_FILE]
273
- entry = _pick_entry(avail)
274
- if not entry:
275
- return {"ok": False, "reason": "no_html", "workdir": wd}
276
- edir = entry.rsplit("/", 1)[0] if "/" in entry else ""
277
- tabs = [f for f in avail
278
- if _ext(f["name"]) in TAB_EXT
279
- and (f["name"].rsplit("/", 1)[0] if "/" in f["name"] else "") == edir]
280
- tabs.sort(key=_tab_key)
281
- names, seen = [], set()
282
- for f in tabs:
283
- if f["name"] in seen:
284
- continue
285
- seen.add(f["name"])
286
- names.append(f["name"])
287
- if len(names) >= MAX_TABS:
288
- break
289
- # 入口一定在,且一定排第一(页签条第一格就是"这个应用",运行视图另占一格)
290
- names = [entry] + [n for n in names if n != entry]
291
- return {"ok": True, "entry": entry, "workdir": wd,
292
- "base": preview_path(run_id, tok),
293
- "files": [{"name": n, "ext": _ext(n).lstrip(".")} for n in names]}
1
+ # -*- coding: utf-8 -*-
2
+ """运行预览:把任务工作目录里的网页成品挂成「可实时查看」的页面。
3
+
4
+ 借鉴对话式编程产品(右栏「运行 / index.html / style.css / script.js」页签):
5
+ 代码任务跑完,用户要的不是翻文件,而是**直接看到东西跑起来**。这里提供两件事:
6
+
7
+ 1. list_app(run_id):从本任务的成品文件里挑出可预览的应用——入口 HTML +
8
+ 同目录的代码文件(按 html→css→js 排序),供前端渲染页签条。
9
+ 2. serve(run_id, rel):只读挂载工作目录里的静态资源,供 iframe / 新窗口直接
10
+ 打开(<img>/<link>/<script> 这些子资源请求带不了自定义请求头)。
11
+
12
+ **访问令牌走路径段**(/preview/<run>/<令牌>/<文件>):子资源是浏览器按文档
13
+ URL 相对解析的,查询串不会被继承——令牌只能放路径里才覆盖得到 style.css /
14
+ script.js。鉴权本身直接复用 remote.request_authed(与 /api/* 同一把尺子,
15
+ 含反向代理下"带转发头的 loopback 算远程"的判断),所以本地免令牌、公网必须
16
+ 带令牌,两条路都不需要新写一套判断。
17
+
18
+ 安全边界(这是把一个任意目录交给浏览器渲染,必须写清楚):
19
+ - 只认静态扩展名白名单,单文件体积上限;路径 resolve 后必须落在工作目录内。
20
+ - 工作目录下的服务端可执行物(.py/.php/.rb/.sh…)不在白名单,不会被预览。
21
+ - HTML 响应注入 <base>(相对资源在子路径下也解析得对)+ 一段极小的补丁:
22
+ 页面里的 fetch/XHR 指向 /api/ 时自动补上访问令牌,让「对话生成的示例应用」
23
+ 能真的连通后端。补丁只加一个请求头;写接口该 423/401 的照旧。
24
+ - 前端把预览 iframe 挂在同一来源下、不加 sandbox:预览要**像真的在跑**——生成的
25
+ 示例应用大量用 localStorage,sandbox 去掉 same-origin 会让它当场抛 SecurityError,
26
+ 而 allow-same-origin 的 sandbox 等于没加(页面可自行摘掉)。这与本地 dev server
27
+ 的信任模型一致:跑的是你自己的代码、在你自己的机器上。代价是预览页能读到
28
+ 本会话的 localStorage 令牌,故「预览」页签只在用户主动点开时才加载(不预跑)。
29
+ """
30
+ from __future__ import annotations
31
+
32
+ import json
33
+ import re
34
+ from pathlib import Path
35
+
36
+ from . import store
37
+
38
+ # 静态资源白名单:只挂能安全交给浏览器渲染的扩展名。刻意不含 .py/.php/.rb/.sh
39
+ # 等工作目录里可能出现的服务端可执行物——预览是只读静态托管,不是网站服务器。
40
+ PREVIEW_EXT = {
41
+ ".html", ".htm", ".css", ".js", ".mjs", ".cjs", ".json", ".map",
42
+ ".svg", ".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".ico", ".avif",
43
+ ".woff", ".woff2", ".ttf", ".otf", ".eot",
44
+ ".mp3", ".wav", ".ogg", ".mp4", ".webm",
45
+ ".txt", ".md", ".csv", ".xml", ".wasm", ".yml", ".yaml", ".toml",
46
+ }
47
+
48
+ # 页签条里列出的扩展名(比可挂载范围窄):图片/字体是资源不是「文件」,
49
+ # 给它们开页签只是噪音。
50
+ TAB_EXT = {".html", ".htm", ".css", ".js", ".mjs", ".cjs", ".json", ".svg",
51
+ ".txt", ".md", ".csv", ".xml", ".yml", ".yaml", ".toml", ".map"}
52
+
53
+ _HTML_EXT = {".html", ".htm"}
54
+
55
+ # 页签排序权重:入口 → 样式 → 脚本 → 数据 → 其它
56
+ _TAB_ORDER = {".html": 0, ".htm": 0, ".css": 1, ".js": 2, ".mjs": 2, ".cjs": 2,
57
+ ".json": 3, ".map": 4, ".svg": 5, ".md": 6, ".txt": 6, ".csv": 6,
58
+ ".xml": 6, ".yml": 6, ".yaml": 6, ".toml": 6}
59
+
60
+ BASE = "/preview" # 路由前缀,与 main.py 的 do_GET 分发约定
61
+ MAX_FILE = 4 << 20 # 单文件上限:超过不挂(大文件走「成果」页签下载)
62
+ MAX_TABS = 12 # 页签上限:防病态目录撑爆页签条
63
+ MAX_LIST = 400 # 参与挑选的成品文件上限
64
+
65
+ _MIME = {
66
+ ".html": "text/html; charset=utf-8", ".htm": "text/html; charset=utf-8",
67
+ ".css": "text/css; charset=utf-8", ".js": "text/javascript; charset=utf-8",
68
+ ".mjs": "text/javascript; charset=utf-8", ".cjs": "text/javascript; charset=utf-8",
69
+ ".json": "application/json; charset=utf-8", ".map": "application/json; charset=utf-8",
70
+ ".svg": "image/svg+xml", ".png": "image/png", ".jpg": "image/jpeg",
71
+ ".jpeg": "image/jpeg", ".gif": "image/gif", ".webp": "image/webp",
72
+ ".bmp": "image/bmp", ".ico": "image/x-icon", ".avif": "image/avif",
73
+ ".woff": "font/woff", ".woff2": "font/woff2", ".ttf": "font/ttf",
74
+ ".otf": "font/otf", ".eot": "application/vnd.ms-fontobject",
75
+ ".mp3": "audio/mpeg", ".wav": "audio/wav", ".ogg": "audio/ogg",
76
+ ".mp4": "video/mp4", ".webm": "video/webm",
77
+ ".txt": "text/plain; charset=utf-8", ".md": "text/plain; charset=utf-8",
78
+ ".csv": "text/plain; charset=utf-8", ".xml": "text/xml; charset=utf-8",
79
+ ".wasm": "application/wasm",
80
+ ".yml": "text/plain; charset=utf-8", ".yaml": "text/plain; charset=utf-8",
81
+ ".toml": "text/plain; charset=utf-8",
82
+ }
83
+
84
+ _TXT = {".html", ".htm", ".css", ".js", ".mjs", ".cjs", ".json", ".map", ".svg",
85
+ ".txt", ".md", ".csv", ".xml", ".yml", ".yaml", ".toml"}
86
+
87
+ _HEAD_RE = re.compile(r"<head\b[^>]*>", re.I)
88
+ _HTML_RE = re.compile(r"<html\b[^>]*>", re.I)
89
+
90
+
91
+ def _ext(name):
92
+ return Path(str(name or "")).suffix.lower()
93
+
94
+
95
+ def safe_rel(rel):
96
+ """归一化工作目录内相对路径。返回 None 表示越界/非法。"""
97
+ rel = str(rel or "").replace("\\", "/").lstrip("/")
98
+ if not rel:
99
+ return None
100
+ parts = [p for p in rel.split("/") if p not in ("", ".")]
101
+ if not parts or any(p == ".." for p in parts):
102
+ return None
103
+ # 隐藏目录(.git/.zcode/.claude…)是工具过程文件,不是成品,一律不给挂
104
+ if any(p.startswith(".") for p in parts):
105
+ return None
106
+ return "/".join(parts)
107
+
108
+
109
+ def resolve(run_id, rel):
110
+ """把 rel 解析到运行工作目录内的真实文件。返回 (Path|None, 错误文本)。
111
+
112
+ 与 store.read_run_file 同一道守卫:resolve 之后必须仍在工作目录内,
113
+ 顺带挡掉软链接指向工作目录之外的形态。
114
+ """
115
+ wd = store.run_workdir(run_id)
116
+ if not wd:
117
+ return None, "该运行没有关联的工作目录"
118
+ safe = safe_rel(rel)
119
+ if not safe:
120
+ return None, "非法路径"
121
+ if _ext(safe) not in PREVIEW_EXT:
122
+ return None, "该类型不支持预览"
123
+ root = Path(wd).resolve()
124
+ try:
125
+ p = (root / safe).resolve()
126
+ except OSError:
127
+ return None, "非法路径"
128
+ if root != p and root not in p.parents:
129
+ return None, "非法路径"
130
+ if not p.is_file():
131
+ return None, "文件不存在"
132
+ return p, None
133
+
134
+
135
+ def _base_href(run_id, tok, rel):
136
+ """入口 HTML 的 <base>:挂在 /preview/<run>/<令牌>/<入口目录>/ 上。
137
+
138
+ 令牌放在路径里是为了让 style.css / ./js/a.js / ../x.css 这些相对子资源
139
+ 自动继承它(查询串不会被子资源继承);这个绝对路径也让相对资源在
140
+ 子目录入口(web/index.html)下解析得对。
141
+ """
142
+ d = rel.rsplit("/", 1)[0] if "/" in rel else ""
143
+ return "%s/%s/%s/%s" % (BASE, run_id, tok, (d + "/") if d else "")
144
+
145
+
146
+ def _patch_token(tok):
147
+ """注入页面脚本:预览页里的 fetch/XHR 打 /api/ 时自动补访问令牌。
148
+
149
+ 生成的示例应用常直接 fetch("/api/..."),而子资源与脚本发起的请求带不了
150
+ 自定义请求头——不补的话预览里一切接口调用都 401,用户会以为是自己代码写
151
+ 错了。补丁只加一个请求头;写接口该 423/401 的照旧,不放行任何服务端能力。
152
+ """
153
+ tk = json.dumps(str(tok or ""))
154
+ return (
155
+ "<script>(function(){var TK=%s;if(!TK)return;try{"
156
+ "var isApi=function(u){u=String(u||'');return u.indexOf('/api/')===0"
157
+ "||u.indexOf('api/')===0;};"
158
+ "var _f=window.fetch;if(_f){window.fetch=function(i,o){o=o||{};"
159
+ "var u=(typeof i==='string')?i:((i&&i.url)||'');"
160
+ "if(isApi(u)){try{o.headers=new Headers(o.headers||{});"
161
+ "o.headers.set('X-CodeBee-Token',TK);}catch(e){}}"
162
+ "return _f.call(this,i,o);};}"
163
+ "var _o=XMLHttpRequest.prototype.open;"
164
+ "XMLHttpRequest.prototype.open=function(m,u){"
165
+ "this.__cbApi=isApi(u);return _o.apply(this,arguments);};"
166
+ "var _s=XMLHttpRequest.prototype.send;"
167
+ "XMLHttpRequest.prototype.send=function(b){"
168
+ "if(this.__cbApi){try{this.setRequestHeader('X-CodeBee-Token',TK);}"
169
+ "catch(e){}}return _s.apply(this,arguments);};"
170
+ "}catch(e){}})();</script>" % tk
171
+ )
172
+
173
+
174
+ def _inject(html, run_id, tok, rel):
175
+ """把 <base> + 令牌补丁插到入口 HTML 的最前面(head 内最优先)。"""
176
+ inject = ('<base href="%s">' % _base_href(run_id, tok, rel)) + _patch_token(tok)
177
+ m = _HEAD_RE.search(html)
178
+ if m:
179
+ return html[:m.end()] + inject + html[m.end():]
180
+ m = _HTML_RE.search(html)
181
+ if m:
182
+ return html[:m.end()] + inject + html[m.end():]
183
+ return inject + html
184
+
185
+
186
+ def _decode(data):
187
+ for enc in ("utf-8", "gbk"):
188
+ try:
189
+ return data.decode(enc)
190
+ except UnicodeDecodeError:
191
+ continue
192
+ return data.decode("utf-8", "replace")
193
+
194
+
195
+ def serve(run_id, rel, tok=""):
196
+ """读取一个预览资源。返回 (bytes|None, content-type, 错误文本)。"""
197
+ p, err = resolve(run_id, rel)
198
+ if err:
199
+ return None, "", err
200
+ try:
201
+ if p.stat().st_size > MAX_FILE:
202
+ return None, "", "文件过大,不支持预览"
203
+ data = p.read_bytes()
204
+ except OSError as e:
205
+ return None, "", "读取失败: %s" % e
206
+ ext = p.suffix.lower()
207
+ ctype = _MIME.get(ext, "application/octet-stream")
208
+ if ext in _HTML_EXT:
209
+ # 入口页:注入 <base> 与令牌补丁,并按 UTF-8 重编码(非 UTF-8 落 GBK
210
+ # 回退——子代理在中文 Windows 上常把文件写成 GBK)
211
+ data = _inject(_decode(data), run_id, tok, rel).encode("utf-8")
212
+ elif ext in _TXT:
213
+ data = _decode(data).encode("utf-8")
214
+ return data, ctype, None
215
+
216
+
217
+ def _pick_entry(avail):
218
+ """挑入口 HTML:优先工作目录根的 index.html,其次最浅、最新。
219
+
220
+ 先只看根目录(单页应用的标准形态),根目录没有 HTML 时才退到最浅的子目录
221
+ 入口——`web/index.html` 这类布局也该能预览。
222
+ """
223
+ htmls = [f for f in avail if _ext(f["name"]) in _HTML_EXT]
224
+ if not htmls:
225
+ return ""
226
+ roots = [f for f in htmls if "/" not in f["name"]]
227
+ cands = roots or htmls
228
+ best = None
229
+ for f in cands:
230
+ name = f["name"]
231
+ base = name.rsplit("/", 1)[-1].lower()
232
+ key = (0 if base in ("index.html", "index.htm") else 1,
233
+ name.count("/"), -int(f.get("mtime") or 0), name)
234
+ if best is None or key < best[0]:
235
+ best = (key, name)
236
+ return best[1] if best else ""
237
+
238
+
239
+ def _tab_key(f):
240
+ name = f["name"]
241
+ return (_TAB_ORDER.get(_ext(name), 9), name.count("/"),
242
+ -int(f.get("mtime") or 0), name)
243
+
244
+
245
+ def preview_path(run_id, tok):
246
+ """预览根路径(带路径令牌):前端据此拼 iframe src 与新窗口地址。
247
+
248
+ 令牌为空时落「-」占位段:远程开着时它会自然 401,本机(remote 未配令牌
249
+ 或 loopback 豁免)照常放行——路径里永远有一段,前端不用做两种拼接。
250
+ """
251
+ return "%s/%s/%s/" % (BASE, run_id, tok or "-")
252
+
253
+
254
+ def list_app(run_id, tok=""):
255
+ """本任务可预览的网页应用。返回前端渲染页签条所需的全部信息。
256
+
257
+ {"ok": False, "reason": "no_run"|"no_workdir"|"no_steps"|"no_html"} 表示没有
258
+ 可预览的东西(前端据此把「预览」页签整体隐藏,不打扰非前端任务)。
259
+ """
260
+ if not store.get_run(run_id):
261
+ return {"ok": False, "reason": "no_run"}
262
+ wd, files = store.run_artifacts(run_id, limit=MAX_LIST)
263
+ if not wd or not Path(wd).is_dir():
264
+ return {"ok": False, "reason": "no_workdir"}
265
+ # 与「成果」同一口径:一步没跑出来过的任务没有成品可言(历次都在检出等
266
+ # 前置环节失败时,工作目录里的变动是并行活动的噪音)
267
+ run = store.get_run(run_id) or {}
268
+ if not store.task_step_count(run.get("task_id") or ""):
269
+ return {"ok": False, "reason": "no_steps"}
270
+ avail = [f for f in (files or [])
271
+ if _ext(f["name"]) in PREVIEW_EXT
272
+ and int(f.get("size") or 0) <= MAX_FILE]
273
+ entry = _pick_entry(avail)
274
+ if not entry:
275
+ return {"ok": False, "reason": "no_html", "workdir": wd}
276
+ edir = entry.rsplit("/", 1)[0] if "/" in entry else ""
277
+ tabs = [f for f in avail
278
+ if _ext(f["name"]) in TAB_EXT
279
+ and (f["name"].rsplit("/", 1)[0] if "/" in f["name"] else "") == edir]
280
+ tabs.sort(key=_tab_key)
281
+ names, seen = [], set()
282
+ for f in tabs:
283
+ if f["name"] in seen:
284
+ continue
285
+ seen.add(f["name"])
286
+ names.append(f["name"])
287
+ if len(names) >= MAX_TABS:
288
+ break
289
+ # 入口一定在,且一定排第一(页签条第一格就是"这个应用",运行视图另占一格)
290
+ names = [entry] + [n for n in names if n != entry]
291
+ return {"ok": True, "entry": entry, "workdir": wd,
292
+ "base": preview_path(run_id, tok),
293
+ "files": [{"name": n, "ext": _ext(n).lstrip(".")} for n in names]}