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.
- package/README.md +86 -447
- package/dist/server/approval/read-only.js +107 -0
- package/dist/server/approval/rules.js +85 -0
- package/dist/server/approval/scope.js +150 -0
- package/dist/server/brand.js +2 -0
- package/dist/server/cli-config.js +52 -0
- package/dist/server/cli-info.js +101 -0
- package/dist/server/cli.js +304 -0
- package/dist/server/engine/builtin.js +61 -0
- package/dist/server/engine/catalog.js +8 -0
- package/dist/server/engine/codex-approvals.js +45 -0
- package/dist/server/engine/codex-map.js +298 -0
- package/dist/server/engine/codex-sessions.js +159 -0
- package/dist/server/engine/codex-transport.js +49 -0
- package/dist/server/engine/codex.js +314 -0
- package/dist/server/engine/dsh-history.js +195 -0
- package/dist/server/engine/dsh-log.js +245 -0
- package/dist/server/engine/dsh-map.js +221 -0
- package/dist/server/engine/dsh-sessions.js +207 -0
- package/dist/server/engine/dsh-transport.js +37 -0
- package/dist/server/engine/dsh.js +284 -0
- package/dist/server/engine/fake.js +231 -0
- package/dist/server/engine/jsonrpc-stdio.js +241 -0
- package/dist/server/engine/model-registry-reload.js +115 -0
- package/dist/server/engine/pi-events.js +148 -0
- package/dist/server/engine/pi-rename.js +14 -0
- package/dist/server/engine/pi-sdk.js +343 -0
- package/dist/server/engine/pi-session-model.js +23 -0
- package/dist/server/engine/pi-thinking.js +34 -0
- package/dist/server/engine/pi-transcript.js +225 -0
- package/dist/server/engine/pi-version.js +11 -0
- package/dist/server/engine/port.js +1 -0
- package/dist/server/engine/registry.js +47 -0
- package/dist/server/engine/scratch.js +13 -0
- package/dist/server/engine/suggestion-guidance.js +9 -0
- package/dist/server/engine/switchable.js +22 -0
- package/dist/server/engine/wiring.js +107 -0
- package/dist/server/files/dir-routes.js +40 -0
- package/dist/server/files/file-search.js +63 -0
- package/dist/server/files/file-service.js +232 -0
- package/dist/server/files/files-routes.js +182 -0
- package/dist/server/files/git-info.js +186 -0
- package/dist/server/http/auth-routes.js +99 -0
- package/dist/server/http/auth.js +191 -0
- package/dist/server/http/aux-routes.js +24 -0
- package/dist/server/http/compress.js +75 -0
- package/dist/server/http/health-and-static.js +33 -0
- package/dist/server/http/live-routes.js +113 -0
- package/dist/server/http/login-page.js +200 -0
- package/dist/server/index.js +342 -520
- package/dist/server/markers/actions.js +262 -0
- package/dist/server/protocol-parse.js +201 -0
- package/dist/server/protocol-settings.js +1 -0
- package/dist/server/protocol.js +6 -0
- package/dist/server/providers/catalog-store.js +85 -0
- package/dist/server/providers/catalog-writes.js +197 -0
- package/dist/server/providers/catalog.js +77 -0
- package/dist/server/providers/enabled-models.js +118 -0
- package/dist/server/providers/import-models.js +69 -0
- package/dist/server/providers/import-routes.js +136 -0
- package/dist/server/providers/model-test.js +52 -0
- package/dist/server/providers/probe.js +64 -0
- package/dist/server/providers/provider-handlers.js +203 -0
- package/dist/server/providers/provider-store.js +327 -0
- package/dist/server/providers/request-headers.js +7 -0
- package/dist/server/providers/validate.js +241 -0
- package/dist/server/push/push-routes.js +114 -0
- package/dist/server/push/push.js +120 -0
- package/dist/server/session/approval-bridge.js +103 -0
- package/dist/server/session/attachment-resolve.js +21 -0
- package/dist/server/session/client-view.js +82 -0
- package/dist/server/session/conversation-create.js +65 -0
- package/dist/server/session/delete-session.js +84 -0
- package/dist/server/session/engine-switch.js +108 -0
- package/dist/server/session/lock.js +82 -0
- package/dist/server/session/model-controller.js +111 -0
- package/dist/server/session/notices.js +31 -0
- package/dist/server/session/pi-opener.js +123 -0
- package/dist/server/session/pi-refresh.js +51 -0
- package/dist/server/session/pi-sessions-provider.js +46 -0
- package/dist/server/session/pi-sessions.js +272 -0
- package/dist/server/session/refresh-actions.js +42 -0
- package/dist/server/session/rename-session.js +23 -0
- package/dist/server/session/runtime-services.js +28 -0
- package/dist/server/session/runtime-types.js +1 -0
- package/dist/server/session/runtime.js +358 -0
- package/dist/server/session/session-stats.js +88 -0
- package/dist/server/session/settings.js +367 -0
- package/dist/server/session/snapshot-for.js +7 -0
- package/dist/server/session/stats-refresher.js +50 -0
- package/dist/server/session/switch-conversation.js +86 -0
- package/dist/server/session/throttle.js +14 -0
- package/dist/server/session/turn-events.js +126 -0
- package/dist/server/session/turn-finish.js +79 -0
- package/dist/server/session/turn-markers.js +26 -0
- package/dist/server/session/turn-queue.js +111 -0
- package/dist/server/session/turn-send.js +88 -0
- package/dist/server/session/turn-watchdog.js +77 -0
- package/dist/server/sessions/session-routes.js +17 -0
- package/dist/server/skills/skill-routes.js +28 -0
- package/dist/server/skills/skill-store.js +134 -0
- package/dist/server/snapshot/fan-out.js +10 -0
- package/dist/server/snapshot/messages.js +75 -0
- package/dist/server/snapshot/sender.js +62 -0
- package/dist/server/snapshot/state.js +193 -0
- package/dist/server/terminal/terminal-routes.js +176 -0
- package/dist/server/terminal/terminal-session.js +140 -0
- package/dist/server/uploads/upload-routes.js +64 -0
- package/dist/server/uploads/upload-store.js +115 -0
- package/dist/server/util/data-dir.js +36 -0
- package/dist/server/util/env.js +140 -0
- package/dist/server/util/ids.js +28 -0
- package/dist/server/util/log-file.js +67 -0
- package/dist/server/util/log.js +52 -0
- package/dist/server/util/origin.js +75 -0
- package/dist/server/util/paths.js +60 -0
- package/dist/server/util/pricing-index.js +121 -0
- package/dist/server/util/pricing.js +214 -0
- package/dist/server/util/startup.js +34 -0
- package/dist/server/util/upstream-version.js +140 -0
- package/dist/server/util/usage.js +19 -0
- package/dist/server/util/version.js +50 -0
- package/dist/server/ws/handle-message.js +95 -0
- package/package.json +49 -63
- package/web/dist/apple-touch-icon-precomposed.png +0 -0
- package/web/dist/apple-touch-icon.png +0 -0
- package/web/dist/assets/abnfDiagram-WEYOIA3C-NLp09iEC.js +1 -0
- package/web/dist/assets/arc-DviL9o3c.js +1 -0
- package/web/dist/assets/architecture-WOLXFQ4H-fjsM_Sy3.js +1 -0
- package/web/dist/assets/architectureDiagram-ZHVYKT53-Cn3B4TE8.js +36 -0
- package/web/dist/assets/array-BifhSqXX.js +1 -0
- package/web/dist/assets/blockDiagram-RJAE7QA3-CIuvxWvi.js +140 -0
- package/web/dist/assets/c4Diagram-XDPPPE54-DvdKmEpF.js +38 -0
- package/web/dist/assets/channel-C3AQ6nAs.js +1 -0
- package/web/dist/assets/chunk-2E7WHVMD-HNNASGCS.js +88 -0
- package/web/dist/assets/chunk-2Q5K7J3B-C1jixKkw.js +1 -0
- package/web/dist/assets/chunk-4EMNKR2T-Cg-eRwBE.js +1 -0
- package/web/dist/assets/chunk-55XCU76K-BweiisZL.js +1 -0
- package/web/dist/assets/chunk-5VM5RSS4-ZNzvKenW.js +15 -0
- package/web/dist/assets/chunk-7S6XSTFO-BCzIiP-6.js +2 -0
- package/web/dist/assets/chunk-AQDTSKZB-Dl0NRFuU.js +72 -0
- package/web/dist/assets/chunk-DSNLSHJZ-zhHCjjN9.js +129 -0
- package/web/dist/assets/chunk-DUW6YSOI-Dgx8z5s3.js +1 -0
- package/web/dist/assets/chunk-HAGFOTED-BDQqU2EH.js +62 -0
- package/web/dist/assets/chunk-HNE4Q36P-B-qRzMjX.js +1 -0
- package/web/dist/assets/chunk-J5ZVWO5B-oDCd-gWO.js +1 -0
- package/web/dist/assets/chunk-JWPE2WC7-DVXcaiue.js +1 -0
- package/web/dist/assets/chunk-NGNAAXSQ-C2Wy9Xmz.js +136 -0
- package/web/dist/assets/chunk-NQOKRVIF-CNcvhMn_.js +213 -0
- package/web/dist/assets/chunk-UHW7PFBP-Dwr8LUzr.js +1 -0
- package/web/dist/assets/chunk-VQ5W62QI-O8sN6jJu.js +1 -0
- package/web/dist/assets/chunk-WQQL4CDK-B6-59Zg2.js +10 -0
- package/web/dist/assets/chunk-XXDRQBXY-DvSbeUlZ.js +1 -0
- package/web/dist/assets/chunk-Y2CYZVJY-DsF7k-Jl.js +1 -0
- package/web/dist/assets/chunk-YZO67HFG-BXpKYKsw.js +1 -0
- package/web/dist/assets/chunk-ZAUX2LTX-B1Z5VSkz.js +1 -0
- package/web/dist/assets/chunk-ZCHMQSOR-h_5mBLIA.js +2 -0
- package/web/dist/assets/classDiagram-v2-4MYWR3UA-Dr6JnaaV.js +217 -0
- package/web/dist/assets/cose-bilkent-JH36ORCC-36ShkR7R.js +1 -0
- package/web/dist/assets/cynefin-EF2NZ3EQ-Dg6bI7pC.js +1 -0
- package/web/dist/assets/cynefinDiagram-WF24UDRP-B0YHpBlR.js +62 -0
- package/web/dist/assets/cytoscape.esm-BegBNzOV.js +321 -0
- package/web/dist/assets/dagre-NK4GIZVX-Cx3mn7YS.js +4 -0
- package/web/dist/assets/defaultLocale-BFoDCU3G.js +1 -0
- package/web/dist/assets/diagram-CBK3MWTC-l8aZG_Bm.js +24 -0
- package/web/dist/assets/diagram-I5XIIV2D-Bd5f14_h.js +24 -0
- package/web/dist/assets/diagram-ODIL2UCW-hBsxCYUj.js +30 -0
- package/web/dist/assets/diagram-PFBEJUXE-BgpIBUYl.js +3 -0
- package/web/dist/assets/diagram-RJA3I4VV-CDU1gUVD.js +41 -0
- package/web/dist/assets/diagram-TYUNFSNR-Iuxh7Tqf.js +200 -0
- package/web/dist/assets/dist-C86cKTuE.js +1 -0
- package/web/dist/assets/ebnfDiagram-V4P3TTPT-Cms0HFKh.js +1 -0
- package/web/dist/assets/elk-QONTBCTQ-B7yEy0PQ.js +27 -0
- package/web/dist/assets/erDiagram-KYQIGQAA-CL48j4aU.js +99 -0
- package/web/dist/assets/eventmodeling-K75KTNOO-CFvkaejh.js +1 -0
- package/web/dist/assets/flowDiagram-IUGLLWBU-6UyKXpqG.js +1 -0
- package/web/dist/assets/ganttDiagram-R7GPNTJY-vdMr-0ZV.js +292 -0
- package/web/dist/assets/gitGraph-VSP46ZUC-2z85Wuwo.js +1 -0
- package/web/dist/assets/gitGraphDiagram-PDYL7XHY-BY4GPiqC.js +106 -0
- package/web/dist/assets/graphlib-DS17s2tU.js +1 -0
- package/web/dist/assets/index-Bjn56a2i.js +76 -0
- package/web/dist/assets/index-Drp6e5iU.css +1 -0
- package/web/dist/assets/info-OHQRW6UA-ECzkYTx-.js +1 -0
- package/web/dist/assets/infoDiagram-MVDRKAX5-DDk5EAeb.js +2 -0
- package/web/dist/assets/init-C-OQMol4.js +1 -0
- package/web/dist/assets/ishikawaDiagram-HPEHV2GE-9yPH4J94.js +70 -0
- package/web/dist/assets/journeyDiagram-BOSHW7O7-BPTIap3l.js +139 -0
- package/web/dist/assets/kanban-definition-FNSA4HC5-BbADHrA8.js +89 -0
- package/web/dist/assets/katex-B_JcIwqj.js +257 -0
- package/web/dist/assets/line-CHV9_4lL.js +1 -0
- package/web/dist/assets/linear-C-eZsBTv.js +1 -0
- package/web/dist/assets/mermaid-parser.core-BiAhX1l1.js +7 -0
- package/web/dist/assets/mermaid.core-BxP_A_zj.js +44 -0
- package/web/dist/assets/mindmap-definition-6OY57UG3-BMWlit95.js +98 -0
- package/web/dist/assets/ordinal-BDEzSJ7C.js +1 -0
- package/web/dist/assets/packet-JDAUHWVQ-FVApuKOp.js +1 -0
- package/web/dist/assets/path-fybaL0A-.js +1 -0
- package/web/dist/assets/pegDiagram-ECIUJNBX-WjWJvDHM.js +1 -0
- package/web/dist/assets/pie-XZMESJXO-BGCZnvhp.js +1 -0
- package/web/dist/assets/pieDiagram-GNMPY74T-DJpAC_JC.js +39 -0
- package/web/dist/assets/quadrantDiagram-D57K4MC3-hxx5c7oq.js +7 -0
- package/web/dist/assets/radar-ABXABTNO-BU9Shc-O.js +1 -0
- package/web/dist/assets/railroad-I3PHUGI6-pWj8d-G4.js +1 -0
- package/web/dist/assets/railroad-abnf-LNEFI6M7-D7HZBTAj.js +1 -0
- package/web/dist/assets/railroad-ebnf-DWYD2UWJ-CKiXJSuM.js +1 -0
- package/web/dist/assets/railroad-peg-JR7BVNR7-DB3VJHgR.js +1 -0
- package/web/dist/assets/railroadDiagram-Y72NRBL7-Cev7Y65k.js +1 -0
- package/web/dist/assets/requirementDiagram-EQYSW5RA-CqbwMLD8.js +84 -0
- package/web/dist/assets/rough.esm-Dy-Kn_BL.js +1 -0
- package/web/dist/assets/sankeyDiagram-SY24JHO5-V2cyVi8q.js +40 -0
- package/web/dist/assets/sequenceDiagram-BLEHNHQU-By6NJOPM.js +169 -0
- package/web/dist/assets/sizeCapture-INFHLROL-3uHXrFqA.js +1 -0
- package/web/dist/assets/src-Da91fG3g.js +1 -0
- package/web/dist/assets/stateDiagram-v2-A6IESKWZ-BnPGX5UI.js +291 -0
- package/web/dist/assets/swimlanes-B3ENRKFQ-C58BpIpm.js +1 -0
- package/web/dist/assets/swimlanesDiagram-AYQRV4AK-RaivxSsc.js +8 -0
- package/web/dist/assets/timeline-definition-JXYKGLGB-Hgo1DhnS.js +120 -0
- package/web/dist/assets/treeView-D4JQ5SDB-DF0dfjqB.js +1 -0
- package/web/dist/assets/treemap-SAJKECNS-CSSM5FqB.js +1 -0
- package/web/dist/assets/usecaseDiagram-WPOLBNNX-BkBBD1NY.js +328 -0
- package/web/dist/assets/vennDiagram-Q3Y3IDCW-Bm28keX_.js +34 -0
- package/web/dist/assets/wardley-7MLQ67FV-By7KgEQX.js +1 -0
- package/web/dist/assets/wardleyDiagram-IVAIDKWA-oVtIrnGa.js +78 -0
- package/web/dist/assets/xychartDiagram-4URNHDU4-aI8FH4g2.js +7 -0
- package/web/dist/favicon-32.png +0 -0
- package/web/dist/favicon.ico +0 -0
- package/web/dist/favicon.svg +20 -8
- package/web/dist/icons/apple-touch-icon-180.png +0 -0
- package/web/dist/icons/icon-192.png +0 -0
- package/web/dist/icons/icon-512.png +0 -0
- package/web/dist/icons/icon-maskable-512.png +0 -0
- package/web/dist/index.html +21 -27
- package/web/dist/manifest.webmanifest +17 -0
- package/web/dist/maskable.svg +25 -0
- package/web/dist/sw.js +40 -0
- package/bin/oh-my-agent-web-ps.mjs +0 -203
- package/bin/oh-my-agent-web-ws.mjs +0 -593
- package/bin/oh-my-agent-web.mjs +0 -321
- package/bin/update-check.mjs +0 -142
- package/dist/server/codex-client.js +0 -310
- package/dist/server/codex-supervisor.js +0 -446
- package/dist/server/fs-service.js +0 -144
- package/dist/server/request-inspector.js +0 -458
- package/dist/shared/protocol.js +0 -112
- package/scripts/manage-server.mjs +0 -155
- package/scripts/omaw.mjs +0 -74
- package/shared/protocol.ts +0 -228
- package/web/dist/assets/index-B8-VzDf2.css +0 -1
- package/web/dist/assets/index-DeC2eiiq.js +0 -42
- package/web/dist/assets/markdown-CYXe6JZx.js +0 -55
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 附件上传的存储(ADR-0020 §P3)。
|
|
3
|
+
*
|
|
4
|
+
* 三条硬约束(都来自既有 ADR):
|
|
5
|
+
* 1. **图片不塞 WS**(ADR-0004 单条 ≤64KB):走 HTTP 上传,消息里只带引用 id;
|
|
6
|
+
* 2. **id 必须防目录穿越**(ADR-0006 的精神):只接受 `[a-z0-9-]`,其余一律拒;
|
|
7
|
+
* 3. **有清理**:上传目录不能无限长大 → 按时间清理旧文件(每次上传时顺手跑)。
|
|
8
|
+
*
|
|
9
|
+
* 不做的事:非图片文件的内容注入(ADR-0002 的原则:只给路径引用)。
|
|
10
|
+
*/
|
|
11
|
+
import { mkdir, readdir, readFile, rm, stat, writeFile } from "node:fs/promises";
|
|
12
|
+
import { randomUUID } from "node:crypto";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
export const UPLOADS_DIR = "uploads";
|
|
15
|
+
/** 单文件上限(与 ADR-0020 §P3 一致) */
|
|
16
|
+
export const MAX_UPLOAD_BYTES = 10 * 1024 * 1024;
|
|
17
|
+
/** 单条消息附件上限 */
|
|
18
|
+
export const MAX_ATTACHMENTS_PER_MESSAGE = 5;
|
|
19
|
+
/** 上传文件保留期(默认 7 天) */
|
|
20
|
+
export const DEFAULT_UPLOAD_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
21
|
+
const ID_RE = /^[a-z0-9-]{8,64}$/;
|
|
22
|
+
export function uploadsDir(dataDir) {
|
|
23
|
+
return join(dataDir, UPLOADS_DIR);
|
|
24
|
+
}
|
|
25
|
+
/** 纯函数:id 是否安全(防 `../` 与绝对路径) */
|
|
26
|
+
export function isSafeUploadId(id) {
|
|
27
|
+
return ID_RE.test(id);
|
|
28
|
+
}
|
|
29
|
+
/** 纯函数:按魔术字节嗅探类型(不信任客户端给的 content-type) */
|
|
30
|
+
export function sniffMime(bytes) {
|
|
31
|
+
const head = bytes.subarray(0, 12);
|
|
32
|
+
const isPng = head[0] === 0x89 && head[1] === 0x50 && head[2] === 0x4e && head[3] === 0x47;
|
|
33
|
+
if (isPng)
|
|
34
|
+
return "image/png";
|
|
35
|
+
const isJpeg = head[0] === 0xff && head[1] === 0xd8 && head[2] === 0xff;
|
|
36
|
+
if (isJpeg)
|
|
37
|
+
return "image/jpeg";
|
|
38
|
+
const isGif = head[0] === 0x47 && head[1] === 0x49 && head[2] === 0x46;
|
|
39
|
+
if (isGif)
|
|
40
|
+
return "image/gif";
|
|
41
|
+
const isWebp = head[0] === 0x52 && head[1] === 0x49 && head[2] === 0x46 && head[3] === 0x46 && head[8] === 0x57;
|
|
42
|
+
if (isWebp)
|
|
43
|
+
return "image/webp";
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
export function isImageMime(mime) {
|
|
47
|
+
return mime.startsWith("image/");
|
|
48
|
+
}
|
|
49
|
+
export function createUploadStore(options) {
|
|
50
|
+
const now = options.now ?? (() => Date.now());
|
|
51
|
+
const ttlMs = options.ttlMs ?? DEFAULT_UPLOAD_TTL_MS;
|
|
52
|
+
return {
|
|
53
|
+
/** 保存一个上传;超限抛错(调用方转成 4xx) */
|
|
54
|
+
async save(input) {
|
|
55
|
+
if (input.bytes.byteLength === 0)
|
|
56
|
+
throw new Error("空文件");
|
|
57
|
+
if (input.bytes.byteLength > MAX_UPLOAD_BYTES) {
|
|
58
|
+
throw new Error(`文件超过上限(${Math.round(MAX_UPLOAD_BYTES / 1024 / 1024)}MB)`);
|
|
59
|
+
}
|
|
60
|
+
const id = randomUUID().replace(/-/g, "").slice(0, 16);
|
|
61
|
+
const mime = sniffMime(input.bytes) ?? (input.declaredMime || "application/octet-stream");
|
|
62
|
+
const dir = uploadsDir(options.dataDir);
|
|
63
|
+
await mkdir(dir, { recursive: true });
|
|
64
|
+
const path = join(dir, id);
|
|
65
|
+
await writeFile(path, input.bytes);
|
|
66
|
+
// 顺手清理旧文件(不做后台任务,省一个定时器)
|
|
67
|
+
await this.cleanup();
|
|
68
|
+
return {
|
|
69
|
+
id,
|
|
70
|
+
path,
|
|
71
|
+
bytes: input.bytes.byteLength,
|
|
72
|
+
mime,
|
|
73
|
+
name: (input.name ?? "附件").slice(0, 200),
|
|
74
|
+
};
|
|
75
|
+
},
|
|
76
|
+
/** 读回一个上传(校验 id 安全 + 文件存在) */
|
|
77
|
+
async read(id) {
|
|
78
|
+
if (!isSafeUploadId(id))
|
|
79
|
+
return null;
|
|
80
|
+
try {
|
|
81
|
+
const bytes = await readFile(join(uploadsDir(options.dataDir), id));
|
|
82
|
+
return { bytes, mime: sniffMime(bytes) ?? "application/octet-stream" };
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
},
|
|
88
|
+
/** 清理超过保留期的上传(返回删掉的数量) */
|
|
89
|
+
async cleanup() {
|
|
90
|
+
const dir = uploadsDir(options.dataDir);
|
|
91
|
+
let names;
|
|
92
|
+
try {
|
|
93
|
+
names = await readdir(dir);
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
return 0;
|
|
97
|
+
}
|
|
98
|
+
const deadline = now() - ttlMs;
|
|
99
|
+
let removed = 0;
|
|
100
|
+
for (const name of names) {
|
|
101
|
+
try {
|
|
102
|
+
const info = await stat(join(dir, name));
|
|
103
|
+
if (info.mtimeMs < deadline) {
|
|
104
|
+
await rm(join(dir, name), { force: true });
|
|
105
|
+
removed += 1;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
// 单个文件的问题不影响其他清理
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return removed;
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 数据目录的**一次性搬家**:`~/.agent-web` → `~/.oh-my-agent-web`。
|
|
3
|
+
*
|
|
4
|
+
* 为什么要在启动时做、而不是让用户手动 mv:
|
|
5
|
+
* 那个目录里放的全是「丢了就麻烦」的东西 —— 远程访问密码、允许的域名、审批规则、
|
|
6
|
+
* 推送订阅、客户端设置、会话索引与日志。改个名字就让远程访问变 403 / 推送失效,
|
|
7
|
+
* 属于「程序自己惹的祸」。
|
|
8
|
+
*
|
|
9
|
+
* 三条边界:
|
|
10
|
+
* 1. 只在**用的是默认目录**时搬(用户显式 `--data-dir` 就绝不动别人的文件);
|
|
11
|
+
* 2. 只在**新目录还不存在**时搬(已经搬过 / 新目录已有数据就什么都不做);
|
|
12
|
+
* 3. 整个目录 rename(同一分区,原子),失败就打印一句该怎么办,不阻断启动。
|
|
13
|
+
*/
|
|
14
|
+
import { existsSync, renameSync } from "node:fs";
|
|
15
|
+
import { homedir } from "node:os";
|
|
16
|
+
import { join, resolve } from "node:path";
|
|
17
|
+
import { DEFAULT_DATA_DIR_NAME, LEGACY_DATA_DIR_NAME } from "./env.js";
|
|
18
|
+
export function migrateLegacyDataDir(options) {
|
|
19
|
+
const home = options.home ?? homedir();
|
|
20
|
+
const target = resolve(options.dataDir);
|
|
21
|
+
if (target !== resolve(join(home, DEFAULT_DATA_DIR_NAME)))
|
|
22
|
+
return false; // 自定义目录:不碰
|
|
23
|
+
const legacy = join(home, LEGACY_DATA_DIR_NAME);
|
|
24
|
+
if (!existsSync(legacy) || existsSync(target))
|
|
25
|
+
return false;
|
|
26
|
+
try {
|
|
27
|
+
renameSync(legacy, target);
|
|
28
|
+
options.log(`已把数据目录从 ${legacy} 迁移到 ${target}(密码 / 推送订阅 / 设置 / 会话索引都在里面)`);
|
|
29
|
+
return true;
|
|
30
|
+
}
|
|
31
|
+
catch (error) {
|
|
32
|
+
options.log(`旧数据目录 ${legacy} 迁移失败:${error instanceof Error ? error.message : String(error)}` +
|
|
33
|
+
`\n → 手动搬一次即可:mv ${legacy} ${target}`);
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 配置与环境变量(ADR-0007 §环境变量、ADR-0011 命名)。
|
|
3
|
+
*
|
|
4
|
+
* 四个环境变量,仅此四个:
|
|
5
|
+
* AGENT_WEB_PORT 默认 25257
|
|
6
|
+
* AGENT_WEB_CWD 默认 process.cwd()
|
|
7
|
+
* AGENT_WEB_DATA_DIR 默认 ~/.oh-my-agent-web
|
|
8
|
+
* AGENT_WEB_HOST 默认 127.0.0.1(对外监听需要它)
|
|
9
|
+
* AGENT_WEB_PASSWORD 远程访问密码(不设 = 只允许本机)
|
|
10
|
+
* AGENT_WEB_ALLOWED_HOSTS 额外允许的域名(逗号分隔,默认只认 loopback)
|
|
11
|
+
* AGENT_WEB_TOKEN 占位:v1 不实现鉴权(ADR-0006),只留开关位
|
|
12
|
+
* 外加一个日志级别:AGENT_WEB_LOG(debug|info|warn|error,默认 info)
|
|
13
|
+
*
|
|
14
|
+
* 监听地址默认 127.0.0.1;改成别的地址也**不会**让远程随便进 ——
|
|
15
|
+
* 没有 AGENT_WEB_PASSWORD 时,非本机请求一律 403(ADR-0031)。
|
|
16
|
+
*
|
|
17
|
+
* 本模块全是纯函数:不读 process.env、不碰文件系统 —— 便于单测与守卫复用。
|
|
18
|
+
*/
|
|
19
|
+
import { join, resolve, sep } from "node:path";
|
|
20
|
+
export const DEFAULT_PORT = 25257;
|
|
21
|
+
/** 默认只听本机;要对外提供访问得显式改它 + 设密码(ADR-0031) */
|
|
22
|
+
export const DEFAULT_HOST = "127.0.0.1";
|
|
23
|
+
export const HOST_ENV = "AGENT_WEB_HOST";
|
|
24
|
+
export const PASSWORD_ENV = "AGENT_WEB_PASSWORD";
|
|
25
|
+
/** 额外允许的主机名(逗号分隔):用自己的域名 / 局域网 IP 访问时配它 */
|
|
26
|
+
export const ALLOWED_HOSTS_ENV = "AGENT_WEB_ALLOWED_HOSTS";
|
|
27
|
+
/** 回合看门狗默认超时:20 分钟(与参考实现同口径) */
|
|
28
|
+
export const DEFAULT_TURN_TIMEOUT_MS = 20 * 60 * 1000;
|
|
29
|
+
export const DEFAULT_DATA_DIR_NAME = ".oh-my-agent-web";
|
|
30
|
+
/**
|
|
31
|
+
* 旧的数据目录名(改名前的)。
|
|
32
|
+
*
|
|
33
|
+
* 里面装的是**这台机器的私有配置**:远程访问密码、允许的域名、审批规则、推送订阅、
|
|
34
|
+
* 客户端设置、日志和锁。改名不能让这些凭空消失,所以启动时会**自动搬家**
|
|
35
|
+
* (见 `server/util/data-dir.ts`),搬完旧目录就不存在了。
|
|
36
|
+
*/
|
|
37
|
+
export const LEGACY_DATA_DIR_NAME = ".agent-web";
|
|
38
|
+
export const DATA_DIR_ENV = "AGENT_WEB_DATA_DIR";
|
|
39
|
+
/** ADR-0011:绝不与参考实现(~/.pi-web)共用数据目录 —— 否则两边会互相覆盖会话与配置 */
|
|
40
|
+
export const FOREIGN_DATA_DIR_NAMES = [".pi-web", ".pi"];
|
|
41
|
+
const LOG_LEVELS = ["debug", "info", "warn", "error"];
|
|
42
|
+
export function readConfig(env, options) {
|
|
43
|
+
const problems = [];
|
|
44
|
+
let port = DEFAULT_PORT;
|
|
45
|
+
const rawPort = env.AGENT_WEB_PORT;
|
|
46
|
+
if (rawPort !== undefined && rawPort !== "") {
|
|
47
|
+
const parsed = Number.parseInt(rawPort, 10);
|
|
48
|
+
if (Number.isInteger(parsed) && parsed > 0 && parsed < 65536)
|
|
49
|
+
port = parsed;
|
|
50
|
+
else
|
|
51
|
+
problems.push(`AGENT_WEB_PORT 不是合法端口:${rawPort}(回落到 ${DEFAULT_PORT})`);
|
|
52
|
+
}
|
|
53
|
+
let logLevel = "info";
|
|
54
|
+
const rawLevel = env.AGENT_WEB_LOG;
|
|
55
|
+
if (rawLevel !== undefined && rawLevel !== "") {
|
|
56
|
+
if (LOG_LEVELS.includes(rawLevel))
|
|
57
|
+
logLevel = rawLevel;
|
|
58
|
+
else
|
|
59
|
+
problems.push(`AGENT_WEB_LOG 不是合法级别:${rawLevel}(回落到 info)`);
|
|
60
|
+
}
|
|
61
|
+
let turnTimeoutMs = DEFAULT_TURN_TIMEOUT_MS;
|
|
62
|
+
const rawTimeout = env.AGENT_WEB_TURN_TIMEOUT_MS;
|
|
63
|
+
if (rawTimeout !== undefined && rawTimeout !== "") {
|
|
64
|
+
const parsedTimeout = Number.parseInt(rawTimeout, 10);
|
|
65
|
+
if (Number.isInteger(parsedTimeout) && parsedTimeout >= 0)
|
|
66
|
+
turnTimeoutMs = parsedTimeout;
|
|
67
|
+
else
|
|
68
|
+
problems.push(`AGENT_WEB_TURN_TIMEOUT_MS 不是合法毫秒数:${rawTimeout}(回落到默认)`);
|
|
69
|
+
}
|
|
70
|
+
let engine = "pi";
|
|
71
|
+
const rawEngine = env.AGENT_WEB_ENGINE;
|
|
72
|
+
if (rawEngine !== undefined && rawEngine !== "") {
|
|
73
|
+
// 只做形状归一化;**是不是注册过的引擎**由注册表判断(ADR-0024):
|
|
74
|
+
// 未注册的名字在那里回落 pi 并告警 —— 这样加引擎不用改这里
|
|
75
|
+
engine = rawEngine.trim().toLowerCase();
|
|
76
|
+
}
|
|
77
|
+
let host = DEFAULT_HOST;
|
|
78
|
+
const rawHost = env[`${HOST_ENV}`];
|
|
79
|
+
if (rawHost !== undefined && rawHost !== "")
|
|
80
|
+
host = rawHost.trim();
|
|
81
|
+
const password = env[`${PASSWORD_ENV}`] && env[`${PASSWORD_ENV}`] !== "" ? String(env[`${PASSWORD_ENV}`]) : null;
|
|
82
|
+
const allowedHosts = (env[ALLOWED_HOSTS_ENV] ?? "")
|
|
83
|
+
.split(",")
|
|
84
|
+
.map((item) => item.trim())
|
|
85
|
+
.filter((item) => item !== "");
|
|
86
|
+
return {
|
|
87
|
+
config: {
|
|
88
|
+
port,
|
|
89
|
+
host,
|
|
90
|
+
workspace: env.AGENT_WEB_CWD && env.AGENT_WEB_CWD !== "" ? resolve(env.AGENT_WEB_CWD) : resolve(options.cwd),
|
|
91
|
+
dataDir: env[DATA_DIR_ENV] && env[DATA_DIR_ENV] !== ""
|
|
92
|
+
? resolve(env[DATA_DIR_ENV])
|
|
93
|
+
: join(options.home, DEFAULT_DATA_DIR_NAME),
|
|
94
|
+
logLevel,
|
|
95
|
+
engine,
|
|
96
|
+
turnTimeoutMs,
|
|
97
|
+
token: env.AGENT_WEB_TOKEN && env.AGENT_WEB_TOKEN !== "" ? env.AGENT_WEB_TOKEN : null,
|
|
98
|
+
codexBin: env.AGENT_WEB_CODEX_BIN?.trim() || "codex",
|
|
99
|
+
codexHome: env.AGENT_WEB_CODEX_HOME?.trim() || null,
|
|
100
|
+
dshBin: env.AGENT_WEB_DSH_BIN?.trim() || "dsh",
|
|
101
|
+
dshHome: env.AGENT_WEB_DSH_HOME?.trim() || null,
|
|
102
|
+
dshModel: env.AGENT_WEB_DSH_MODEL?.trim() || null,
|
|
103
|
+
dshPatches: (env.AGENT_WEB_DSH_PATCH ?? "")
|
|
104
|
+
.split(":")
|
|
105
|
+
.map((item) => item.trim())
|
|
106
|
+
.filter((item) => item !== ""),
|
|
107
|
+
password,
|
|
108
|
+
allowedHosts,
|
|
109
|
+
},
|
|
110
|
+
problems,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
/** 去掉尾部分隔符(保留根目录 `/`),便于比较 */
|
|
114
|
+
export function normalizeDir(path) {
|
|
115
|
+
const resolved = resolve(path);
|
|
116
|
+
if (resolved === sep)
|
|
117
|
+
return sep;
|
|
118
|
+
return resolved.endsWith(sep) ? resolved.slice(0, -1) : resolved;
|
|
119
|
+
}
|
|
120
|
+
/** ADR-0006:工作区根不得是家目录,也不得是文件系统根 */
|
|
121
|
+
export function isForbiddenWorkspaceRoot(workspace, home) {
|
|
122
|
+
const normalized = normalizeDir(workspace);
|
|
123
|
+
return normalized === normalizeDir(home) || normalized === normalizeDir(sep);
|
|
124
|
+
}
|
|
125
|
+
/** ADR-0011:数据目录不得是参考实现的目录 */
|
|
126
|
+
export function isForeignDataDir(dataDir) {
|
|
127
|
+
const base = normalizeDir(dataDir).split(sep).pop() ?? "";
|
|
128
|
+
return FOREIGN_DATA_DIR_NAMES.includes(base);
|
|
129
|
+
}
|
|
130
|
+
/** 结构性校验:与 env 值是否合法无关,属「这个配置能不能安全地跑起来」 */
|
|
131
|
+
export function validateConfig(config, options) {
|
|
132
|
+
const problems = [];
|
|
133
|
+
if (isForbiddenWorkspaceRoot(config.workspace, options.home)) {
|
|
134
|
+
problems.push(`工作区根不能是家目录或文件系统根:${config.workspace}(ADR-0006)`);
|
|
135
|
+
}
|
|
136
|
+
if (isForeignDataDir(config.dataDir)) {
|
|
137
|
+
problems.push(`数据目录不能与参考实现共用:${config.dataDir}(ADR-0011,会互相覆盖会话)`);
|
|
138
|
+
}
|
|
139
|
+
return problems;
|
|
140
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* id 生成(ADR-0019)。
|
|
3
|
+
*
|
|
4
|
+
* 为什么不用进程内自增计数器(曾经的做法,踩过):**会话是跨进程持久的,而计数器不是**。
|
|
5
|
+
* 重启后计数器从 1 重新开始 → 生成出磁盘上已存在的 `conv-1` → 两条会话被写进同一个
|
|
6
|
+
* JSONL(连 meta 行都追加了两次)、消息 id 也重复 → 前端的「按 id 找到那条消息」全部错位。
|
|
7
|
+
*
|
|
8
|
+
* 规则:id 必须**全局唯一**,且不依赖进程状态。
|
|
9
|
+
*/
|
|
10
|
+
import { randomUUID } from "node:crypto";
|
|
11
|
+
/** 短 id:10 个十六进制字符(≈40 bit),单机自用场景足够 */
|
|
12
|
+
export function shortId() {
|
|
13
|
+
return randomUUID().replace(/-/g, "").slice(0, 10);
|
|
14
|
+
}
|
|
15
|
+
/** 带前缀的 id 工厂:`msg-…`、`appr-…`、`notice-…`(会话不用它,见下面的 conversationId) */
|
|
16
|
+
export function makeIdFactory() {
|
|
17
|
+
return (prefix) => `${prefix}-${shortId()}`;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* 会话 id:直接给 UUID,和 pi CLI 自己建会话时一模一样(`01a13d8d-a916-70b8-…`)。
|
|
21
|
+
*
|
|
22
|
+
* 为什么单独一个函数而不是 `makeIdFactory()("conv")`:会话 id 会**进入 pi 的会话文件名**
|
|
23
|
+
* 和地址栏的 `?session=`,跟 pi 生成的会话用同一种格式,才不用去猜「这个 id 是谁建的」;
|
|
24
|
+
* 另外 122 bit 随机比短 id 的 40 bit 更抗撞(会话是要长期保存的)。
|
|
25
|
+
*/
|
|
26
|
+
export function conversationId() {
|
|
27
|
+
return randomUUID();
|
|
28
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 日志落文件 + 按大小轮转(ADR-0013 §B)。
|
|
3
|
+
*
|
|
4
|
+
* 为什么需要文件:前台运行时 stdout 会随终端关闭而消失,而排障需要的是**崩溃前**那几行。
|
|
5
|
+
* 为什么不引日志库:这层总共 60 行,而且行为要精确可控(ADR-0008 的依赖预算也不允许)。
|
|
6
|
+
*
|
|
7
|
+
* 轮转:`agent-web.log` 超过上限 → 依次改名 `agent-web.log.1` / `.2` / …,最多保留 keep 个,
|
|
8
|
+
* 最老的被丢弃。同步写(日志量小,换来的是「崩溃时最后一行也落地」)。
|
|
9
|
+
*/
|
|
10
|
+
import { appendFileSync, existsSync, mkdirSync, renameSync, rmSync, statSync } from "node:fs";
|
|
11
|
+
import { dirname, join } from "node:path";
|
|
12
|
+
export const DEFAULT_MAX_BYTES = 5 * 1024 * 1024;
|
|
13
|
+
export const DEFAULT_KEEP = 3;
|
|
14
|
+
/** 纯函数:决定要保留哪些归档(把最老的挤掉) */
|
|
15
|
+
export function archivePlan(keep) {
|
|
16
|
+
return Array.from({ length: Math.max(0, keep) }, (_, index) => index + 1);
|
|
17
|
+
}
|
|
18
|
+
export function createRotatingFileSink(options) {
|
|
19
|
+
const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
|
|
20
|
+
const keep = options.keep ?? DEFAULT_KEEP;
|
|
21
|
+
let size = 0;
|
|
22
|
+
try {
|
|
23
|
+
mkdirSync(dirname(options.file), { recursive: true });
|
|
24
|
+
if (existsSync(options.file))
|
|
25
|
+
size = statSync(options.file).size;
|
|
26
|
+
}
|
|
27
|
+
catch (error) {
|
|
28
|
+
options.onError?.(`准备日志文件失败:${error instanceof Error ? error.message : String(error)}`);
|
|
29
|
+
}
|
|
30
|
+
function rotate() {
|
|
31
|
+
try {
|
|
32
|
+
// 最老的先删:log.<keep> 没了,然后从 keep-1 往上挪
|
|
33
|
+
const archives = archivePlan(keep);
|
|
34
|
+
rmSync(`${options.file}.${archives[archives.length - 1]}`, { force: true });
|
|
35
|
+
for (let index = archives.length - 1; index > 0; index -= 1) {
|
|
36
|
+
const from = `${options.file}.${index}`;
|
|
37
|
+
if (!existsSync(from))
|
|
38
|
+
continue;
|
|
39
|
+
renameSync(from, `${options.file}.${index + 1}`);
|
|
40
|
+
}
|
|
41
|
+
if (existsSync(options.file))
|
|
42
|
+
renameSync(options.file, `${options.file}.1`);
|
|
43
|
+
size = 0;
|
|
44
|
+
}
|
|
45
|
+
catch (error) {
|
|
46
|
+
options.onError?.(`日志轮转失败:${error instanceof Error ? error.message : String(error)}`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return {
|
|
50
|
+
write(line) {
|
|
51
|
+
try {
|
|
52
|
+
const payload = `${line}\n`;
|
|
53
|
+
if (size + Buffer.byteLength(payload, "utf8") > maxBytes)
|
|
54
|
+
rotate();
|
|
55
|
+
appendFileSync(options.file, payload, "utf8");
|
|
56
|
+
size += Buffer.byteLength(payload, "utf8");
|
|
57
|
+
}
|
|
58
|
+
catch (error) {
|
|
59
|
+
options.onError?.(`写日志失败:${error instanceof Error ? error.message : String(error)}`);
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
size: () => size,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
export function logFileName(dataDir) {
|
|
66
|
+
return join(dataDir, "logs", "agent-web.log");
|
|
67
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
export const LOG_LEVELS = ["debug", "info", "warn", "error"];
|
|
2
|
+
const LEVEL_ORDER = { debug: 10, info: 20, warn: 30, error: 40 };
|
|
3
|
+
/** 这些 key 的值一律不写日志(ADR-0013 §脱敏) */
|
|
4
|
+
const SECRET_KEY_RE = /(key|token|secret|password|passwd|credential|authorization|cookie)/i;
|
|
5
|
+
const MAX_VALUE_CHARS = 200;
|
|
6
|
+
function redactValue(key, value) {
|
|
7
|
+
if (SECRET_KEY_RE.test(key))
|
|
8
|
+
return "[redacted]";
|
|
9
|
+
if (typeof value === "string" && value.length > MAX_VALUE_CHARS) {
|
|
10
|
+
return `${value.slice(0, MAX_VALUE_CHARS)}…[${value.length} 字]`;
|
|
11
|
+
}
|
|
12
|
+
if (Array.isArray(value))
|
|
13
|
+
return value.slice(0, 20).map((item, index) => redactValue(`${key}.${index}`, item));
|
|
14
|
+
if (typeof value === "object" && value !== null) {
|
|
15
|
+
const out = {};
|
|
16
|
+
for (const [childKey, childValue] of Object.entries(value)) {
|
|
17
|
+
out[childKey] = redactValue(childKey, childValue);
|
|
18
|
+
}
|
|
19
|
+
return out;
|
|
20
|
+
}
|
|
21
|
+
return value;
|
|
22
|
+
}
|
|
23
|
+
/** 纯函数:把上下文里的敏感值抹掉、把长值截断(可单测) */
|
|
24
|
+
export function redactLogContext(ctx) {
|
|
25
|
+
const out = {};
|
|
26
|
+
for (const [key, value] of Object.entries(ctx))
|
|
27
|
+
out[key] = redactValue(key, value);
|
|
28
|
+
return out;
|
|
29
|
+
}
|
|
30
|
+
/** 纯函数:把一条日志格式化成单行 JSON(便于单测脱敏与字段形状) */
|
|
31
|
+
export function formatLogLine(entry) {
|
|
32
|
+
return JSON.stringify(entry.ctx ? entry : { ...entry, ctx: undefined });
|
|
33
|
+
}
|
|
34
|
+
export function createLogger(options) {
|
|
35
|
+
const now = options.now ?? (() => Date.now());
|
|
36
|
+
const sink = options.sink ?? ((line) => process.stdout.write(`${line}\n`));
|
|
37
|
+
const scope = options.scope ?? "app";
|
|
38
|
+
const threshold = LEVEL_ORDER[options.level];
|
|
39
|
+
const write = (level, message, ctx) => {
|
|
40
|
+
if (LEVEL_ORDER[level] < threshold)
|
|
41
|
+
return;
|
|
42
|
+
const safeCtx = ctx ? redactLogContext(ctx) : undefined;
|
|
43
|
+
sink(formatLogLine({ ts: now(), level, scope, msg: message, ...(safeCtx ? { ctx: safeCtx } : {}) }));
|
|
44
|
+
};
|
|
45
|
+
return {
|
|
46
|
+
debug: (message, ctx) => write("debug", message, ctx),
|
|
47
|
+
info: (message, ctx) => write("info", message, ctx),
|
|
48
|
+
warn: (message, ctx) => write("warn", message, ctx),
|
|
49
|
+
error: (message, ctx) => write("error", message, ctx),
|
|
50
|
+
child: (childScope) => createLogger({ ...options, scope: `${scope}.${childScope}` }),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 握手校验(ADR-0006 §握手)—— 本 ADR 的核心条目。
|
|
3
|
+
*
|
|
4
|
+
* 为什么必须有:WebSocket **不受同源策略保护**。你浏览器里打开的任意网页都能向
|
|
5
|
+
* `ws://127.0.0.1:<port>` 发起升级请求;若服务端不校验 Origin,那个网页就能驱动
|
|
6
|
+
* 你这台机器上的 shell。所以「只绑 loopback」并不够,握手是第一道也是唯一一道门。
|
|
7
|
+
*
|
|
8
|
+
* 规则:
|
|
9
|
+
* - 无 Origin(非浏览器客户端:测试脚本 / CLI / 健康检查)→ 放行;
|
|
10
|
+
* - 有 Origin → 主机名在允许列表(loopback 或 `AGENT_WEB_ALLOWED_HOSTS` 里配的域名)+ 端口匹配;
|
|
11
|
+
* - Host 头:如果带了,必须是 loopback 且端口在白名单里(防 DNS rebinding 一类)。
|
|
12
|
+
*
|
|
13
|
+
* 全是纯函数,单测直接覆盖。
|
|
14
|
+
*/
|
|
15
|
+
export const LOOPBACK_HOSTNAMES = ["127.0.0.1", "localhost", "::1", "[::1]"];
|
|
16
|
+
export function allowedPorts(policy) {
|
|
17
|
+
return [policy.port, ...(policy.devPorts ?? [])];
|
|
18
|
+
}
|
|
19
|
+
export function isLoopbackHostname(hostname) {
|
|
20
|
+
const normalized = hostname.toLowerCase();
|
|
21
|
+
return LOOPBACK_HOSTNAMES.includes(normalized) || normalized === "::1";
|
|
22
|
+
}
|
|
23
|
+
/** 主机名在白名单里(loopback 或用户显式配置的域名/局域网 IP) */
|
|
24
|
+
export function isAllowedHostname(hostname, policy) {
|
|
25
|
+
if (isLoopbackHostname(hostname))
|
|
26
|
+
return true;
|
|
27
|
+
const normalized = hostname.toLowerCase().replace(/^\[|\]$/g, "");
|
|
28
|
+
return (policy.extraHosts ?? []).some((item) => item.toLowerCase() === normalized);
|
|
29
|
+
}
|
|
30
|
+
function portMatches(port, policy) {
|
|
31
|
+
// 缺端口时按默认端口推断:http→80 / https→443,都不可能命中,视为不匹配
|
|
32
|
+
if (!port)
|
|
33
|
+
return false;
|
|
34
|
+
return allowedPorts(policy).includes(Number.parseInt(port, 10));
|
|
35
|
+
}
|
|
36
|
+
/** 校验 `Origin` 请求头 */
|
|
37
|
+
export function isAllowedOrigin(origin, policy) {
|
|
38
|
+
if (!origin)
|
|
39
|
+
return true; // 非浏览器客户端
|
|
40
|
+
let url;
|
|
41
|
+
try {
|
|
42
|
+
url = new URL(origin);
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
if (url.protocol !== "http:" && url.protocol !== "https:")
|
|
48
|
+
return false;
|
|
49
|
+
if (!isAllowedHostname(url.hostname, policy))
|
|
50
|
+
return false;
|
|
51
|
+
// 配置了域名(反向代理场景)时端口可能是 80/443,那两种按名字放行
|
|
52
|
+
if (isLoopbackHostname(url.hostname) === false && (url.port === "" || url.port === "80" || url.port === "443")) {
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
return portMatches(url.port, policy);
|
|
56
|
+
}
|
|
57
|
+
/** 校验 `Host` 请求头(缺失时放行;带了就必须是 loopback + 白名单端口) */
|
|
58
|
+
export function isAllowedHost(host, policy) {
|
|
59
|
+
if (!host)
|
|
60
|
+
return true;
|
|
61
|
+
const trimmed = host.trim();
|
|
62
|
+
const [hostname, port] = trimmed.startsWith("[")
|
|
63
|
+
? [trimmed.slice(0, trimmed.indexOf("]") + 1), trimmed.slice(trimmed.indexOf("]") + 2)]
|
|
64
|
+
: trimmed.split(":");
|
|
65
|
+
if (!isAllowedHostname(hostname ?? "", policy))
|
|
66
|
+
return false;
|
|
67
|
+
if (port === undefined || port === "" || port === "80" || port === "443")
|
|
68
|
+
return isAllowedHostname(hostname ?? "", policy);
|
|
69
|
+
return portMatches(port, policy);
|
|
70
|
+
}
|
|
71
|
+
/** 给人类看的错误信息(连不上时能立刻明白原因,而不是干瞪眼) */
|
|
72
|
+
export function describePolicy(policy) {
|
|
73
|
+
const hosts = [...LOOPBACK_HOSTNAMES, ...(policy.extraHosts ?? [])];
|
|
74
|
+
return `允许的来源:${hosts.join(" / ")};端口 ${allowedPorts(policy).join(" / ")}`;
|
|
75
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 路径解析与约束(纯函数)。
|
|
3
|
+
*
|
|
4
|
+
* 两个场景用它:
|
|
5
|
+
* 1. `web/dist` 在哪 —— 源码运行与编译运行的层数不同(M1 踩过);
|
|
6
|
+
* 2. 路径是否落在工作区内 —— ADR-0006:`realpath` 之后必须在根内(防 `..` 与 symlink 逃逸)。
|
|
7
|
+
*
|
|
8
|
+
* 刻意把 `realpath` 作为参数注入:单测不需要造文件系统,调用方传 `fs.realpathSync`。
|
|
9
|
+
*/
|
|
10
|
+
import { join, relative, resolve, sep } from "node:path";
|
|
11
|
+
export function pickExisting(candidates, exists) {
|
|
12
|
+
for (const candidate of candidates)
|
|
13
|
+
if (exists(candidate))
|
|
14
|
+
return candidate;
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
/** web/dist 的候选路径:先是「源码运行」,再是「编译后运行」 */
|
|
18
|
+
export function webDistCandidates(serverDir) {
|
|
19
|
+
return [join(serverDir, "..", "web", "dist"), join(serverDir, "..", "..", "web", "dist")];
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* `candidate` 是否在 `root` 之内。
|
|
23
|
+
*
|
|
24
|
+
* 用「相对路径不以 `..` 开头」判断,而不是字符串 `startsWith`:
|
|
25
|
+
* `/root2` 不是 `/root` 的子路径,但 `startsWith("/root")` 会误判为真。
|
|
26
|
+
* 传入的路径应当是**已经 realpath 过**的(调用方负责),否则 symlink 能逃出去。
|
|
27
|
+
*/
|
|
28
|
+
export function isPathInside(root, candidate) {
|
|
29
|
+
const relativePath = relative(resolve(root), resolve(candidate));
|
|
30
|
+
if (relativePath === "")
|
|
31
|
+
return true; // 就是根本身
|
|
32
|
+
return !relativePath.startsWith("..") && !relativePath.startsWith(`..${sep}`) && !isAbsoluteLike(relativePath);
|
|
33
|
+
}
|
|
34
|
+
function isAbsoluteLike(path) {
|
|
35
|
+
return path.startsWith(sep) || /^[A-Za-z]:[\\/]/.test(path);
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* 解析到真实路径并确认在工作区内(ADR-0006)。
|
|
39
|
+
* `realpath` 失败(路径不存在)时按「不在根内」处理 —— 宁可拒绝,不要放行。
|
|
40
|
+
*/
|
|
41
|
+
export function resolveInsideRoot(root, candidate, realpath) {
|
|
42
|
+
let realRoot;
|
|
43
|
+
try {
|
|
44
|
+
realRoot = realpath(root);
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return { ok: false, reason: `工作区根无法解析:${root}` };
|
|
48
|
+
}
|
|
49
|
+
let realCandidate;
|
|
50
|
+
try {
|
|
51
|
+
realCandidate = realpath(candidate);
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return { ok: false, reason: `路径不存在或无法解析:${candidate}` };
|
|
55
|
+
}
|
|
56
|
+
if (!isPathInside(realRoot, realCandidate)) {
|
|
57
|
+
return { ok: false, reason: `路径越界(不在工作区内):${candidate}` };
|
|
58
|
+
}
|
|
59
|
+
return { ok: true, path: realCandidate };
|
|
60
|
+
}
|