pi-skill-evolve 0.0.0-stage → 0.1.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 (157) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +81 -2
  3. package/extensions/skill-evolve/fixtures/selftest/delete-ban/after.md +10 -0
  4. package/extensions/skill-evolve/fixtures/selftest/delete-ban/change.diff +8 -0
  5. package/extensions/skill-evolve/fixtures/selftest/delete-ban/fixture.json +6 -0
  6. package/extensions/skill-evolve/fixtures/selftest/rg-to-find/after.md +9 -0
  7. package/extensions/skill-evolve/fixtures/selftest/rg-to-find/change.diff +8 -0
  8. package/extensions/skill-evolve/fixtures/selftest/rg-to-find/fixture.json +6 -0
  9. package/extensions/skill-evolve/fixtures/selftest/weak-description/after.md +8 -0
  10. package/extensions/skill-evolve/fixtures/selftest/weak-description/change.diff +8 -0
  11. package/extensions/skill-evolve/fixtures/selftest/weak-description/fixture.json +6 -0
  12. package/extensions/skill-evolve/index.ts +300 -0
  13. package/extensions/skill-evolve/lib/apply-gates.ts +204 -0
  14. package/extensions/skill-evolve/lib/apply-targets.ts +90 -0
  15. package/extensions/skill-evolve/lib/apply.ts +141 -0
  16. package/extensions/skill-evolve/lib/attribution.ts +138 -0
  17. package/extensions/skill-evolve/lib/audit.ts +221 -0
  18. package/extensions/skill-evolve/lib/baseline.ts +176 -0
  19. package/extensions/skill-evolve/lib/child-env.ts +10 -0
  20. package/extensions/skill-evolve/lib/classify-action.ts +161 -0
  21. package/extensions/skill-evolve/lib/command.ts +100 -0
  22. package/extensions/skill-evolve/lib/config-command.ts +38 -0
  23. package/extensions/skill-evolve/lib/config.ts +194 -0
  24. package/extensions/skill-evolve/lib/detect.ts +62 -0
  25. package/extensions/skill-evolve/lib/diff-apply.ts +90 -0
  26. package/extensions/skill-evolve/lib/diff-gen.ts +78 -0
  27. package/extensions/skill-evolve/lib/dispatch.ts +300 -0
  28. package/extensions/skill-evolve/lib/evolution-apply.ts +265 -0
  29. package/extensions/skill-evolve/lib/evolution-model.ts +72 -0
  30. package/extensions/skill-evolve/lib/evolution-observe.ts +82 -0
  31. package/extensions/skill-evolve/lib/evolution-views.ts +89 -0
  32. package/extensions/skill-evolve/lib/evolution.ts +289 -0
  33. package/extensions/skill-evolve/lib/flags.ts +44 -0
  34. package/extensions/skill-evolve/lib/gate0.ts +78 -0
  35. package/extensions/skill-evolve/lib/hard-rules.ts +167 -0
  36. package/extensions/skill-evolve/lib/lock.ts +215 -0
  37. package/extensions/skill-evolve/lib/logfile.ts +52 -0
  38. package/extensions/skill-evolve/lib/mechanical-report.ts +95 -0
  39. package/extensions/skill-evolve/lib/mechanical.ts +136 -0
  40. package/extensions/skill-evolve/lib/metrics.ts +172 -0
  41. package/extensions/skill-evolve/lib/model-config.ts +95 -0
  42. package/extensions/skill-evolve/lib/notify.ts +132 -0
  43. package/extensions/skill-evolve/lib/panel.ts +174 -0
  44. package/extensions/skill-evolve/lib/pending.ts +34 -0
  45. package/extensions/skill-evolve/lib/proc.ts +65 -0
  46. package/extensions/skill-evolve/lib/prompt.ts +80 -0
  47. package/extensions/skill-evolve/lib/proposal.ts +227 -0
  48. package/extensions/skill-evolve/lib/prune-plan.ts +62 -0
  49. package/extensions/skill-evolve/lib/queue.ts +136 -0
  50. package/extensions/skill-evolve/lib/replay-fixture.ts +80 -0
  51. package/extensions/skill-evolve/lib/replay.ts +246 -0
  52. package/extensions/skill-evolve/lib/report.ts +181 -0
  53. package/extensions/skill-evolve/lib/review-post.ts +101 -0
  54. package/extensions/skill-evolve/lib/review-prompt.ts +82 -0
  55. package/extensions/skill-evolve/lib/review-record.ts +63 -0
  56. package/extensions/skill-evolve/lib/review-run.ts +131 -0
  57. package/extensions/skill-evolve/lib/review-spawn.ts +83 -0
  58. package/extensions/skill-evolve/lib/review-supervisor.ts +267 -0
  59. package/extensions/skill-evolve/lib/round.ts +116 -0
  60. package/extensions/skill-evolve/lib/run-watch.ts +34 -0
  61. package/extensions/skill-evolve/lib/runs.ts +208 -0
  62. package/extensions/skill-evolve/lib/selftest.ts +201 -0
  63. package/extensions/skill-evolve/lib/session-index.ts +145 -0
  64. package/extensions/skill-evolve/lib/shutdown-dispatch.ts +177 -0
  65. package/extensions/skill-evolve/lib/single-flight.ts +111 -0
  66. package/extensions/skill-evolve/lib/spawn-plan.ts +103 -0
  67. package/extensions/skill-evolve/lib/stale.ts +217 -0
  68. package/extensions/skill-evolve/lib/telemetry.ts +205 -0
  69. package/extensions/skill-evolve/lib/ui-gate.ts +34 -0
  70. package/extensions/skill-evolve/lib/web/actions.ts +299 -0
  71. package/extensions/skill-evolve/lib/web/activity.ts +57 -0
  72. package/extensions/skill-evolve/lib/web/applied.ts +64 -0
  73. package/extensions/skill-evolve/lib/web/approvals.ts +58 -0
  74. package/extensions/skill-evolve/lib/web/audit-log.ts +115 -0
  75. package/extensions/skill-evolve/lib/web/autostart.ts +177 -0
  76. package/extensions/skill-evolve/lib/web/data.ts +298 -0
  77. package/extensions/skill-evolve/lib/web/diff-lines.ts +141 -0
  78. package/extensions/skill-evolve/lib/web/filter.ts +203 -0
  79. package/extensions/skill-evolve/lib/web/http.ts +68 -0
  80. package/extensions/skill-evolve/lib/web/json-read.ts +33 -0
  81. package/extensions/skill-evolve/lib/web/panel-pid.ts +99 -0
  82. package/extensions/skill-evolve/lib/web/render-controls.ts +156 -0
  83. package/extensions/skill-evolve/lib/web/render-rows.ts +252 -0
  84. package/extensions/skill-evolve/lib/web/render-run.ts +188 -0
  85. package/extensions/skill-evolve/lib/web/render-skills.ts +164 -0
  86. package/extensions/skill-evolve/lib/web/render.ts +136 -0
  87. package/extensions/skill-evolve/lib/web/reports.ts +160 -0
  88. package/extensions/skill-evolve/lib/web/run-detail.ts +234 -0
  89. package/extensions/skill-evolve/lib/web/run-path.ts +21 -0
  90. package/extensions/skill-evolve/lib/web/server.ts +268 -0
  91. package/extensions/skill-evolve/lib/web/shell.ts +87 -0
  92. package/extensions/skill-evolve/lib/web/skills.ts +202 -0
  93. package/extensions/skill-evolve/lib/web/style.ts +107 -0
  94. package/extensions/skill-evolve/lib/web/text.ts +52 -0
  95. package/extensions/skill-evolve/lib/web/trash.ts +36 -0
  96. package/extensions/skill-evolve/lib/weekly.ts +285 -0
  97. package/extensions/skill-evolve/prompts//345/244/215/347/233/230.md +172 -0
  98. package/extensions/skill-evolve/scripts/replay.ts +93 -0
  99. package/extensions/skill-evolve/test/apply.test.ts +278 -0
  100. package/extensions/skill-evolve/test/approve.test.ts +248 -0
  101. package/extensions/skill-evolve/test/attribution.test.ts +133 -0
  102. package/extensions/skill-evolve/test/audit.test.ts +151 -0
  103. package/extensions/skill-evolve/test/baseline.test.ts +132 -0
  104. package/extensions/skill-evolve/test/classify-action.test.ts +133 -0
  105. package/extensions/skill-evolve/test/command.test.ts +267 -0
  106. package/extensions/skill-evolve/test/config-command.test.ts +73 -0
  107. package/extensions/skill-evolve/test/config.test.ts +154 -0
  108. package/extensions/skill-evolve/test/detect.test.ts +71 -0
  109. package/extensions/skill-evolve/test/diff-apply.test.ts +46 -0
  110. package/extensions/skill-evolve/test/dispatch-busy.test.ts +223 -0
  111. package/extensions/skill-evolve/test/dispatch-supervisor.test.ts +88 -0
  112. package/extensions/skill-evolve/test/dispatch.test.ts +306 -0
  113. package/extensions/skill-evolve/test/evolution.test.ts +378 -0
  114. package/extensions/skill-evolve/test/fake-review-spawn.ts +60 -0
  115. package/extensions/skill-evolve/test/hard-rules.test.ts +116 -0
  116. package/extensions/skill-evolve/test/lock.test.ts +138 -0
  117. package/extensions/skill-evolve/test/logfile.test.ts +85 -0
  118. package/extensions/skill-evolve/test/mechanical-wiring.test.ts +217 -0
  119. package/extensions/skill-evolve/test/metrics.test.ts +129 -0
  120. package/extensions/skill-evolve/test/model-config.test.ts +84 -0
  121. package/extensions/skill-evolve/test/notify.test.ts +133 -0
  122. package/extensions/skill-evolve/test/observe.test.ts +100 -0
  123. package/extensions/skill-evolve/test/panel-pid.test.ts +84 -0
  124. package/extensions/skill-evolve/test/panel.test.ts +115 -0
  125. package/extensions/skill-evolve/test/proposal.test.ts +159 -0
  126. package/extensions/skill-evolve/test/queue.test.ts +110 -0
  127. package/extensions/skill-evolve/test/reject.test.ts +134 -0
  128. package/extensions/skill-evolve/test/replay.test.ts +111 -0
  129. package/extensions/skill-evolve/test/report.test.ts +66 -0
  130. package/extensions/skill-evolve/test/review-post.test.ts +92 -0
  131. package/extensions/skill-evolve/test/review-prompt.test.ts +105 -0
  132. package/extensions/skill-evolve/test/review-spawn.test.ts +81 -0
  133. package/extensions/skill-evolve/test/review-supervisor.test.ts +243 -0
  134. package/extensions/skill-evolve/test/selftest.test.ts +113 -0
  135. package/extensions/skill-evolve/test/session-index.test.ts +128 -0
  136. package/extensions/skill-evolve/test/single-flight.test.ts +122 -0
  137. package/extensions/skill-evolve/test/stale.test.ts +272 -0
  138. package/extensions/skill-evolve/test/telemetry.test.ts +145 -0
  139. package/extensions/skill-evolve/test/ui-gate.test.ts +77 -0
  140. package/extensions/skill-evolve/test/web-actions.test.ts +262 -0
  141. package/extensions/skill-evolve/test/web-applied.test.ts +100 -0
  142. package/extensions/skill-evolve/test/web-audit.test.ts +87 -0
  143. package/extensions/skill-evolve/test/web-autostart.test.ts +268 -0
  144. package/extensions/skill-evolve/test/web-data.test.ts +420 -0
  145. package/extensions/skill-evolve/test/web-diff-lines.test.ts +155 -0
  146. package/extensions/skill-evolve/test/web-filter.test.ts +212 -0
  147. package/extensions/skill-evolve/test/web-panel-pid.test.ts +97 -0
  148. package/extensions/skill-evolve/test/web-render-run.test.ts +132 -0
  149. package/extensions/skill-evolve/test/web-render-skills.test.ts +134 -0
  150. package/extensions/skill-evolve/test/web-render.test.ts +300 -0
  151. package/extensions/skill-evolve/test/web-run-detail.test.ts +256 -0
  152. package/extensions/skill-evolve/test/web-server-views.test.ts +113 -0
  153. package/extensions/skill-evolve/test/web-server.test.ts +465 -0
  154. package/extensions/skill-evolve/test/web-shell.test.ts +67 -0
  155. package/extensions/skill-evolve/test/web-skills.test.ts +239 -0
  156. package/extensions/skill-evolve/test/weekly.test.ts +123 -0
  157. package/package.json +34 -4
