oh-my-agent-web 0.2.0 → 0.4.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 (250) hide show
  1. package/README.md +86 -447
  2. package/dist/server/approval/read-only.js +107 -0
  3. package/dist/server/approval/rules.js +85 -0
  4. package/dist/server/approval/scope.js +150 -0
  5. package/dist/server/brand.js +2 -0
  6. package/dist/server/cli-config.js +52 -0
  7. package/dist/server/cli-info.js +101 -0
  8. package/dist/server/cli.js +304 -0
  9. package/dist/server/engine/builtin.js +61 -0
  10. package/dist/server/engine/catalog.js +8 -0
  11. package/dist/server/engine/codex-approvals.js +45 -0
  12. package/dist/server/engine/codex-map.js +298 -0
  13. package/dist/server/engine/codex-sessions.js +159 -0
  14. package/dist/server/engine/codex-transport.js +49 -0
  15. package/dist/server/engine/codex.js +314 -0
  16. package/dist/server/engine/dsh-history.js +195 -0
  17. package/dist/server/engine/dsh-log.js +245 -0
  18. package/dist/server/engine/dsh-map.js +221 -0
  19. package/dist/server/engine/dsh-sessions.js +207 -0
  20. package/dist/server/engine/dsh-transport.js +37 -0
  21. package/dist/server/engine/dsh.js +284 -0
  22. package/dist/server/engine/fake.js +231 -0
  23. package/dist/server/engine/jsonrpc-stdio.js +241 -0
  24. package/dist/server/engine/model-registry-reload.js +115 -0
  25. package/dist/server/engine/pi-events.js +148 -0
  26. package/dist/server/engine/pi-rename.js +14 -0
  27. package/dist/server/engine/pi-sdk.js +343 -0
  28. package/dist/server/engine/pi-session-model.js +23 -0
  29. package/dist/server/engine/pi-thinking.js +34 -0
  30. package/dist/server/engine/pi-transcript.js +225 -0
  31. package/dist/server/engine/pi-version.js +11 -0
  32. package/dist/server/engine/port.js +1 -0
  33. package/dist/server/engine/registry.js +47 -0
  34. package/dist/server/engine/scratch.js +13 -0
  35. package/dist/server/engine/suggestion-guidance.js +9 -0
  36. package/dist/server/engine/switchable.js +22 -0
  37. package/dist/server/engine/wiring.js +107 -0
  38. package/dist/server/files/dir-routes.js +40 -0
  39. package/dist/server/files/file-search.js +63 -0
  40. package/dist/server/files/file-service.js +232 -0
  41. package/dist/server/files/files-routes.js +182 -0
  42. package/dist/server/files/git-info.js +186 -0
  43. package/dist/server/http/auth-routes.js +99 -0
  44. package/dist/server/http/auth.js +191 -0
  45. package/dist/server/http/aux-routes.js +24 -0
  46. package/dist/server/http/compress.js +75 -0
  47. package/dist/server/http/health-and-static.js +33 -0
  48. package/dist/server/http/live-routes.js +113 -0
  49. package/dist/server/http/login-page.js +200 -0
  50. package/dist/server/index.js +342 -520
  51. package/dist/server/markers/actions.js +262 -0
  52. package/dist/server/protocol-parse.js +201 -0
  53. package/dist/server/protocol-settings.js +1 -0
  54. package/dist/server/protocol.js +6 -0
  55. package/dist/server/providers/catalog-store.js +85 -0
  56. package/dist/server/providers/catalog-writes.js +197 -0
  57. package/dist/server/providers/catalog.js +77 -0
  58. package/dist/server/providers/enabled-models.js +118 -0
  59. package/dist/server/providers/import-models.js +69 -0
  60. package/dist/server/providers/import-routes.js +136 -0
  61. package/dist/server/providers/model-test.js +52 -0
  62. package/dist/server/providers/probe.js +64 -0
  63. package/dist/server/providers/provider-handlers.js +203 -0
  64. package/dist/server/providers/provider-store.js +327 -0
  65. package/dist/server/providers/request-headers.js +7 -0
  66. package/dist/server/providers/validate.js +241 -0
  67. package/dist/server/push/push-routes.js +114 -0
  68. package/dist/server/push/push.js +120 -0
  69. package/dist/server/session/approval-bridge.js +103 -0
  70. package/dist/server/session/attachment-resolve.js +21 -0
  71. package/dist/server/session/client-view.js +82 -0
  72. package/dist/server/session/conversation-create.js +65 -0
  73. package/dist/server/session/delete-session.js +84 -0
  74. package/dist/server/session/engine-switch.js +108 -0
  75. package/dist/server/session/lock.js +82 -0
  76. package/dist/server/session/model-controller.js +111 -0
  77. package/dist/server/session/notices.js +31 -0
  78. package/dist/server/session/pi-opener.js +123 -0
  79. package/dist/server/session/pi-refresh.js +51 -0
  80. package/dist/server/session/pi-sessions-provider.js +46 -0
  81. package/dist/server/session/pi-sessions.js +272 -0
  82. package/dist/server/session/refresh-actions.js +42 -0
  83. package/dist/server/session/rename-session.js +23 -0
  84. package/dist/server/session/runtime-services.js +28 -0
  85. package/dist/server/session/runtime-types.js +1 -0
  86. package/dist/server/session/runtime.js +358 -0
  87. package/dist/server/session/session-stats.js +88 -0
  88. package/dist/server/session/settings.js +367 -0
  89. package/dist/server/session/snapshot-for.js +7 -0
  90. package/dist/server/session/stats-refresher.js +50 -0
  91. package/dist/server/session/switch-conversation.js +86 -0
  92. package/dist/server/session/throttle.js +14 -0
  93. package/dist/server/session/turn-events.js +126 -0
  94. package/dist/server/session/turn-finish.js +79 -0
  95. package/dist/server/session/turn-markers.js +26 -0
  96. package/dist/server/session/turn-queue.js +111 -0
  97. package/dist/server/session/turn-send.js +88 -0
  98. package/dist/server/session/turn-watchdog.js +77 -0
  99. package/dist/server/sessions/session-routes.js +17 -0
  100. package/dist/server/skills/skill-routes.js +28 -0
  101. package/dist/server/skills/skill-store.js +134 -0
  102. package/dist/server/snapshot/fan-out.js +10 -0
  103. package/dist/server/snapshot/messages.js +75 -0
  104. package/dist/server/snapshot/sender.js +62 -0
  105. package/dist/server/snapshot/state.js +193 -0
  106. package/dist/server/terminal/terminal-routes.js +176 -0
  107. package/dist/server/terminal/terminal-session.js +140 -0
  108. package/dist/server/uploads/upload-routes.js +64 -0
  109. package/dist/server/uploads/upload-store.js +115 -0
  110. package/dist/server/util/data-dir.js +36 -0
  111. package/dist/server/util/env.js +140 -0
  112. package/dist/server/util/ids.js +28 -0
  113. package/dist/server/util/log-file.js +67 -0
  114. package/dist/server/util/log.js +52 -0
  115. package/dist/server/util/origin.js +75 -0
  116. package/dist/server/util/paths.js +60 -0
  117. package/dist/server/util/pricing-index.js +121 -0
  118. package/dist/server/util/pricing.js +214 -0
  119. package/dist/server/util/startup.js +34 -0
  120. package/dist/server/util/upstream-version.js +140 -0
  121. package/dist/server/util/usage.js +19 -0
  122. package/dist/server/util/version.js +50 -0
  123. package/dist/server/ws/handle-message.js +95 -0
  124. package/package.json +49 -63
  125. package/web/dist/apple-touch-icon-precomposed.png +0 -0
  126. package/web/dist/apple-touch-icon.png +0 -0
  127. package/web/dist/assets/abnfDiagram-WEYOIA3C-NLp09iEC.js +1 -0
  128. package/web/dist/assets/arc-DviL9o3c.js +1 -0
  129. package/web/dist/assets/architecture-WOLXFQ4H-fjsM_Sy3.js +1 -0
  130. package/web/dist/assets/architectureDiagram-ZHVYKT53-Cn3B4TE8.js +36 -0
  131. package/web/dist/assets/array-BifhSqXX.js +1 -0
  132. package/web/dist/assets/blockDiagram-RJAE7QA3-CIuvxWvi.js +140 -0
  133. package/web/dist/assets/c4Diagram-XDPPPE54-DvdKmEpF.js +38 -0
  134. package/web/dist/assets/channel-C3AQ6nAs.js +1 -0
  135. package/web/dist/assets/chunk-2E7WHVMD-HNNASGCS.js +88 -0
  136. package/web/dist/assets/chunk-2Q5K7J3B-C1jixKkw.js +1 -0
  137. package/web/dist/assets/chunk-4EMNKR2T-Cg-eRwBE.js +1 -0
  138. package/web/dist/assets/chunk-55XCU76K-BweiisZL.js +1 -0
  139. package/web/dist/assets/chunk-5VM5RSS4-ZNzvKenW.js +15 -0
  140. package/web/dist/assets/chunk-7S6XSTFO-BCzIiP-6.js +2 -0
  141. package/web/dist/assets/chunk-AQDTSKZB-Dl0NRFuU.js +72 -0
  142. package/web/dist/assets/chunk-DSNLSHJZ-zhHCjjN9.js +129 -0
  143. package/web/dist/assets/chunk-DUW6YSOI-Dgx8z5s3.js +1 -0
  144. package/web/dist/assets/chunk-HAGFOTED-BDQqU2EH.js +62 -0
  145. package/web/dist/assets/chunk-HNE4Q36P-B-qRzMjX.js +1 -0
  146. package/web/dist/assets/chunk-J5ZVWO5B-oDCd-gWO.js +1 -0
  147. package/web/dist/assets/chunk-JWPE2WC7-DVXcaiue.js +1 -0
  148. package/web/dist/assets/chunk-NGNAAXSQ-C2Wy9Xmz.js +136 -0
  149. package/web/dist/assets/chunk-NQOKRVIF-CNcvhMn_.js +213 -0
  150. package/web/dist/assets/chunk-UHW7PFBP-Dwr8LUzr.js +1 -0
  151. package/web/dist/assets/chunk-VQ5W62QI-O8sN6jJu.js +1 -0
  152. package/web/dist/assets/chunk-WQQL4CDK-B6-59Zg2.js +10 -0
  153. package/web/dist/assets/chunk-XXDRQBXY-DvSbeUlZ.js +1 -0
  154. package/web/dist/assets/chunk-Y2CYZVJY-DsF7k-Jl.js +1 -0
  155. package/web/dist/assets/chunk-YZO67HFG-BXpKYKsw.js +1 -0
  156. package/web/dist/assets/chunk-ZAUX2LTX-B1Z5VSkz.js +1 -0
  157. package/web/dist/assets/chunk-ZCHMQSOR-h_5mBLIA.js +2 -0
  158. package/web/dist/assets/classDiagram-v2-4MYWR3UA-Dr6JnaaV.js +217 -0
  159. package/web/dist/assets/cose-bilkent-JH36ORCC-36ShkR7R.js +1 -0
  160. package/web/dist/assets/cynefin-EF2NZ3EQ-Dg6bI7pC.js +1 -0
  161. package/web/dist/assets/cynefinDiagram-WF24UDRP-B0YHpBlR.js +62 -0
  162. package/web/dist/assets/cytoscape.esm-BegBNzOV.js +321 -0
  163. package/web/dist/assets/dagre-NK4GIZVX-Cx3mn7YS.js +4 -0
  164. package/web/dist/assets/defaultLocale-BFoDCU3G.js +1 -0
  165. package/web/dist/assets/diagram-CBK3MWTC-l8aZG_Bm.js +24 -0
  166. package/web/dist/assets/diagram-I5XIIV2D-Bd5f14_h.js +24 -0
  167. package/web/dist/assets/diagram-ODIL2UCW-hBsxCYUj.js +30 -0
  168. package/web/dist/assets/diagram-PFBEJUXE-BgpIBUYl.js +3 -0
  169. package/web/dist/assets/diagram-RJA3I4VV-CDU1gUVD.js +41 -0
  170. package/web/dist/assets/diagram-TYUNFSNR-Iuxh7Tqf.js +200 -0
  171. package/web/dist/assets/dist-C86cKTuE.js +1 -0
  172. package/web/dist/assets/ebnfDiagram-V4P3TTPT-Cms0HFKh.js +1 -0
  173. package/web/dist/assets/elk-QONTBCTQ-B7yEy0PQ.js +27 -0
  174. package/web/dist/assets/erDiagram-KYQIGQAA-CL48j4aU.js +99 -0
  175. package/web/dist/assets/eventmodeling-K75KTNOO-CFvkaejh.js +1 -0
  176. package/web/dist/assets/flowDiagram-IUGLLWBU-6UyKXpqG.js +1 -0
  177. package/web/dist/assets/ganttDiagram-R7GPNTJY-vdMr-0ZV.js +292 -0
  178. package/web/dist/assets/gitGraph-VSP46ZUC-2z85Wuwo.js +1 -0
  179. package/web/dist/assets/gitGraphDiagram-PDYL7XHY-BY4GPiqC.js +106 -0
  180. package/web/dist/assets/graphlib-DS17s2tU.js +1 -0
  181. package/web/dist/assets/index-Bjn56a2i.js +76 -0
  182. package/web/dist/assets/index-Drp6e5iU.css +1 -0
  183. package/web/dist/assets/info-OHQRW6UA-ECzkYTx-.js +1 -0
  184. package/web/dist/assets/infoDiagram-MVDRKAX5-DDk5EAeb.js +2 -0
  185. package/web/dist/assets/init-C-OQMol4.js +1 -0
  186. package/web/dist/assets/ishikawaDiagram-HPEHV2GE-9yPH4J94.js +70 -0
  187. package/web/dist/assets/journeyDiagram-BOSHW7O7-BPTIap3l.js +139 -0
  188. package/web/dist/assets/kanban-definition-FNSA4HC5-BbADHrA8.js +89 -0
  189. package/web/dist/assets/katex-B_JcIwqj.js +257 -0
  190. package/web/dist/assets/line-CHV9_4lL.js +1 -0
  191. package/web/dist/assets/linear-C-eZsBTv.js +1 -0
  192. package/web/dist/assets/mermaid-parser.core-BiAhX1l1.js +7 -0
  193. package/web/dist/assets/mermaid.core-BxP_A_zj.js +44 -0
  194. package/web/dist/assets/mindmap-definition-6OY57UG3-BMWlit95.js +98 -0
  195. package/web/dist/assets/ordinal-BDEzSJ7C.js +1 -0
  196. package/web/dist/assets/packet-JDAUHWVQ-FVApuKOp.js +1 -0
  197. package/web/dist/assets/path-fybaL0A-.js +1 -0
  198. package/web/dist/assets/pegDiagram-ECIUJNBX-WjWJvDHM.js +1 -0
  199. package/web/dist/assets/pie-XZMESJXO-BGCZnvhp.js +1 -0
  200. package/web/dist/assets/pieDiagram-GNMPY74T-DJpAC_JC.js +39 -0
  201. package/web/dist/assets/quadrantDiagram-D57K4MC3-hxx5c7oq.js +7 -0
  202. package/web/dist/assets/radar-ABXABTNO-BU9Shc-O.js +1 -0
  203. package/web/dist/assets/railroad-I3PHUGI6-pWj8d-G4.js +1 -0
  204. package/web/dist/assets/railroad-abnf-LNEFI6M7-D7HZBTAj.js +1 -0
  205. package/web/dist/assets/railroad-ebnf-DWYD2UWJ-CKiXJSuM.js +1 -0
  206. package/web/dist/assets/railroad-peg-JR7BVNR7-DB3VJHgR.js +1 -0
  207. package/web/dist/assets/railroadDiagram-Y72NRBL7-Cev7Y65k.js +1 -0
  208. package/web/dist/assets/requirementDiagram-EQYSW5RA-CqbwMLD8.js +84 -0
  209. package/web/dist/assets/rough.esm-Dy-Kn_BL.js +1 -0
  210. package/web/dist/assets/sankeyDiagram-SY24JHO5-V2cyVi8q.js +40 -0
  211. package/web/dist/assets/sequenceDiagram-BLEHNHQU-By6NJOPM.js +169 -0
  212. package/web/dist/assets/sizeCapture-INFHLROL-3uHXrFqA.js +1 -0
  213. package/web/dist/assets/src-Da91fG3g.js +1 -0
  214. package/web/dist/assets/stateDiagram-v2-A6IESKWZ-BnPGX5UI.js +291 -0
  215. package/web/dist/assets/swimlanes-B3ENRKFQ-C58BpIpm.js +1 -0
  216. package/web/dist/assets/swimlanesDiagram-AYQRV4AK-RaivxSsc.js +8 -0
  217. package/web/dist/assets/timeline-definition-JXYKGLGB-Hgo1DhnS.js +120 -0
  218. package/web/dist/assets/treeView-D4JQ5SDB-DF0dfjqB.js +1 -0
  219. package/web/dist/assets/treemap-SAJKECNS-CSSM5FqB.js +1 -0
  220. package/web/dist/assets/usecaseDiagram-WPOLBNNX-BkBBD1NY.js +328 -0
  221. package/web/dist/assets/vennDiagram-Q3Y3IDCW-Bm28keX_.js +34 -0
  222. package/web/dist/assets/wardley-7MLQ67FV-By7KgEQX.js +1 -0
  223. package/web/dist/assets/wardleyDiagram-IVAIDKWA-oVtIrnGa.js +78 -0
  224. package/web/dist/assets/xychartDiagram-4URNHDU4-aI8FH4g2.js +7 -0
  225. package/web/dist/favicon-32.png +0 -0
  226. package/web/dist/favicon.ico +0 -0
  227. package/web/dist/favicon.svg +20 -8
  228. package/web/dist/icons/apple-touch-icon-180.png +0 -0
  229. package/web/dist/icons/icon-192.png +0 -0
  230. package/web/dist/icons/icon-512.png +0 -0
  231. package/web/dist/icons/icon-maskable-512.png +0 -0
  232. package/web/dist/index.html +21 -27
  233. package/web/dist/manifest.webmanifest +17 -0
  234. package/web/dist/maskable.svg +25 -0
  235. package/web/dist/sw.js +40 -0
  236. package/bin/oh-my-agent-web-ps.mjs +0 -203
  237. package/bin/oh-my-agent-web-ws.mjs +0 -593
  238. package/bin/oh-my-agent-web.mjs +0 -321
  239. package/bin/update-check.mjs +0 -142
  240. package/dist/server/codex-client.js +0 -310
  241. package/dist/server/codex-supervisor.js +0 -446
  242. package/dist/server/fs-service.js +0 -144
  243. package/dist/server/request-inspector.js +0 -458
  244. package/dist/shared/protocol.js +0 -112
  245. package/scripts/manage-server.mjs +0 -155
  246. package/scripts/omaw.mjs +0 -74
  247. package/shared/protocol.ts +0 -228
  248. package/web/dist/assets/index-B8-VzDf2.css +0 -1
  249. package/web/dist/assets/index-DeC2eiiq.js +0 -42
  250. package/web/dist/assets/markdown-CYXe6JZx.js +0 -55
