fanluc 2.0.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 (145) hide show
  1. package/bin/fanluc.js +54 -0
  2. package/dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
  3. package/dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
  4. package/dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
  5. package/dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
  6. package/dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
  7. package/dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
  8. package/dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
  9. package/dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
  10. package/dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
  11. package/dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
  12. package/dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
  13. package/dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
  14. package/dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
  15. package/dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
  16. package/dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
  17. package/dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
  18. package/dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
  19. package/dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
  20. package/dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
  21. package/dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
  22. package/dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
  23. package/dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
  24. package/dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
  25. package/dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
  26. package/dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
  27. package/dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
  28. package/dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
  29. package/dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
  30. package/dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
  31. package/dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
  32. package/dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
  33. package/dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
  34. package/dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
  35. package/dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
  36. package/dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
  37. package/dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
  38. package/dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
  39. package/dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
  40. package/dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
  41. package/dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
  42. package/dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
  43. package/dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
  44. package/dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
  45. package/dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
  46. package/dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
  47. package/dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
  48. package/dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
  49. package/dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
  50. package/dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
  51. package/dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
  52. package/dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
  53. package/dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
  54. package/dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
  55. package/dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
  56. package/dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
  57. package/dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
  58. package/dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
  59. package/dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
  60. package/dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
  61. package/dist/assets/_basePickBy-CP3UBwOk.js +1 -0
  62. package/dist/assets/_baseUniq-CZ8FG28m.js +1 -0
  63. package/dist/assets/abnfDiagram-O67JEVCF-hiYMdxpV.js +1 -0
  64. package/dist/assets/arc-DokRg_wy.js +1 -0
  65. package/dist/assets/architectureDiagram-NJMV4G6O-UzHYe9Bx.js +36 -0
  66. package/dist/assets/blockDiagram-BEXU5L5S-DqM5UILT.js +140 -0
  67. package/dist/assets/c4Diagram-YGBWAQC7-Cxkdm7sV.js +38 -0
  68. package/dist/assets/channel-SPfNTvmj.js +1 -0
  69. package/dist/assets/chunk-2Q5K7J3B-Nl4zmLlG.js +1 -0
  70. package/dist/assets/chunk-3FUC2YCW-BCol78O2.js +2 -0
  71. package/dist/assets/chunk-5VM5RSS4-1u4YRv2Y.js +15 -0
  72. package/dist/assets/chunk-GTNCS2PH-BG9YIV2h.js +88 -0
  73. package/dist/assets/chunk-GWA4HPMP-BTKfkJAH.js +1 -0
  74. package/dist/assets/chunk-JWPE2WC7-DCjElfA7.js +1 -0
  75. package/dist/assets/chunk-NETBCI7D-QoJORlVn.js +1 -0
  76. package/dist/assets/chunk-WEXAMYUT-Da-T8ct9.js +1 -0
  77. package/dist/assets/chunk-XXDRQBXY-CLu69Uei.js +1 -0
  78. package/dist/assets/classDiagram-v2-NBCMYWYE-DIXavbI9.js +217 -0
  79. package/dist/assets/cose-bilkent-JH36ORCC-DuWclG8-.js +1 -0
  80. package/dist/assets/cynefin-OW5HDTMX-D2CG5-xd.js +178 -0
  81. package/dist/assets/cynefinDiagram-VND7K2PF-goI7_Qfg.js +62 -0
  82. package/dist/assets/cytoscape.esm-Ix0LnXOy.js +331 -0
  83. package/dist/assets/dagre-6A5THRUB-BmOaZ0cm.js +4 -0
  84. package/dist/assets/defaultLocale-DX6XiGOO.js +1 -0
  85. package/dist/assets/diagram-22UHCM2B-DjDJFMw3.js +200 -0
  86. package/dist/assets/diagram-3UASUU5V-J6z81NH3.js +24 -0
  87. package/dist/assets/diagram-ATOU4E4O-B1MPU8ai.js +3 -0
  88. package/dist/assets/diagram-CDSNMT55-X5Uss0Yv.js +30 -0
  89. package/dist/assets/diagram-MLGK6HIB-CRDqfYy1.js +24 -0
  90. package/dist/assets/diagram-MPIPVDR6-bhJtyiOq.js +41 -0
  91. package/dist/assets/ebnfDiagram-ZINNZB2B-Dn1mZAV8.js +1 -0
  92. package/dist/assets/elk-276RUBZZ-C93BlBEU.js +27 -0
  93. package/dist/assets/erDiagram-OPXOYQCR-PuzjAvhD.js +99 -0
  94. package/dist/assets/flowDiagram-KWPJA3E3-oRhFgO0p.js +213 -0
  95. package/dist/assets/ganttDiagram-FUAMR5RP-DKiU1XjU.js +292 -0
  96. package/dist/assets/gitGraphDiagram-X574FWY7-CWBMI0vb.js +106 -0
  97. package/dist/assets/graph-tGV3vi3M.js +1 -0
  98. package/dist/assets/index-BylpTP-E.css +1 -0
  99. package/dist/assets/index-DcpVteR3.js +3237 -0
  100. package/dist/assets/infoDiagram-VRGFBTTK-C5Hd4Afz.js +2 -0
  101. package/dist/assets/init-Gi6I4Gst.js +1 -0
  102. package/dist/assets/ishikawaDiagram-OU5B5YK6-BdD6ulRK.js +70 -0
  103. package/dist/assets/journeyDiagram-ZHPQQLJL-CzT-uvks.js +139 -0
  104. package/dist/assets/kanban-definition-PNTS6WVX-BkrazFMR.js +89 -0
  105. package/dist/assets/katex-C5jXJg4s.js +257 -0
  106. package/dist/assets/linear-B0wSM1Dr.js +1 -0
  107. package/dist/assets/mindmap-definition-NLK3R4M7-Df1Acqkf.js +96 -0
  108. package/dist/assets/ordinal-Cboi1Yqb.js +1 -0
  109. package/dist/assets/pegDiagram-GJSIUBJH-DK_F7h_0.js +1 -0
  110. package/dist/assets/permissionHelper--88tJJlz.js +3 -0
  111. package/dist/assets/pieDiagram-5QR66LMP-BMXyNb9R.js +39 -0
  112. package/dist/assets/quadrantDiagram-O4NWA36T-Dfu7Z8so.js +7 -0
  113. package/dist/assets/railroadDiagram-XR7U4H2S-CfnBD-aQ.js +1 -0
  114. package/dist/assets/requirementDiagram-PLB6GJNP-DrvsZJPP.js +84 -0
  115. package/dist/assets/sankeyDiagram-IPEJSGJF-DvdVDpT1.js +40 -0
  116. package/dist/assets/sequenceDiagram-PO4LG4MO-D1UTrvZH.js +169 -0
  117. package/dist/assets/sizeCapture-INFHLROL-CJrOBcai.js +1 -0
  118. package/dist/assets/stateDiagram-v2-GCMORJYK-CX8VmtqD.js +291 -0
  119. package/dist/assets/swimlanes-2SLR337P-DUWmhiK6.js +1 -0
  120. package/dist/assets/swimlanesDiagram-TC7HE7FX-DMQy1553.js +8 -0
  121. package/dist/assets/timeline-definition-EJHVYXUP-CBovVdOU.js +120 -0
  122. package/dist/assets/usecaseDiagram-POWQR4AR-LD5N5BAG.js +370 -0
  123. package/dist/assets/vennDiagram-UO4OBE2U-DdwfzB3A.js +34 -0
  124. package/dist/assets/wardleyDiagram-VNRHLVJA-BMHK4bav.js +78 -0
  125. package/dist/assets/xychartDiagram-PMCCYNJV-q1BNzMC-.js +7 -0
  126. package/dist/cli.js +474 -0
  127. package/dist/index.html +18 -0
  128. package/dist/msh_spin_inverted.gif +0 -0
  129. package/dist/server.cjs +1974 -0
  130. package/dist/server.cjs.map +7 -0
  131. package/dist/shrink_rotate.gif +0 -0
  132. package/dist/tui.js +5949 -0
  133. package/docs/fanluc_ui.md +48 -0
  134. package/docs/guide-connect.md +63 -0
  135. package/docs/mondk.md +6 -0
  136. package/docs/msh.md +3 -0
  137. package/index.html +17 -0
  138. package/mcp.json +0 -0
  139. package/package.json +92 -0
  140. package/public/msh_spin_inverted.gif +0 -0
  141. package/public/shrink_rotate.gif +0 -0
  142. package/searxng/docker-compose.yml +20 -0
  143. package/searxng/settings.yml +23 -0
  144. package/tsconfig.json +26 -0
  145. package/vite.config.ts +22 -0