@@ -0,0 +1,465 @@
1
+ /**
2
+ * 只读面板的 HTTP 外壳:真监听 127.0.0.1(port=0 取随机端口)、路由白名单、只读 405、端口占用结构化报错。
3
+ * 只起本地 http server,不起 pi / node 子进程。
4
+ */
5
+ import assert from "node:assert/strict";
6
+ import * as fs from "node:fs";
7
+ import * as http from "node:http";
8
+ import * as os from "node:os";
9
+ import * as path from "node:path";
10
+ import test from "node:test";
11
+ import type { WebState } from "../lib/web/data.ts";
12
+ import { writeWebPid } from "../lib/web/panel-pid.ts";
13
+ import { startWebPanel, type WebPanel, type WebPanelOptions } from "../lib/web/server.ts";
14
+
15
+ /** 不存在的 pid:Linux 的 pid_max 远小于 INT32_MAX,这个号系统里不会有进程 */
16
+ const DEAD_PID = 2_147_483_646;
17
+
18
+ function workspace(): string {
19
+ const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "skill-evolve-webserver-"));
20
+ const evolutionDir = path.join(cwd, ".pi", "evolution");
21
+ fs.mkdirSync(path.join(evolutionDir, "runs"), { recursive: true });
22
+ // 一条待人批的队列记录:确保页面不是空态,tab 文案与列表都真的渲染出来
23
+ fs.writeFileSync(
24
+ path.join(evolutionDir, "queue.json"),
25
+ JSON.stringify([
26
+ {
27
+ runId: "run-web",
28
+ level: "approval",
29
+ skill: "demo",
30
+ targets: [".pi/skills/demo/SKILL.md"],
31
+ createdAt: Date.UTC(2026, 10, 8, 12),
32
+ status: "pending",
33
+ },
34
+ ]),
35
+ "utf8",
36
+ );
37
+ return evolutionDir;
38
+ }
39
+
40
+ const FORM = "application/x-www-form-urlencoded";
41
+
42
+ async function open(evolutionDir: string, port = 0, extra: Partial<WebPanelOptions> = {}): Promise<WebPanel> {
43
+ const result = await startWebPanel({ evolutionDir, port, ...extra });
44
+ assert.equal(result.ok, true, `面板应该起来:${result.ok ? "" : result.message}`);
45
+ if (!result.ok) throw new Error("unreachable");
46
+ return result.panel;
47
+ }
48
+
49
+ test("GET /:真监听 127.0.0.1,返回四个 tab 与队列里的待批条目", async () => {
50
+ const panel = await open(workspace());
51
+ try {
52
+ assert.match(panel.url, /^http:\/\/127\.0\.0\.1:\d+$/);
53
+ const response = await fetch(panel.url);
54
+ assert.equal(response.status, 200);
55
+ assert.match(response.headers.get("content-type") ?? "", /text\/html/);
56
+ const html = await response.text();
57
+ assert.match(html, /待处理/);
58
+ assert.match(html, /待审批/);
59
+ assert.match(html, /已处理/);
60
+ assert.match(html, /复盘报告/);
61
+ assert.match(html, /\[待批\] run-web · demo/);
62
+ } finally {
63
+ await panel.close();
64
+ }
65
+ });
66
+
67
+ test("GET /?view=done 与 ?view=reports:切视图走 query,非法值回落待处理", async () => {
68
+ const panel = await open(workspace());
69
+ try {
70
+ const done = await (await fetch(`${panel.url}/?view=done`)).text();
71
+ assert.match(done, /<title>技能进化 · 已处理<\/title>/);
72
+ const reports = await (await fetch(`${panel.url}/?view=reports`)).text();
73
+ assert.match(reports, /<title>技能进化 · 复盘报告<\/title>/);
74
+ const bogus = await (await fetch(`${panel.url}/?view=nope`)).text();
75
+ assert.match(bogus, /<title>技能进化 · 待处理<\/title>/);
76
+ } finally {
77
+ await panel.close();
78
+ }
79
+ });
80
+
81
+ test("GET /api/state:合法 JSON 且带六个顶层字段", async () => {
82
+ const panel = await open(workspace());
83
+ try {
84
+ const response = await fetch(`${panel.url}/api/state`);
85
+ assert.equal(response.status, 200);
86
+ assert.match(response.headers.get("content-type") ?? "", /application\/json/);
87
+ const state = (await response.json()) as WebState;
88
+ assert.deepEqual(Object.keys(state).sort(), ["applied", "approvals", "done", "pending", "reports", "summary"]);
89
+ assert.equal(state.pending[0]?.runId, "run-web");
90
+ assert.equal(state.approvals[0]?.runId, "run-web");
91
+ assert.equal(state.summary.pendingApprovals, 1);
92
+ } finally {
93
+ await panel.close();
94
+ }
95
+ });
96
+
97
+ test("非 GET 一律 405 且带 allow: GET(面板不能写)", async () => {
98
+ const panel = await open(workspace());
99
+ try {
100
+ const response = await fetch(panel.url, { method: "POST", body: "x" });
101
+ assert.equal(response.status, 405);
102
+ assert.equal(response.headers.get("allow"), "GET");
103
+ // 405 之后面板照常工作:没有被写状态
104
+ assert.equal((await fetch(panel.url)).status, 200);
105
+ } finally {
106
+ await panel.close();
107
+ }
108
+ });
109
+
110
+ test("白名单之外一律 404:不接受任意文件读取", async () => {
111
+ const panel = await open(workspace());
112
+ try {
113
+ const passwd = await fetch(`${panel.url}/etc/passwd`);
114
+ assert.equal(passwd.status, 404);
115
+ const body = await passwd.text();
116
+ assert.equal(body.includes("root:"), false, "不能把文件内容吐出来");
117
+ assert.equal((await fetch(`${panel.url}/../.pi/evolution/queue.json`)).status, 404);
118
+ assert.equal((await fetch(`${panel.url}/api/state/extra`)).status, 404);
119
+ } finally {
120
+ await panel.close();
121
+ }
122
+ });
123
+
124
+ test("close() 幂等,关闭后可以从同一端口重新 start", async () => {
125
+ const evolutionDir = workspace();
126
+ const first = await open(evolutionDir);
127
+ const port = first.port;
128
+ await first.close();
129
+ await first.close(); // 重复关闭不抛 ERR_SERVER_NOT_RUNNING
130
+ await assert.rejects(fetch(`${first.url}/api/state`), "关闭后旧连接不再服务");
131
+
132
+ const second = await open(evolutionDir, port);
133
+ try {
134
+ assert.equal((await fetch(`${second.url}/api/state`)).status, 200);
135
+ } finally {
136
+ await second.close();
137
+ }
138
+ });
139
+
140
+ test("端口占用返回结构化 EADDRINUSE,不抛栈", async () => {
141
+ const evolutionDir = workspace();
142
+ const first = await open(evolutionDir);
143
+ try {
144
+ // 记录换成已死 pid:本用例只验结构化报错,不走「本机面板已在跑」那一支(那支在 web-panel-pid.test.ts)
145
+ writeWebPid(evolutionDir, { pid: DEAD_PID, host: "127.0.0.1", port: first.port, url: first.url, startedAt: Date.now() });
146
+ const second = await startWebPanel({ evolutionDir, port: first.port });
147
+ assert.equal(second.ok, false);
148
+ if (second.ok) throw new Error("同端口不该起来第二份");
149
+ assert.equal(second.code, "EADDRINUSE");
150
+ assert.match(second.message, /已被占用/);
151
+ assert.match(second.message, /\/skill-evolve web --port/);
152
+ } finally {
153
+ await first.close();
154
+ }
155
+ });
156
+
157
+ /** 记一次调用:POST 测试统一用这两个替身,不碰真写盘 */
158
+ function spy(result: string | Error = "已采纳 run-web"): { calls: string[]; fn: (runId: string) => Promise<string> } {
159
+ const calls: string[] = [];
160
+ return {
161
+ calls,
162
+ fn: async (runId: string) => {
163
+ calls.push(runId);
164
+ if (result instanceof Error) throw result;
165
+ return result;
166
+ },
167
+ };
168
+ }
169
+
170
+ function form(fields: Record<string, string>): { method: "POST"; body: string; headers: Record<string, string> } {
171
+ return {
172
+ method: "POST",
173
+ body: new URLSearchParams(fields).toString(),
174
+ headers: { "content-type": FORM },
175
+ };
176
+ }
177
+
178
+ /** 303 回跳:手动跟重定向,才看得到 location 与状态码 */
179
+ async function post(panel: WebPanel, pathname: string, fields: Record<string, string>, headers: Record<string, string> = {}) {
180
+ const init = form(fields);
181
+ return fetch(`${panel.url}${pathname}`, { ...init, headers: { ...init.headers, ...headers }, redirect: "manual" });
182
+ }
183
+
184
+ test("GET /?view=approvals:列表带批准/拒绝表单与 csrf hidden(注入回调才算可写)", async () => {
185
+ const approve = spy();
186
+ const reject = spy();
187
+ const panel = await open(workspace(), 0, { onApprove: approve.fn, onReject: reject.fn });
188
+ try {
189
+ const html = await (await fetch(`${panel.url}/?view=approvals`)).text();
190
+ assert.match(html, /<title>技能进化 · 待审批<\/title>/);
191
+ assert.match(html, /action="\/approve"/);
192
+ assert.match(html, /action="\/reject"/);
193
+ assert.match(html, new RegExp(`name="csrf" value="${panel.csrf}"`));
194
+ assert.match(html, /name="runId" value="run-web"/);
195
+ assert.match(html, /点「批准」= 在 TUI 敲/);
196
+ } finally {
197
+ await panel.close();
198
+ }
199
+ });
200
+
201
+ test("POST /approve:csrf 正确 → onApprove 恰一次,303 回待审批页带 msg 首行", async () => {
202
+ const approve = spy("已采纳 run-web(approval · 演示技能)\n第二行不进 URL");
203
+ const panel = await open(workspace(), 0, { onApprove: approve.fn, onReject: spy().fn });
204
+ try {
205
+ const response = await post(panel, "/approve", { csrf: panel.csrf, runId: "run-web" });
206
+ assert.equal(response.status, 303);
207
+ const location = new URL(response.headers.get("location") ?? "", panel.url);
208
+ assert.equal(location.pathname, "/");
209
+ assert.equal(location.searchParams.get("view"), "approvals");
210
+ assert.equal(location.searchParams.get("msg"), "已采纳 run-web(approval · 演示技能)");
211
+ assert.deepEqual(approve.calls, ["run-web"], "只调一次,runId 原样传下去");
212
+ } finally {
213
+ await panel.close();
214
+ }
215
+ });
216
+
217
+ test("POST /approve:csrf 不对 → 403,onApprove 一次都没被调", async () => {
218
+ const approve = spy();
219
+ const panel = await open(workspace(), 0, { onApprove: approve.fn, onReject: spy().fn });
220
+ try {
221
+ for (const csrf of ["wrong", ""]) {
222
+ const response = await post(panel, "/approve", { csrf, runId: "run-web" });
223
+ assert.equal(response.status, 403);
224
+ assert.match(await response.text(), /csrf 校验失败/);
225
+ }
226
+ assert.deepEqual(approve.calls, []);
227
+ } finally {
228
+ await panel.close();
229
+ }
230
+ });
231
+
232
+ test("POST /approve:Origin 是跨站的 http://evil.com → 403 且不执行", async () => {
233
+ const approve = spy();
234
+ const panel = await open(workspace(), 0, { onApprove: approve.fn, onReject: spy().fn });
235
+ try {
236
+ const response = await post(panel, "/approve", { csrf: panel.csrf, runId: "run-web" }, { origin: "http://evil.com" });
237
+ assert.equal(response.status, 403);
238
+ assert.match(await response.text(), /Origin 头与面板地址不一致/);
239
+ assert.deepEqual(approve.calls, []);
240
+ // 同源 Origin 是正常浏览器行为,必须放行
241
+ const ok = await post(panel, "/approve", { csrf: panel.csrf, runId: "run-web" }, { origin: panel.url });
242
+ assert.equal(ok.status, 303);
243
+ } finally {
244
+ await panel.close();
245
+ }
246
+ });
247
+
248
+ /**
249
+ * 裸 http 请求:fetch(undici)不让改 Host 头,而 Host 校验正是要挡的 DNS rebinding 面,
250
+ * 所以这一条必须手写请求。仍然只连本机,不起任何子进程。
251
+ */
252
+ function rawPost(panel: WebPanel, pathname: string, body: string, headers: Record<string, string>): Promise<{ status: number; text: string }> {
253
+ return new Promise((resolve, reject) => {
254
+ const request = http.request(
255
+ {
256
+ host: panel.host,
257
+ port: panel.port,
258
+ path: pathname,
259
+ method: "POST",
260
+ headers: { "content-type": FORM, "content-length": Buffer.byteLength(body), ...headers },
261
+ },
262
+ (response) => {
263
+ const chunks: Buffer[] = [];
264
+ response.on("data", (chunk: Buffer) => chunks.push(chunk));
265
+ response.on("end", () => resolve({ status: response.statusCode ?? 0, text: Buffer.concat(chunks).toString("utf8") }));
266
+ },
267
+ );
268
+ request.on("error", reject);
269
+ request.end(body);
270
+ });
271
+ }
272
+
273
+ test("POST /approve:Host 头不是本机当前端口 → 403(挡 DNS rebinding)", async () => {
274
+ const approve = spy();
275
+ const panel = await open(workspace(), 0, { onApprove: approve.fn, onReject: spy().fn });
276
+ try {
277
+ const body = new URLSearchParams({ csrf: panel.csrf, runId: "run-web" }).toString();
278
+ const denied = await rawPost(panel, "/approve", body, { host: "evil.example.com:80" });
279
+ assert.equal(denied.status, 403);
280
+ assert.match(denied.text, /Host 头不在白名单/);
281
+ assert.deepEqual(approve.calls, [], "Host 不对时一个动作都不能执行");
282
+ // 白名单里的 localhost 与 127.0.0.1 都要能过
283
+ const allowed = await rawPost(panel, "/approve", body, { host: `127.0.0.1:${panel.port}` });
284
+ assert.equal(allowed.status, 303);
285
+ } finally {
286
+ await panel.close();
287
+ }
288
+ });
289
+
290
+ test("POST /approve:请求体超 2KB → 413 且不执行", async () => {
291
+ const approve = spy();
292
+ const panel = await open(workspace(), 0, { onApprove: approve.fn, onReject: spy().fn });
293
+ try {
294
+ const response = await post(panel, "/approve", { csrf: panel.csrf, runId: "run-web", pad: "x".repeat(4096) });
295
+ assert.equal(response.status, 413);
296
+ assert.match(await response.text(), /超过 2048 字节/);
297
+ assert.deepEqual(approve.calls, []);
298
+ } finally {
299
+ await panel.close();
300
+ }
301
+ });
302
+
303
+ test("POST /approve:Content-Type 不是表单 → 415", async () => {
304
+ const approve = spy();
305
+ const panel = await open(workspace(), 0, { onApprove: approve.fn, onReject: spy().fn });
306
+ try {
307
+ const response = await fetch(`${panel.url}/approve`, {
308
+ method: "POST",
309
+ body: JSON.stringify({ csrf: panel.csrf, runId: "run-web" }),
310
+ headers: { "content-type": "application/json" },
311
+ redirect: "manual",
312
+ });
313
+ assert.equal(response.status, 415);
314
+ assert.deepEqual(approve.calls, []);
315
+ } finally {
316
+ await panel.close();
317
+ }
318
+ });
319
+
320
+ test("POST /approve:回调抛错 → 303 带 err,页面不变 500", async () => {
321
+ const approve = spy(new Error("闸2 没通过:md-check 报错"));
322
+ const panel = await open(workspace(), 0, { onApprove: approve.fn, onReject: spy().fn });
323
+ try {
324
+ const response = await post(panel, "/approve", { csrf: panel.csrf, runId: "run-web" });
325
+ assert.equal(response.status, 303);
326
+ const location = new URL(response.headers.get("location") ?? "", panel.url);
327
+ assert.equal(location.searchParams.get("err"), "Error: 闸2 没通过:md-check 报错");
328
+ assert.equal((await fetch(panel.url)).status, 200, "面板本身照常工作");
329
+ } finally {
330
+ await panel.close();
331
+ }
332
+ });
333
+
334
+ test("POST /reject:csrf 正确 → onReject 恰一次,303 回待审批页带 msg", async () => {
335
+ const reject = spy("已拒绝 run-web:技能文件未改动");
336
+ const approve = spy();
337
+ const panel = await open(workspace(), 0, { onApprove: approve.fn, onReject: reject.fn });
338
+ try {
339
+ const response = await post(panel, "/reject", { csrf: panel.csrf, runId: "run-web" });
340
+ assert.equal(response.status, 303);
341
+ const location = new URL(response.headers.get("location") ?? "", panel.url);
342
+ assert.equal(location.searchParams.get("view"), "approvals");
343
+ assert.equal(location.searchParams.get("msg"), "已拒绝 run-web:技能文件未改动");
344
+ assert.deepEqual(reject.calls, ["run-web"]);
345
+ assert.deepEqual(approve.calls, [], "拒绝不能走批准回调");
346
+ } finally {
347
+ await panel.close();
348
+ }
349
+ });
350
+
351
+ test("没注入回调:待审批页无按钮,POST /approve 返回 403(不是 405,路径本身合法)", async () => {
352
+ const panel = await open(workspace());
353
+ try {
354
+ const html = await (await fetch(`${panel.url}/?view=approvals`)).text();
355
+ assert.equal(html.includes("<form"), false);
356
+ assert.match(html, /本实例只读/);
357
+ const response = await post(panel, "/approve", { csrf: panel.csrf, runId: "run-web" });
358
+ assert.equal(response.status, 403);
359
+ assert.match(await response.text(), /本实例只读/);
360
+ } finally {
361
+ await panel.close();
362
+ }
363
+ });
364
+
365
+ test("POST /:路径不在写白名单 → 405 且带 allow: GET", async () => {
366
+ const panel = await open(workspace());
367
+ try {
368
+ const response = await post(panel, "/", { csrf: panel.csrf, runId: "run-web" });
369
+ assert.equal(response.status, 405);
370
+ assert.equal(response.headers.get("allow"), "GET");
371
+ } finally {
372
+ await panel.close();
373
+ }
374
+ });
375
+
376
+ test("csrf 由面板生成且唯一:两次 start 的令牌不同,显式传入则原样使用", async () => {
377
+ const evolutionDir = workspace();
378
+ const first = await open(evolutionDir);
379
+ const second = await open(evolutionDir);
380
+ try {
381
+ assert.match(first.csrf, /^[0-9a-f-]{36}$/);
382
+ assert.notEqual(first.csrf, second.csrf);
383
+ } finally {
384
+ await first.close();
385
+ await second.close();
386
+ }
387
+ const fixed = await open(evolutionDir, 0, { csrf: "fixed-token", onApprove: spy().fn, onReject: spy().fn });
388
+ try {
389
+ assert.equal(fixed.csrf, "fixed-token");
390
+ assert.match(await (await fetch(`${fixed.url}/?view=approvals`)).text(), /name="csrf" value="fixed-token"/);
391
+ } finally {
392
+ await fixed.close();
393
+ }
394
+ });
395
+
396
+ /** 造一个真 run 目录,child.log 里有 lineCount 行(末行带序号,方便断言「只回尾部」) */
397
+ function runWithLog(lineCount: number, runId = "run-log"): string {
398
+ const evolutionDir = workspace();
399
+ const runDir = path.join(evolutionDir, "runs", runId);
400
+ fs.mkdirSync(runDir, { recursive: true });
401
+ const body = Array.from({ length: lineCount }, (_, index) => `第 ${index + 1} 行`).join("\n");
402
+ fs.writeFileSync(path.join(runDir, "child.log"), `${body}\n`, "utf8");
403
+ return evolutionDir;
404
+ }
405
+
406
+ test("POST /action:csrf 不对 → 403,一个回调都不执行(顺序与 /approve 一致)", async () => {
407
+ let runs = 0;
408
+ const panel = await open(workspace(), 0, {
409
+ onRunNow: async () => {
410
+ runs += 1;
411
+ return "已派生复盘:run-new";
412
+ },
413
+ });
414
+ try {
415
+ const response = await post(panel, "/action", { csrf: "wrong-token", name: "run" });
416
+ assert.equal(response.status, 403);
417
+ assert.match(await response.text(), /csrf 校验失败/);
418
+ assert.equal(runs, 0, "csrf 不对时连 name 都不该被读");
419
+ } finally {
420
+ await panel.close();
421
+ }
422
+ });
423
+
424
+ test("GET /log:runId 出白名单或解析后跑出 runs/ → 404,且不吐文件内容", async () => {
425
+ const panel = await open(workspace());
426
+ try {
427
+ for (const bad of ["../../etc/passwd", "../queue.json", "a/b", "", ".."]) {
428
+ const response = await fetch(`${panel.url}/log?runId=${encodeURIComponent(bad)}`);
429
+ assert.equal(response.status, 404, `runId=${bad} 必须 404`);
430
+ assert.equal((await response.text()).includes("root:"), false);
431
+ }
432
+ } finally {
433
+ await panel.close();
434
+ }
435
+ });
436
+
437
+ test("GET /log:真实 run → 200 text/plain,带来源行与日志末行;没有 run 目录则 404", async () => {
438
+ const evolutionDir = runWithLog(3);
439
+ const panel = await open(evolutionDir);
440
+ try {
441
+ const response = await fetch(`${panel.url}/log?runId=run-log`);
442
+ assert.equal(response.status, 200);
443
+ assert.match(response.headers.get("content-type") ?? "", /text\/plain/);
444
+ const text = await response.text();
445
+ assert.match(text, /^# run-log · child\.log(末 3 行)/);
446
+ assert.match(text, /第 3 行/, "末行要在");
447
+ assert.equal((await fetch(`${panel.url}/log?runId=run-nope`)).status, 404);
448
+ } finally {
449
+ await panel.close();
450
+ }
451
+ });
452
+
453
+ test("GET /log:日志再长也只回末 200 行(面板不是下载器)", async () => {
454
+ const panel = await open(runWithLog(250));
455
+ try {
456
+ const text = await (await fetch(`${panel.url}/log?runId=run-log`)).text();
457
+ const lines = text.trimEnd().split("\n");
458
+ assert.equal(lines.length, 201, "首行来源 + 200 行日志");
459
+ assert.match(lines[0]!, /末 200 行/);
460
+ assert.equal(lines[1], "第 51 行", "前 50 行被截掉");
461
+ assert.equal(lines.at(-1), "第 250 行");
462
+ } finally {
463
+ await panel.close();
464
+ }
465
+ });
@@ -0,0 +1,67 @@
1
+ /**
2
+ * 页面骨架与导航:六个 tab 的顺序与高亮、状态栏里的两个跳转、`?view=` 白名单。
3
+ * 「已采纳」必须是独立 tab(用户原话:已采纳的 tab 单独出来),技能管理排最后。
4
+ */
5
+ import assert from "node:assert/strict";
6
+ import test from "node:test";
7
+ import type { WebState } from "../lib/web/data.ts";
8
+ import { parsePanelView, parseRedirectView, parseView } from "../lib/web/filter.ts";
9
+ import { TAB_ORDER, VIEW_LABEL, shellPage, statusBar, tabs } from "../lib/web/shell.ts";
10
+
11
+ function state(): WebState {
12
+ return {
13
+ pending: [],
14
+ approvals: [],
15
+ applied: [],
16
+ done: [],
17
+ reports: [],
18
+ summary: { reviewing: 2, stale: 1, pendingApprovals: 3, appliedTotal: 4, rollbackRate: 0.26 },
19
+ };
20
+ }
21
+
22
+ test("六个 tab:待处理 / 待审批 / 已采纳 / 已处理 / 复盘报告 / 技能管理", () => {
23
+ assert.deepEqual([...TAB_ORDER], ["pending", "approvals", "applied", "done", "reports", "skills"]);
24
+ assert.deepEqual(
25
+ TAB_ORDER.map((item) => VIEW_LABEL[item]),
26
+ ["待处理", "待审批", "已采纳", "已处理", "复盘报告", "技能管理"],
27
+ );
28
+ assert.equal(tabs("applied").split("\n").length, 6, "不能有第七个 tab 或漏掉一个");
29
+ });
30
+
31
+ test("高亮只落在当前 tab 上;详情页(active=null)一个都不高亮", () => {
32
+ const applied = tabs("applied");
33
+ assert.match(applied, /<a href="\/\?view=applied" class="active">已采纳<\/a>/);
34
+ assert.equal(applied.match(/class="active"/g)?.length, 1);
35
+ const skills = tabs("skills");
36
+ assert.match(skills, /<a href="\/\?view=skills" class="active">技能管理<\/a>/);
37
+ assert.equal(tabs(null).includes('class="active"'), false);
38
+ });
39
+
40
+ test("状态栏:「已采纳 N」自己就是一个链接,点数字落到已采纳页", () => {
41
+ const html = statusBar(state());
42
+ assert.match(html, /复盘中 2 · 卡死 1 · 待批 <a href="\/\?view=approvals">3<\/a> · 已采纳 <a href="\/\?view=applied">4<\/a> · 回滚率 26%/);
43
+ });
44
+
45
+ test("骨架:标题、状态栏、导航、正文、页脚各就各位", () => {
46
+ const html = shellPage({ state: state(), title: "已采纳", active: "applied", body: "<main>正文</main>" });
47
+ assert.match(html, /<title>技能进化 · 已采纳<\/title>/);
48
+ assert.match(html, /<p class="bar">复盘中 2/);
49
+ assert.match(html, /<nav>\n<a href="\/\?view=pending">待处理<\/a>/);
50
+ assert.match(html, /<\/nav>\n<main>正文<\/main>\n<footer>/);
51
+ });
52
+
53
+ test("?view= 白名单:三个新视图认,乱值一律回落待处理", () => {
54
+ assert.equal(parsePanelView("applied"), "applied");
55
+ assert.equal(parsePanelView("skills"), "skills");
56
+ assert.equal(parsePanelView("skill"), "skill");
57
+ assert.equal(parsePanelView("run"), "run");
58
+ for (const bad of [null, "", "done2", "../skills", "SKILLS"]) {
59
+ assert.equal(parsePanelView(bad), "pending", `${String(bad)} 不该被当成视图名`);
60
+ }
61
+ // actions 的 303 回跳只有列表页能显示横幅,详情页归一成待处理
62
+ assert.equal(parseRedirectView("applied"), "applied");
63
+ assert.equal(parseRedirectView("skills"), "skills");
64
+ assert.equal(parseRedirectView("run"), "pending");
65
+ assert.equal(parseRedirectView("skill"), "pending");
66
+ assert.equal(parseView("run"), "pending");
67
+ });