@@ -0,0 +1,182 @@
1
+ /**
2
+ * 文件浏览的 HTTP 路由(M4)——从 index.ts 抽出(400 行护栏)。
3
+ *
4
+ * `GET /api/files/list?path=<相对路径>` → 列一层目录(懒惰展开)
5
+ * `GET /api/files/read?path=<相对路径>` → 只读预览(≤512KB,二进制给标记)
6
+ *
7
+ * `workspaceOf()` 返回**当前会话**的工作区(多项目:文件树跟着当前会话走,ADR-0021 §3)。
8
+ */
9
+ import { createReadStream, realpathSync, statSync } from "node:fs";
10
+ import { listDirectory, parseRange, readPreview, readRaw, resolveMediaFile } from "./file-service.js";
11
+ import { searchFiles } from "./file-search.js";
12
+ import { checkoutBranch, readGitBranches } from "./git-info.js";
13
+ export function registerFileRoutes(app, options) {
14
+ app.get("/api/files/list", async (request, response) => {
15
+ const root = rootOf(request.query.root, options.workspaceOf());
16
+ if (!root) {
17
+ response
18
+ .status(409)
19
+ .json({ error: { code: "files.no_workspace", message: "还没有打开会话(不知道是哪个项目)" } });
20
+ return;
21
+ }
22
+ const relPath = typeof request.query.path === "string" ? request.query.path : "";
23
+ // 隐藏文件由界面的「眼睛」按钮显式打开(?hidden=1)
24
+ const result = await listDirectory(root, relPath, { showHidden: request.query.hidden === "1" });
25
+ if (!result.ok) {
26
+ response.status(400).json({ error: { code: "files.list_failed", message: result.error } });
27
+ return;
28
+ }
29
+ response.json(result);
30
+ });
31
+ // markdown 图片用:直接回字节(只放行图片/PDF 白名单,仍受工作区约束)
32
+ app.get("/api/files/raw", async (request, response) => {
33
+ const root = rootOf(request.query.root, options.workspaceOf());
34
+ if (!root) {
35
+ response
36
+ .status(409)
37
+ .json({ error: { code: "files.no_workspace", message: "还没有打开会话(不知道是哪个项目)" } });
38
+ return;
39
+ }
40
+ const relPath = typeof request.query.path === "string" ? request.query.path : "";
41
+ const result = await readRaw(root, relPath);
42
+ if (!result.ok) {
43
+ response.status(result.tooLarge ? 413 : 400).json({ error: { code: "files.raw_failed", message: result.error } });
44
+ return;
45
+ }
46
+ response.setHeader("Content-Type", result.mime);
47
+ response.setHeader("Cache-Control", "private, max-age=60");
48
+ response.send(result.bytes);
49
+ });
50
+ /**
51
+ * 视频 / 音频:**流式 + Range**(拖动进度条、边下边播都靠它)。
52
+ * 只让白名单里的媒体类型通过;大小上限 4GB;路径同样受工作区约束。
53
+ */
54
+ app.get("/api/files/media", async (request, response) => {
55
+ const root = rootOf(request.query.root, options.workspaceOf());
56
+ if (!root) {
57
+ response
58
+ .status(409)
59
+ .json({ error: { code: "files.no_workspace", message: "还没有打开会话(不知道是哪个项目)" } });
60
+ return;
61
+ }
62
+ const relPath = typeof request.query.path === "string" ? request.query.path : "";
63
+ const file = await resolveMediaFile(root, relPath);
64
+ if (!file.ok) {
65
+ response.status(file.tooLarge ? 413 : 400).json({ error: { code: "files.media_failed", message: file.error } });
66
+ return;
67
+ }
68
+ const range = parseRange(request.headers.range, file.size);
69
+ response.setHeader("Content-Type", file.mime);
70
+ response.setHeader("Accept-Ranges", "bytes");
71
+ response.setHeader("Cache-Control", "private, max-age=600");
72
+ if (range) {
73
+ response.status(206);
74
+ response.setHeader("Content-Range", `bytes ${range.start}-${range.end}/${file.size}`);
75
+ response.setHeader("Content-Length", String(range.end - range.start + 1));
76
+ }
77
+ else {
78
+ response.setHeader("Content-Length", String(file.size));
79
+ }
80
+ if (request.method === "HEAD") {
81
+ response.end();
82
+ return;
83
+ }
84
+ createReadStream(file.path, range ? { start: range.start, end: range.end } : undefined)
85
+ .on("error", () => response.destroy())
86
+ .pipe(response);
87
+ });
88
+ /** 文件名搜索(放大镜):只匹配名字,不做内容搜索 */
89
+ app.get("/api/files/search", async (request, response) => {
90
+ const root = rootOf(request.query.root, options.workspaceOf());
91
+ if (!root) {
92
+ response
93
+ .status(409)
94
+ .json({ error: { code: "files.no_workspace", message: "还没有打开会话(不知道是哪个项目)" } });
95
+ return;
96
+ }
97
+ const query = typeof request.query.q === "string" ? request.query.q : "";
98
+ const result = await searchFiles(root, query);
99
+ if (!result.ok) {
100
+ response.status(400).json({ error: { code: "files.search_failed", message: result.error } });
101
+ return;
102
+ }
103
+ response.json(result);
104
+ });
105
+ /** 当前项目的 git 信息(分支名 + 本地分支清单;不是仓库就 isRepo=false) */
106
+ app.get("/api/files/git", async (request, response) => {
107
+ const root = rootOf(request.query.root, options.workspaceOf());
108
+ if (!root) {
109
+ response.json({ isRepo: false, branch: null, branches: [] });
110
+ return;
111
+ }
112
+ response.json(await readGitBranches(root));
113
+ });
114
+ /**
115
+ * 切分支:`{ root, branch, force? }` → `git checkout <branch>`。
116
+ * 只接受**本地已有分支**;工作区有未提交改动时先 409 回一份改动清单(`files.dirty_worktree`),
117
+ * 界面确认后带 `force: true` 再发一次。冲突时 git 会拒绝,报错原样回给界面。
118
+ */
119
+ app.post("/api/files/git/checkout", async (request, response) => {
120
+ const body = request.body;
121
+ const root = rootOf(body?.root, options.workspaceOf());
122
+ if (!root) {
123
+ response
124
+ .status(409)
125
+ .json({ error: { code: "files.no_workspace", message: "还没有打开会话(不知道是哪个项目)" } });
126
+ return;
127
+ }
128
+ if (typeof body?.branch !== "string" || body.branch.trim() === "") {
129
+ response.status(400).json({ error: { code: "files.no_branch", message: "没有指定要切换的分支" } });
130
+ return;
131
+ }
132
+ const result = await checkoutBranch(root, body.branch, { force: body.force === true });
133
+ if (!result.ok) {
134
+ if (result.dirty) {
135
+ response.status(409).json({
136
+ error: {
137
+ code: "files.dirty_worktree",
138
+ message: `工作区有 ${result.dirty.total} 个未提交改动`,
139
+ files: result.dirty.files,
140
+ total: result.dirty.total,
141
+ },
142
+ });
143
+ return;
144
+ }
145
+ response.status(400).json({ error: { code: "files.checkout_failed", message: result.message } });
146
+ return;
147
+ }
148
+ response.json({ ...(await readGitBranches(root)), branch: result.branch });
149
+ });
150
+ app.get("/api/files/read", async (request, response) => {
151
+ const root = rootOf(request.query.root, options.workspaceOf());
152
+ if (!root) {
153
+ response
154
+ .status(409)
155
+ .json({ error: { code: "files.no_workspace", message: "还没有打开会话(不知道是哪个项目)" } });
156
+ return;
157
+ }
158
+ const relPath = typeof request.query.path === "string" ? request.query.path : "";
159
+ const result = await readPreview(root, relPath);
160
+ if (!result.ok) {
161
+ response.status(400).json({ error: { code: "files.read_failed", message: result.error } });
162
+ return;
163
+ }
164
+ response.json(result);
165
+ });
166
+ }
167
+ /**
168
+ * 文件根目录:默认是当前会话的工作区;「在文件中打开」某个项目时传 root 覆盖。
169
+ * 校验:必须是存在的目录(realpath)。不做工作区包含检查 —— 因为根目录本身就是工作区,
170
+ * 一旦允许换根,"包含于工作区"就退化成"包含于自己",没有意义;真正的边界是**只读**。
171
+ */
172
+ function rootOf(raw, fallback) {
173
+ if (typeof raw !== "string" || !raw.startsWith("/"))
174
+ return fallback;
175
+ try {
176
+ const real = realpathSync(raw);
177
+ return statSync(real).isDirectory() ? real : fallback;
178
+ }
179
+ catch {
180
+ return fallback;
181
+ }
182
+ }
@@ -0,0 +1,186 @@
1
+ /**
2
+ * 当前项目的 git 分支(文件管理下拉框右边那枚「⎇ main」小标签)。
3
+ *
4
+ * 两件事分开做:
5
+ * - **读当前分支**:直接读 `.git/HEAD`,不 spawn 进程 —— 只读几十字节,
6
+ * 也不需要 git 装在某处、不会因为仓库损坏而卡住;
7
+ * - **列分支 / 切分支**(下拉菜单):必须 spawn `git`(refs 可能塞在 packed-refs、
8
+ * 切分支还要更新工作区),带超时、不带 shell、参数逐个传(注入不了)。
9
+ * 兼容 `.git` 是**文件**的情况(worktree / submodule 里 `.git` 指向别处)。
10
+ */
11
+ import { spawn } from "node:child_process";
12
+ import { readFile, stat } from "node:fs/promises";
13
+ import { join, isAbsolute, resolve } from "node:path";
14
+ /** 纯函数:把 HEAD 内容解析成分支名(detached 时返回短 hash) */
15
+ export function parseHead(content) {
16
+ const text = content.trim();
17
+ if (text === "")
18
+ return null;
19
+ const ref = /^ref:\s*refs\/heads\/(.+)$/.exec(text);
20
+ if (ref?.[1])
21
+ return ref[1];
22
+ // detached HEAD:直接是 40 位 sha,取前 7 位够认
23
+ return /^[0-9a-f]{7,40}$/i.test(text) ? text.slice(0, 7) : null;
24
+ }
25
+ /** 工作区可能在仓库的子目录里:从 root 往上找 6 层 `.git`,返回**仓库(工作树)根** */
26
+ export async function findRepoRoot(root) {
27
+ let dir = resolve(root);
28
+ for (let depth = 0; depth < 6; depth += 1) {
29
+ try {
30
+ await stat(join(dir, ".git"));
31
+ return dir;
32
+ }
33
+ catch {
34
+ const parent = resolve(dir, "..");
35
+ if (parent === dir)
36
+ break;
37
+ dir = parent;
38
+ }
39
+ }
40
+ return null;
41
+ }
42
+ export async function readGitInfo(root) {
43
+ const repo = await findRepoRoot(root);
44
+ if (!repo)
45
+ return { branch: null, isRepo: false };
46
+ const gitPath = join(repo, ".git");
47
+ try {
48
+ const info = await stat(gitPath);
49
+ const headPath = info.isDirectory() ? join(gitPath, "HEAD") : await worktreeHead(gitPath);
50
+ if (!headPath)
51
+ return { branch: null, isRepo: false };
52
+ return { branch: parseHead(await readFile(headPath, "utf8")), isRepo: true };
53
+ }
54
+ catch {
55
+ return { branch: null, isRepo: false };
56
+ }
57
+ }
58
+ /** `.git` 是文件时内容形如 `gitdir: /path/to/repo/.git/worktrees/x` */
59
+ async function worktreeHead(gitFile) {
60
+ try {
61
+ const content = (await readFile(gitFile, "utf8")).trim();
62
+ const match = /^gitdir:\s*(.+)$/.exec(content);
63
+ if (!match?.[1])
64
+ return null;
65
+ const target = isAbsolute(match[1]) ? match[1] : resolve(join(gitFile, ".."), match[1]);
66
+ return join(target, "HEAD");
67
+ }
68
+ catch {
69
+ return null;
70
+ }
71
+ }
72
+ /**
73
+ * 跑一条 git 命令:`git -C <repo> …`,5 秒超时,`LC_ALL=C`(错误信息稳定成英文)。
74
+ * 不经过 shell —— 分支名即使带 `;` `$()` 也只是普通参数。
75
+ */
76
+ function runGit(repo, args, timeoutMs = 5000) {
77
+ return new Promise((done) => {
78
+ let stdout = "";
79
+ let stderr = "";
80
+ let settled = false;
81
+ const finish = (result) => {
82
+ if (settled)
83
+ return;
84
+ settled = true;
85
+ clearTimeout(timer);
86
+ done(result);
87
+ };
88
+ const child = spawn("git", ["-C", repo, ...args], {
89
+ stdio: ["ignore", "pipe", "pipe"],
90
+ env: { ...process.env, GIT_TERMINAL_PROMPT: "0", LC_ALL: "C" },
91
+ });
92
+ const timer = setTimeout(() => {
93
+ child.kill("SIGKILL");
94
+ finish({ ok: false, stdout, stderr: stderr || "git 命令超时(5s)" });
95
+ }, timeoutMs);
96
+ child.stdout.setEncoding("utf8").on("data", (chunk) => {
97
+ stdout += chunk;
98
+ });
99
+ child.stderr.setEncoding("utf8").on("data", (chunk) => {
100
+ stderr += chunk;
101
+ });
102
+ child.on("error", (error) => finish({ ok: false, stdout, stderr: error.message }));
103
+ child.on("close", (code) => finish({ ok: code === 0, stdout, stderr }));
104
+ });
105
+ }
106
+ /** 当前分支 + 本地分支清单(下拉菜单用;每次打开菜单可以刷新一次) */
107
+ export async function readGitBranches(root) {
108
+ const info = await readGitInfo(root);
109
+ if (!info.isRepo)
110
+ return { ...info, branches: [] };
111
+ const repo = await findRepoRoot(root);
112
+ if (!repo)
113
+ return { ...info, branches: [] };
114
+ const result = await runGit(repo, ["branch", "--no-color", "--format=%(refname:short)", "--sort=-committerdate"]);
115
+ if (!result.ok)
116
+ return { ...info, branches: [], error: firstLine(result.stderr) || "列不出分支" };
117
+ const branches = result.stdout
118
+ .split("\n")
119
+ .map((line) => line.trim())
120
+ .filter((line) => line !== "");
121
+ return { ...info, branches };
122
+ }
123
+ export async function readWorktreeStatus(repo) {
124
+ const result = await runGit(repo, ["status", "--porcelain"], 8000);
125
+ if (!result.ok)
126
+ return { files: [], total: 0 };
127
+ const lines = result.stdout
128
+ .split("\n")
129
+ .map((line) => line.trimEnd())
130
+ .filter((line) => line !== "");
131
+ // 每行前 3 个字符是状态码(` M ` / `?? ` / `R `),后面才是路径
132
+ const files = lines.slice(0, 20).map((line) => line.slice(3).trim() || line.trim());
133
+ return { files, total: lines.length };
134
+ }
135
+ /**
136
+ * 切分支(`git checkout <branch>`,不用 shell)。
137
+ *
138
+ * 只允许切到**本地已有分支**:名字先在清单里对一遍,既挡掉 `-b` 这类会被 git 当成
139
+ * 参数的名字,也避免"点了一下结果多建出一个分支"。工作区有冲突改动时 git 自己会拒绝,
140
+ * 它的报错原样回给界面(比我们自己猜准)。
141
+ *
142
+ * 工作区脏的时候默认**不切**,先把改动清单交给界面去问一句(`force: true` 才真切)——
143
+ * 切过去那些改动会跟着走,用户至少得知道一声。
144
+ */
145
+ export async function checkoutBranch(root, branch, options = {}) {
146
+ const name = branch.trim();
147
+ if (!/^[^\s~^:?*[\\]+$/.test(name) || name.startsWith("-")) {
148
+ return { ok: false, message: "分支名不合法" };
149
+ }
150
+ const repo = await findRepoRoot(root);
151
+ if (!repo)
152
+ return { ok: false, message: "这里不是 git 仓库" };
153
+ const listed = await readGitBranches(repo);
154
+ if (!listed.isRepo)
155
+ return { ok: false, message: "这里不是 git 仓库" };
156
+ if (!listed.branches.includes(name))
157
+ return { ok: false, message: `没有本地分支 ${name}` };
158
+ if (listed.branch === name)
159
+ return { ok: true, branch: name };
160
+ if (options.force !== true) {
161
+ const dirty = await readWorktreeStatus(repo);
162
+ if (dirty.total > 0) {
163
+ return { ok: false, dirty, message: `工作区有 ${dirty.total} 个未提交改动,没切换` };
164
+ }
165
+ }
166
+ const result = await runGit(repo, ["checkout", "--quiet", name], 15000);
167
+ if (!result.ok) {
168
+ return { ok: false, message: firstLine(result.stderr) || `切换到 ${name} 失败` };
169
+ }
170
+ const after = await readGitInfo(repo);
171
+ return { ok: true, branch: after.branch };
172
+ }
173
+ /**
174
+ * git 的报错常常是多行(还会列一串文件):挑最有用的一两行喂给界面。
175
+ * `error:` / `fatal:` 那行是结论,后面通常跟着「哪些文件挡路了」。
176
+ */
177
+ function firstLine(text) {
178
+ const lines = text
179
+ .split("\n")
180
+ .map((line) => line.trim())
181
+ .filter((line) => line !== "");
182
+ const pick = lines.find((line) => /^(error|fatal):/i.test(line)) ?? lines[0] ?? "";
183
+ const detail = lines.find((line) => line !== pick && !/^hint:/i.test(line)) ?? "";
184
+ const summary = detail ? `${pick} ${detail}` : pick;
185
+ return summary.length > 240 ? `${summary.slice(0, 240)}…` : summary;
186
+ }
@@ -0,0 +1,99 @@
1
+ /**
2
+ * 登录 / 登出端点(ADR-0031 修订):自建登录页把密码 POST 到这里。
3
+ *
4
+ * 为什么单独成文件、且**注册在鉴权中间件之前**:密码都还没校验,不能要求先有 cookie。
5
+ * 这两个端点自己只做三件事 —— 比对密码(常量时间)、节流失败次数、种/清 cookie。
6
+ *
7
+ * 登录成功后的 cookie 有效期 **7 天**(`AUTH_COOKIE_MAX_AGE`),且每次通过鉴权的访问都会
8
+ * 顺延一次;7 天不访问才需要重新输密码。
9
+ */
10
+ import express, {} from "express";
11
+ import { BRAND_NAME } from "../brand.js";
12
+ import { CLEAR_AUTH_COOKIE, authCookieHeader, effectiveAddress, samePassword } from "./auth.js";
13
+ import { renderLoginPage } from "./login-page.js";
14
+ /** 失败节流窗口与上限:同一来源 10 分钟内失败 20 次就从这里起拒收(挡在线爆破) */
15
+ const FAILURE_WINDOW_MS = 10 * 60_000;
16
+ const MAX_FAILURES = 20;
17
+ const MAX_TRACKED_SOURCES = 512;
18
+ function addressOf(request) {
19
+ const forwarded = request.headers["x-forwarded-for"];
20
+ return (effectiveAddress(request.socket.remoteAddress, typeof forwarded === "string" ? forwarded : undefined) ?? "unknown");
21
+ }
22
+ function acceptsHtml(request) {
23
+ const accept = request.headers.accept;
24
+ return typeof accept === "string" && accept.includes("text/html");
25
+ }
26
+ export function registerAuthRoutes(app, options) {
27
+ const brandName = options.brandName ?? BRAND_NAME;
28
+ const attempts = new Map();
29
+ /** 失败计数(顺手清掉过期的,避免 Map 只增不减) */
30
+ function noteFailure(address, now) {
31
+ const current = attempts.get(address);
32
+ if (!current || now > current.resetAt) {
33
+ if (attempts.size >= MAX_TRACKED_SOURCES) {
34
+ for (const [key, value] of attempts)
35
+ if (now > value.resetAt)
36
+ attempts.delete(key);
37
+ }
38
+ attempts.set(address, { count: 1, resetAt: now + FAILURE_WINDOW_MS });
39
+ return;
40
+ }
41
+ current.count += 1;
42
+ }
43
+ function throttled(address, now) {
44
+ const current = attempts.get(address);
45
+ if (!current)
46
+ return false;
47
+ if (now > current.resetAt) {
48
+ attempts.delete(address);
49
+ return false;
50
+ }
51
+ return current.count >= MAX_FAILURES;
52
+ }
53
+ app.post("/api/auth/login", express.urlencoded({ extended: false, limit: "2kb" }), express.json({ limit: "2kb" }), (request, response) => {
54
+ const password = options.password;
55
+ const html = acceptsHtml(request);
56
+ // 没设密码 = 不开放远程(与本机直连无关:本机根本不需要登录)
57
+ if (!password) {
58
+ response.setHeader("Content-Type", "text/plain; charset=utf-8");
59
+ response.status(403).end("未设置 AGENT_WEB_PASSWORD:本服务不开放远程访问\n");
60
+ return;
61
+ }
62
+ const address = addressOf(request);
63
+ const now = Date.now();
64
+ if (throttled(address, now)) {
65
+ response.setHeader("Content-Type", "application/json; charset=utf-8");
66
+ response.status(429).end(JSON.stringify({ ok: false, error: "尝试次数过多,请稍后再试" }));
67
+ return;
68
+ }
69
+ const body = request.body;
70
+ const submitted = typeof body?.password === "string" ? body.password : "";
71
+ if (submitted === "" || !samePassword(submitted, password)) {
72
+ noteFailure(address, now);
73
+ const error = "密码不正确";
74
+ if (html) {
75
+ response.setHeader("Content-Type", "text/html; charset=utf-8");
76
+ response.setHeader("Cache-Control", "no-store");
77
+ response.status(401).end(renderLoginPage({ brandName, error }));
78
+ return;
79
+ }
80
+ response.setHeader("Content-Type", "application/json; charset=utf-8");
81
+ response.setHeader("Cache-Control", "no-store");
82
+ response.status(401).end(JSON.stringify({ ok: false, error }));
83
+ return;
84
+ }
85
+ attempts.delete(address);
86
+ response.setHeader("Set-Cookie", authCookieHeader(password));
87
+ // 无 JS 的表单提交:种完 cookie 直接跳回首页(不采信任何外部传入的 next,避免开放重定向)
88
+ if (html) {
89
+ response.redirect(303, "/");
90
+ return;
91
+ }
92
+ response.setHeader("Cache-Control", "no-store");
93
+ response.status(204).end();
94
+ });
95
+ app.post("/api/auth/logout", (_request, response) => {
96
+ response.setHeader("Set-Cookie", CLEAR_AUTH_COOKIE);
97
+ response.status(204).end();
98
+ });
99
+ }
@@ -0,0 +1,191 @@
1
+ /**
2
+ * 访问鉴权(ADR-0031,2026-10 修订):本机直连放行,**其它来源必须输密码**。
3
+ *
4
+ * 登录界面是**自己渲染的页面**(`login-page.ts`),不再用浏览器原生 Basic 弹窗:
5
+ * `POST /api/auth/login` 校验密码后种一张 **7 天**的 cookie(`auth-routes.ts`),
6
+ * 页面 / 静态资源 / API / WS 握手全都认这张 cookie。
7
+ * Basic 头仍然接受(curl、脚本、老客户端都还在用),只是不再拿它去触发弹窗。
8
+ *
9
+ * 判定顺序(先便宜后贵):
10
+ * 1. 来源是本机回环 → 放行(本地开发不该被打断输密码)
11
+ * 2. 没设 `AGENT_WEB_PASSWORD` → **远程一律 403**(不给登录页:没有密码就没法远程进)
12
+ * 3. 带有效 cookie 或 Basic 凭据且密码正确 → 放行
13
+ * 4. 其它 → 401
14
+ */
15
+ import { createHash, timingSafeEqual } from "node:crypto";
16
+ import { BRAND_NAME } from "../brand.js";
17
+ import { renderLoginPage } from "./login-page.js";
18
+ export const AUTH_REALM = BRAND_NAME;
19
+ /** 登录状态的保存时长:**7 天**(每次访问顺延) */
20
+ export const AUTH_COOKIE_MAX_AGE = 7 * 24 * 60 * 60;
21
+ /**
22
+ * 登录成功后下发的 cookie 值:`sha256(密码)`,不含明文密码。
23
+ *
24
+ * 为什么需要它:**WebSocket 握手不一定带 Basic 凭据**(浏览器只在部分场景复用),
25
+ * 但 cookie 一定会带 —— 有了它,WS 才能稳定通过鉴权。
26
+ */
27
+ export const AUTH_COOKIE = "agent-web-auth";
28
+ export function cookieToken(password) {
29
+ return createHash("sha256").update(`agent-web:${password}`).digest("hex");
30
+ }
31
+ /** 登录成功后要写回的 `Set-Cookie`(7 天;HttpOnly:前端读不到也就偷不走) */
32
+ export function authCookieHeader(password) {
33
+ return [
34
+ `${AUTH_COOKIE}=${cookieToken(password)}`,
35
+ "Path=/",
36
+ "HttpOnly",
37
+ "SameSite=Lax",
38
+ `Max-Age=${AUTH_COOKIE_MAX_AGE}`,
39
+ ].join("; ");
40
+ }
41
+ /** 清掉登录态(登出 / 密码错误兜底) */
42
+ export const CLEAR_AUTH_COOKIE = `${AUTH_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`;
43
+ /**
44
+ * 免鉴权的静态路径(图标 / 清单)。
45
+ *
46
+ * 为什么必须放行:手机「添加到主屏幕」时,iOS 抳 `apple-touch-icon`、Android 抳
47
+ * `manifest` 和清单里的图标,**这些子请求不保证带 Basic 凭据** —— 回 401 设备就
48
+ * 拿不到图,自己生成一个「主题色底 + 首字母」的方块顶上去(蓝底白 A 就是这么来的)。
49
+ * 这些响应里没有任何用户数据(纯图片 / 图标清单),放行不扩大攻击面;
50
+ * `/api/*`、页面本身、WebSocket 仍然一律要过密码。
51
+ */
52
+ const PUBLIC_STATIC = /^\/(favicon[^/]*|icons\/.*|apple-touch-icon[^/]*|manifest\.webmanifest)$/;
53
+ export function isPublicStaticPath(path) {
54
+ return PUBLIC_STATIC.test(path);
55
+ }
56
+ /** 从 Cookie 头里取登录令牌 */
57
+ export function tokenFromCookie(header) {
58
+ if (!header)
59
+ return null;
60
+ for (const part of header.split(";")) {
61
+ const [name, ...rest] = part.trim().split("=");
62
+ if (name === AUTH_COOKIE)
63
+ return rest.join("=").trim();
64
+ }
65
+ return null;
66
+ }
67
+ /** 是否是本机回环地址(IPv4 / IPv6 / IPv4-mapped) */
68
+ export function isLoopback(address) {
69
+ if (!address)
70
+ return false;
71
+ if (address === "::1")
72
+ return true;
73
+ if (address.startsWith("127."))
74
+ return true;
75
+ return address.startsWith("::ffff:127.");
76
+ }
77
+ /** 常量时间比较(长度不同直接 false,避免泄漏长度以外的信息) */
78
+ export function samePassword(a, b) {
79
+ const left = Buffer.from(a);
80
+ const right = Buffer.from(b);
81
+ if (left.length !== right.length)
82
+ return false;
83
+ return timingSafeEqual(left, right);
84
+ }
85
+ /**
86
+ * 这次请求该不该拿到**登录页**(而不是 401 JSON)?
87
+ *
88
+ * 只认 `text/html`:fetch / EventSource / XHR 的默认 `Accept: *\/*` 必须继续拿 JSON,
89
+ * 否则前端会把一张 HTML 当数据去解析。
90
+ */
91
+ export function wantsLoginPage(method, accept) {
92
+ if (method !== "GET" && method !== "HEAD")
93
+ return false;
94
+ return typeof accept === "string" && accept.includes("text/html");
95
+ }
96
+ /**
97
+ * 真实来源:本机对端(比如 Vite 反代)带了 `X-Forwarded-For` 时取**最右边**那个。
98
+ *
99
+ * 为什么不是最左边:那个位置客户端可以自己伪造(`X-Forwarded-For: 127.0.0.1` 就冒充本机了);
100
+ * 最右边是**我们的代理附加的**,才是可信值。同时只在对端本身是回环时才看这个头。
101
+ */
102
+ export function effectiveAddress(peer, forwardedFor) {
103
+ if (!isLoopback(peer) || !forwardedFor)
104
+ return peer;
105
+ const parts = forwardedFor
106
+ .split(",")
107
+ .map((part) => part.trim())
108
+ .filter((part) => part !== "");
109
+ const last = parts[parts.length - 1];
110
+ return last ?? peer;
111
+ }
112
+ /** 从 `Authorization: Basic base64(user:pass)` 里取密码(用户名忽略) */
113
+ export function passwordFromHeader(header) {
114
+ if (!header || !header.toLowerCase().startsWith("basic "))
115
+ return null;
116
+ const decoded = Buffer.from(header.slice(6).trim(), "base64").toString("utf8");
117
+ const index = decoded.indexOf(":");
118
+ return index < 0 ? null : decoded.slice(index + 1);
119
+ }
120
+ export function decide(input) {
121
+ if (isLoopback(input.remoteAddress))
122
+ return "allow";
123
+ // 没设密码 = 根本不开放远程(不是"留个空密码",那样等于谁都能进)
124
+ if (!input.password)
125
+ return "forbid";
126
+ // 先看 cookie(浏览器加载页面时已经登录过),再看 Basic
127
+ const token = tokenFromCookie(input.cookie);
128
+ if (token !== null && samePassword(token, cookieToken(input.password)))
129
+ return "allow";
130
+ const provided = passwordFromHeader(input.authorization);
131
+ return provided !== null && samePassword(provided, input.password) ? "allow" : "challenge";
132
+ }
133
+ /**
134
+ * Express 中间件:本机放行;远程未登录时——**页面请求给登录页**,其余给 401。
135
+ *
136
+ * 不再发 `WWW-Authenticate`:那会让浏览器弹原生框,与自建登录页打架。
137
+ */
138
+ export function authMiddleware(options) {
139
+ const { password } = options;
140
+ return (request, response, next) => {
141
+ // 图标 / 清单:手机加桌面时不带凭据也能取(见 isPublicStaticPath)
142
+ if (isPublicStaticPath(request.path)) {
143
+ next();
144
+ return;
145
+ }
146
+ const authorization = request.headers.authorization;
147
+ const forwarded = request.headers["x-forwarded-for"];
148
+ const cookie = request.headers.cookie;
149
+ const decision = decide({
150
+ password,
151
+ remoteAddress: effectiveAddress(request.socket.remoteAddress, typeof forwarded === "string" ? forwarded : undefined),
152
+ authorization: typeof authorization === "string" ? authorization : undefined,
153
+ cookie: typeof cookie === "string" ? cookie : undefined,
154
+ });
155
+ if (decision === "allow") {
156
+ // 每次访问都把 7 天重新算起(顺延);WS / SSE 握手一定带 cookie,Basic 不保证
157
+ if (password)
158
+ response.setHeader("Set-Cookie", authCookieHeader(password));
159
+ next();
160
+ return;
161
+ }
162
+ if (decision === "forbid") {
163
+ response.setHeader("Content-Type", "text/plain; charset=utf-8");
164
+ response.status(403).end("未设置 AGENT_WEB_PASSWORD:本服务不开放远程访问\n");
165
+ return;
166
+ }
167
+ const accept = request.headers.accept;
168
+ if (wantsLoginPage(request.method, typeof accept === "string" ? accept : undefined)) {
169
+ const page = renderLoginPage(options.brandName ? { brandName: options.brandName } : {});
170
+ response.setHeader("Content-Type", "text/html; charset=utf-8");
171
+ response.setHeader("Cache-Control", "no-store");
172
+ // connect-src 必须显式放开 'self':default-src 'none' 会把 fetch 一起禁掉,
173
+ // 表现就是「点了登录没反应」(密码 POST 被浏览器拦下,根本没有请求出去)。
174
+ // form-action 'self' 兜住无 JS 的原生表单提交,两者缺一个登录都会坏。
175
+ response.setHeader("Content-Security-Policy", [
176
+ "default-src 'none'",
177
+ "img-src 'self'",
178
+ "style-src 'unsafe-inline'",
179
+ "script-src 'unsafe-inline'",
180
+ "connect-src 'self'",
181
+ "form-action 'self'",
182
+ "base-uri 'none'",
183
+ ].join("; "));
184
+ response.status(200).end(page);
185
+ return;
186
+ }
187
+ response.setHeader("Content-Type", "application/json; charset=utf-8");
188
+ response.setHeader("Cache-Control", "no-store");
189
+ response.status(401).end(JSON.stringify({ ok: false, error: "未登录或登录已过期" }));
190
+ };
191
+ }