@@ -0,0 +1,1974 @@
1
+ var __create = Object.create;
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __getProtoOf = Object.getPrototypeOf;
6
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
7
+ var __export = (target, all) => {
8
+ for (var name in all)
9
+ __defProp(target, name, { get: all[name], enumerable: true });
10
+ };
11
+ var __copyProps = (to, from, except, desc) => {
12
+ if (from && typeof from === "object" || typeof from === "function") {
13
+ for (let key of __getOwnPropNames(from))
14
+ if (!__hasOwnProp.call(to, key) && key !== except)
15
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
16
+ }
17
+ return to;
18
+ };
19
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
20
+ // If the importer is in node compatibility mode or this is not an ESM
21
+ // file that has been converted to a CommonJS file using a Babel-
22
+ // compatible transform (i.e. "__esModule" has not been set), then set
23
+ // "default" to the CommonJS "module.exports" for node compatibility.
24
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
25
+ mod
26
+ ));
27
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
28
+
29
+ // server.ts
30
+ var server_exports = {};
31
+ __export(server_exports, {
32
+ WORKSPACE_DIR: () => WORKSPACE_DIR,
33
+ startServer: () => startServer
34
+ });
35
+ module.exports = __toCommonJS(server_exports);
36
+ var import_express = __toESM(require("express"), 1);
37
+ var import_path = __toESM(require("path"), 1);
38
+ var import_fs = __toESM(require("fs"), 1);
39
+ var import_os = __toESM(require("os"), 1);
40
+ var import_child_process = require("child_process");
41
+ var import_vite = require("vite");
42
+ var import_google_translate_api_x = require("google-translate-api-x");
43
+ var import_jsdom = require("jsdom");
44
+ var import_readability = require("@mozilla/readability");
45
+ var import_dotenv = __toESM(require("dotenv"), 1);
46
+ var import_promises = __toESM(require("node:dns/promises"), 1);
47
+ var import_node_net = __toESM(require("node:net"), 1);
48
+
49
+ // src/utils/decodeText.ts
50
+ function decodeTextBuffer(buf) {
51
+ if (buf.length >= 3 && buf[0] === 239 && buf[1] === 187 && buf[2] === 191) {
52
+ return { text: buf.subarray(3).toString("utf8"), encoding: "utf-8-bom" };
53
+ }
54
+ if (buf.length >= 2 && buf[0] === 255 && buf[1] === 254) {
55
+ return { text: buf.subarray(2).toString("utf16le"), encoding: "utf-16le" };
56
+ }
57
+ if (buf.length >= 2 && buf[0] === 254 && buf[1] === 255) {
58
+ const sw = Buffer.from(buf.subarray(2));
59
+ for (let i = 0; i + 1 < sw.length; i += 2) {
60
+ const t = sw[i];
61
+ sw[i] = sw[i + 1];
62
+ sw[i + 1] = t;
63
+ }
64
+ return { text: sw.toString("utf16le"), encoding: "utf-16be" };
65
+ }
66
+ const sample = buf.subarray(0, Math.min(buf.length, 1024));
67
+ let nulOdd = 0, nulTotal = 0;
68
+ for (let i = 0; i < sample.length; i++) if (sample[i] === 0) {
69
+ nulTotal++;
70
+ if (i % 2 === 1) nulOdd++;
71
+ }
72
+ if (sample.length >= 4 && nulTotal > sample.length * 0.2 && nulOdd >= nulTotal * 0.9) {
73
+ return { text: buf.toString("utf16le"), encoding: "utf-16le" };
74
+ }
75
+ try {
76
+ return { text: new TextDecoder("utf-8", { fatal: true }).decode(buf), encoding: "utf-8" };
77
+ } catch {
78
+ }
79
+ try {
80
+ return { text: new TextDecoder("windows-1258").decode(buf), encoding: "windows-1258" };
81
+ } catch {
82
+ }
83
+ return { text: buf.toString("latin1"), encoding: "latin1" };
84
+ }
85
+
86
+ // src/core/security/blocklist.ts
87
+ var import_node_path = __toESM(require("node:path"), 1);
88
+ var DANGEROUS_PATTERNS = [
89
+ { pattern: /\bsudo\b|\bsu\s+(-|root)\b/i, reason: "privilege escalation (sudo/su)" },
90
+ { pattern: /\brm\s+(-[a-z]*r[a-z]*f[a-z]*|-[a-z]*f[a-z]*r[a-z]*)\s+\/(?!home\/|tmp\/|workspace)/i, reason: "recursive delete outside the workspace" },
91
+ { pattern: /\brm\s+(-[a-z]*r[a-z]*f[a-z]*|-[a-z]*f[a-z]*r[a-z]*)\s+[~*]\s*(\/|$)/i, reason: "recursive delete of home/root" },
92
+ { pattern: /:\(\)\s*\{\s*:\s*\|\s*:\s*&?\s*\}\s*;/, reason: "fork bomb" },
93
+ { pattern: /\bmkfs\b|\bdd\s+if=.*of=\/dev\/|\bshred\b/i, reason: "disk-level destructive command" },
94
+ { pattern: /\b(shutdown|reboot|halt|poweroff)\b/i, reason: "system power control" },
95
+ { pattern: /\bchmod\s+.*777\s+\//i, reason: "world-writable permission change on root" },
96
+ { pattern: /\b(curl|wget)\b[^\n|]*\|\s*(sudo\s+)?(bash|sh|zsh)\b/i, reason: "piping a remote script straight into a shell" },
97
+ { pattern: /\/etc\/(passwd|shadow|sudoers)\b/i, reason: "reading system credential files" },
98
+ { pattern: /\.env\b/i, reason: "accessing .env (may contain server secrets, not exposed to run_cmd on purpose)" },
99
+ { pattern: /\bnc\b.*-e\b|\/dev\/tcp\//i, reason: "raw reverse-shell / netcat exec pattern" },
100
+ { pattern: /\bcrontab\b/i, reason: "persistence via crontab" },
101
+ // --- Windows / PowerShell ---
102
+ { pattern: /\b(rd|rmdir)\b[^&|;]*\/s/i, reason: "Windows rd/rmdir /s recursive delete" },
103
+ { pattern: /\b(del|erase)\b[^&|;]*\/s/i, reason: "Windows del/erase /s recursive delete" },
104
+ { pattern: /\b(Format-Volume|Format-C|Clear-Disk|Remove-Partition)\b/i, reason: "Windows disk destructive command" },
105
+ { pattern: /\b(iwr|Invoke-WebRequest|Invoke-RestMethod|curl|wget)\b[^\n|;]*\|\s*(iex|Invoke-Expression)\b/i, reason: "PowerShell piping remote script into Invoke-Expression (iwr | iex)" },
106
+ { pattern: /\biex\b|\bInvoke-Expression\b/i, reason: "PowerShell Invoke-Expression" },
107
+ { pattern: /\b(Start-Process|Invoke-Item)\b.*\b(powershell|cmd|wscript|cscript)\b/i, reason: "PowerShell spawning shell via Start-Process" },
108
+ { pattern: /-EncodedCommand\b/i, reason: "PowerShell EncodedCommand (obfuscated script)" },
109
+ { pattern: /\bFromBase64String\b/i, reason: "PowerShell FromBase64String (obfuscated)" }
110
+ ];
111
+ function tokenizeSegments(cmd) {
112
+ const segments = [];
113
+ let tokens = [];
114
+ let cur = "";
115
+ let hasCur = false;
116
+ let curFromPipe = false;
117
+ const pushToken = () => {
118
+ if (hasCur) tokens.push(cur);
119
+ cur = "";
120
+ hasCur = false;
121
+ };
122
+ const endSegment = (sep) => {
123
+ pushToken();
124
+ if (tokens.length) segments.push({ tokens, fromPipe: curFromPipe });
125
+ tokens = [];
126
+ curFromPipe = sep === "|";
127
+ };
128
+ for (let i = 0; i < cmd.length; i++) {
129
+ const ch = cmd[i];
130
+ if (ch === "'" || ch === '"') {
131
+ hasCur = true;
132
+ const q = ch;
133
+ i++;
134
+ while (i < cmd.length) {
135
+ if (cmd[i] === q) {
136
+ if (cmd[i + 1] === q) {
137
+ cur += q;
138
+ i += 2;
139
+ continue;
140
+ }
141
+ break;
142
+ }
143
+ if (q === '"' && cmd[i] === "`" && i + 1 < cmd.length) {
144
+ cur += cmd[i + 1];
145
+ i += 2;
146
+ continue;
147
+ }
148
+ cur += cmd[i++];
149
+ }
150
+ continue;
151
+ }
152
+ if (ch === ";" || ch === "\n" || ch === "\r" || ch === "&" || ch === "{" || ch === "}" || ch === "(" || ch === ")") {
153
+ endSegment(ch);
154
+ continue;
155
+ }
156
+ if (ch === "|") {
157
+ endSegment("|");
158
+ continue;
159
+ }
160
+ if (ch === " " || ch === " " || ch === ",") {
161
+ pushToken();
162
+ continue;
163
+ }
164
+ cur += ch;
165
+ hasCur = true;
166
+ }
167
+ endSegment(";");
168
+ return segments;
169
+ }
170
+ function looksWindows(opts) {
171
+ const ws = opts.workspaceDir || "";
172
+ return opts.platform === "win32" || /^[a-zA-Z]:[\\/]/.test(ws) || ws.startsWith("\\\\");
173
+ }
174
+ var UNIVERSAL_WILDCARD = /^(\.?[\\/])?\*+(\.\*)?$/;
175
+ function classifyTarget(rawTarget, recurse, force, opts) {
176
+ const t = rawTarget.trim();
177
+ if (!t) return null;
178
+ if (t === "~" || /^~[\\/]/.test(t) || /\$home\b/i.test(t) || /\$env:/i.test(t) || /%[^%]+%/.test(t)) {
179
+ return "target is home directory or environment path (~, $HOME, $env:, %VAR%)";
180
+ }
181
+ if (recurse && (/\$/.test(t) || /`/.test(t))) {
182
+ return "recursive delete with a target that cannot be resolved statically (variable / subexpression)";
183
+ }
184
+ const hasWildcard = /[*?]/.test(t);
185
+ if (recurse && UNIVERSAL_WILDCARD.test(t)) {
186
+ return "recursive delete of everything via wildcard (*)";
187
+ }
188
+ if (recurse && force && hasWildcard) {
189
+ return "recursive + force delete with a wildcard target";
190
+ }
191
+ const isWin = looksWindows(opts);
192
+ const p = isWin ? import_node_path.default.win32 : import_node_path.default.posix;
193
+ const isAbs = /^[a-zA-Z]:/.test(t) || t.startsWith("\\\\") || t.startsWith("\\") || t.startsWith("/") || p.isAbsolute(t);
194
+ if (!isWin && (/^[a-zA-Z]:/.test(t) || t.startsWith("\\"))) {
195
+ return "target is a Windows-style absolute path outside the workspace";
196
+ }
197
+ const ws = opts.workspaceDir ? p.resolve(opts.workspaceDir) : null;
198
+ const norm = (s) => isWin ? s.toLowerCase() : s;
199
+ const forResolve = hasWildcard ? t.replace(/[\\/]?[^\\/]*[*?][^\\/]*$/, "") || "." : t;
200
+ if (!ws) {
201
+ if (isAbs) return "absolute path target (workspace unknown)";
202
+ if (forResolve.split(/[\\/]+/).includes("..")) return "target escapes the workspace (..)";
203
+ return null;
204
+ }
205
+ const resolved = p.resolve(ws, forResolve);
206
+ const rel = p.relative(norm(ws), norm(resolved));
207
+ const escapes = rel === ".." || rel.startsWith(".." + p.sep) || p.isAbsolute(rel);
208
+ if (escapes) {
209
+ return isAbs ? "target is an absolute path outside the workspace" : "target escapes the workspace (..)";
210
+ }
211
+ if (recurse && rel === "" && !hasWildcard) {
212
+ return "recursive delete of the workspace root itself";
213
+ }
214
+ return null;
215
+ }
216
+ var PS_ONLY_DELETE = /* @__PURE__ */ new Set(["remove-item", "ri"]);
217
+ var WIN_ALIAS_DELETE = /* @__PURE__ */ new Set(["rm", "del", "erase", "rd", "rmdir"]);
218
+ var VALUE_FLAGS_TARGET = ["path", "literalpath"];
219
+ var VALUE_FLAGS_SKIP = ["include", "exclude", "filter", "credential", "stream"];
220
+ function flagMatches(name, full, minLen) {
221
+ const n = name.toLowerCase();
222
+ return n.length >= minLen && full.startsWith(n);
223
+ }
224
+ function analyzePowerShellDelete(command, opts) {
225
+ const platform = opts.platform ?? process.platform;
226
+ const heads = new Set(PS_ONLY_DELETE);
227
+ if (platform === "win32") for (const a of WIN_ALIAS_DELETE) heads.add(a);
228
+ const segments = tokenizeSegments(command);
229
+ let analyzedRemoveItem = 0;
230
+ for (const seg of segments) {
231
+ const head = (seg.tokens[0] || "").toLowerCase().replace(/^.*\\/, "");
232
+ if (!heads.has(head)) continue;
233
+ if (head === "remove-item") analyzedRemoveItem++;
234
+ let recurse = false;
235
+ let force = false;
236
+ const targets = [];
237
+ for (let i = 1; i < seg.tokens.length; i++) {
238
+ const tok = seg.tokens[i];
239
+ if (tok.startsWith("-") && tok.length > 1 && !/^-\d/.test(tok)) {
240
+ const body = tok.slice(1);
241
+ const colon = body.indexOf(":");
242
+ const name = colon >= 0 ? body.slice(0, colon) : body;
243
+ const inlineVal = colon >= 0 ? body.slice(colon + 1) : null;
244
+ if (flagMatches(name, "recurse", 1)) {
245
+ recurse = !(inlineVal && /^\$?false$/i.test(inlineVal));
246
+ } else if (flagMatches(name, "force", 2)) {
247
+ force = !(inlineVal && /^\$?false$/i.test(inlineVal));
248
+ } else if (VALUE_FLAGS_TARGET.some((f) => flagMatches(name, f, 1))) {
249
+ const v = inlineVal ?? seg.tokens[++i];
250
+ if (v) targets.push(v);
251
+ } else if (VALUE_FLAGS_SKIP.some((f) => flagMatches(name, f, 1))) {
252
+ if (inlineVal === null) i++;
253
+ }
254
+ continue;
255
+ }
256
+ targets.push(tok);
257
+ }
258
+ if (recurse && targets.length === 0 && seg.fromPipe) {
259
+ return "recursive delete with pipeline input (target unknown)";
260
+ }
261
+ if (recurse && targets.length === 0) {
262
+ return "recursive delete without an explicit target";
263
+ }
264
+ for (const t of targets) {
265
+ const why = classifyTarget(t, recurse, force, opts);
266
+ if (why) return `PowerShell delete blocked: ${why}`;
267
+ }
268
+ }
269
+ const rawCount = (command.match(/\bRemove-Item\b/gi) || []).length;
270
+ if (rawCount > analyzedRemoveItem && /-r(e(c(u(r(s(e)?)?)?)?)?)?\b/i.test(command)) {
271
+ return "PowerShell recursive delete inside a nested/quoted command (cannot verify target)";
272
+ }
273
+ return null;
274
+ }
275
+ function findBlockedPattern(command, opts = {}) {
276
+ for (const { pattern, reason } of DANGEROUS_PATTERNS) {
277
+ if (pattern.test(command)) return reason;
278
+ }
279
+ return analyzePowerShellDelete(command, opts);
280
+ }
281
+
282
+ // server.ts
283
+ var import_client = require("@modelcontextprotocol/sdk/client/index.js");
284
+ var import_stdio = require("@modelcontextprotocol/sdk/client/stdio.js");
285
+ var import_streamableHttp = require("@modelcontextprotocol/sdk/client/streamableHttp.js");
286
+ import_dotenv.default.config();
287
+ var IS_WINDOWS = process.platform === "win32";
288
+ function resolveWorkspaceDir() {
289
+ const override = process.env.FANLUC_WORKSPACE?.trim();
290
+ if (override) return import_path.default.resolve(override);
291
+ if (process.env.WORKSPACE_DIR?.trim()) return import_path.default.resolve(process.env.WORKSPACE_DIR.trim());
292
+ return import_path.default.join(process.cwd(), "workspace");
293
+ }
294
+ var WORKSPACE_DIR = resolveWorkspaceDir();
295
+ function isInsideWorkspace(p) {
296
+ const rel = import_path.default.relative(WORKSPACE_DIR, import_path.default.resolve(p));
297
+ return rel === "" || !rel.startsWith("..") && !import_path.default.isAbsolute(rel);
298
+ }
299
+ try {
300
+ import_fs.default.mkdirSync(WORKSPACE_DIR, { recursive: true });
301
+ } catch (e) {
302
+ console.error("Failed to create workspace dir:", e);
303
+ }
304
+ var SAFE_ENV = IS_WINDOWS ? {
305
+ PATH: process.env.PATH,
306
+ SystemRoot: process.env.SystemRoot,
307
+ windir: process.env.windir,
308
+ COMSPEC: process.env.COMSPEC,
309
+ OS: process.env.OS,
310
+ SystemDrive: process.env.SystemDrive,
311
+ TEMP: process.env.TEMP,
312
+ TMP: process.env.TMP,
313
+ // On Windows, don't override HOME/USERPROFILE to WORKSPACE_DIR — that causes
314
+ // PowerShell to create AppData\Local inside the workspace (polluting project).
315
+ // Keep original USERPROFILE/HOME so profile data stays in real home.
316
+ HOME: process.env.HOME || process.env.USERPROFILE || WORKSPACE_DIR,
317
+ USERPROFILE: process.env.USERPROFILE || process.env.HOME || WORKSPACE_DIR,
318
+ APPDATA: process.env.APPDATA,
319
+ LOCALAPPDATA: process.env.LOCALAPPDATA,
320
+ PYTHONDONTWRITEBYTECODE: "1",
321
+ PATHEXT: process.env.PATHEXT,
322
+ LANG: process.env.LANG || "en_US.UTF-8"
323
+ } : {
324
+ PATH: process.env.PATH,
325
+ HOME: WORKSPACE_DIR,
326
+ LANG: process.env.LANG || "en_US.UTF-8",
327
+ PYTHONDONTWRITEBYTECODE: "1"
328
+ };
329
+ var permissionGrants = { file: 0, shell: 0 };
330
+ var GRANT_TTL_MS = Number(process.env.FANLUC_GRANT_TTL_MS) > 0 ? Number(process.env.FANLUC_GRANT_TTL_MS) : 5 * 60 * 1e3;
331
+ function isGranted(type) {
332
+ return permissionGrants[type] > Date.now();
333
+ }
334
+ function grantPermission(type) {
335
+ const until = Date.now() + GRANT_TTL_MS;
336
+ if (type === "all" || type === "file") permissionGrants.file = until;
337
+ if (type === "all" || type === "shell") permissionGrants.shell = until;
338
+ }
339
+ function clearGrants() {
340
+ permissionGrants = { file: 0, shell: 0 };
341
+ }
342
+ var FIREJAIL_AVAILABLE = false;
343
+ if (!IS_WINDOWS) {
344
+ try {
345
+ (0, import_child_process.execFileSync)("which", ["firejail"], { stdio: "ignore" });
346
+ FIREJAIL_AVAILABLE = true;
347
+ console.log("[run_cmd] firejail detected \u2014 sandboxing run_cmd with it.");
348
+ } catch {
349
+ console.warn(
350
+ "[run_cmd] firejail not found \u2014 run_cmd will still be jailed to " + WORKSPACE_DIR + " with a stripped env + blocklist, but for real OS-level isolation install firejail (`sudo apt-get install -y firejail`) or run this whole server inside a Docker container."
351
+ );
352
+ }
353
+ } else {
354
+ console.log("[run_cmd] Windows detected \u2014 firejail disabled, using PowerShell/cmd sandbox (blocklist enforced, permission ask blocks by default)");
355
+ }
356
+ var ZIP_AVAILABLE = false;
357
+ var UNZIP_AVAILABLE = false;
358
+ if (!IS_WINDOWS) {
359
+ try {
360
+ (0, import_child_process.execFileSync)("which", ["zip"], { stdio: "ignore" });
361
+ ZIP_AVAILABLE = true;
362
+ } catch {
363
+ }
364
+ try {
365
+ (0, import_child_process.execFileSync)("which", ["unzip"], { stdio: "ignore" });
366
+ UNZIP_AVAILABLE = true;
367
+ } catch {
368
+ }
369
+ } else {
370
+ try {
371
+ (0, import_child_process.execFileSync)("where", ["zip"], { stdio: "ignore" });
372
+ ZIP_AVAILABLE = true;
373
+ } catch {
374
+ }
375
+ try {
376
+ (0, import_child_process.execFileSync)("where", ["unzip"], { stdio: "ignore" });
377
+ UNZIP_AVAILABLE = true;
378
+ } catch {
379
+ }
380
+ }
381
+ function preprocessCommand(command) {
382
+ let trimmed = command.trim();
383
+ if (!ZIP_AVAILABLE && /^zip\s+/i.test(trimmed)) {
384
+ const parts = trimmed.split(/\s+/).slice(1);
385
+ if (parts.length >= 2) {
386
+ const zipFile = parts[0];
387
+ const files = parts.slice(1).join(" ");
388
+ return `python3 -m zipfile -c ${zipFile} ${files}`;
389
+ }
390
+ }
391
+ if (!UNZIP_AVAILABLE && /^unzip\s+/i.test(trimmed)) {
392
+ const parts = trimmed.split(/\s+/).slice(1);
393
+ let zipFile = "";
394
+ let outDir = WORKSPACE_DIR;
395
+ for (let i = 0; i < parts.length; i++) {
396
+ if (parts[i] === "-d" && parts[i + 1]) {
397
+ outDir = parts[i + 1];
398
+ i++;
399
+ } else if (!zipFile && !parts[i].startsWith("-")) {
400
+ zipFile = parts[i];
401
+ }
402
+ }
403
+ if (zipFile) {
404
+ return `python3 -m zipfile -e ${zipFile} ${outDir}`;
405
+ }
406
+ }
407
+ return trimmed;
408
+ }
409
+ function findBlockedPattern2(command) {
410
+ return findBlockedPattern(command, { workspaceDir: WORKSPACE_DIR, platform: process.platform });
411
+ }
412
+ var DESKTOP_UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36";
413
+ var BLOCKED_HOST_SUFFIXES = [
414
+ "facebook.com",
415
+ "fb.com",
416
+ "instagram.com",
417
+ "threads.net",
418
+ "twitter.com",
419
+ "x.com",
420
+ "tiktok.com",
421
+ "linkedin.com",
422
+ "pinterest.com",
423
+ "pinterest.jp",
424
+ "quora.com",
425
+ "discord.com",
426
+ "discord.gg",
427
+ "messenger.com",
428
+ "whatsapp.com",
429
+ "t.me",
430
+ "telegram.org",
431
+ "youtube.com",
432
+ "youtu.be",
433
+ "music.youtube.com",
434
+ "zalo.me",
435
+ "scribd.com",
436
+ "academia.edu",
437
+ "accounts.google.com",
438
+ "myaccount.google.com"
439
+ ];
440
+ var DOMAIN_REWRITES = {
441
+ "reddit.com": "old.reddit.com",
442
+ "www.reddit.com": "old.reddit.com"
443
+ };
444
+ var REPUTABLE_HOST_SUFFIXES = [
445
+ "wikipedia.org",
446
+ "github.com",
447
+ "stackoverflow.com",
448
+ "developer.mozilla.org",
449
+ "docs.python.org",
450
+ "npmjs.com",
451
+ "arxiv.org",
452
+ "bbc.com",
453
+ "reuters.com",
454
+ "apnews.com",
455
+ "nytimes.com",
456
+ "theguardian.com",
457
+ "forbes.com",
458
+ "bloomberg.com",
459
+ "nature.com",
460
+ "sciencedirect.com",
461
+ "vnexpress.net",
462
+ "tuoitre.vn",
463
+ "thanhnien.vn",
464
+ "nhandan.vn",
465
+ "baochinhphu.vn",
466
+ "vietnamnet.vn",
467
+ "laodong.vn",
468
+ "dantri.com.vn",
469
+ "vtv.vn",
470
+ "vov.vn"
471
+ ];
472
+ var REPUTABLE_TLD_SUFFIXES = [".gov", ".edu", ".gov.vn", ".edu.vn"];
473
+ var NON_HTML_EXTENSIONS = [
474
+ ".pdf",
475
+ ".doc",
476
+ ".docx",
477
+ ".xls",
478
+ ".xlsx",
479
+ ".ppt",
480
+ ".pptx",
481
+ ".zip",
482
+ ".rar",
483
+ ".7z",
484
+ ".tar",
485
+ ".gz",
486
+ ".jpg",
487
+ ".jpeg",
488
+ ".png",
489
+ ".gif",
490
+ ".webp",
491
+ ".svg",
492
+ ".bmp",
493
+ ".ico",
494
+ ".mp3",
495
+ ".mp4",
496
+ ".avi",
497
+ ".mov",
498
+ ".wav",
499
+ ".exe",
500
+ ".apk",
501
+ ".dmg"
502
+ ];
503
+ function hostMatchesSuffixList(hostname, suffixes) {
504
+ const h = hostname.toLowerCase().replace(/^www\./, "");
505
+ return suffixes.some((s) => h === s || h.endsWith("." + s));
506
+ }
507
+ function isBlockedHost(hostname) {
508
+ if (!hostname) return false;
509
+ return hostMatchesSuffixList(hostname, BLOCKED_HOST_SUFFIXES);
510
+ }
511
+ function applyDomainRewrite(urlStr) {
512
+ try {
513
+ const u = new URL(urlStr);
514
+ const host = u.hostname.toLowerCase();
515
+ if (DOMAIN_REWRITES[host]) {
516
+ u.hostname = DOMAIN_REWRITES[host];
517
+ return u.toString();
518
+ }
519
+ return urlStr;
520
+ } catch {
521
+ return urlStr;
522
+ }
523
+ }
524
+ function reputationScore(hostname) {
525
+ if (!hostname) return 0;
526
+ const h = hostname.toLowerCase();
527
+ if (hostMatchesSuffixList(h, REPUTABLE_HOST_SUFFIXES)) return 10;
528
+ if (REPUTABLE_TLD_SUFFIXES.some((tld) => h.endsWith(tld))) return 8;
529
+ return 0;
530
+ }
531
+ function looksLikeNonHtmlFile(urlStr) {
532
+ try {
533
+ const p = new URL(urlStr).pathname.toLowerCase();
534
+ return NON_HTML_EXTENSIONS.some((ext) => p.endsWith(ext));
535
+ } catch {
536
+ return false;
537
+ }
538
+ }
539
+ function decodeHtmlEntities(str) {
540
+ if (!str) return "";
541
+ return str.replace(/&nbsp;/g, " ").replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&#0?39;/g, "'").replace(/&apos;/g, "'").replace(/&rsquo;|&lsquo;/g, "'").replace(/&rdquo;|&ldquo;/g, '"').replace(/&mdash;/g, "\u2014").replace(/&ndash;/g, "\u2013").replace(/&hellip;/g, "\u2026").replace(/&#x([0-9a-fA-F]+);/g, (_, hex) => String.fromCodePoint(parseInt(hex, 16))).replace(/&#(\d+);/g, (_, dec) => String.fromCodePoint(parseInt(dec, 10)));
542
+ }
543
+ function stripInnerTags(str) {
544
+ if (!str) return "";
545
+ return str.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
546
+ }
547
+ async function searchDuckDuckGo(query, count) {
548
+ const results = [];
549
+ try {
550
+ const q = encodeURIComponent(query.trim());
551
+ const resp = await fetch(`https://html.duckduckgo.com/html/?q=${q}&kl=wt-wt`, {
552
+ headers: {
553
+ "User-Agent": DESKTOP_UA,
554
+ "Accept-Language": "en-US,en;q=0.9,vi;q=0.8"
555
+ }
556
+ });
557
+ if (!resp.ok) return results;
558
+ const html = await resp.text();
559
+ const resultRegex = /<a[^>]+class="result__a"[^>]+href="([^"]+)"[^>]*>([\s\S]*?)<\/a>[\s\S]*?<a[^>]+class="result__snippet"[^>]*>([\s\S]*?)<\/a>/gi;
560
+ let m;
561
+ while ((m = resultRegex.exec(html)) !== null && results.length < count * 2) {
562
+ let url = m[1];
563
+ if (url.startsWith("//duckduckgo.com/l/")) {
564
+ const uddgMatch = url.match(/[?&]uddg=([^&]+)/);
565
+ if (uddgMatch) url = decodeURIComponent(uddgMatch[1]);
566
+ } else if (url.startsWith("//")) {
567
+ url = "https:" + url;
568
+ }
569
+ if (!url.startsWith("http")) continue;
570
+ const title = decodeHtmlEntities(stripInnerTags(m[2]));
571
+ const snippet = decodeHtmlEntities(stripInnerTags(m[3])) || title;
572
+ if (!title && !snippet) continue;
573
+ results.push({ url, title, snippet, engine: "duckduckgo" });
574
+ }
575
+ } catch (err) {
576
+ console.warn("DuckDuckGo search error:", err);
577
+ }
578
+ return results;
579
+ }
580
+ async function searchBing(query, count) {
581
+ const results = [];
582
+ try {
583
+ const q = encodeURIComponent(query.trim());
584
+ const resp = await fetch(`https://www.bing.com/search?q=${q}&count=${Math.min(30, count * 3)}&setlang=en-US`, {
585
+ headers: {
586
+ "User-Agent": DESKTOP_UA,
587
+ "Accept-Language": "en-US,en;q=0.9,vi;q=0.8"
588
+ }
589
+ });
590
+ if (!resp.ok) return results;
591
+ const html = await resp.text();
592
+ const resultRegex = /<li class="b_algo"[^>]*>[\s\S]*?<h2>[\s\S]*?<a href="([^"]+)"[^>]*>([\s\S]*?)<\/a>[\s\S]*?<\/h2>([\s\S]*?)<\/li>/gi;
593
+ let m;
594
+ while ((m = resultRegex.exec(html)) !== null && results.length < count * 2) {
595
+ const url = m[1];
596
+ if (!url.startsWith("http")) continue;
597
+ const title = decodeHtmlEntities(stripInnerTags(m[2]));
598
+ const bodyBlock = m[3] || "";
599
+ const snippetMatch = bodyBlock.match(/<p[^>]*>([\s\S]*?)<\/p>/i) || bodyBlock.match(/<div class="b_caption"[^>]*>([\s\S]*?)<\/div>/i);
600
+ const snippet = snippetMatch ? decodeHtmlEntities(stripInnerTags(snippetMatch[1])) : title;
601
+ if (!title && !snippet) continue;
602
+ results.push({ url, title, snippet, engine: "bing" });
603
+ }
604
+ } catch (err) {
605
+ console.warn("Bing search error:", err);
606
+ }
607
+ return results;
608
+ }
609
+ async function searchYahooJP(query, count) {
610
+ const results = [];
611
+ const q = encodeURIComponent(query.trim());
612
+ try {
613
+ const response = await fetch(`https://search.yahoo.co.jp/search?p=${q}`, {
614
+ headers: {
615
+ "User-Agent": DESKTOP_UA,
616
+ "Accept-Language": "en-US,en;q=0.9,vi;q=0.8"
617
+ }
618
+ });
619
+ if (response.ok) {
620
+ const html = await response.text();
621
+ const sectionRegex = /<div class="sw-Card[^"]*"[\s\S]*?<a\s+href="([^"]+)"[^>]*>[\s\S]*?<h3[^>]*>([\s\S]*?)<\/h3>[\s\S]*?(?:<p class="sw-Card__summary">([\s\S]*?)<\/p>)?/gi;
622
+ let match;
623
+ while ((match = sectionRegex.exec(html)) !== null && results.length < count) {
624
+ const url = match[1];
625
+ if (url.startsWith("http") && !url.includes("search.yahoo.co.jp")) {
626
+ const title = decodeHtmlEntities(stripInnerTags(match[2]));
627
+ const snippet = match[3] ? decodeHtmlEntities(stripInnerTags(match[3])) : title;
628
+ results.push({ url, title, snippet, engine: "yahoo" });
629
+ }
630
+ }
631
+ }
632
+ } catch (err) {
633
+ console.warn("Yahoo search primary endpoint error:", err);
634
+ }
635
+ if (results.length === 0) {
636
+ try {
637
+ const newsRes = await fetch(`https://news.search.yahoo.com/search?p=${q}`, {
638
+ headers: { "User-Agent": DESKTOP_UA }
639
+ });
640
+ if (newsRes.ok) {
641
+ const newsHtml = await newsRes.text();
642
+ const newsRegex = /<h4[^>]*><a[^>]+href="([^"]+)"[^>]*>(.*?)<\/a><\/h4>/gi;
643
+ let nMatch;
644
+ while ((nMatch = newsRegex.exec(newsHtml)) !== null && results.length < count) {
645
+ let link = nMatch[1];
646
+ if (link.includes("/RU=")) {
647
+ const ruMatch = link.match(/\/RU=([^/]+)/);
648
+ if (ruMatch) link = decodeURIComponent(ruMatch[1]);
649
+ }
650
+ const title = decodeHtmlEntities(stripInnerTags(nMatch[2]));
651
+ if (link.startsWith("http")) {
652
+ results.push({ url: link, title, snippet: title, engine: "yahoo-news" });
653
+ }
654
+ }
655
+ }
656
+ } catch (err) {
657
+ console.warn("Yahoo news fallback error:", err);
658
+ }
659
+ }
660
+ return results;
661
+ }
662
+ function interleaveResults(arrays) {
663
+ const out = [];
664
+ const maxLen = Math.max(0, ...arrays.map((a) => a.length));
665
+ for (let i = 0; i < maxLen; i++) {
666
+ for (const arr of arrays) {
667
+ if (arr[i]) out.push(arr[i]);
668
+ }
669
+ }
670
+ return out;
671
+ }
672
+ function normalizeForDedupe(url) {
673
+ try {
674
+ const u = new URL(url);
675
+ return (u.hostname.replace(/^www\./, "") + u.pathname.replace(/\/$/, "")).toLowerCase();
676
+ } catch {
677
+ return url.toLowerCase();
678
+ }
679
+ }
680
+ function mergeAndFilterResults(all, count) {
681
+ const seen = /* @__PURE__ */ new Set();
682
+ const perDomainCount = /* @__PURE__ */ new Map();
683
+ const filtered = [];
684
+ for (const r of all) {
685
+ if (!r.url || !r.url.startsWith("http")) continue;
686
+ let hostname = "";
687
+ try {
688
+ hostname = new URL(r.url).hostname.toLowerCase();
689
+ } catch {
690
+ continue;
691
+ }
692
+ if (isBlockedHost(hostname)) continue;
693
+ if (looksLikeNonHtmlFile(r.url)) continue;
694
+ const dedupeKey = normalizeForDedupe(r.url);
695
+ if (seen.has(dedupeKey)) continue;
696
+ const domainKey = hostname.replace(/^www\./, "");
697
+ const domainCount = perDomainCount.get(domainKey) || 0;
698
+ if (domainCount >= 2) continue;
699
+ seen.add(dedupeKey);
700
+ perDomainCount.set(domainKey, domainCount + 1);
701
+ filtered.push({ ...r, url: applyDomainRewrite(r.url), score: reputationScore(hostname) });
702
+ }
703
+ filtered.sort((a, b) => b.score - a.score);
704
+ return filtered.slice(0, count).map((r) => ({
705
+ url: r.url,
706
+ snippet: r.snippet && r.snippet.trim() || r.title || r.url
707
+ }));
708
+ }
709
+ var MAX_FETCH_BYTES = 6 * 1024 * 1024;
710
+ async function fetchWithByteLimit(url, signal, maxBytes) {
711
+ const response = await fetch(url, {
712
+ signal,
713
+ redirect: "follow",
714
+ headers: {
715
+ "User-Agent": DESKTOP_UA,
716
+ "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
717
+ "Accept-Language": "vi-VN,vi;q=0.9,en-US;q=0.8,en;q=0.7"
718
+ }
719
+ });
720
+ const contentType = response.headers.get("content-type") || "";
721
+ if (!response.body) {
722
+ const text = await response.text();
723
+ return { ok: response.ok, status: response.status, contentType, body: text.slice(0, maxBytes) };
724
+ }
725
+ const reader = response.body.getReader();
726
+ const decoder = new TextDecoder();
727
+ let received = 0;
728
+ let body = "";
729
+ while (true) {
730
+ const { done, value } = await reader.read();
731
+ if (done) break;
732
+ received += value.byteLength;
733
+ body += decoder.decode(value, { stream: true });
734
+ if (received >= maxBytes) {
735
+ try {
736
+ await reader.cancel();
737
+ } catch {
738
+ }
739
+ break;
740
+ }
741
+ }
742
+ body += decoder.decode();
743
+ return { ok: response.ok, status: response.status, contentType, body };
744
+ }
745
+ function fallbackCleanHtml(html) {
746
+ const stripped = html.replace(/<script[\s\S]*?<\/script>/gi, " ").replace(/<style[\s\S]*?<\/style>/gi, " ").replace(/<noscript[\s\S]*?<\/noscript>/gi, " ").replace(/<svg[\s\S]*?<\/svg>/gi, " ").replace(/<iframe[\s\S]*?<\/iframe>/gi, " ").replace(/<!--[\s\S]*?-->/g, " ").replace(/<(nav|header|footer|aside|form|button|select|figure|figcaption)[^>]*>[\s\S]*?<\/\1>/gi, " ").replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
747
+ return decodeHtmlEntities(stripped);
748
+ }
749
+ function extractReadableContent(html, url) {
750
+ try {
751
+ const dom = new import_jsdom.JSDOM(html, { url });
752
+ const doc = dom.window.document;
753
+ doc.querySelectorAll("script, style, noscript, iframe, svg, form").forEach((el) => el.remove());
754
+ const reader = new import_readability.Readability(doc, { charThreshold: 200 });
755
+ const article = reader.parse();
756
+ if (article && article.textContent && article.textContent.trim().length > 150) {
757
+ const cleanText = article.textContent.replace(/[ \t]+/g, " ").replace(/\n{3,}/g, "\n\n").trim();
758
+ return { title: article.title || "", text: cleanText, usedReadability: true };
759
+ }
760
+ } catch (err) {
761
+ console.warn("Readability extraction failed, falling back:", err?.message);
762
+ }
763
+ return { title: "", text: fallbackCleanHtml(html), usedReadability: false };
764
+ }
765
+ var SEARXNG_URL = (process.env.SEARXNG_URL || process.env.SEARXNG || "").replace(/\/$/, "");
766
+ async function searchSearXNG(query, count, categories = "general", baseUrl = SEARXNG_URL) {
767
+ const results = [];
768
+ if (!baseUrl) return results;
769
+ try {
770
+ const params = new URLSearchParams({ q: query.trim(), format: "json", categories, pageno: "1" });
771
+ const url = `${baseUrl}/search?${params.toString()}`;
772
+ const resp = await fetch(url, {
773
+ signal: AbortSignal.timeout(6e3),
774
+ headers: {
775
+ "User-Agent": DESKTOP_UA,
776
+ "Accept": "application/json",
777
+ "Accept-Language": "en-US,en;q=0.9,vi;q=0.8"
778
+ }
779
+ });
780
+ if (!resp.ok) {
781
+ console.warn(`SearXNG search failed ${resp.status} for "${query}"`);
782
+ return results;
783
+ }
784
+ const j = await resp.json();
785
+ const raw = Array.isArray(j.results) ? j.results : [];
786
+ for (const x of raw) {
787
+ if (results.length >= count * 2) break;
788
+ if (categories === "images") {
789
+ const title = (x.title || x.content || "").toString();
790
+ const url2 = (x.url || x.img_src || "").toString();
791
+ const img_src = (x.img_src || x.url || "").toString();
792
+ const thumb = (x.thumbnail_src || x.img_src || x.thumbnail || "").toString();
793
+ if (!img_src && !url2) continue;
794
+ results.push({
795
+ url: url2 || img_src,
796
+ title,
797
+ snippet: title,
798
+ engine: "searxng-images",
799
+ _img_src: img_src,
800
+ _thumb: thumb
801
+ });
802
+ } else {
803
+ const title = (x.title || "").toString();
804
+ const url2 = (x.url || "").toString();
805
+ const snippet = (x.content || x.title || "").toString();
806
+ if (!url2 || !url2.startsWith("http")) continue;
807
+ results.push({ url: url2, title, snippet, engine: "searxng" });
808
+ }
809
+ }
810
+ } catch (err) {
811
+ console.warn("SearXNG search error:", err);
812
+ }
813
+ return results;
814
+ }
815
+ function isPrivateIp(ip) {
816
+ const v = ip.toLowerCase();
817
+ if (v.startsWith("::ffff:")) return isPrivateIp(v.slice(7));
818
+ if (import_node_net.default.isIPv4(v)) {
819
+ const [a, b] = v.split(".").map(Number);
820
+ return a === 0 || a === 10 || a === 127 || a === 169 && b === 254 || a === 172 && b >= 16 && b <= 31 || a === 192 && b === 168 || a === 100 && b >= 64 && b <= 127;
821
+ }
822
+ return v === "::1" || v === "::" || v.startsWith("fc") || v.startsWith("fd") || v.startsWith("fe80");
823
+ }
824
+ async function assertPublicUrl(raw) {
825
+ const u = new URL(raw);
826
+ if (u.protocol !== "http:" && u.protocol !== "https:") throw new Error("Only http/https URLs are allowed");
827
+ if (process.env.ALLOW_PRIVATE_URLS === "1") return u;
828
+ const host = u.hostname.replace(/^\[|\]$/g, "");
829
+ if (host === "localhost" || host.endsWith(".local") || host.endsWith(".internal")) throw new Error("Private/local hosts are not allowed");
830
+ const addrs = import_node_net.default.isIP(host) ? [{ address: host }] : await import_promises.default.lookup(host, { all: true });
831
+ for (const a of addrs) if (isPrivateIp(a.address)) throw new Error("Private/local hosts are not allowed");
832
+ return u;
833
+ }
834
+ async function resolveSearxUrl(req) {
835
+ const raw = String(req.body?.searxngUrl || req.headers["x-searxng-url"] || "").trim();
836
+ if (!raw) return SEARXNG_URL;
837
+ try {
838
+ const u = await assertPublicUrl(raw);
839
+ return (u.origin + u.pathname).replace(/\/$/, "");
840
+ } catch (e) {
841
+ console.warn("[SearXNG] user URL rejected:", e?.message);
842
+ return SEARXNG_URL;
843
+ }
844
+ }
845
+ function stripJsonc(s) {
846
+ return s.replace(/("(?:\\.|[^"\\])*")|\/\/.*$|\/\*[\s\S]*?\*\//gm, (_m, str) => str ?? "");
847
+ }
848
+ function mcpAdminGuard(req, res, next) {
849
+ const token = process.env.ADMIN_TOKEN;
850
+ if (!token) return next();
851
+ if (req.headers["x-admin-token"] === token) return next();
852
+ return res.status(403).json({ error: "Admin only: send header x-admin-token matching ADMIN_TOKEN." });
853
+ }
854
+ function resolveEnvVars(str) {
855
+ if (typeof str !== "string") return str;
856
+ return str.replace(/\$\{([^}]+)\}/g, (_, name) => process.env[name.trim()] ?? "");
857
+ }
858
+ function resolveEnvObject(obj) {
859
+ if (!obj || typeof obj !== "object") return obj;
860
+ const out = {};
861
+ for (const [k, v] of Object.entries(obj)) {
862
+ out[k] = typeof v === "string" ? resolveEnvVars(v) : v;
863
+ }
864
+ return out;
865
+ }
866
+ var mcp = /* @__PURE__ */ new Map();
867
+ function normalizeMcpType(t) {
868
+ const low = (t || "").toLowerCase();
869
+ if (low === "http" || low === "remote" || low === "streamablehttp" || low === "sse") return "http";
870
+ return "stdio";
871
+ }
872
+ async function connectMcp(name, cfg) {
873
+ const explicitlyDisabled = cfg.enabled === false || cfg.disabled === true;
874
+ if (explicitlyDisabled) {
875
+ mcp.set(name, { client: null, tools: [], status: "disabled", config: cfg, type: normalizeMcpType(cfg.type) });
876
+ return;
877
+ }
878
+ const type = normalizeMcpType(cfg.type || (cfg.url ? "http" : "stdio"));
879
+ const entry = { client: null, tools: [], status: "connecting", config: cfg, type };
880
+ mcp.set(name, entry);
881
+ try {
882
+ let transport;
883
+ if (type === "http") {
884
+ const rawUrl = cfg.url;
885
+ if (!rawUrl) throw new Error("Missing url for http/remote MCP server");
886
+ const resolvedUrl = resolveEnvVars(String(rawUrl));
887
+ const headers = resolveEnvObject(cfg.headers) || {};
888
+ transport = new import_streamableHttp.StreamableHTTPClientTransport(new URL(resolvedUrl), { requestInit: { headers } });
889
+ } else {
890
+ let command = cfg.command;
891
+ let args = Array.isArray(cfg.args) ? cfg.args : [];
892
+ let env = resolveEnvObject(cfg.env || cfg.environment);
893
+ if (Array.isArray(command)) {
894
+ args = command.slice(1);
895
+ command = command[0];
896
+ }
897
+ if (!command) throw new Error("Missing command for stdio/local MCP server");
898
+ const resolvedCommand = resolveEnvVars(String(command));
899
+ const resolvedArgs = args.map((a) => resolveEnvVars(String(a)));
900
+ transport = new import_stdio.StdioClientTransport({ command: resolvedCommand, args: resolvedArgs, env });
901
+ }
902
+ const client = new import_client.Client({ name: "fanluc", version: "1.0.0" }, { capabilities: {} });
903
+ await client.connect(transport);
904
+ const toolsRes = await client.listTools();
905
+ const tools = Array.isArray(toolsRes?.tools) ? toolsRes.tools : [];
906
+ const truncated = tools.map((t) => ({
907
+ ...t,
908
+ description: typeof t.description === "string" ? t.description : t.description
909
+ }));
910
+ mcp.set(name, { client, tools: truncated, status: "connected", config: cfg, type });
911
+ console.log(`[MCP] ${name} connected \u2014 ${truncated.length} tools`);
912
+ } catch (e) {
913
+ const msg = e?.message || String(e);
914
+ console.warn(`[MCP] ${name} failed:`, msg);
915
+ mcp.set(name, { client: null, tools: [], status: "failed", error: msg, config: cfg, type });
916
+ }
917
+ }
918
+ function getFanlucConfigDir() {
919
+ if (process.env.FANLUC_CONFIG_DIR?.trim()) return process.env.FANLUC_CONFIG_DIR.trim();
920
+ if (IS_WINDOWS) {
921
+ const appData = process.env.APPDATA;
922
+ if (appData) return import_path.default.join(appData, "fanluc");
923
+ return import_path.default.join(import_os.default.homedir(), "AppData", "Roaming", "fanluc");
924
+ }
925
+ const xdg = process.env.XDG_CONFIG_HOME;
926
+ if (xdg) return import_path.default.join(xdg, "fanluc");
927
+ return import_path.default.join(import_os.default.homedir(), ".config", "fanluc");
928
+ }
929
+ function mcpConfigPath() {
930
+ const dir = getFanlucConfigDir();
931
+ try {
932
+ import_fs.default.mkdirSync(dir, { recursive: true });
933
+ } catch {
934
+ }
935
+ return import_path.default.join(dir, "mcp.json");
936
+ }
937
+ function readMcpServersFile(mcpPath) {
938
+ if (!import_fs.default.existsSync(mcpPath)) return {};
939
+ const raw = import_fs.default.readFileSync(mcpPath, "utf-8").replace(/^\uFEFF/, "");
940
+ if (!raw.trim()) return {};
941
+ const j = JSON.parse(stripJsonc(raw));
942
+ if (j.mcpServers) return j.mcpServers;
943
+ if (j.mcp && j.mcp.servers) return j.mcp.servers;
944
+ if (j.mcp && typeof j.mcp === "object") return j.mcp;
945
+ return j;
946
+ }
947
+ function loadMcpConfigSync() {
948
+ const configDir = getFanlucConfigDir();
949
+ const candidates = [
950
+ import_path.default.join(configDir, "mcp.json"),
951
+ import_path.default.join(configDir, "mcp.jsonc")
952
+ ];
953
+ for (const p of candidates) {
954
+ try {
955
+ if (import_fs.default.existsSync(p)) {
956
+ const raw = import_fs.default.readFileSync(p, "utf-8").replace(/^\uFEFF/, "");
957
+ const cleaned = stripJsonc(raw);
958
+ const j = JSON.parse(cleaned);
959
+ if (j.mcpServers && typeof j.mcpServers === "object") return j.mcpServers;
960
+ if (j.mcp && j.mcp.servers && typeof j.mcp.servers === "object") return j.mcp.servers;
961
+ if (j.mcp && typeof j.mcp === "object" && !j.mcp.servers) {
962
+ const maybe = j.mcp;
963
+ const vals = Object.values(maybe);
964
+ if (vals.length && vals.every((v) => v && typeof v.type === "string")) return maybe;
965
+ }
966
+ const topVals = Object.values(j);
967
+ if (topVals.length && topVals.every((v) => v && typeof v.type === "string")) return j;
968
+ return {};
969
+ }
970
+ } catch (e) {
971
+ console.warn(`Failed to load MCP config from ${p}:`, e);
972
+ }
973
+ }
974
+ return {};
975
+ }
976
+ async function initMcpFromFile() {
977
+ const cfgMap = loadMcpConfigSync();
978
+ const entries = Object.entries(cfgMap);
979
+ if (entries.length === 0) {
980
+ console.log("[MCP] no mcp.json found or empty \u2014 skipping MCP init");
981
+ return;
982
+ }
983
+ console.log(`[MCP] loading ${entries.length} server(s) from mcp.json`);
984
+ for (const [name, cfg] of entries) {
985
+ await connectMcp(name, cfg);
986
+ }
987
+ }
988
+ function getMcpToolsForModel() {
989
+ const out = [];
990
+ for (const [server, entry] of mcp.entries()) {
991
+ if (entry.status !== "connected") continue;
992
+ for (const t of entry.tools) {
993
+ out.push({
994
+ server,
995
+ name: `${server}_${t.name}`,
996
+ originalName: t.name,
997
+ description: t.description || "",
998
+ inputSchema: t.inputSchema
999
+ });
1000
+ }
1001
+ }
1002
+ return out;
1003
+ }
1004
+ async function startServer() {
1005
+ const app = (0, import_express.default)();
1006
+ {
1007
+ const bind = process.env.HOST || "0.0.0.0";
1008
+ if (bind === "127.0.0.1" || bind === "localhost" || bind === "::1") {
1009
+ app.use((req, res, next) => {
1010
+ const h = String(req.headers.host || "").replace(/:\d+$/, "").replace(/^\[|\]$/g, "");
1011
+ if (h === "localhost" || h === "127.0.0.1" || h === "::1") return next();
1012
+ return res.status(403).json({ error: "Forbidden host" });
1013
+ });
1014
+ }
1015
+ }
1016
+ const PORT = Number(process.env.PORT) || 3e3;
1017
+ app.use(import_express.default.json({ limit: "50mb" }));
1018
+ initMcpFromFile().catch((e) => console.warn("[MCP] init failed:", e));
1019
+ app.post("/api/proxy-chat", async (req, res) => {
1020
+ try {
1021
+ const { apiUrl, apiKey, body, customHeaders } = req.body;
1022
+ if (!apiUrl) {
1023
+ return res.status(400).json({ error: "Missing apiUrl parameter" });
1024
+ }
1025
+ const headers = {
1026
+ "Content-Type": "application/json",
1027
+ "ngrok-skip-browser-warning": "true",
1028
+ "User-Agent": "AI-Studio-Rest-Client/1.0",
1029
+ "Authorization": `Bearer ${apiKey || ""}`,
1030
+ ...customHeaders || {}
1031
+ };
1032
+ const controller = new AbortController();
1033
+ const timeoutId = setTimeout(() => controller.abort(), 3e5);
1034
+ const response = await fetch(apiUrl, {
1035
+ method: "POST",
1036
+ headers,
1037
+ body: JSON.stringify(body),
1038
+ signal: controller.signal
1039
+ });
1040
+ clearTimeout(timeoutId);
1041
+ if (!response.ok) {
1042
+ const responseText2 = await response.text();
1043
+ return res.status(response.status).send(responseText2);
1044
+ }
1045
+ if (body?.stream && response.body) {
1046
+ res.setHeader("Content-Type", "text/event-stream");
1047
+ res.setHeader("Cache-Control", "no-cache");
1048
+ res.setHeader("Connection", "keep-alive");
1049
+ const reader = response.body.getReader();
1050
+ try {
1051
+ while (true) {
1052
+ const { done, value } = await reader.read();
1053
+ if (done) break;
1054
+ res.write(Buffer.from(value));
1055
+ }
1056
+ } catch (streamErr) {
1057
+ console.warn("Proxy stream pipe interrupted:", streamErr);
1058
+ } finally {
1059
+ res.end();
1060
+ }
1061
+ return;
1062
+ }
1063
+ const responseText = await response.text();
1064
+ try {
1065
+ const data = JSON.parse(responseText);
1066
+ return res.json(data);
1067
+ } catch {
1068
+ return res.send(responseText);
1069
+ }
1070
+ } catch (error) {
1071
+ console.error("Proxy error:", error);
1072
+ if (error?.name === "AbortError") {
1073
+ return res.status(504).json({
1074
+ error: "API response timed out (>5 min) or instance disconnected."
1075
+ });
1076
+ }
1077
+ return res.status(500).json({ error: error?.message || "Internal Proxy Error" });
1078
+ }
1079
+ });
1080
+ app.post("/api/yahoo-search", async (req, res) => {
1081
+ try {
1082
+ const { query, count = 7 } = req.body;
1083
+ if (!query || typeof query !== "string") {
1084
+ return res.status(400).json({ error: "Missing query parameter" });
1085
+ }
1086
+ const maxCount = Math.min(15, Math.max(1, parseInt(count) || 7));
1087
+ let searxResults = [];
1088
+ try {
1089
+ searxResults = await searchSearXNG(query, maxCount, "general", await resolveSearxUrl(req));
1090
+ } catch (e) {
1091
+ console.warn("SearXNG general search error, falling back:", e);
1092
+ }
1093
+ if (searxResults.length > 0) {
1094
+ let results2 = mergeAndFilterResults(searxResults, maxCount);
1095
+ if (results2.length === 0) {
1096
+ results2 = searxResults.slice(0, maxCount).map((r) => ({ url: r.url, snippet: r.snippet || r.title || r.url }));
1097
+ }
1098
+ const formattedText2 = `${results2.length} results:
1099
+ ` + results2.map((r) => `${r.url}: ${r.snippet}`).join("\n");
1100
+ return res.json({ count: results2.length, results: results2, formattedText: formattedText2, engine: "searxng" });
1101
+ }
1102
+ const perEngineCount = Math.min(20, maxCount + 6);
1103
+ const withTimeout = (p, ms = 8e3) => Promise.race([p.catch(() => []), new Promise((r) => setTimeout(() => r([]), ms))]);
1104
+ const [ddgResults, bingResults, yahooResults] = await Promise.all([
1105
+ withTimeout(searchDuckDuckGo(query, perEngineCount)),
1106
+ withTimeout(searchBing(query, perEngineCount)),
1107
+ withTimeout(searchYahooJP(query, perEngineCount))
1108
+ ]);
1109
+ let results = mergeAndFilterResults(
1110
+ interleaveResults([ddgResults, bingResults, yahooResults]),
1111
+ maxCount
1112
+ );
1113
+ if (results.length === 0) {
1114
+ const rawAll = [...ddgResults, ...bingResults, ...yahooResults];
1115
+ results = rawAll.slice(0, maxCount).map((r) => ({ url: r.url, snippet: r.snippet || r.title || r.url }));
1116
+ }
1117
+ const formattedText = results.length > 0 ? `${results.length} results:
1118
+ ` + results.map((r) => `${r.url}: ${r.snippet}`).join("\n") : `0 results:
1119
+ (no results found for "${query}")`;
1120
+ return res.json({
1121
+ count: results.length,
1122
+ results,
1123
+ formattedText,
1124
+ engine: results.length ? "fallback" : "none"
1125
+ });
1126
+ } catch (error) {
1127
+ console.error("Web search error:", error);
1128
+ return res.status(500).json({ error: error?.message || "Web search failed" });
1129
+ }
1130
+ });
1131
+ app.post("/api/searxng-search", async (req, res) => {
1132
+ try {
1133
+ const { query, count = 7, categories = "general" } = req.body;
1134
+ if (!query || typeof query !== "string") return res.status(400).json({ error: "Missing query" });
1135
+ const maxCount = Math.min(15, Math.max(1, parseInt(count) || 7));
1136
+ const cat = categories === "images" ? "images" : "general";
1137
+ const raw = await searchSearXNG(query, maxCount, cat, await resolveSearxUrl(req));
1138
+ let results = [];
1139
+ if (cat === "images") {
1140
+ const filtered = raw.slice(0, maxCount).map((r) => ({
1141
+ title: r.title,
1142
+ src: r._img_src || r.url,
1143
+ thumb: r._thumb || r._img_src || r.url,
1144
+ page: r.url,
1145
+ url: r.url
1146
+ }));
1147
+ return res.json({ count: filtered.length, results: filtered, formattedText: filtered.length ? `${filtered.length} images:${filtered.map((x) => " " + x.src).join(",")}` : "0 images" });
1148
+ } else {
1149
+ results = mergeAndFilterResults(raw, maxCount);
1150
+ if (results.length === 0) results = raw.slice(0, maxCount).map((r) => ({ url: r.url, snippet: r.snippet }));
1151
+ const formattedText = results.length ? `${results.length} results:
1152
+ ` + results.map((r) => `${r.url}: ${r.snippet}`).join("\n") : `0 results
1153
+ (no results)`;
1154
+ return res.json({ count: results.length, results, formattedText });
1155
+ }
1156
+ } catch (e) {
1157
+ return res.status(500).json({ error: e?.message });
1158
+ }
1159
+ });
1160
+ app.post("/api/image-search", async (req, res) => {
1161
+ try {
1162
+ const { query, count = 6 } = req.body;
1163
+ if (!query || typeof query !== "string") return res.status(400).json({ error: "Missing query" });
1164
+ const maxCount = Math.min(12, Math.max(1, parseInt(count) || 6));
1165
+ const raw = await searchSearXNG(query, maxCount, "images", await resolveSearxUrl(req));
1166
+ let images = raw.slice(0, maxCount).map((r) => ({
1167
+ title: r.title || query,
1168
+ src: r._img_src || r.url,
1169
+ thumb: r._thumb || r._img_src || r.url,
1170
+ page: r.url
1171
+ })).filter((x) => x.src && x.src.startsWith("http"));
1172
+ const seen = /* @__PURE__ */ new Set();
1173
+ images = images.filter((x) => {
1174
+ if (seen.has(x.src)) return false;
1175
+ seen.add(x.src);
1176
+ return true;
1177
+ });
1178
+ const formattedText = images.length ? `\u0110\xE3 hi\u1EC3n th\u1ECB ${images.length} \u1EA3nh v\u1EC1 "${query}"` : `Kh\xF4ng t\xECm th\u1EA5y \u1EA3nh cho "${query}"`;
1179
+ return res.json({ count: images.length, images, formattedText });
1180
+ } catch (e) {
1181
+ console.error("Image search error:", e);
1182
+ return res.status(500).json({ error: e?.message || "Image search failed" });
1183
+ }
1184
+ });
1185
+ app.get("/api/image-proxy", async (req, res) => {
1186
+ try {
1187
+ const url = String(req.query.url || "");
1188
+ if (!url || !url.startsWith("http")) return res.status(400).send("Missing url");
1189
+ const parsed = new URL(url);
1190
+ if (isBlockedHost(parsed.hostname)) return res.status(403).send("Blocked host");
1191
+ let resp = null;
1192
+ let cur = url;
1193
+ for (let hop = 0; hop < 4; hop++) {
1194
+ const h = new URL(cur).hostname;
1195
+ if (isBlockedHost(h)) return res.status(403).send("Blocked host");
1196
+ resp = await fetch(cur, { redirect: "manual", headers: { "User-Agent": DESKTOP_UA, "Accept": "image/*,*/*" } });
1197
+ const loc = resp.headers.get("location");
1198
+ if (resp.status >= 300 && resp.status < 400 && loc) {
1199
+ cur = new URL(loc, cur).toString();
1200
+ resp = null;
1201
+ continue;
1202
+ }
1203
+ break;
1204
+ }
1205
+ if (!resp) return res.status(508).send("Too many redirects");
1206
+ if (!resp.ok) return res.status(resp.status).send("Fetch failed");
1207
+ const ct = resp.headers.get("content-type") || "image/jpeg";
1208
+ if (!ct.startsWith("image/")) return res.status(415).send("Not an image");
1209
+ res.setHeader("Content-Type", ct);
1210
+ res.setHeader("Cache-Control", "public, max-age=86400");
1211
+ if (resp.body) {
1212
+ const reader = resp.body.getReader();
1213
+ res.flushHeaders?.();
1214
+ while (true) {
1215
+ const { done, value } = await reader.read();
1216
+ if (done) break;
1217
+ res.write(Buffer.from(value));
1218
+ }
1219
+ res.end();
1220
+ } else {
1221
+ const buf = Buffer.from(await resp.arrayBuffer());
1222
+ res.send(buf);
1223
+ }
1224
+ } catch (e) {
1225
+ return res.status(500).send(e?.message);
1226
+ }
1227
+ });
1228
+ app.get("/api/mcp/status", (_req, res) => {
1229
+ const list = [...mcp.entries()].map(([name, v]) => ({
1230
+ name,
1231
+ type: v.type,
1232
+ // stdio / http
1233
+ status: v.status,
1234
+ // connected / failed / disabled / connecting
1235
+ tools: v.tools,
1236
+ toolCount: v.tools.length,
1237
+ error: v.error || null
1238
+ }));
1239
+ const modelTools = getMcpToolsForModel();
1240
+ res.json({ servers: list, modelTools, count: list.length });
1241
+ });
1242
+ app.get("/api/mcp/tools", (_req, res) => {
1243
+ res.json({ tools: getMcpToolsForModel() });
1244
+ });
1245
+ app.post("/api/mcp/call", async (req, res) => {
1246
+ try {
1247
+ const { server, tool, args, arguments: altArgs } = req.body || {};
1248
+ const toolName = tool;
1249
+ const toolArgs = args ?? altArgs ?? {};
1250
+ if (!server || !toolName) return res.status(400).json({ error: "Missing server or tool" });
1251
+ const entry = mcp.get(server);
1252
+ if (!entry) return res.status(404).json({ error: `MCP server "${server}" not found` });
1253
+ if (entry.status !== "connected" || !entry.client) return res.status(400).json({ error: `MCP server "${server}" not connected (${entry.status})${entry.error ? ": " + entry.error : ""}` });
1254
+ const actualTool = toolName.includes("_") && toolName.startsWith(server + "_") ? toolName.slice(server.length + 1) : toolName;
1255
+ const result = await entry.client.callTool({ name: actualTool, arguments: toolArgs });
1256
+ return res.json(result);
1257
+ } catch (e) {
1258
+ console.error("MCP call error:", e);
1259
+ return res.status(500).json({ error: e?.message || "MCP call failed" });
1260
+ }
1261
+ });
1262
+ async function withUserMcp(cfg, fn) {
1263
+ const u = await assertPublicUrl(String(cfg?.url || ""));
1264
+ const headers = {};
1265
+ if (cfg?.headers && typeof cfg.headers === "object") {
1266
+ for (const [k, v] of Object.entries(cfg.headers)) if (typeof v === "string" && /^[\w-]+$/.test(k)) headers[k] = v;
1267
+ }
1268
+ const transport = new import_streamableHttp.StreamableHTTPClientTransport(u, { requestInit: { headers } });
1269
+ const client = new import_client.Client({ name: "fanluc", version: "1.0.0" }, { capabilities: {} });
1270
+ let timer;
1271
+ try {
1272
+ const run = (async () => {
1273
+ await client.connect(transport);
1274
+ return fn(client);
1275
+ })();
1276
+ return await Promise.race([run, new Promise((_, rej) => {
1277
+ timer = setTimeout(() => rej(new Error("MCP timeout (20s)")), 2e4);
1278
+ })]);
1279
+ } finally {
1280
+ clearTimeout(timer);
1281
+ try {
1282
+ await client.close();
1283
+ } catch {
1284
+ }
1285
+ }
1286
+ }
1287
+ app.post("/api/mcp/user/probe", async (req, res) => {
1288
+ const list = Array.isArray(req.body?.servers) ? req.body.servers.slice(0, 8) : [];
1289
+ const out = await Promise.all(list.map(async (s) => {
1290
+ const name = String(s?.name || "").replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 40);
1291
+ try {
1292
+ const tools = await withUserMcp(s, async (c) => {
1293
+ const r = await c.listTools();
1294
+ return Array.isArray(r?.tools) ? r.tools : [];
1295
+ });
1296
+ const t = tools.map((x) => ({ ...x, description: typeof x.description === "string" ? x.description : x.description }));
1297
+ return { name, type: "http", status: "connected", tools: t, toolCount: t.length, error: null, source: "browser" };
1298
+ } catch (e) {
1299
+ return { name, type: "http", status: "failed", tools: [], toolCount: 0, error: e?.message || String(e), source: "browser" };
1300
+ }
1301
+ }));
1302
+ res.json({ servers: out });
1303
+ });
1304
+ app.post("/api/mcp/user/call", async (req, res) => {
1305
+ try {
1306
+ const { server, tool, args } = req.body || {};
1307
+ if (!server || !tool) return res.status(400).json({ error: "Missing server or tool" });
1308
+ const name = String(server.name || "");
1309
+ const actual = String(tool).startsWith(name + "_") ? String(tool).slice(name.length + 1) : String(tool);
1310
+ const r = await withUserMcp(server, (c) => c.callTool({ name: actual, arguments: args && typeof args === "object" ? args : {} }));
1311
+ return res.json(r);
1312
+ } catch (e) {
1313
+ return res.status(500).json({ error: e?.message || "MCP call failed" });
1314
+ }
1315
+ });
1316
+ app.post("/api/mcp/reconnect", mcpAdminGuard, async (req, res) => {
1317
+ try {
1318
+ const { server } = req.body || {};
1319
+ if (!server) return res.status(400).json({ error: "Missing server" });
1320
+ const cfgMap = loadMcpConfigSync();
1321
+ const cfg = cfgMap[server];
1322
+ if (!cfg) return res.status(404).json({ error: "Server not found in mcp.json" });
1323
+ const old = mcp.get(server);
1324
+ try {
1325
+ await old?.client?.close?.();
1326
+ } catch {
1327
+ }
1328
+ await connectMcp(server, cfg);
1329
+ const entry = mcp.get(server);
1330
+ return res.json({ name: server, status: entry.status, toolCount: entry.tools.length, error: entry.error || null });
1331
+ } catch (e) {
1332
+ return res.status(500).json({ error: e?.message });
1333
+ }
1334
+ });
1335
+ app.post("/api/mcp/toggle", mcpAdminGuard, async (req, res) => {
1336
+ try {
1337
+ const { server, enabled } = req.body || {};
1338
+ if (!server) return res.status(400).json({ error: "Missing server" });
1339
+ const cfgMap = loadMcpConfigSync();
1340
+ let cfg = cfgMap[server];
1341
+ if (!cfg) {
1342
+ const mem = mcp.get(server);
1343
+ if (!mem) return res.status(404).json({ error: "Server not found" });
1344
+ cfg = mem.config;
1345
+ }
1346
+ const newEnabled = enabled !== void 0 ? !!enabled : mcp.get(server)?.status === "disabled";
1347
+ const newCfg = { ...cfg, enabled: newEnabled, disabled: !newEnabled };
1348
+ if (newEnabled) {
1349
+ await connectMcp(server, newCfg);
1350
+ } else {
1351
+ const old = mcp.get(server);
1352
+ try {
1353
+ await old?.client?.close?.();
1354
+ } catch {
1355
+ }
1356
+ mcp.set(server, { client: null, tools: [], status: "disabled", config: newCfg, type: normalizeMcpType(newCfg.type) });
1357
+ }
1358
+ try {
1359
+ const mcpPath = mcpConfigPath();
1360
+ const existing = readMcpServersFile(mcpPath);
1361
+ if (existing[server]) {
1362
+ existing[server] = { ...existing[server], enabled: newEnabled };
1363
+ delete existing[server].disabled;
1364
+ import_fs.default.writeFileSync(mcpPath, JSON.stringify({ mcpServers: existing }, null, 2), "utf-8");
1365
+ }
1366
+ } catch (e) {
1367
+ console.warn("[MCP] could not persist toggle:", e?.message);
1368
+ }
1369
+ const entry = mcp.get(server);
1370
+ return res.json({ name: server, status: entry.status, enabled: newEnabled });
1371
+ } catch (e) {
1372
+ return res.status(500).json({ error: e?.message });
1373
+ }
1374
+ });
1375
+ app.post("/api/mcp/add", mcpAdminGuard, async (req, res) => {
1376
+ try {
1377
+ const { name, type, command, args, url, headers, env, enabled = true } = req.body || {};
1378
+ if (!name || typeof name !== "string") return res.status(400).json({ error: "Missing name" });
1379
+ const cleanName = name.trim().replace(/[^a-zA-Z0-9_-]/g, "_");
1380
+ if (!cleanName) return res.status(400).json({ error: "Invalid name" });
1381
+ const newCfg = { type: type || (url ? "http" : "stdio"), enabled: !!enabled };
1382
+ if (newCfg.type === "http" || newCfg.type === "remote") {
1383
+ if (!url || !String(url).startsWith("http")) return res.status(400).json({ error: "Invalid url for http server" });
1384
+ newCfg.url = String(url);
1385
+ if (headers && typeof headers === "object") newCfg.headers = headers;
1386
+ } else {
1387
+ if (!command || typeof command !== "string") return res.status(400).json({ error: "Missing command for stdio server" });
1388
+ newCfg.command = String(command);
1389
+ if (Array.isArray(args) && args.length) newCfg.args = args.map(String);
1390
+ else if (typeof args === "string" && args.trim()) newCfg.args = String(args).trim().split(/\s+/);
1391
+ if (env && typeof env === "object") newCfg.env = env;
1392
+ }
1393
+ const mcpPath = mcpConfigPath();
1394
+ let existing = {};
1395
+ try {
1396
+ existing = readMcpServersFile(mcpPath);
1397
+ } catch (e) {
1398
+ return res.status(500).json({ error: "mcp.json is invalid, refusing to overwrite: " + (e?.message || e) });
1399
+ }
1400
+ existing[cleanName] = newCfg;
1401
+ const toWrite = { mcpServers: existing };
1402
+ import_fs.default.writeFileSync(mcpPath, JSON.stringify(toWrite, null, 2));
1403
+ await connectMcp(cleanName, newCfg);
1404
+ const entry = mcp.get(cleanName);
1405
+ return res.json({ name: cleanName, status: entry.status, toolCount: entry.tools.length, error: entry.error || null });
1406
+ } catch (e) {
1407
+ return res.status(500).json({ error: e?.message });
1408
+ }
1409
+ });
1410
+ app.post("/api/fetch-url", async (req, res) => {
1411
+ const errorFallback = "you entered an incorrect URL, are missing characters, or the website no longer exists";
1412
+ try {
1413
+ const { url, length = 1100 } = req.body;
1414
+ if (!url || typeof url !== "string") {
1415
+ return res.json({
1416
+ url: url || "",
1417
+ text: errorFallback,
1418
+ formattedText: errorFallback
1419
+ });
1420
+ }
1421
+ const urlMatch = url.match(/https?:\/\/[^\s"']+/);
1422
+ let targetUrl = urlMatch ? urlMatch[0] : url.trim();
1423
+ try {
1424
+ new URL(targetUrl);
1425
+ } catch {
1426
+ return res.json({
1427
+ url: targetUrl,
1428
+ text: errorFallback,
1429
+ formattedText: errorFallback
1430
+ });
1431
+ }
1432
+ targetUrl = applyDomainRewrite(targetUrl);
1433
+ let hostname = "";
1434
+ try {
1435
+ hostname = new URL(targetUrl).hostname.toLowerCase();
1436
+ } catch {
1437
+ }
1438
+ if (isBlockedHost(hostname)) {
1439
+ const blockedMsg = `This site (${hostname}) blocks automated crawling or requires login, so its content cannot be reliably extracted. Try a different, publicly accessible source instead.`;
1440
+ return res.json({
1441
+ url: targetUrl,
1442
+ text: blockedMsg,
1443
+ formattedText: `fetched url: ${targetUrl}
1444
+ text: ${blockedMsg}`
1445
+ });
1446
+ }
1447
+ if (looksLikeNonHtmlFile(targetUrl)) {
1448
+ const fileMsg = "This URL points to a non-HTML file (document, image, archive, or media) and cannot be read as article text.";
1449
+ return res.json({
1450
+ url: targetUrl,
1451
+ text: fileMsg,
1452
+ formattedText: `fetched url: ${targetUrl}
1453
+ text: ${fileMsg}`
1454
+ });
1455
+ }
1456
+ const cleanLength = Math.min(1e4, Math.max(100, parseInt(length) || 1100));
1457
+ const controller = new AbortController();
1458
+ const timeoutId = setTimeout(() => controller.abort(), 2e4);
1459
+ let fetchResult;
1460
+ try {
1461
+ fetchResult = await fetchWithByteLimit(targetUrl, controller.signal, MAX_FETCH_BYTES);
1462
+ } finally {
1463
+ clearTimeout(timeoutId);
1464
+ }
1465
+ if (!fetchResult.ok) {
1466
+ return res.json({
1467
+ url: targetUrl,
1468
+ text: errorFallback,
1469
+ formattedText: errorFallback
1470
+ });
1471
+ }
1472
+ if (fetchResult.contentType && !/text\/html|application\/xhtml/i.test(fetchResult.contentType)) {
1473
+ const nonHtmlMsg = `This URL returned non-HTML content (${fetchResult.contentType.split(";")[0]}) and cannot be read as article text.`;
1474
+ return res.json({
1475
+ url: targetUrl,
1476
+ text: nonHtmlMsg,
1477
+ formattedText: `fetched url: ${targetUrl}
1478
+ text: ${nonHtmlMsg}`
1479
+ });
1480
+ }
1481
+ const { title, text: extractedFull } = extractReadableContent(fetchResult.body, targetUrl);
1482
+ if (!extractedFull) {
1483
+ return res.json({
1484
+ url: targetUrl,
1485
+ text: errorFallback,
1486
+ formattedText: errorFallback
1487
+ });
1488
+ }
1489
+ const extracted = extractedFull.slice(0, cleanLength);
1490
+ const titlePart = title ? `title: ${title}
1491
+ ` : "";
1492
+ const formattedText = `fetched url: ${targetUrl}
1493
+ ${titlePart}text: ${extracted}`;
1494
+ return res.json({
1495
+ url: targetUrl,
1496
+ title,
1497
+ text: extracted,
1498
+ formattedText
1499
+ });
1500
+ } catch (error) {
1501
+ console.error("Fetch URL error:", error);
1502
+ return res.json({
1503
+ url: req.body?.url,
1504
+ text: errorFallback,
1505
+ formattedText: errorFallback
1506
+ });
1507
+ }
1508
+ });
1509
+ app.post("/api/read-doc", async (req, res) => {
1510
+ try {
1511
+ const { name } = req.body;
1512
+ if (!name || typeof name !== "string") {
1513
+ return res.status(400).json({ error: "Missing name parameter" });
1514
+ }
1515
+ const cleanName = name.trim().toLowerCase().replace(/^['"`]|['"`]$/g, "");
1516
+ const docsDir = import_path.default.join(process.cwd(), "docs");
1517
+ try {
1518
+ const files = await import_fs.default.promises.readdir(docsDir);
1519
+ let targetFile = files.find(
1520
+ (f) => f.toLowerCase() === cleanName || f.toLowerCase() === `${cleanName}.md`
1521
+ );
1522
+ if (!targetFile) {
1523
+ if (cleanName.includes("fanluc")) targetFile = files.find((f) => f.includes("fanluc"));
1524
+ else if (cleanName.includes("guide")) targetFile = files.find((f) => f.includes("guide"));
1525
+ else if (cleanName.includes("mondk")) targetFile = files.find((f) => f.includes("mondk"));
1526
+ else if (cleanName.includes("msh")) targetFile = files.find((f) => f.includes("msh"));
1527
+ else targetFile = files.find((f) => f.toLowerCase().includes(cleanName));
1528
+ }
1529
+ if (targetFile) {
1530
+ const content = await import_fs.default.promises.readFile(import_path.default.join(docsDir, targetFile), "utf-8");
1531
+ return res.json({
1532
+ found: true,
1533
+ fileName: targetFile,
1534
+ content,
1535
+ formattedText: `doc: ${targetFile}
1536
+ content:
1537
+ ${content}`
1538
+ });
1539
+ }
1540
+ return res.json({
1541
+ found: false,
1542
+ fileName: name,
1543
+ content: `Doc '${name}' not found in /docs/`,
1544
+ formattedText: `Doc '${name}' not found in /docs/`
1545
+ });
1546
+ } catch (err) {
1547
+ return res.json({
1548
+ found: false,
1549
+ fileName: name,
1550
+ content: `Error reading docs directory: ${err?.message}`,
1551
+ formattedText: `Error reading docs directory: ${err?.message}`
1552
+ });
1553
+ }
1554
+ } catch (error) {
1555
+ return res.status(500).json({ error: error?.message || "Failed to read doc" });
1556
+ }
1557
+ });
1558
+ app.post("/api/translate", async (req, res) => {
1559
+ try {
1560
+ const { text, to = "en", from = "auto" } = req.body;
1561
+ if (!text || typeof text !== "string") {
1562
+ return res.status(400).json({ error: "Missing text parameter" });
1563
+ }
1564
+ const cleanText = text.trim();
1565
+ if (!cleanText) {
1566
+ return res.status(400).json({ error: "Empty text parameter" });
1567
+ }
1568
+ const result = await (0, import_google_translate_api_x.translate)(cleanText, {
1569
+ to: to || "en",
1570
+ from: from || "auto"
1571
+ });
1572
+ const translatedText = typeof result?.text === "string" ? result.text : String(result?.text ?? "");
1573
+ const detectedFrom = result?.from?.language?.iso || from || "auto";
1574
+ return res.json({
1575
+ translatedText,
1576
+ detectedFrom,
1577
+ originalText: cleanText,
1578
+ targetLang: to,
1579
+ formattedText: `translated "${cleanText}" (${detectedFrom} -> ${to}): "${translatedText}"`
1580
+ });
1581
+ } catch (error) {
1582
+ console.error("Translation API error:", error);
1583
+ return res.status(500).json({
1584
+ error: error?.message || "Translation failed",
1585
+ translatedText: "",
1586
+ detectedFrom: req.body?.from || "auto",
1587
+ formattedText: `translation failed: ${error?.message || "error"}`
1588
+ });
1589
+ }
1590
+ });
1591
+ app.get("/api/weather", async (req, res) => {
1592
+ try {
1593
+ const { lat, lon, city } = req.query;
1594
+ let finalLat = lat;
1595
+ let finalLon = lon;
1596
+ let locationName = "Unknown Location";
1597
+ if (city && city !== "auto") {
1598
+ const geoRes = await fetch(`https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(String(city))}&count=1&language=en&format=json`);
1599
+ const geoData = await geoRes.json();
1600
+ if (geoData.results && geoData.results.length > 0) {
1601
+ finalLat = geoData.results[0].latitude;
1602
+ finalLon = geoData.results[0].longitude;
1603
+ locationName = geoData.results[0].name + (geoData.results[0].country ? `, ${geoData.results[0].country}` : "");
1604
+ } else {
1605
+ return res.status(404).json({ error: "City not found" });
1606
+ }
1607
+ } else if (lat && lon) {
1608
+ try {
1609
+ const revGeoRes = await fetch(`https://api.bigdatacloud.net/data/reverse-geocode-client?latitude=${lat}&longitude=${lon}&localityLanguage=en`);
1610
+ const revGeoData = await revGeoRes.json();
1611
+ locationName = revGeoData.city || revGeoData.locality || revGeoData.principalSubdivision || "Your Location";
1612
+ } catch (e) {
1613
+ locationName = "Your Location";
1614
+ }
1615
+ } else {
1616
+ return res.status(400).json({ error: "Provide lat/lon or city" });
1617
+ }
1618
+ const weatherRes = await fetch(`https://api.open-meteo.com/v1/forecast?latitude=${finalLat}&longitude=${finalLon}&current_weather=true`);
1619
+ const weatherData = await weatherRes.json();
1620
+ if (!weatherData.current_weather) {
1621
+ return res.status(500).json({ error: "Weather data not available" });
1622
+ }
1623
+ const current = weatherData.current_weather;
1624
+ const code = current.weathercode;
1625
+ let condition = "Clear";
1626
+ if (code === 0) condition = "Sunny";
1627
+ else if (code === 1 || code === 2 || code === 3) condition = "Cloudy";
1628
+ else if (code === 45 || code === 48) condition = "Fog";
1629
+ else if (code >= 51 && code <= 67) condition = "Rainy";
1630
+ else if (code >= 71 && code <= 77) condition = "Snow";
1631
+ else if (code >= 80 && code <= 82) condition = "Rainy";
1632
+ else if (code >= 85 && code <= 86) condition = "Snow";
1633
+ else if (code >= 95 && code <= 99) condition = "Stormy";
1634
+ if (current.windspeed > 20 && (condition === "Sunny" || condition === "Cloudy" || condition === "Clear")) {
1635
+ condition = "Windy";
1636
+ }
1637
+ res.json({
1638
+ location: locationName,
1639
+ temperature: current.temperature,
1640
+ condition,
1641
+ isDay: current.is_day === 1,
1642
+ time: current.time
1643
+ });
1644
+ } catch (error) {
1645
+ console.error("Weather API error:", error);
1646
+ res.status(500).json({ error: "Failed to fetch weather" });
1647
+ }
1648
+ });
1649
+ function getPermissionMode() {
1650
+ const m = (process.env.FANLUC_PERMISSION || "").trim();
1651
+ if (m === "readOnly" || m === "autoEdit" || m === "ask") return m;
1652
+ try {
1653
+ const cfgDir2 = getFanlucConfigDir();
1654
+ const p = import_path.default.join(cfgDir2, "settings.json");
1655
+ if (import_fs.default.existsSync(p)) {
1656
+ const raw = import_fs.default.readFileSync(p, "utf-8").replace(/^\uFEFF/, "");
1657
+ const j = JSON.parse(raw);
1658
+ if (j.permission === "readOnly" || j.permission === "autoEdit" || j.permission === "ask") return j.permission;
1659
+ }
1660
+ } catch {
1661
+ }
1662
+ return "ask";
1663
+ }
1664
+ function loadFanlucMdSync() {
1665
+ const candidates = [
1666
+ import_path.default.join(WORKSPACE_DIR, "FANLUC.md"),
1667
+ import_path.default.join(WORKSPACE_DIR, "fanluc.md"),
1668
+ import_path.default.join(WORKSPACE_DIR, ".fanluc", "FANLUC.md")
1669
+ ];
1670
+ for (const p of candidates) {
1671
+ try {
1672
+ if (import_fs.default.existsSync(p) && import_fs.default.statSync(p).isFile()) {
1673
+ const raw = import_fs.default.readFileSync(p, "utf-8").replace(/^\uFEFF/, "");
1674
+ const MAX = 6e3;
1675
+ const content = raw.length > MAX ? raw.slice(0, MAX) + "\n\n[... truncated ...]" : raw;
1676
+ return { path: p, content };
1677
+ }
1678
+ } catch {
1679
+ }
1680
+ }
1681
+ return null;
1682
+ }
1683
+ app.get("/api/fanluc-md", (_req, res) => {
1684
+ const md = loadFanlucMdSync();
1685
+ if (!md) return res.json({ found: false, path: null, content: "" });
1686
+ return res.json({ found: true, path: md.path, content: md.content });
1687
+ });
1688
+ app.get("/api/config", (_req, res) => {
1689
+ const cfgDir2 = getFanlucConfigDir();
1690
+ const perm = getPermissionMode();
1691
+ const ws = WORKSPACE_DIR;
1692
+ const md = loadFanlucMdSync();
1693
+ res.json({
1694
+ workspace: ws,
1695
+ configDir: cfgDir2,
1696
+ permission: perm,
1697
+ fanlucMd: md ? { path: md.path, length: md.content.length } : null,
1698
+ platform: process.platform
1699
+ });
1700
+ });
1701
+ app.get("/api/permission/status", (_req, res) => {
1702
+ const perm = getPermissionMode();
1703
+ res.json({
1704
+ permission: perm,
1705
+ fileGranted: isGranted("file"),
1706
+ shellGranted: isGranted("shell"),
1707
+ ttlMs: GRANT_TTL_MS
1708
+ });
1709
+ });
1710
+ app.post("/api/permission/approve", (req, res) => {
1711
+ const { type } = req.body || {};
1712
+ const t = type === "file" ? "file" : type === "shell" ? "shell" : "all";
1713
+ const perm = getPermissionMode();
1714
+ if (perm === "readOnly") {
1715
+ return res.status(403).json({ error: "readOnly: cannot grant permission" });
1716
+ }
1717
+ grantPermission(t);
1718
+ res.json({ ok: true, granted: t, until: Date.now() + GRANT_TTL_MS });
1719
+ });
1720
+ app.post("/api/permission/mode", (req, res) => {
1721
+ const m = String(req.body?.mode || "");
1722
+ if (m !== "ask" && m !== "autoEdit" && m !== "readOnly") return res.status(400).json({ error: "mode must be ask | autoEdit | readOnly" });
1723
+ process.env.FANLUC_PERMISSION = m;
1724
+ return res.json({ ok: true, permission: m });
1725
+ });
1726
+ app.post("/api/permission/reset", (_req, res) => {
1727
+ clearGrants();
1728
+ res.json({ ok: true });
1729
+ });
1730
+ app.post("/api/run-cmd", (req, res) => {
1731
+ if (process.env.DISABLE_RUN_CMD === "1") {
1732
+ const msg = "run_cmd is disabled on this server.";
1733
+ return res.json({ stdout: "", stderr: msg, formattedText: msg, blocked: true });
1734
+ }
1735
+ const perm = getPermissionMode();
1736
+ const needShellGrant = perm === "ask" || perm === "autoEdit";
1737
+ if (perm === "readOnly" || needShellGrant && !isGranted("shell")) {
1738
+ const hint = perm === "readOnly" ? "readOnly \u2014 shell disabled" : `permission '${perm}' requires confirmation via web ConfirmModal or POST /api/permission/approve {type:'shell'}`;
1739
+ const msg = `Blocked by permission mode '${perm}': shell execution requires confirmation. ${hint}`;
1740
+ return res.status(403).json({ stdout: "", stderr: msg, formattedText: msg, blocked: true, needConfirm: true, permission: perm });
1741
+ }
1742
+ const { command } = req.body || {};
1743
+ if (!command || typeof command !== "string" || !command.trim()) {
1744
+ return res.status(400).json({ error: "Missing command", formattedText: "Error: missing command" });
1745
+ }
1746
+ const trimmed = preprocessCommand(command.trim());
1747
+ const blockedReason = findBlockedPattern2(trimmed);
1748
+ if (blockedReason) {
1749
+ const msg = `Command blocked for safety: ${blockedReason}.`;
1750
+ return res.json({ stdout: "", stderr: msg, formattedText: msg, blocked: true });
1751
+ }
1752
+ const TIMEOUT_MS = 2e4;
1753
+ const MAX_BUFFER = 2 * 1024 * 1024;
1754
+ let bin;
1755
+ let args;
1756
+ if (IS_WINDOWS) {
1757
+ bin = "powershell.exe";
1758
+ const PS_UTF8 = "[Console]::OutputEncoding=[System.Text.UTF8Encoding]::new($false);$OutputEncoding=[Console]::OutputEncoding;$PSDefaultParameterValues['Get-Content:Encoding']='utf8';$PSDefaultParameterValues['Set-Content:Encoding']='utf8';$PSDefaultParameterValues['Add-Content:Encoding']='utf8';$PSDefaultParameterValues['Out-File:Encoding']='utf8';";
1759
+ args = ["-NoProfile", "-Command", PS_UTF8 + trimmed];
1760
+ } else if (FIREJAIL_AVAILABLE) {
1761
+ bin = "firejail";
1762
+ args = [
1763
+ "--quiet",
1764
+ "--noprofile",
1765
+ `--private=${WORKSPACE_DIR}`,
1766
+ "--net=none",
1767
+ "--rlimit-fsize=50000000",
1768
+ "--rlimit-as=1073741824",
1769
+ "bash",
1770
+ "-c",
1771
+ trimmed
1772
+ ];
1773
+ } else {
1774
+ bin = "bash";
1775
+ args = ["-c", trimmed];
1776
+ }
1777
+ (0, import_child_process.execFile)(
1778
+ bin,
1779
+ args,
1780
+ { cwd: WORKSPACE_DIR, timeout: TIMEOUT_MS, maxBuffer: MAX_BUFFER, env: SAFE_ENV },
1781
+ (err, stdout, stderr) => {
1782
+ const out = (stdout || "").toString().slice(0, 2e4);
1783
+ const errOut = (stderr || "").toString().slice(0, 2e4);
1784
+ if (err && err.killed) {
1785
+ const msg = `Command timed out (${TIMEOUT_MS / 1e3}s limit).`;
1786
+ return res.json({ stdout: out, stderr: msg, formattedText: `$ ${trimmed}
1787
+ ${out}
1788
+ ${msg}`.trim(), timedOut: true });
1789
+ }
1790
+ const exitCode = err ? typeof err.code === "number" ? err.code : 1 : 0;
1791
+ const body = `${out}${errOut ? (out ? "\n" : "") + errOut : ""}`.trim() || "(no output)";
1792
+ const formattedText = `$ ${trimmed}
1793
+ ${body}`;
1794
+ return res.json({ stdout: out, stderr: errOut, exitCode, formattedText });
1795
+ }
1796
+ );
1797
+ });
1798
+ app.post("/api/upload-workspace-file", import_express.default.json({ limit: "10mb" }), async (req, res) => {
1799
+ const perm = getPermissionMode();
1800
+ if (perm === "readOnly" || perm === "ask" && !isGranted("file")) {
1801
+ const hint = perm === "ask" ? "requires confirmation via ConfirmModal or POST /api/permission/approve {type:'file'}" : "readOnly";
1802
+ return res.status(403).json({ error: `Blocked by permission mode '${perm}': file write ${hint}`, needConfirm: perm === "ask", permission: perm });
1803
+ }
1804
+ try {
1805
+ const { name, content, encoding = "utf-8" } = req.body || {};
1806
+ if (!name || typeof name !== "string") {
1807
+ return res.status(400).json({ error: "Missing file name" });
1808
+ }
1809
+ const cleanName = name.trim().replace(/^(\.\/|\/)+/, "");
1810
+ const filePath = import_path.default.join(WORKSPACE_DIR, cleanName);
1811
+ const dir = import_path.default.dirname(filePath);
1812
+ if (!isInsideWorkspace(dir)) {
1813
+ return res.status(403).json({ error: "Invalid file path outside workspace" });
1814
+ }
1815
+ await import_fs.default.promises.mkdir(dir, { recursive: true });
1816
+ let buffer;
1817
+ if ((encoding === "base64" || cleanName.toLowerCase().endsWith(".zip")) && typeof content === "string") {
1818
+ let rawBase64 = content;
1819
+ if (rawBase64.includes(",")) {
1820
+ rawBase64 = rawBase64.split(",")[1];
1821
+ }
1822
+ buffer = Buffer.from(rawBase64, "base64");
1823
+ } else if (typeof content === "string") {
1824
+ buffer = Buffer.from(content, "utf-8");
1825
+ } else {
1826
+ buffer = Buffer.from("");
1827
+ }
1828
+ await import_fs.default.promises.writeFile(filePath, buffer);
1829
+ const stats = await import_fs.default.promises.stat(filePath);
1830
+ return res.json({
1831
+ success: true,
1832
+ path: cleanName,
1833
+ name: import_path.default.basename(cleanName),
1834
+ size: stats.size,
1835
+ updatedAt: stats.mtimeMs
1836
+ });
1837
+ } catch (err) {
1838
+ console.error("Upload workspace file error:", err);
1839
+ return res.status(500).json({ error: err?.message || "Failed to upload workspace file" });
1840
+ }
1841
+ });
1842
+ app.post("/api/delete-workspace-file", import_express.default.json(), async (req, res) => {
1843
+ const perm = getPermissionMode();
1844
+ if (perm === "readOnly" || perm === "ask" && !isGranted("file")) {
1845
+ const hint = perm === "ask" ? "requires confirmation via ConfirmModal or POST /api/permission/approve {type:'file'}" : "readOnly";
1846
+ return res.status(403).json({ error: `Blocked by permission mode '${perm}': file delete ${hint}`, needConfirm: perm === "ask", permission: perm });
1847
+ }
1848
+ try {
1849
+ const { path: filePathParam } = req.body || {};
1850
+ if (!filePathParam || typeof filePathParam !== "string") {
1851
+ return res.status(400).json({ error: "Missing file path" });
1852
+ }
1853
+ const cleanName = filePathParam.trim().replace(/^(\.\/|\/)+/, "");
1854
+ const targetPath = import_path.default.join(WORKSPACE_DIR, cleanName);
1855
+ if (!isInsideWorkspace(targetPath)) {
1856
+ return res.status(403).json({ error: "Invalid file path outside workspace" });
1857
+ }
1858
+ if (import_fs.default.existsSync(targetPath)) {
1859
+ await import_fs.default.promises.rm(targetPath, { recursive: true, force: true });
1860
+ }
1861
+ return res.json({ success: true, path: cleanName });
1862
+ } catch (err) {
1863
+ console.error("Delete workspace file error:", err);
1864
+ return res.status(500).json({ error: err?.message || "Failed to delete workspace file" });
1865
+ }
1866
+ });
1867
+ app.get("/api/workspace-files", async (_req, res) => {
1868
+ try {
1869
+ const filesMap = {};
1870
+ async function walkDir(currentDir, relativeDir = "") {
1871
+ let entries = [];
1872
+ try {
1873
+ entries = await import_fs.default.promises.readdir(currentDir, { withFileTypes: true });
1874
+ } catch {
1875
+ return;
1876
+ }
1877
+ for (const entry of entries) {
1878
+ if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
1879
+ const fullPath = import_path.default.join(currentDir, entry.name);
1880
+ const relPath = relativeDir ? `${relativeDir}/${entry.name}` : entry.name;
1881
+ if (entry.isDirectory()) {
1882
+ await walkDir(fullPath, relPath);
1883
+ } else if (entry.isFile()) {
1884
+ try {
1885
+ const stats = await import_fs.default.promises.stat(fullPath);
1886
+ if (stats.size <= 2 * 1024 * 1024) {
1887
+ const ext = entry.name.split(".").pop()?.toLowerCase() || "";
1888
+ const isBinary = ["zip", "gz", "tar", "tgz", "rar", "7z", "png", "jpg", "jpeg", "gif", "pdf", "exe", "bin", "apk", "iso"].includes(ext);
1889
+ let content = "";
1890
+ let fileEncoding = "utf-8";
1891
+ if (isBinary) {
1892
+ content = `[Binary File: ${entry.name} (${(stats.size / 1024).toFixed(1)} KB)]`;
1893
+ } else {
1894
+ try {
1895
+ const dec = decodeTextBuffer(await import_fs.default.promises.readFile(fullPath));
1896
+ content = dec.text;
1897
+ fileEncoding = dec.encoding;
1898
+ } catch {
1899
+ content = `[Unreadable File: ${(stats.size / 1024).toFixed(1)} KB]`;
1900
+ }
1901
+ }
1902
+ filesMap[relPath] = {
1903
+ path: relPath,
1904
+ name: entry.name,
1905
+ content,
1906
+ size: stats.size,
1907
+ language: ext,
1908
+ encoding: fileEncoding,
1909
+ updatedAt: stats.mtimeMs
1910
+ };
1911
+ } else {
1912
+ filesMap[relPath] = {
1913
+ path: relPath,
1914
+ name: entry.name,
1915
+ content: `[Large File: ${(stats.size / 1024).toFixed(1)} KB]`,
1916
+ size: stats.size,
1917
+ language: entry.name.split(".").pop()?.toLowerCase() || "binary",
1918
+ updatedAt: stats.mtimeMs
1919
+ };
1920
+ }
1921
+ } catch {
1922
+ }
1923
+ }
1924
+ }
1925
+ }
1926
+ await walkDir(WORKSPACE_DIR);
1927
+ return res.json({ files: filesMap });
1928
+ } catch (err) {
1929
+ return res.status(500).json({ error: err?.message || "Failed to list workspace files" });
1930
+ }
1931
+ });
1932
+ app.get("/api/health", (_req, res) => {
1933
+ res.json({ status: "ok", workspace: WORKSPACE_DIR, permission: getPermissionMode(), platform: process.platform });
1934
+ });
1935
+ if (process.env.NODE_ENV !== "production") {
1936
+ const vite = await (0, import_vite.createServer)({
1937
+ server: { middlewareMode: true },
1938
+ appType: "spa"
1939
+ });
1940
+ app.use(vite.middlewares);
1941
+ } else {
1942
+ const distPath = import_path.default.join(process.cwd(), "dist");
1943
+ app.use(import_express.default.static(distPath));
1944
+ app.get("*", (_req, res) => {
1945
+ res.sendFile(import_path.default.join(distPath, "index.html"));
1946
+ });
1947
+ }
1948
+ const HOST = process.env.HOST || "0.0.0.0";
1949
+ const cfgDir = getFanlucConfigDir();
1950
+ const permMode = getPermissionMode();
1951
+ const fanlucMd = loadFanlucMdSync();
1952
+ console.log(`[fanluc] Workspace : ${WORKSPACE_DIR}`);
1953
+ console.log(`[fanluc] Config dir: ${cfgDir}`);
1954
+ console.log(`[fanluc] Permission: ${permMode}`);
1955
+ if (fanlucMd) console.log(`[fanluc] FANLUC.md : ${fanlucMd.path} (${fanlucMd.content.length} chars)`);
1956
+ else console.log(`[fanluc] FANLUC.md : (none)`);
1957
+ app.listen(PORT, HOST, () => {
1958
+ const displayHost = HOST === "0.0.0.0" ? "localhost" : HOST;
1959
+ console.log(`Server running on http://${displayHost}:${PORT}`);
1960
+ console.log(`Health: http://${displayHost}:${PORT}/api/health`);
1961
+ });
1962
+ }
1963
+ if (process.env.FANLUC_NO_AUTO_START !== "1") {
1964
+ startServer().catch((e) => {
1965
+ console.error("Failed to start server:", e);
1966
+ process.exit(1);
1967
+ });
1968
+ }
1969
+ // Annotate the CommonJS export names for ESM import in node:
1970
+ 0 && (module.exports = {
1971
+ WORKSPACE_DIR,
1972
+ startServer
1973
+ });
1974
+ //# sourceMappingURL=server.cjs.map