fanluc 2.0.0 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/README.md +83 -0
  2. package/dist/assets/{abnfDiagram-O67JEVCF-hiYMdxpV.js → abnfDiagram-O67JEVCF-Di2iDQfl.js} +1 -1
  3. package/dist/assets/{arc-DokRg_wy.js → arc-wXq_yD5s.js} +1 -1
  4. package/dist/assets/{architectureDiagram-NJMV4G6O-UzHYe9Bx.js → architectureDiagram-NJMV4G6O-CTXbg-3W.js} +1 -1
  5. package/dist/assets/{blockDiagram-BEXU5L5S-DqM5UILT.js → blockDiagram-BEXU5L5S-DSDoL3RP.js} +1 -1
  6. package/dist/assets/{c4Diagram-YGBWAQC7-Cxkdm7sV.js → c4Diagram-YGBWAQC7-3sFM3iyw.js} +1 -1
  7. package/dist/assets/channel-atnI5OIR.js +1 -0
  8. package/dist/assets/{chunk-2Q5K7J3B-Nl4zmLlG.js → chunk-2Q5K7J3B-DUOKiJNI.js} +1 -1
  9. package/dist/assets/{chunk-3FUC2YCW-BCol78O2.js → chunk-3FUC2YCW-ablWb8_c.js} +2 -2
  10. package/dist/assets/{chunk-5VM5RSS4-1u4YRv2Y.js → chunk-5VM5RSS4-qmlSVZ_1.js} +1 -1
  11. package/dist/assets/{chunk-GTNCS2PH-BG9YIV2h.js → chunk-GTNCS2PH-PLlC7cNS.js} +1 -1
  12. package/dist/assets/{chunk-GWA4HPMP-BTKfkJAH.js → chunk-GWA4HPMP-DK-1a6YJ.js} +1 -1
  13. package/dist/assets/{chunk-JWPE2WC7-DCjElfA7.js → chunk-JWPE2WC7-D9zynFKs.js} +1 -1
  14. package/dist/assets/{chunk-NETBCI7D-QoJORlVn.js → chunk-NETBCI7D-BvzPMYyE.js} +1 -1
  15. package/dist/assets/{chunk-WEXAMYUT-Da-T8ct9.js → chunk-WEXAMYUT-CzcfZFWB.js} +1 -1
  16. package/dist/assets/{chunk-XXDRQBXY-CLu69Uei.js → chunk-XXDRQBXY-DznN-rwG.js} +1 -1
  17. package/dist/assets/{classDiagram-v2-NBCMYWYE-DIXavbI9.js → classDiagram-v2-NBCMYWYE-D4jem0hV.js} +1 -1
  18. package/dist/assets/{cose-bilkent-JH36ORCC-DuWclG8-.js → cose-bilkent-JH36ORCC-VALsUQJS.js} +1 -1
  19. package/dist/assets/{cynefin-OW5HDTMX-D2CG5-xd.js → cynefin-OW5HDTMX-DK77QSI8.js} +1 -1
  20. package/dist/assets/{cynefinDiagram-VND7K2PF-goI7_Qfg.js → cynefinDiagram-VND7K2PF-CdYXfGNR.js} +1 -1
  21. package/dist/assets/{dagre-6A5THRUB-BmOaZ0cm.js → dagre-6A5THRUB-DeeUQzDV.js} +1 -1
  22. package/dist/assets/{diagram-22UHCM2B-DjDJFMw3.js → diagram-22UHCM2B-B9shZ914.js} +1 -1
  23. package/dist/assets/{diagram-3UASUU5V-J6z81NH3.js → diagram-3UASUU5V-gSHvaXBI.js} +1 -1
  24. package/dist/assets/{diagram-ATOU4E4O-B1MPU8ai.js → diagram-ATOU4E4O-BrCI5HzS.js} +1 -1
  25. package/dist/assets/{diagram-CDSNMT55-X5Uss0Yv.js → diagram-CDSNMT55-XtVlMRq0.js} +1 -1
  26. package/dist/assets/{diagram-MLGK6HIB-CRDqfYy1.js → diagram-MLGK6HIB-BVa6KsvA.js} +1 -1
  27. package/dist/assets/{diagram-MPIPVDR6-bhJtyiOq.js → diagram-MPIPVDR6-C8P23P2T.js} +1 -1
  28. package/dist/assets/{ebnfDiagram-ZINNZB2B-Dn1mZAV8.js → ebnfDiagram-ZINNZB2B-BrooaQj5.js} +1 -1
  29. package/dist/assets/{elk-276RUBZZ-C93BlBEU.js → elk-276RUBZZ-B-qiKlkF.js} +1 -1
  30. package/dist/assets/{erDiagram-OPXOYQCR-PuzjAvhD.js → erDiagram-OPXOYQCR-CL1bp2Mf.js} +1 -1
  31. package/dist/assets/{flowDiagram-KWPJA3E3-oRhFgO0p.js → flowDiagram-KWPJA3E3-BMQvaQ-5.js} +1 -1
  32. package/dist/assets/{ganttDiagram-FUAMR5RP-DKiU1XjU.js → ganttDiagram-FUAMR5RP-PLnTGRI8.js} +1 -1
  33. package/dist/assets/{gitGraphDiagram-X574FWY7-CWBMI0vb.js → gitGraphDiagram-X574FWY7-CiTN3Joc.js} +1 -1
  34. package/dist/assets/index-BEKLwzQT.css +1 -0
  35. package/dist/assets/index-C8gDJtDK.js +2841 -0
  36. package/dist/assets/{infoDiagram-VRGFBTTK-C5Hd4Afz.js → infoDiagram-VRGFBTTK-ComkS_96.js} +1 -1
  37. package/dist/assets/{ishikawaDiagram-OU5B5YK6-BdD6ulRK.js → ishikawaDiagram-OU5B5YK6-D-dNoDFJ.js} +1 -1
  38. package/dist/assets/{journeyDiagram-ZHPQQLJL-CzT-uvks.js → journeyDiagram-ZHPQQLJL-BMm9BQM8.js} +1 -1
  39. package/dist/assets/{kanban-definition-PNTS6WVX-BkrazFMR.js → kanban-definition-PNTS6WVX-D5If-l_D.js} +1 -1
  40. package/dist/assets/{linear-B0wSM1Dr.js → linear-DXAHb0Ag.js} +1 -1
  41. package/dist/assets/{mindmap-definition-NLK3R4M7-Df1Acqkf.js → mindmap-definition-NLK3R4M7-CIdwCXTJ.js} +1 -1
  42. package/dist/assets/{pegDiagram-GJSIUBJH-DK_F7h_0.js → pegDiagram-GJSIUBJH-MY7FoVQh.js} +1 -1
  43. package/dist/assets/{pieDiagram-5QR66LMP-BMXyNb9R.js → pieDiagram-5QR66LMP-C7lWWAtM.js} +1 -1
  44. package/dist/assets/{quadrantDiagram-O4NWA36T-Dfu7Z8so.js → quadrantDiagram-O4NWA36T-jHqCwvi8.js} +1 -1
  45. package/dist/assets/{railroadDiagram-XR7U4H2S-CfnBD-aQ.js → railroadDiagram-XR7U4H2S-BwoIA8Ln.js} +1 -1
  46. package/dist/assets/{requirementDiagram-PLB6GJNP-DrvsZJPP.js → requirementDiagram-PLB6GJNP-Cw8w0Rwb.js} +1 -1
  47. package/dist/assets/{sankeyDiagram-IPEJSGJF-DvdVDpT1.js → sankeyDiagram-IPEJSGJF-DTRo1Yxy.js} +1 -1
  48. package/dist/assets/{sequenceDiagram-PO4LG4MO-D1UTrvZH.js → sequenceDiagram-PO4LG4MO-DqZddXsc.js} +1 -1
  49. package/dist/assets/{sizeCapture-INFHLROL-CJrOBcai.js → sizeCapture-INFHLROL-BAEQ4XP5.js} +1 -1
  50. package/dist/assets/{stateDiagram-v2-GCMORJYK-CX8VmtqD.js → stateDiagram-v2-GCMORJYK-CYB4CouD.js} +1 -1
  51. package/dist/assets/{swimlanes-2SLR337P-DUWmhiK6.js → swimlanes-2SLR337P-Dw53LU13.js} +1 -1
  52. package/dist/assets/swimlanesDiagram-TC7HE7FX-Dzkep6h_.js +8 -0
  53. package/dist/assets/{timeline-definition-EJHVYXUP-CBovVdOU.js → timeline-definition-EJHVYXUP-Chk-unGo.js} +1 -1
  54. package/dist/assets/{usecaseDiagram-POWQR4AR-LD5N5BAG.js → usecaseDiagram-POWQR4AR-D_JpD-4l.js} +1 -1
  55. package/dist/assets/{vennDiagram-UO4OBE2U-DdwfzB3A.js → vennDiagram-UO4OBE2U-BnPO8gk8.js} +1 -1
  56. package/dist/assets/{wardleyDiagram-VNRHLVJA-BMHK4bav.js → wardleyDiagram-VNRHLVJA-CD0z1l8R.js} +1 -1
  57. package/dist/assets/{xychartDiagram-PMCCYNJV-q1BNzMC-.js → xychartDiagram-PMCCYNJV-CHEqj1Pe.js} +1 -1
  58. package/dist/cli.js +52 -57
  59. package/dist/index.html +4 -4
  60. package/dist/server.cjs +2144 -841
  61. package/dist/server.cjs.map +4 -4
  62. package/dist/tui.js +3220 -3310
  63. package/docs/fanluc_ui.md +120 -40
  64. package/docs/guide-connect.md +233 -4
  65. package/docs/run-cmd-security.md +26 -0
  66. package/index.html +2 -2
  67. package/mcp.json +3 -0
  68. package/package.json +88 -92
  69. package/searxng/docker-compose.yml +3 -3
  70. package/vite.config.ts +2 -2
  71. package/dist/assets/channel-SPfNTvmj.js +0 -1
  72. package/dist/assets/index-BylpTP-E.css +0 -1
  73. package/dist/assets/index-DcpVteR3.js +0 -3237
  74. package/dist/assets/permissionHelper--88tJJlz.js +0 -3
  75. package/dist/assets/swimlanesDiagram-TC7HE7FX-DMQy1553.js +0 -8
package/dist/server.cjs CHANGED
@@ -4,10 +4,6 @@ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
4
  var __getOwnPropNames = Object.getOwnPropertyNames;
5
5
  var __getProtoOf = Object.getPrototypeOf;
6
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
7
  var __copyProps = (to, from, except, desc) => {
12
8
  if (from && typeof from === "object" || typeof from === "function") {
13
9
  for (let key of __getOwnPropNames(from))
@@ -24,391 +20,1833 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
24
20
  isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
25
21
  mod
26
22
  ));
27
- var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
28
23
 
29
24
  // 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
25
  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");
26
+ var import_path3 = __toESM(require("path"), 1);
27
+ var import_fs3 = __toESM(require("fs"), 1);
41
28
  var import_vite = require("vite");
42
29
  var import_google_translate_api_x = require("google-translate-api-x");
43
30
  var import_jsdom = require("jsdom");
44
31
  var import_readability = require("@mozilla/readability");
45
32
  var import_dotenv = __toESM(require("dotenv"), 1);
46
- var import_promises = __toESM(require("node:dns/promises"), 1);
33
+ var import_promises2 = __toESM(require("node:dns/promises"), 1);
47
34
  var import_node_net = __toESM(require("node:net"), 1);
35
+ var import_client = require("@modelcontextprotocol/sdk/client/index.js");
48
36
 
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
- }
37
+ // localExec.ts
38
+ var import_fs2 = __toESM(require("fs"), 1);
39
+ var import_os2 = __toESM(require("os"), 1);
40
+ var import_path2 = __toESM(require("path"), 1);
41
+ var import_promises = require("stream/promises");
42
+
43
+ // permissions.ts
44
+ var import_fs = __toESM(require("fs"), 1);
45
+ var import_os = __toESM(require("os"), 1);
46
+ var import_path = __toESM(require("path"), 1);
47
+ var DEFAULTS = {
48
+ modes: { create: "ask", move: "allow", delete: "ask", archive: "allow", install: "ask", edit: "allow" },
49
+ limits: { maxCreateMB: "", maxDeleteMB: "", maxInstallMB: "", maxToolCalls: "" }
50
+ };
51
+ var LABEL = {
52
+ create: "create file/folder",
53
+ move: "move/rename",
54
+ delete: "delete",
55
+ archive: "zip/unzip",
56
+ install: "install/uninstall libraries",
57
+ edit: "edit file"
58
+ };
59
+ var FILE = import_path.default.join(import_os.default.homedir(), ".fanluc", "permissions.json");
60
+ var cache = null;
61
+ function getPerms() {
62
+ if (cache) return cache;
63
+ let raw = {};
75
64
  try {
76
- return { text: new TextDecoder("utf-8", { fatal: true }).decode(buf), encoding: "utf-8" };
65
+ raw = JSON.parse(import_fs.default.readFileSync(FILE, "utf-8"));
77
66
  } catch {
78
67
  }
68
+ cache = sanitize(raw);
69
+ return cache;
70
+ }
71
+ function sanitize(raw) {
72
+ const out = JSON.parse(JSON.stringify(DEFAULTS));
73
+ for (const k of Object.keys(DEFAULTS.modes)) {
74
+ const v = raw?.modes?.[k];
75
+ if (v === "allow" || v === "ask" || v === "deny") out.modes[k] = v;
76
+ }
77
+ for (const k of Object.keys(DEFAULTS.limits)) {
78
+ const v = raw?.limits?.[k];
79
+ if (typeof v === "string" || typeof v === "number") out.limits[k] = String(v).trim().replace(/[^0-9.]/g, "").slice(0, 12);
80
+ }
81
+ return out;
82
+ }
83
+ function setPerms(raw) {
84
+ cache = sanitize(raw);
79
85
  try {
80
- return { text: new TextDecoder("windows-1258").decode(buf), encoding: "windows-1258" };
81
- } catch {
86
+ import_fs.default.mkdirSync(import_path.default.dirname(FILE), { recursive: true });
87
+ import_fs.default.writeFileSync(FILE, JSON.stringify(cache, null, 2), { mode: 384 });
88
+ } catch (e) {
89
+ console.warn("[permissions] save failed:", e);
82
90
  }
83
- return { text: buf.toString("latin1"), encoding: "latin1" };
91
+ return cache;
84
92
  }
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 = [];
93
+ function limitBytes(s) {
94
+ const n = parseFloat(s);
95
+ return Number.isFinite(n) && n > 0 ? n * 1024 * 1024 : null;
96
+ }
97
+ var fmt = (b) => b >= 1024 * 1024 ? `${(b / 1024 / 1024).toFixed(1)} MB` : `${Math.max(1, Math.round(b / 1024))} KB`;
98
+ var WIN = process.platform === "win32";
99
+ function splitCommand(cmd) {
100
+ const segs = [];
113
101
  let tokens = [];
102
+ let redirects = [];
114
103
  let cur = "";
115
- let hasCur = false;
116
- let curFromPipe = false;
117
- const pushToken = () => {
118
- if (hasCur) tokens.push(cur);
104
+ let has = false;
105
+ let heredocEnd = null;
106
+ const pendingHeredocs = [];
107
+ const endWord = () => {
108
+ if (has) tokens.push(cur);
119
109
  cur = "";
120
- hasCur = false;
110
+ has = false;
121
111
  };
122
- const endSegment = (sep) => {
123
- pushToken();
124
- if (tokens.length) segments.push({ tokens, fromPipe: curFromPipe });
112
+ const endSeg = () => {
113
+ endWord();
114
+ if (tokens.length || redirects.length) segs.push({ tokens, redirects });
125
115
  tokens = [];
126
- curFromPipe = sep === "|";
116
+ redirects = [];
117
+ };
118
+ const readWord = (i2) => {
119
+ while (i2 < cmd.length && (cmd[i2] === " " || cmd[i2] === " ")) i2++;
120
+ let w = "";
121
+ let q = null;
122
+ while (i2 < cmd.length) {
123
+ const c = cmd[i2];
124
+ if (q) {
125
+ if (c === q) q = null;
126
+ else w += c;
127
+ } else if (c === "'" || c === '"') q = c;
128
+ else if (/[\s;&|<>()]/.test(c)) break;
129
+ else w += c;
130
+ i2++;
131
+ }
132
+ return { word: w, next: i2 };
127
133
  };
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;
134
+ let i = 0;
135
+ let quote = null;
136
+ while (i < cmd.length) {
137
+ const c = cmd[i];
138
+ if (quote) {
139
+ if (c === quote) quote = null;
140
+ else if (c === "\\" && quote === '"' && !WIN && i + 1 < cmd.length) {
141
+ cur += cmd[++i];
142
+ } else cur += c;
133
143
  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;
144
+ continue;
145
+ }
146
+ if (c === "'" || c === '"') {
147
+ quote = c;
148
+ has = true;
149
+ i++;
150
+ continue;
151
+ }
152
+ if (c === "\\" && !WIN && i + 1 < cmd.length) {
153
+ cur += cmd[i + 1];
154
+ has = true;
155
+ i += 2;
156
+ continue;
157
+ }
158
+ if (c === "\n") {
159
+ endSeg();
160
+ i++;
161
+ if (pendingHeredocs.length) {
162
+ for (const h of pendingHeredocs) {
163
+ while (i < cmd.length) {
164
+ let e = cmd.indexOf("\n", i);
165
+ if (e < 0) e = cmd.length;
166
+ const line = cmd.slice(i, e);
167
+ i = Math.min(cmd.length, e + 1);
168
+ if ((h.strip ? line.trim() : line.replace(/\r$/, "")) === h.marker) break;
140
169
  }
141
- break;
142
170
  }
143
- if (q === '"' && cmd[i] === "`" && i + 1 < cmd.length) {
144
- cur += cmd[i + 1];
145
- i += 2;
146
- continue;
171
+ pendingHeredocs.length = 0;
172
+ }
173
+ continue;
174
+ }
175
+ if (c === ";" || c === "|" || c === "&") {
176
+ if ((c === "&" || c === "|") && cmd[i + 1] === c) i++;
177
+ endSeg();
178
+ i++;
179
+ continue;
180
+ }
181
+ if (c === "(" || c === ")") {
182
+ endSeg();
183
+ i++;
184
+ continue;
185
+ }
186
+ if (c === "<") {
187
+ if (cmd[i + 1] === "<" && cmd[i + 2] !== "<") {
188
+ let j = i + 2;
189
+ let strip = false;
190
+ if (cmd[j] === "-") {
191
+ strip = true;
192
+ j++;
147
193
  }
148
- cur += cmd[i++];
194
+ const { word, next } = readWord(j);
195
+ if (word) pendingHeredocs.push({ marker: word, strip });
196
+ i = next;
197
+ } else {
198
+ const { next } = readWord(i + (cmd[i + 1] === "<" ? 3 : 1));
199
+ i = next;
149
200
  }
150
201
  continue;
151
202
  }
152
- if (ch === ";" || ch === "\n" || ch === "\r" || ch === "&" || ch === "{" || ch === "}" || ch === "(" || ch === ")") {
153
- endSegment(ch);
203
+ if (c === ">") {
204
+ let fd = "";
205
+ if (has && /^\d+$/.test(cur)) {
206
+ fd = cur;
207
+ cur = "";
208
+ has = false;
209
+ } else endWord();
210
+ let j = i + 1;
211
+ if (cmd[j] === ">") j++;
212
+ if (cmd[j] === "&") {
213
+ const r2 = readWord(j + 1);
214
+ i = r2.next;
215
+ continue;
216
+ }
217
+ const r = readWord(j);
218
+ i = r.next;
219
+ if (fd !== "2" && r.word) redirects.push(r.word);
154
220
  continue;
155
221
  }
156
- if (ch === "|") {
157
- endSegment("|");
222
+ if (c === " " || c === " " || c === "\r") {
223
+ endWord();
224
+ i++;
158
225
  continue;
159
226
  }
160
- if (ch === " " || ch === " " || ch === ",") {
161
- pushToken();
227
+ cur += c;
228
+ has = true;
229
+ i++;
230
+ }
231
+ endSeg();
232
+ void heredocEnd;
233
+ return segs;
234
+ }
235
+ var base = (s) => import_path.default.basename(s.replace(/\\/g, "/")).toLowerCase().replace(/\.(exe|cmd|bat|ps1|com)$/, "");
236
+ var NPM_INST = {
237
+ npm: { inst: ["install", "i", "in", "ins", "inst", "add", "ci", "update", "up", "upgrade", "link", "ln", "rebuild", "dedupe"], un: ["uninstall", "remove", "rm", "r", "un", "unlink"] },
238
+ cnpm: { inst: ["install", "i", "add", "update"], un: ["uninstall", "remove", "rm"] },
239
+ pnpm: { inst: ["add", "install", "i", "update", "up", "upgrade", "link", "ln", "import", "dlx"], un: ["remove", "rm", "uninstall", "un", "unlink"] },
240
+ yarn: { inst: ["add", "install", "upgrade", "up", "global", "dlx", "link", ""], un: ["remove"] },
241
+ bun: { inst: ["add", "install", "i", "update", "link", "x"], un: ["remove", "rm", "unlink"] }
242
+ };
243
+ var SYS_PM = /* @__PURE__ */ new Set(["apt", "apt-get", "aptitude", "dnf", "yum", "zypper", "pacman", "apk", "brew", "choco", "winget", "scoop", "snap", "flatpak", "nix-env", "port", "pkg", "emerge", "xbps-install"]);
244
+ var SYS_INST = /* @__PURE__ */ new Set(["install", "add", "upgrade", "dist-upgrade", "full-upgrade", "reinstall", "update-all"]);
245
+ var SYS_UN = /* @__PURE__ */ new Set(["remove", "uninstall", "purge", "autoremove", "del", "erase"]);
246
+ var PIP_VALUE_FLAGS = /* @__PURE__ */ new Set(["-r", "--requirement", "-e", "--editable", "-c", "--constraint", "-i", "--index-url", "--extra-index-url", "-t", "--target", "--prefix", "-f", "--find-links", "--python", "--root", "--src", "--cache-dir", "--proxy"]);
247
+ function pkgArgs(args, valueFlags) {
248
+ const pkgs = [];
249
+ let explicit = true;
250
+ for (let i = 0; i < args.length; i++) {
251
+ const a = args[i];
252
+ if (a.startsWith("-")) {
253
+ if (a === "-r" || a === "--requirement" || a === "-e" || a === "--editable" || a === "-c" || a === "--constraint") explicit = false;
254
+ if (valueFlags.has(a)) i++;
162
255
  continue;
163
256
  }
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 (..)";
257
+ pkgs.push(a);
258
+ }
259
+ return { pkgs, explicit };
260
+ }
261
+ function installInfo(name, args) {
262
+ const global = args.some((a) => a === "-g" || a === "--global" || a === "global");
263
+ const subIdx = args.findIndex((a) => !a.startsWith("-"));
264
+ const sub = subIdx >= 0 ? args[subIdx].toLowerCase() : "";
265
+ const after = subIdx >= 0 ? args.slice(subIdx + 1) : [];
266
+ if (NPM_INST[name]) {
267
+ const t = NPM_INST[name];
268
+ const un = t.un.includes(sub);
269
+ if (!un && !t.inst.includes(sub)) return null;
270
+ if (name === "yarn" && sub === "" && args.length > 0 && !args.every((a) => a.startsWith("-"))) return null;
271
+ const p = pkgArgs(after.filter((a) => a !== "global"), /* @__PURE__ */ new Set(["--prefix", "--registry", "-w", "--workspace", "--cwd"]));
272
+ const noPkgsMeansAll = !un && p.pkgs.length === 0;
273
+ return { mgr: name, uninstall: un, pkgs: p.pkgs, explicit: !noPkgsMeansAll && p.explicit, global };
274
+ }
275
+ if (name === "npx" || name === "pnpx" || name === "bunx") {
276
+ const p = args.filter((a) => !a.startsWith("-"))[0];
277
+ return { mgr: "npm", uninstall: false, pkgs: p ? [p] : [], explicit: !!p, global: false };
278
+ }
279
+ if (name === "pip" || name === "pip3" || name === "pipx" || name === "pip3.11" || name === "pip3.12") {
280
+ const un = sub === "uninstall";
281
+ const inst = sub === "install" || sub === "download" || name === "pipx" && (sub === "run" || sub === "inject" || sub === "upgrade");
282
+ if (!un && !inst) return null;
283
+ const p = pkgArgs(after, PIP_VALUE_FLAGS);
284
+ return { mgr: "pip", uninstall: un, pkgs: p.pkgs, explicit: p.explicit && p.pkgs.length > 0, global: false };
285
+ }
286
+ if (/^python(\d(\.\d+)?)?$|^py$/.test(name)) {
287
+ const m = args.indexOf("-m");
288
+ if (m >= 0 && args[m + 1] === "pip") return installInfo("pip", args.slice(m + 2));
289
+ return null;
290
+ }
291
+ if (name === "uv") {
292
+ if (sub === "pip") return installInfo("pip", after);
293
+ if (sub === "add") return { mgr: "pip", uninstall: false, ...(() => {
294
+ const p = pkgArgs(after, /* @__PURE__ */ new Set());
295
+ return { pkgs: p.pkgs, explicit: p.explicit && p.pkgs.length > 0 };
296
+ })(), global: false };
297
+ if (sub === "remove") return { mgr: "pip", uninstall: true, pkgs: after.filter((a) => !a.startsWith("-")), explicit: false, global: false };
298
+ if (sub === "sync" || sub === "lock") return { mgr: "pip", uninstall: false, pkgs: [], explicit: false, global: false };
299
+ if (sub === "tool") {
300
+ const s2 = after.find((a) => !a.startsWith("-"));
301
+ if (s2 === "install" || s2 === "upgrade") return { mgr: "pip", uninstall: false, pkgs: [], explicit: false, global: true };
302
+ if (s2 === "uninstall") return { mgr: "pip", uninstall: true, pkgs: [], explicit: false, global: true };
303
+ }
203
304
  return null;
204
305
  }
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 (..)";
306
+ if (name === "uvx") return { mgr: "pip", uninstall: false, pkgs: [], explicit: false, global: false };
307
+ if (name === "poetry" || name === "pdm" || name === "pipenv") {
308
+ if (["add", "install", "update", "sync"].includes(sub)) return { mgr: "pip", uninstall: false, pkgs: [], explicit: false, global: false };
309
+ if (sub === "remove") return { mgr: "pip", uninstall: true, pkgs: [], explicit: false, global: false };
310
+ return null;
210
311
  }
211
- if (recurse && rel === "" && !hasWildcard) {
212
- return "recursive delete of the workspace root itself";
312
+ if (name === "conda" || name === "mamba" || name === "micromamba") {
313
+ if (["install", "update", "upgrade"].includes(sub)) return { mgr: name, uninstall: false, pkgs: [], explicit: false, global: false };
314
+ if (["remove", "uninstall"].includes(sub)) return { mgr: name, uninstall: true, pkgs: [], explicit: false, global: false };
315
+ return null;
213
316
  }
317
+ if (SYS_PM.has(name)) {
318
+ const first = args[0] || "";
319
+ if (name === "pacman") {
320
+ if (/^-S/.test(first) && !/^-Ss|^-Si|^-Sq/.test(first)) return { mgr: name, uninstall: false, pkgs: [], explicit: false, global: true };
321
+ if (/^-R/.test(first)) return { mgr: name, uninstall: true, pkgs: [], explicit: false, global: true };
322
+ return null;
323
+ }
324
+ if (SYS_INST.has(sub)) return { mgr: name, uninstall: false, pkgs: after.filter((a) => !a.startsWith("-")), explicit: false, global: true };
325
+ if (SYS_UN.has(sub)) return { mgr: name, uninstall: true, pkgs: after.filter((a) => !a.startsWith("-")), explicit: false, global: true };
326
+ return null;
327
+ }
328
+ if (name === "cargo") {
329
+ if (sub === "install" || sub === "add") return { mgr: name, uninstall: false, pkgs: [], explicit: false, global: sub === "install" };
330
+ if (sub === "uninstall" || sub === "remove") return { mgr: name, uninstall: true, pkgs: [], explicit: false, global: sub === "uninstall" };
331
+ return null;
332
+ }
333
+ if (name === "go" && (sub === "install" || sub === "get")) return { mgr: name, uninstall: false, pkgs: [], explicit: false, global: false };
334
+ if (name === "gem" || name === "bundle" || name === "composer" || name === "dotnet" || name === "pecl") {
335
+ if (["install", "add", "require", "update"].includes(sub) || name === "dotnet" && sub === "tool") return { mgr: name, uninstall: false, pkgs: [], explicit: false, global: false };
336
+ if (["uninstall", "remove"].includes(sub)) return { mgr: name, uninstall: true, pkgs: [], explicit: false, global: false };
337
+ return null;
338
+ }
339
+ if (/^install-(module|package|script|packageprovider)$/.test(name)) return { mgr: "ps", uninstall: false, pkgs: [], explicit: false, global: true };
340
+ if (/^uninstall-(module|package|script)$/.test(name)) return { mgr: "ps", uninstall: true, pkgs: [], explicit: false, global: true };
214
341
  return null;
215
342
  }
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
- }
343
+ var NULL_TARGETS = /^(\/dev\/(null|stdout|stderr|tty|fd\/\d+)|nul|\$null|&\d+)$/i;
344
+ var WRAPPERS = /* @__PURE__ */ new Set(["sudo", "doas", "time", "nohup", "command", "exec", "nice", "ionice", "stdbuf", "env", "builtin"]);
345
+ var SHELLS = /* @__PURE__ */ new Set(["bash", "sh", "zsh", "dash", "ksh", "fish"]);
346
+ function classify(cmd, cwd, depth = 0) {
347
+ const out = [];
348
+ if (depth > 3 || !cmd.trim()) return out;
349
+ for (const m of cmd.matchAll(/\$\(([^()]*)\)/g)) out.push(...classify(m[1], cwd, depth + 1));
350
+ for (const m of cmd.matchAll(/`([^`]*)`/g)) out.push(...classify(m[1], cwd, depth + 1));
351
+ for (const seg of splitCommand(cmd)) {
352
+ let t = seg.tokens.slice();
353
+ let viaXargs = false;
354
+ while (t.length) {
355
+ if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(t[0])) {
356
+ t.shift();
357
+ continue;
358
+ }
359
+ const n = base(t[0]);
360
+ if (WRAPPERS.has(n)) {
361
+ t.shift();
362
+ while (t.length && (t[0].startsWith("-") || /^[A-Za-z_][A-Za-z0-9_]*=/.test(t[0]))) t.shift();
254
363
  continue;
255
364
  }
256
- targets.push(tok);
365
+ if (n === "xargs") {
366
+ viaXargs = true;
367
+ t.shift();
368
+ while (t.length && t[0].startsWith("-")) t.shift();
369
+ continue;
370
+ }
371
+ break;
372
+ }
373
+ if (t.length) {
374
+ const name = base(t[0]);
375
+ const args = t.slice(1);
376
+ const nonFlag = args.filter((a) => !a.startsWith("-"));
377
+ const sub = (nonFlag[0] || "").toLowerCase();
378
+ const has = (re) => args.some((a) => re.test(a));
379
+ if (SHELLS.has(name)) {
380
+ const i = args.findIndex((a) => /^-[a-z]*c$/.test(a));
381
+ if (i >= 0 && args[i + 1]) out.push(...classify(args[i + 1], cwd, depth + 1));
382
+ } else if (name === "powershell" || name === "pwsh") {
383
+ const i = args.findIndex((a) => /^-(c|command)$/i.test(a));
384
+ if (i >= 0) out.push(...classify(args.slice(i + 1).join(" "), cwd, depth + 1));
385
+ } else if (name === "cmd") {
386
+ const i = args.findIndex((a) => /^\/(c|k)$/i.test(a));
387
+ if (i >= 0) out.push(...classify(args.slice(i + 1).join(" "), cwd, depth + 1));
388
+ }
389
+ const flagLikeWin = (a) => WIN && /^\/[a-z?]$/i.test(a);
390
+ if (["rm", "rmdir", "del", "erase", "rd", "unlink", "shred", "trash", "trash-put", "remove-item", "ri"].includes(name)) {
391
+ const targets = [];
392
+ for (let i = 0; i < args.length; i++) {
393
+ const a = args[i];
394
+ if (/^-(path|literalpath)$/i.test(a)) {
395
+ if (args[i + 1]) targets.push(args[++i]);
396
+ continue;
397
+ }
398
+ if (a.startsWith("-") || flagLikeWin(a)) continue;
399
+ targets.push(a);
400
+ }
401
+ out.push({ cat: "delete", targets, dynamic: viaXargs || targets.length === 0 });
402
+ } else if (name === "git" && sub === "clean") {
403
+ out.push({ cat: "delete", dynamic: true });
404
+ } else if (name === "git" && sub === "rm") {
405
+ out.push({ cat: "delete", targets: nonFlag.slice(1), dynamic: nonFlag.length < 2 });
406
+ } else if (name === "find" && (args.includes("-delete") || args.includes("-exec") && /(^|\/)(rm|rmdir)$/.test(args[args.indexOf("-exec") + 1] || ""))) {
407
+ out.push({ cat: "delete", dynamic: true });
408
+ }
409
+ if (["mv", "move", "ren", "rename", "move-item", "mi", "rename-item", "rni"].includes(name) || name === "git" && sub === "mv") {
410
+ out.push({ cat: "move" });
411
+ }
412
+ if (["zip", "unzip", "tar", "7z", "7za", "7zr", "7zz", "gzip", "gunzip", "bzip2", "bunzip2", "xz", "unxz", "zstd", "unzstd", "rar", "unrar", "compress-archive", "expand-archive"].includes(name)) {
413
+ out.push({ cat: "archive" });
414
+ } else if (/^python(\d(\.\d+)?)?$|^py$/.test(name) && args[0] === "-m" && (args[1] === "zipfile" || args[1] === "tarfile")) {
415
+ out.push({ cat: "archive" });
416
+ }
417
+ const inst = installInfo(name, args);
418
+ if (inst) out.push({ cat: "install", install: inst });
419
+ if (["mkdir", "md", "touch", "new-item", "ni", "mkfifo", "cp", "copy", "copy-item", "cpi", "xcopy", "robocopy", "ln", "fallocate", "virtualenv"].includes(name)) {
420
+ out.push({ cat: "create" });
421
+ } else if (name === "git" && (sub === "clone" || sub === "init" || sub === "worktree")) {
422
+ out.push({ cat: "create" });
423
+ } else if (name === "curl" && has(/^(-[a-zA-Z]*[oOJ][a-zA-Z]*|--output|--remote-name|--create-dirs)$/)) {
424
+ out.push({ cat: "create" });
425
+ } else if (name === "wget" && !(args.includes("-O-") || args.some((a, i) => a === "-O" && args[i + 1] === "-") || has(/^-\w*O-$/))) {
426
+ out.push({ cat: "create" });
427
+ } else if (name === "dd" && has(/^of=/)) {
428
+ out.push({ cat: "create" });
429
+ } else if (name === "cargo" && (sub === "new" || sub === "init")) {
430
+ out.push({ cat: "create" });
431
+ } else if (name === "dotnet" && sub === "new") {
432
+ out.push({ cat: "create" });
433
+ } else if ((name === "npm" || name === "pnpm" || name === "yarn" || name === "bun") && (sub === "init" || sub === "create")) {
434
+ out.push({ cat: "create" });
435
+ } else if (name === "uv" && (sub === "venv" || sub === "init")) {
436
+ out.push({ cat: "create" });
437
+ } else if (/^python(\d(\.\d+)?)?$|^py$/.test(name) && args[0] === "-m" && args[1] === "venv") {
438
+ out.push({ cat: "create" });
439
+ } else if (name === "tee") {
440
+ for (const f of nonFlag) pushWriteTarget(out, f, cwd);
441
+ }
442
+ if (name === "sed" && has(/^(-[a-zA-Z]*i[a-zA-Z.]*|--in-place(=.*)?)$/)) out.push({ cat: "edit" });
443
+ else if (name === "perl" && has(/^-[a-zA-Z]*i[a-zA-Z.]*$/)) out.push({ cat: "edit" });
444
+ else if (name === "awk" && args.some((a, i) => a === "-i" && /inplace/.test(args[i + 1] || ""))) out.push({ cat: "edit" });
445
+ else if (["set-content", "sc", "add-content", "ac", "out-file", "clear-content", "patch", "sponge", "dos2unix", "unix2dos", "truncate"].includes(name)) out.push({ cat: "edit" });
446
+ else if (name === "git" && ["apply", "am", "restore"].includes(sub)) out.push({ cat: "edit" });
447
+ else if (name === "git" && sub === "reset" && args.includes("--hard")) out.push({ cat: "edit" });
448
+ }
449
+ for (const r of seg.redirects) pushWriteTarget(out, r, cwd);
450
+ }
451
+ return out;
452
+ }
453
+ function pushWriteTarget(out, target, cwd) {
454
+ if (!target || NULL_TARGETS.test(target)) return;
455
+ if (/[$`]/.test(target)) {
456
+ out.push({ cat: "edit" });
457
+ return;
458
+ }
459
+ const abs = resolveToken(target, cwd);
460
+ out.push({ cat: import_fs.default.existsSync(abs) ? "edit" : "create" });
461
+ }
462
+ function resolveToken(tok, cwd) {
463
+ let t = tok;
464
+ if (t === "~" || t.startsWith("~/") || t.startsWith("~\\")) t = import_path.default.join(import_os.default.homedir(), t.slice(1));
465
+ return import_path.default.resolve(cwd, t);
466
+ }
467
+ async function sizeOf(p, budget = { n: 3e5 }) {
468
+ let st;
469
+ try {
470
+ st = await import_fs.default.promises.lstat(p);
471
+ } catch {
472
+ return { bytes: 0, exists: false, complete: true };
473
+ }
474
+ if (!st.isDirectory()) return { bytes: st.size, exists: true, complete: true };
475
+ let bytes = 0;
476
+ let complete = true;
477
+ let entries = [];
478
+ try {
479
+ entries = await import_fs.default.promises.readdir(p);
480
+ } catch {
481
+ return { bytes: 0, exists: true, complete: false };
482
+ }
483
+ for (const e of entries) {
484
+ if (--budget.n < 0) {
485
+ complete = false;
486
+ break;
487
+ }
488
+ const r = await sizeOf(import_path.default.join(p, e), budget);
489
+ bytes += r.bytes;
490
+ if (!r.complete) complete = false;
491
+ }
492
+ return { bytes, exists: true, complete };
493
+ }
494
+ async function expandTargets(tokens, cwd) {
495
+ const paths = [];
496
+ let unknown = false;
497
+ for (const tok of tokens) {
498
+ if (/[$`]/.test(tok)) {
499
+ unknown = true;
500
+ continue;
501
+ }
502
+ const abs = resolveToken(tok, cwd);
503
+ const dir = import_path.default.dirname(abs);
504
+ const leaf = import_path.default.basename(abs);
505
+ if (/[*?[]/.test(dir)) {
506
+ unknown = true;
507
+ continue;
508
+ }
509
+ if (/[*?[]/.test(leaf)) {
510
+ try {
511
+ const re = new RegExp(
512
+ "^" + leaf.replace(/[.+^${}()|\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".") + "$"
513
+ );
514
+ for (const f of await import_fs.default.promises.readdir(dir)) if (re.test(f) && !(f.startsWith(".") && !leaf.startsWith("."))) paths.push(import_path.default.join(dir, f));
515
+ } catch {
516
+ unknown = true;
517
+ }
518
+ } else paths.push(abs);
519
+ }
520
+ return { paths, unknown };
521
+ }
522
+ async function fetchJson(url, ms = 6e3) {
523
+ const ac = new AbortController();
524
+ const t = setTimeout(() => ac.abort(), ms);
525
+ try {
526
+ const r = await fetch(url, { signal: ac.signal, headers: { accept: "application/json" } });
527
+ if (!r.ok) return null;
528
+ return await r.json();
529
+ } catch {
530
+ return null;
531
+ } finally {
532
+ clearTimeout(t);
533
+ }
534
+ }
535
+ async function estimateInstallBytes(info) {
536
+ if (!info.explicit || info.pkgs.length === 0) return null;
537
+ let total = 0;
538
+ for (const spec of info.pkgs) {
539
+ if (/^(\.|\/|file:|git|http|github:|[a-z]:\\)/i.test(spec) || spec.includes("/") && !spec.startsWith("@")) return null;
540
+ if (info.mgr === "npm" || NPM_INST[info.mgr]) {
541
+ const m = spec.match(/^(@?[^@]+)(?:@(.+))?$/);
542
+ if (!m) return null;
543
+ const name = m[1];
544
+ const ver = m[2] && /^\d[\w.\-+]*$/.test(m[2]) ? m[2] : "latest";
545
+ const j = await fetchJson(`https://registry.npmjs.org/${name.replace("/", "%2F")}/${ver}`);
546
+ const sz = j?.dist?.unpackedSize;
547
+ if (typeof sz !== "number") return null;
548
+ total += sz;
549
+ } else if (info.mgr === "pip") {
550
+ const m = spec.match(/^([A-Za-z0-9_.\-]+)(?:\[[^\]]*\])?(?:==([\w.\-+]+))?/);
551
+ if (!m) return null;
552
+ const j = await fetchJson(m[2] ? `https://pypi.org/pypi/${m[1]}/${m[2]}/json` : `https://pypi.org/pypi/${m[1]}/json`);
553
+ const files = j?.urls || [];
554
+ if (!files.length) return null;
555
+ const wheels = files.filter((f) => f.packagetype === "bdist_wheel");
556
+ const pool = wheels.length ? wheels : files;
557
+ total += Math.min(...pool.map((f) => Number(f.size) || Infinity));
558
+ } else return null;
559
+ }
560
+ return Number.isFinite(total) ? total : null;
561
+ }
562
+ var pending = /* @__PURE__ */ new Map();
563
+ var seq = 0;
564
+ var APPROVAL_TIMEOUT_MS = 10 * 60 * 1e3;
565
+ function requestApproval(info) {
566
+ return new Promise((resolve) => {
567
+ const id = `ap${(++seq).toString(36)}${Date.now().toString(36).slice(-4)}`;
568
+ const timer = setTimeout(() => {
569
+ pending.delete(id);
570
+ resolve({ allow: false, remember: false, timedOut: true });
571
+ }, APPROVAL_TIMEOUT_MS);
572
+ pending.set(id, {
573
+ info: { ...info, id, createdAt: Date.now() },
574
+ resolve: (d) => {
575
+ clearTimeout(timer);
576
+ pending.delete(id);
577
+ resolve(d);
578
+ }
579
+ });
580
+ });
581
+ }
582
+ async function evaluatePermission(command, cwd, opts) {
583
+ const cfg = getPerms();
584
+ const findings = classify(command, cwd);
585
+ const cats = new Set(findings.map((f) => f.cat));
586
+ for (const c of cats) {
587
+ if (cfg.modes[c] === "deny") {
588
+ return { ok: false, message: `Blocked by the user's permission settings: "${LABEL[c]}" is set to DENY. Don't retry this; use a different approach or tell the user.` };
257
589
  }
258
- if (recurse && targets.length === 0 && seg.fromPipe) {
259
- return "recursive delete with pipeline input (target unknown)";
590
+ }
591
+ const askCats = new Set([...cats].filter((c) => cfg.modes[c] === "ask"));
592
+ const modeAsk = new Set(askCats);
593
+ const reasons = [];
594
+ const delLimit = limitBytes(cfg.limits.maxDeleteMB);
595
+ if (delLimit) {
596
+ let total = 0;
597
+ for (const f of findings) {
598
+ if (f.cat === "delete") {
599
+ if (f.dynamic) {
600
+ askCats.add("delete");
601
+ reasons.push("can't tell from the command how much will be deleted");
602
+ continue;
603
+ }
604
+ const { paths, unknown } = await expandTargets(f.targets || [], cwd);
605
+ if (unknown) {
606
+ askCats.add("delete");
607
+ reasons.push("some delete targets can't be resolved (variables / complex globs)");
608
+ }
609
+ for (const p of paths) {
610
+ const s = await sizeOf(p);
611
+ total += s.bytes;
612
+ if (!s.complete) {
613
+ askCats.add("delete");
614
+ reasons.push(`${import_path.default.basename(p)} is too large to measure fully`);
615
+ }
616
+ }
617
+ } else if (f.cat === "install" && f.install?.uninstall) {
618
+ const i = f.install;
619
+ if (i.global || !(NPM_INST[i.mgr] || i.mgr === "npm") || i.pkgs.length === 0) {
620
+ askCats.add("install");
621
+ reasons.push("can't measure how much the uninstall removes");
622
+ } else {
623
+ for (const p of i.pkgs) {
624
+ const s = await sizeOf(import_path.default.join(cwd, "node_modules", p.replace(/@[^/]+$/, "")));
625
+ total += s.bytes;
626
+ }
627
+ }
628
+ }
629
+ }
630
+ if (total > delLimit) {
631
+ return { ok: false, message: `Blocked: this would delete about ${fmt(total)}, over the user's limit of ${cfg.limits.maxDeleteMB} MB. Don't retry; tell the user.` };
632
+ }
633
+ }
634
+ const insLimit = limitBytes(cfg.limits.maxInstallMB);
635
+ if (insLimit) {
636
+ let total = 0;
637
+ let unknown = false;
638
+ let any = false;
639
+ for (const f of findings) {
640
+ if (f.cat === "install" && f.install && !f.install.uninstall) {
641
+ any = true;
642
+ const b = await estimateInstallBytes(f.install);
643
+ if (b == null) unknown = true;
644
+ else total += b;
645
+ }
260
646
  }
261
- if (recurse && targets.length === 0) {
262
- return "recursive delete without an explicit target";
647
+ if (any && total > insLimit) {
648
+ return { ok: false, message: `Blocked: the named packages alone are about ${fmt(total)}, over the user's install limit of ${cfg.limits.maxInstallMB} MB. Don't retry; tell the user.` };
263
649
  }
264
- for (const t of targets) {
265
- const why = classifyTarget(t, recurse, force, opts);
266
- if (why) return `PowerShell delete blocked: ${why}`;
650
+ if (any && unknown) {
651
+ askCats.add("install");
652
+ reasons.push("install size can't be verified beforehand");
267
653
  }
268
654
  }
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)";
655
+ if (askCats.size > 0) {
656
+ const d = await requestApproval({ command, cwd, categories: [...askCats].map((c) => LABEL[c]), reasons: [...new Set(reasons)] });
657
+ if (!d.allow) {
658
+ return {
659
+ ok: false,
660
+ message: d.timedOut ? "The user did not answer the permission prompt in time, so the command was NOT run. Ask them first, then retry." : "The user denied this command. Don't retry it; tell the user what you wanted to do or choose another approach."
661
+ };
662
+ }
663
+ if (d.remember) {
664
+ const next = getPerms();
665
+ for (const c of modeAsk) next.modes[c] = "allow";
666
+ setPerms(next);
667
+ }
272
668
  }
273
- return null;
669
+ const crLimit = limitBytes(cfg.limits.maxCreateMB);
670
+ if (crLimit && !cats.has("install")) {
671
+ return opts.canUlimit ? { ok: true, ulimitMb: crLimit / 1024 / 1024 } : { ok: true, scanBytes: crLimit };
672
+ }
673
+ return { ok: true };
274
674
  }
275
- function findBlockedPattern(command, opts = {}) {
276
- for (const { pattern, reason } of DANGEROUS_PATTERNS) {
277
- if (pattern.test(command)) return reason;
675
+ var SKIP_DIRS = /* @__PURE__ */ new Set(["node_modules", ".git", ".venv", "venv", "__pycache__", "dist", "build", ".next", "target"]);
676
+ async function removeOversizeNewFiles(root, sinceMs, limit) {
677
+ const removed = [];
678
+ let visits = 0;
679
+ async function walk(dir) {
680
+ let ents = [];
681
+ try {
682
+ ents = await import_fs.default.promises.readdir(dir, { withFileTypes: true });
683
+ } catch {
684
+ return;
685
+ }
686
+ for (const e of ents) {
687
+ if (++visits > 6e4) return;
688
+ const full = import_path.default.join(dir, e.name);
689
+ if (e.isDirectory()) {
690
+ if (!SKIP_DIRS.has(e.name)) await walk(full);
691
+ } else if (e.isFile()) {
692
+ try {
693
+ const st = await import_fs.default.promises.stat(full);
694
+ if (st.size > limit && st.birthtimeMs >= sinceMs - 1e3) {
695
+ await import_fs.default.promises.rm(full, { force: true });
696
+ removed.push(`${import_path.default.relative(root, full)} (${fmt(st.size)})`);
697
+ }
698
+ } catch {
699
+ }
700
+ }
701
+ }
278
702
  }
279
- return analyzePowerShellDelete(command, opts);
703
+ await walk(root);
704
+ return removed;
705
+ }
706
+ function registerPermissionRoutes(app, requireLocal2) {
707
+ app.get("/api/permissions", requireLocal2, (_req, res) => res.json(getPerms()));
708
+ app.post("/api/permissions", requireLocal2, (req, res) => res.json(setPerms(req.body)));
709
+ app.get("/api/permissions/pending", requireLocal2, (_req, res) => res.json({ pending: [...pending.values()].map((p) => p.info) }));
710
+ app.post("/api/permissions/decide", requireLocal2, (req, res) => {
711
+ const { id, allow, remember } = req.body || {};
712
+ const p = pending.get(String(id));
713
+ if (!p) return res.status(404).json({ error: "No such pending request (maybe it already timed out)" });
714
+ p.resolve({ allow: !!allow, remember: !!remember && !!allow });
715
+ return res.json({ ok: true });
716
+ });
280
717
  }
281
718
 
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();
719
+ // localExec.ts
720
+ var import_child_process = require("child_process");
721
+ var HOME_DIR = process.env.FANLUC_HOME || import_path2.default.join(import_os2.default.homedir(), ".fanluc");
722
+ var CONFIG_FILE = import_path2.default.join(HOME_DIR, "config.json");
723
+ function readConfig() {
724
+ try {
725
+ return JSON.parse(import_fs2.default.readFileSync(CONFIG_FILE, "utf-8")) || {};
726
+ } catch {
727
+ return {};
728
+ }
333
729
  }
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;
730
+ function writeConfig(cfg) {
731
+ try {
732
+ import_fs2.default.mkdirSync(HOME_DIR, { recursive: true });
733
+ import_fs2.default.writeFileSync(CONFIG_FILE, JSON.stringify(cfg, null, 2), "utf-8");
734
+ } catch (e) {
735
+ console.warn("[workspace] could not save config:", e?.message || e);
736
+ }
338
737
  }
339
- function clearGrants() {
340
- permissionGrants = { file: 0, shell: 0 };
738
+ function isDir(p) {
739
+ try {
740
+ return import_fs2.default.statSync(p).isDirectory();
741
+ } catch {
742
+ return false;
743
+ }
341
744
  }
342
- var FIREJAIL_AVAILABLE = false;
343
- if (!IS_WINDOWS) {
745
+ function pickInitialWorkspace() {
746
+ const forced = process.env.WORKSPACE_DIR || process.env.WORKSPACE;
747
+ if (forced && isDir(forced)) return import_path2.default.resolve(forced);
748
+ const saved = readConfig().workspace;
749
+ if (saved && isDir(saved)) return import_path2.default.resolve(saved);
750
+ const fallback = import_path2.default.join(process.cwd(), "workspace");
344
751
  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.");
752
+ import_fs2.default.mkdirSync(fallback, { recursive: true });
348
753
  } 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
754
  }
353
- } else {
354
- console.log("[run_cmd] Windows detected \u2014 firejail disabled, using PowerShell/cmd sandbox (blocklist enforced, permission ask blocks by default)");
755
+ return fallback;
756
+ }
757
+ var currentWorkspace = pickInitialWorkspace();
758
+ function getWorkspace() {
759
+ return currentWorkspace;
355
760
  }
356
- var ZIP_AVAILABLE = false;
357
- var UNZIP_AVAILABLE = false;
358
- if (!IS_WINDOWS) {
761
+ var workspaceListeners = [];
762
+ function onWorkspaceChange(cb) {
763
+ workspaceListeners.push(cb);
764
+ }
765
+ function setWorkspace(dir) {
766
+ if (!dir || typeof dir !== "string") return { ok: false, error: "Missing path" };
767
+ const abs = import_path2.default.resolve(dir.trim().replace(/^~(?=$|[\\/])/, import_os2.default.homedir()));
768
+ if (!isDir(abs)) return { ok: false, error: `Folder does not exist: ${abs}` };
359
769
  try {
360
- (0, import_child_process.execFileSync)("which", ["zip"], { stdio: "ignore" });
361
- ZIP_AVAILABLE = true;
770
+ import_fs2.default.accessSync(abs, import_fs2.default.constants.R_OK | import_fs2.default.constants.W_OK);
362
771
  } catch {
772
+ return { ok: false, error: `No read/write permission: ${abs}` };
363
773
  }
774
+ currentWorkspace = abs;
775
+ const cfg = readConfig();
776
+ const recent = [abs, ...(cfg.recent || []).filter((r) => r !== abs)].filter(isDir).slice(0, 10);
777
+ writeConfig({ workspace: abs, recent });
778
+ console.log(`[workspace] now: ${abs}`);
779
+ for (const cb of workspaceListeners) {
780
+ try {
781
+ cb(abs);
782
+ } catch {
783
+ }
784
+ }
785
+ return { ok: true, workspace: abs };
786
+ }
787
+ function resolveInWorkspace(rel) {
788
+ const clean = String(rel || "").trim().replace(/^(\.\/|\/|\\)+/, "");
789
+ const abs = import_path2.default.resolve(currentWorkspace, clean);
790
+ if (abs === currentWorkspace) return abs;
791
+ return abs.startsWith(currentWorkspace + import_path2.default.sep) ? abs : null;
792
+ }
793
+ function agentDir(workspaceId) {
794
+ if (!workspaceId || typeof workspaceId !== "string") return currentWorkspace;
795
+ const safeId = workspaceId.replace(/[^a-zA-Z0-9_-]/g, "").slice(0, 64);
796
+ if (!safeId) return currentWorkspace;
797
+ const dir = import_path2.default.join(currentWorkspace, ".fanluc", "agents", safeId);
364
798
  try {
365
- (0, import_child_process.execFileSync)("which", ["unzip"], { stdio: "ignore" });
366
- UNZIP_AVAILABLE = true;
799
+ import_fs2.default.mkdirSync(dir, { recursive: true });
367
800
  } catch {
368
801
  }
369
- } else {
802
+ return dir;
803
+ }
804
+ function which(name) {
370
805
  try {
371
- (0, import_child_process.execFileSync)("where", ["zip"], { stdio: "ignore" });
372
- ZIP_AVAILABLE = true;
806
+ const finder = process.platform === "win32" ? "where" : "which";
807
+ const out = (0, import_child_process.execFileSync)(finder, [name], { stdio: ["ignore", "pipe", "ignore"] }).toString().split(/\r?\n/)[0].trim();
808
+ return out || null;
373
809
  } catch {
810
+ return null;
811
+ }
812
+ }
813
+ function shellFromBinary(bin) {
814
+ const base2 = import_path2.default.basename(bin).toLowerCase();
815
+ if (base2.includes("pwsh") || base2.includes("powershell")) {
816
+ return {
817
+ bin,
818
+ kind: "powershell",
819
+ label: base2.includes("pwsh") ? "PowerShell 7 (pwsh)" : "Windows PowerShell",
820
+ args: (cmd) => ["-NoLogo", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", cmd]
821
+ };
822
+ }
823
+ if (base2 === "cmd.exe" || base2 === "cmd") {
824
+ return { bin, kind: "cmd", label: "cmd.exe", args: (cmd) => ["/d", "/s", "/c", cmd] };
374
825
  }
826
+ return { bin, kind: "posix", label: base2.includes("bash") ? "bash" : base2, args: (cmd) => ["-c", cmd] };
827
+ }
828
+ function detectShell() {
829
+ const override = process.env.FANLUC_SHELL;
830
+ if (override) return shellFromBinary(override);
831
+ if (process.platform === "win32") {
832
+ const pf = process.env.ProgramFiles || "C:\\Program Files";
833
+ const pf86 = process.env["ProgramFiles(x86)"] || "C:\\Program Files (x86)";
834
+ const local = process.env.LOCALAPPDATA || "";
835
+ const gitBash = [
836
+ import_path2.default.join(pf, "Git", "bin", "bash.exe"),
837
+ import_path2.default.join(pf86, "Git", "bin", "bash.exe"),
838
+ local && import_path2.default.join(local, "Programs", "Git", "bin", "bash.exe")
839
+ ].filter(Boolean);
840
+ for (const c of gitBash) if (import_fs2.default.existsSync(c)) return { ...shellFromBinary(c), label: "Git Bash" };
841
+ const pwsh = which("pwsh");
842
+ if (pwsh) return shellFromBinary(pwsh);
843
+ return shellFromBinary(which("powershell") || "powershell.exe");
844
+ }
845
+ const bash = which("bash");
846
+ return shellFromBinary(bash || "/bin/sh");
847
+ }
848
+ var SHELL = detectShell();
849
+ function systemInfo() {
850
+ return {
851
+ platform: process.platform,
852
+ arch: process.arch,
853
+ os: `${import_os2.default.type()} ${import_os2.default.release()}`,
854
+ shell: SHELL.label,
855
+ shellKind: SHELL.kind,
856
+ home: import_os2.default.homedir(),
857
+ workspace: currentWorkspace,
858
+ node: process.version,
859
+ python: which("python3") ? "python3" : which("python") ? "python" : null,
860
+ git: !!which("git")
861
+ };
862
+ }
863
+ var STRIP_ENV = new Set(
864
+ ["GEMINI_API_KEY", "TOKEN", "ADMIN_TOKEN", "ACCESS_TOKEN", ...(process.env.STRIP_ENV || "").split(",").map((s) => s.trim())].filter(Boolean)
865
+ );
866
+ function buildEnv() {
867
+ const env = {};
868
+ for (const [k, v] of Object.entries(process.env)) if (!STRIP_ENV.has(k)) env[k] = v;
869
+ return {
870
+ ...env,
871
+ PYTHONIOENCODING: "utf-8",
872
+ PYTHONUTF8: "1",
873
+ NO_COLOR: "1",
874
+ FORCE_COLOR: "0",
875
+ GIT_TERMINAL_PROMPT: "0",
876
+ // fail instead of hanging on a credential prompt
877
+ GIT_PAGER: "cat",
878
+ PAGER: "cat",
879
+ npm_config_yes: "true",
880
+ DEBIAN_FRONTEND: "noninteractive",
881
+ MPLBACKEND: env.MPLBACKEND || "Agg"
882
+ };
883
+ }
884
+ var CATASTROPHIC = [
885
+ { pattern: /\brm\s+(-[a-z]*\s+)*-[a-z]*[rf][a-z]*\s+(-[a-z]+\s+)*(--no-preserve-root\s+)?(\/|~|\$HOME|\/\*|~\/\*)\s*(;|&|\||$)/i, reason: "recursive delete of / or home" },
886
+ { pattern: /\bmkfs(\.\w+)?\b/i, reason: "formatting a filesystem" },
887
+ { pattern: /\bdd\b[^\n]*\bof=\/dev\/(sd|nvme|hd|disk|mmcblk)/i, reason: "writing raw to a disk device" },
888
+ { pattern: /:\(\)\s*\{\s*:\s*\|\s*:\s*&?\s*\}\s*;/, reason: "fork bomb" },
889
+ { pattern: /\bformat\s+[a-z]:/i, reason: "formatting a drive" },
890
+ { pattern: /\b(rd|rmdir)\s+\/s\s+(\/q\s+)?[a-z]:\\?\s*$/i, reason: "deleting a whole drive" },
891
+ { pattern: /\bRemove-Item\b[^\n]*-Recurse[^\n]*\s[a-z]:\\?\s*$/i, reason: "deleting a whole drive" }
892
+ ];
893
+ function findBlocked(command) {
894
+ if (process.env.FANLUC_UNRESTRICTED === "1") return null;
895
+ for (const { pattern, reason } of CATASTROPHIC) if (pattern.test(command)) return reason;
896
+ return null;
897
+ }
898
+ function killTree(child) {
899
+ const pid = child.pid;
900
+ if (!pid) return;
375
901
  try {
376
- (0, import_child_process.execFileSync)("where", ["unzip"], { stdio: "ignore" });
377
- UNZIP_AVAILABLE = true;
902
+ if (process.platform === "win32") {
903
+ (0, import_child_process.spawn)("taskkill", ["/pid", String(pid), "/T", "/F"], { stdio: "ignore", windowsHide: true });
904
+ } else {
905
+ try {
906
+ process.kill(-pid, "SIGTERM");
907
+ } catch {
908
+ child.kill("SIGTERM");
909
+ }
910
+ setTimeout(() => {
911
+ try {
912
+ process.kill(-pid, "SIGKILL");
913
+ } catch {
914
+ try {
915
+ child.kill("SIGKILL");
916
+ } catch {
917
+ }
918
+ }
919
+ }, 2e3).unref?.();
920
+ }
378
921
  } catch {
379
922
  }
380
923
  }
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}`;
924
+ var CAN_ULIMIT = process.platform !== "win32" && SHELL.kind === "posix";
925
+ function spawnShell(command, cwd) {
926
+ return (0, import_child_process.spawn)(SHELL.bin, SHELL.args(command), {
927
+ cwd,
928
+ env: buildEnv(),
929
+ stdio: ["ignore", "pipe", "pipe"],
930
+ // no stdin: interactive prompts fail fast instead of hanging
931
+ detached: process.platform !== "win32",
932
+ // own process group so we can kill the whole tree
933
+ windowsHide: true
934
+ });
935
+ }
936
+ var MAX_CAPTURE = 2 * 1024 * 1024;
937
+ var MAX_RETURN = Number(process.env.FANLUC_MAX_RETURN) > 0 ? Number(process.env.FANLUC_MAX_RETURN) : 1e5;
938
+ function runCommand(command, cwd, timeoutMs) {
939
+ return new Promise((resolve) => {
940
+ let child;
941
+ try {
942
+ child = spawnShell(command, cwd);
943
+ } catch (e) {
944
+ return resolve({ stdout: "", stderr: `Failed to start shell (${SHELL.bin}): ${e?.message || e}`, exitCode: 127, timedOut: false, truncated: false });
389
945
  }
946
+ let stdout = "";
947
+ let stderr = "";
948
+ let truncated = false;
949
+ let timedOut = false;
950
+ const add = (which2, d) => {
951
+ const s = d.toString("utf-8");
952
+ if (which2 === "o") stdout += s;
953
+ else stderr += s;
954
+ if (stdout.length + stderr.length > MAX_CAPTURE) {
955
+ truncated = true;
956
+ stdout = stdout.slice(-MAX_CAPTURE / 2);
957
+ stderr = stderr.slice(-MAX_CAPTURE / 2);
958
+ }
959
+ };
960
+ child.stdout?.on("data", (d) => add("o", d));
961
+ child.stderr?.on("data", (d) => add("e", d));
962
+ const timer = setTimeout(() => {
963
+ timedOut = true;
964
+ killTree(child);
965
+ }, timeoutMs);
966
+ child.on("error", (err) => {
967
+ clearTimeout(timer);
968
+ resolve({ stdout, stderr: stderr + `
969
+ [spawn error: ${err.message}]`, exitCode: 127, timedOut, truncated });
970
+ });
971
+ child.on("close", (code) => {
972
+ clearTimeout(timer);
973
+ resolve({ stdout, stderr, exitCode: code, timedOut, truncated });
974
+ });
975
+ });
976
+ }
977
+ var BG_JOBS = /* @__PURE__ */ new Map();
978
+ var BG_MAX_LOG = 3e5;
979
+ var BG_MAX_CONCURRENT = 20;
980
+ var BG_EVICT_MS = 30 * 60 * 1e3;
981
+ var bgSeq = 0;
982
+ function bgAppend(job, chunk) {
983
+ if (!chunk) return;
984
+ job.log += chunk;
985
+ if (job.log.length > BG_MAX_LOG) {
986
+ job.log = job.log.slice(job.log.length - BG_MAX_LOG);
987
+ job.truncated = true;
390
988
  }
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];
989
+ }
990
+ function bgStatus(job) {
991
+ if (job.status === "exited") return `exited (code ${job.exitCode})`;
992
+ return job.status;
993
+ }
994
+ function lastLine(text) {
995
+ const lines = text.split("\n").map((l) => l.trimEnd());
996
+ for (let i = lines.length - 1; i >= 0; i--) if (lines[i].trim()) return lines[i];
997
+ return "(no output yet)";
998
+ }
999
+ function bgEvictLater(job) {
1000
+ setTimeout(() => {
1001
+ if (BG_JOBS.get(job.id) === job) BG_JOBS.delete(job.id);
1002
+ }, BG_EVICT_MS).unref?.();
1003
+ }
1004
+ function killAllJobs() {
1005
+ for (const j of BG_JOBS.values()) if (j.status === "running") killTree(j.child);
1006
+ }
1007
+ process.on("exit", killAllJobs);
1008
+ for (const sig of ["SIGINT", "SIGTERM"]) {
1009
+ process.on(sig, () => {
1010
+ killAllJobs();
1011
+ process.exit(0);
1012
+ });
1013
+ }
1014
+ function isLoopbackAddr(ip) {
1015
+ return ip === "127.0.0.1" || ip === "::1" || ip === "::ffff:127.0.0.1";
1016
+ }
1017
+ function requireLocal(req, res, next) {
1018
+ if (process.env.ALLOW_REMOTE_EXEC === "1") return next();
1019
+ const ip = String(req.socket?.remoteAddress || "");
1020
+ const viaProxy = !!req.headers["x-forwarded-for"] || !!req.headers["x-forwarded-host"];
1021
+ const host = String(req.headers.host || "").replace(/:\d+$/, "").replace(/^\[|\]$/g, "").toLowerCase();
1022
+ const extra = (process.env.ALLOWED_HOSTS || "").split(",").map((s) => s.trim().toLowerCase()).filter(Boolean);
1023
+ const hostOk = host === "localhost" || host === "127.0.0.1" || host === "::1" || host.endsWith(".localhost") || extra.includes(host);
1024
+ if (isLoopbackAddr(ip) && !viaProxy && hostOk) return next();
1025
+ return res.status(403).json({
1026
+ error: "Local only: running commands / browsing folders is allowed only from the machine running the server (open http://localhost:PORT). Set ALLOW_REMOTE_EXEC=1 to override (dangerous).",
1027
+ formattedText: "Blocked: this request did not come from localhost."
1028
+ });
1029
+ }
1030
+ var SKIP_DIRS2 = /* @__PURE__ */ new Set([
1031
+ "node_modules",
1032
+ ".git",
1033
+ ".hg",
1034
+ ".svn",
1035
+ "dist",
1036
+ "build",
1037
+ "out",
1038
+ ".next",
1039
+ ".nuxt",
1040
+ ".cache",
1041
+ ".venv",
1042
+ "venv",
1043
+ "env",
1044
+ "__pycache__",
1045
+ ".pytest_cache",
1046
+ ".mypy_cache",
1047
+ "target",
1048
+ "vendor",
1049
+ ".gradle",
1050
+ ".idea",
1051
+ ".vscode",
1052
+ "coverage",
1053
+ ".turbo",
1054
+ ".fanluc"
1055
+ ]);
1056
+ var BINARY_EXT = /* @__PURE__ */ new Set([
1057
+ "zip",
1058
+ "gz",
1059
+ "tar",
1060
+ "tgz",
1061
+ "rar",
1062
+ "7z",
1063
+ "png",
1064
+ "jpg",
1065
+ "jpeg",
1066
+ "gif",
1067
+ "webp",
1068
+ "bmp",
1069
+ "ico",
1070
+ "pdf",
1071
+ "exe",
1072
+ "dll",
1073
+ "so",
1074
+ "dylib",
1075
+ "bin",
1076
+ "apk",
1077
+ "iso",
1078
+ "mp3",
1079
+ "mp4",
1080
+ "mkv",
1081
+ "avi",
1082
+ "mov",
1083
+ "wav",
1084
+ "flac",
1085
+ "ogg",
1086
+ "woff",
1087
+ "woff2",
1088
+ "ttf",
1089
+ "otf",
1090
+ "class",
1091
+ "jar",
1092
+ "pyc",
1093
+ "sqlite",
1094
+ "db"
1095
+ ]);
1096
+ var MAX_FILES = 5e3;
1097
+ var VIEW_LIMIT = 70 * 1024 * 1024;
1098
+ var MAX_INLINE_FILE = 256 * 1024 * 1024;
1099
+ async function listWorkspaceFiles(root) {
1100
+ const files = {};
1101
+ let count = 0;
1102
+ let truncated = false;
1103
+ async function walk(dir, rel) {
1104
+ if (truncated) return;
1105
+ let entries = [];
1106
+ try {
1107
+ entries = await import_fs2.default.promises.readdir(dir, { withFileTypes: true });
1108
+ } catch {
1109
+ return;
1110
+ }
1111
+ entries.sort((a, b) => a.name.localeCompare(b.name));
1112
+ for (const entry of entries) {
1113
+ if (truncated) return;
1114
+ const name = entry.name;
1115
+ if (entry.isDirectory()) {
1116
+ if (SKIP_DIRS2.has(name) || name.startsWith(".")) continue;
1117
+ await walk(import_path2.default.join(dir, name), rel ? `${rel}/${name}` : name);
1118
+ } else if (entry.isFile()) {
1119
+ if (name.startsWith(".")) continue;
1120
+ if (count >= MAX_FILES) {
1121
+ truncated = true;
1122
+ return;
1123
+ }
1124
+ const full = import_path2.default.join(dir, name);
1125
+ const relPath = rel ? `${rel}/${name}` : name;
1126
+ let st;
1127
+ try {
1128
+ st = await import_fs2.default.promises.stat(full);
1129
+ } catch {
1130
+ continue;
1131
+ }
1132
+ const ext = name.split(".").pop()?.toLowerCase() || "";
1133
+ let content;
1134
+ if (BINARY_EXT.has(ext)) content = `[Binary File: ${name} (${(st.size / 1024).toFixed(1)} KB)]`;
1135
+ else if (st.size > MAX_INLINE_FILE || st.size > VIEW_LIMIT && !/\.html?$/i.test(name))
1136
+ content = `[Large File: ${(st.size / 1024).toFixed(1)} KB \u2014 read it with run_cmd (cat / sed -n)]`;
1137
+ else {
1138
+ try {
1139
+ content = await import_fs2.default.promises.readFile(full, "utf-8");
1140
+ } catch {
1141
+ content = `[Unreadable File: ${(st.size / 1024).toFixed(1)} KB]`;
1142
+ }
1143
+ }
1144
+ files[relPath] = { path: relPath, name, content, size: st.size, language: ext, updatedAt: st.mtimeMs };
1145
+ count++;
401
1146
  }
402
1147
  }
403
- if (zipFile) {
404
- return `python3 -m zipfile -e ${zipFile} ${outDir}`;
1148
+ }
1149
+ await walk(root, "");
1150
+ return { files, truncated, count };
1151
+ }
1152
+ var KV_FILE = import_path2.default.join(import_os2.default.homedir(), ".fanluc", "state.json");
1153
+ var kvCache = null;
1154
+ function kvLoad() {
1155
+ if (kvCache) return kvCache;
1156
+ try {
1157
+ kvCache = JSON.parse(import_fs2.default.readFileSync(KV_FILE, "utf-8")) || {};
1158
+ } catch {
1159
+ kvCache = {};
1160
+ }
1161
+ return kvCache;
1162
+ }
1163
+ var kvTimer = null;
1164
+ function kvSave(data) {
1165
+ kvCache = data;
1166
+ if (kvTimer) return;
1167
+ kvTimer = setTimeout(() => {
1168
+ kvTimer = null;
1169
+ try {
1170
+ import_fs2.default.mkdirSync(import_path2.default.dirname(KV_FILE), { recursive: true });
1171
+ const tmp = KV_FILE + ".tmp";
1172
+ import_fs2.default.writeFileSync(tmp, JSON.stringify(kvCache), { mode: 384 });
1173
+ import_fs2.default.renameSync(tmp, KV_FILE);
1174
+ } catch (e) {
1175
+ console.warn("[kv] save failed:", e);
1176
+ }
1177
+ }, 300);
1178
+ }
1179
+ process.on("exit", () => {
1180
+ if (kvTimer) {
1181
+ try {
1182
+ import_fs2.default.mkdirSync(import_path2.default.dirname(KV_FILE), { recursive: true });
1183
+ import_fs2.default.writeFileSync(KV_FILE, JSON.stringify(kvCache), { mode: 384 });
1184
+ } catch {
405
1185
  }
406
1186
  }
407
- return trimmed;
1187
+ });
1188
+ function clampTimeout(v) {
1189
+ const n = Number(v);
1190
+ if (!Number.isFinite(n) || n <= 0) return 12e4;
1191
+ return Math.min(6e5, Math.max(1e3, n));
408
1192
  }
409
- function findBlockedPattern2(command) {
410
- return findBlockedPattern(command, { workspaceDir: WORKSPACE_DIR, platform: process.platform });
1193
+ function listRoots() {
1194
+ const roots = [];
1195
+ if (process.platform === "win32") {
1196
+ for (let c = 65; c <= 90; c++) {
1197
+ const d = `${String.fromCharCode(c)}:\\`;
1198
+ try {
1199
+ if (import_fs2.default.existsSync(d)) roots.push({ name: d, path: d });
1200
+ } catch {
1201
+ }
1202
+ }
1203
+ } else {
1204
+ roots.push({ name: "/", path: "/" });
1205
+ }
1206
+ return roots;
1207
+ }
1208
+ function registerLocalRoutes(app) {
1209
+ registerPermissionRoutes(app, requireLocal);
1210
+ app.get("/api/workspace", requireLocal, (_req, res) => {
1211
+ const cfg = readConfig();
1212
+ res.json({
1213
+ workspace: currentWorkspace,
1214
+ name: import_path2.default.basename(currentWorkspace) || currentWorkspace,
1215
+ recent: (cfg.recent || []).filter(isDir),
1216
+ home: import_os2.default.homedir(),
1217
+ roots: listRoots(),
1218
+ system: systemInfo()
1219
+ });
1220
+ });
1221
+ app.post("/api/workspace/set", requireLocal, (req, res) => {
1222
+ const r = setWorkspace(String(req.body?.path || ""));
1223
+ if (!r.ok) return res.status(400).json(r);
1224
+ return res.json({ ok: true, workspace: r.workspace, name: import_path2.default.basename(r.workspace) || r.workspace });
1225
+ });
1226
+ app.get("/api/fs/browse", requireLocal, async (req, res) => {
1227
+ try {
1228
+ const raw = String(req.query.path || "").trim();
1229
+ const target = import_path2.default.resolve(raw ? raw.replace(/^~(?=$|[\\/])/, import_os2.default.homedir()) : import_os2.default.homedir());
1230
+ if (!isDir(target)) return res.status(404).json({ error: `Not a folder: ${target}` });
1231
+ const showHidden = req.query.hidden === "1";
1232
+ let entries = [];
1233
+ try {
1234
+ entries = await import_fs2.default.promises.readdir(target, { withFileTypes: true });
1235
+ } catch (e) {
1236
+ return res.status(403).json({ error: e?.message || "Cannot read folder" });
1237
+ }
1238
+ const dirs = entries.filter((e) => {
1239
+ if (!e.isDirectory() && !(e.isSymbolicLink() && isDir(import_path2.default.join(target, e.name)))) return false;
1240
+ return showHidden || !e.name.startsWith(".");
1241
+ }).map((e) => e.name).sort((a, b) => a.localeCompare(b));
1242
+ const parent = import_path2.default.dirname(target);
1243
+ res.json({
1244
+ path: target,
1245
+ parent: parent === target ? null : parent,
1246
+ dirs,
1247
+ fileCount: entries.filter((e) => e.isFile()).length,
1248
+ roots: listRoots()
1249
+ });
1250
+ } catch (e) {
1251
+ res.status(500).json({ error: e?.message || "browse failed" });
1252
+ }
1253
+ });
1254
+ app.post("/api/fs/mkdir", requireLocal, async (req, res) => {
1255
+ try {
1256
+ const parent = import_path2.default.resolve(String(req.body?.parent || ""));
1257
+ const name = String(req.body?.name || "").trim();
1258
+ if (!name || /[\\/:*?"<>|]/.test(name) || name === "." || name === "..") return res.status(400).json({ error: "Invalid folder name" });
1259
+ if (!isDir(parent)) return res.status(404).json({ error: "Parent folder not found" });
1260
+ const full = import_path2.default.join(parent, name);
1261
+ await import_fs2.default.promises.mkdir(full, { recursive: false });
1262
+ res.json({ ok: true, path: full });
1263
+ } catch (e) {
1264
+ res.status(500).json({ error: e?.message || "mkdir failed" });
1265
+ }
1266
+ });
1267
+ app.get("/api/system-info", requireLocal, (_req, res) => res.json(systemInfo()));
1268
+ app.post("/api/run-cmd", requireLocal, async (req, res) => {
1269
+ if (process.env.DISABLE_RUN_CMD === "1") {
1270
+ const msg = "run_cmd is disabled on this server (DISABLE_RUN_CMD=1).";
1271
+ return res.json({ stdout: "", stderr: msg, formattedText: msg, blocked: true });
1272
+ }
1273
+ const { command, workspaceId, timeoutMs } = req.body || {};
1274
+ if (!command || typeof command !== "string" || !command.trim()) {
1275
+ return res.status(400).json({ error: "Missing command", formattedText: "Error: missing command" });
1276
+ }
1277
+ const trimmed = command.trim();
1278
+ const blocked = findBlocked(trimmed);
1279
+ if (blocked) {
1280
+ const msg = `Command blocked as catastrophic: ${blocked}. (Set FANLUC_UNRESTRICTED=1 to disable this guard.)`;
1281
+ return res.json({ stdout: "", stderr: msg, formattedText: msg, blocked: true });
1282
+ }
1283
+ const cwd = agentDir(workspaceId);
1284
+ const limit = clampTimeout(timeoutMs);
1285
+ const ev = await evaluatePermission(trimmed, cwd, { canUlimit: CAN_ULIMIT });
1286
+ if (!ev.ok) {
1287
+ return res.json({ stdout: "", stderr: ev.message, formattedText: `$ ${trimmed}
1288
+ ${ev.message}`, blocked: true });
1289
+ }
1290
+ const startedAt = Date.now();
1291
+ const toRun = ev.ulimitMb ? `ulimit -f ${Math.max(1, Math.floor(ev.ulimitMb * 1024))}; ${trimmed}` : trimmed;
1292
+ const r = await runCommand(toRun, cwd, limit);
1293
+ const out = r.stdout.slice(-MAX_RETURN);
1294
+ const errOut = r.stderr.slice(-MAX_RETURN);
1295
+ const notes = [];
1296
+ if (r.truncated || r.stdout.length > MAX_RETURN || r.stderr.length > MAX_RETURN) notes.push("[output truncated \u2014 showing the end]");
1297
+ if (ev.ulimitMb && r.exitCode === 153) notes.push("[stopped: a file would exceed the user's max file-size limit (permission settings)]");
1298
+ if (ev.scanBytes) {
1299
+ const removed = await removeOversizeNewFiles(cwd, startedAt, ev.scanBytes);
1300
+ if (removed.length) notes.push(`[removed new files over the user's max file-size limit: ${removed.join(", ")}]`);
1301
+ }
1302
+ if (r.timedOut) notes.push(`[timed out after ${Math.round(limit / 1e3)}s and was killed \u2014 use run_cmd_bg for long-running commands]`);
1303
+ const body = `${out}${errOut ? (out ? "\n" : "") + errOut : ""}`.trim() || "(no output)";
1304
+ const formattedText = `$ ${trimmed}
1305
+ ${body}${notes.length ? "\n" + notes.join("\n") : ""}${!r.timedOut && r.exitCode ? `
1306
+ [exit code ${r.exitCode}]` : ""}`;
1307
+ return res.json({ stdout: out, stderr: errOut, exitCode: r.exitCode, timedOut: r.timedOut, formattedText });
1308
+ });
1309
+ app.post("/api/run-cmd-bg", requireLocal, async (req, res) => {
1310
+ if (process.env.DISABLE_RUN_CMD === "1") {
1311
+ return res.json({ id: null, formattedText: "run_cmd_bg is disabled on this server.", blocked: true });
1312
+ }
1313
+ const { command, workspaceId } = req.body || {};
1314
+ if (!command || typeof command !== "string" || !command.trim()) {
1315
+ return res.status(400).json({ error: "Missing command", formattedText: "Error: missing command" });
1316
+ }
1317
+ const trimmed = command.trim();
1318
+ const blocked = findBlocked(trimmed);
1319
+ if (blocked) return res.json({ id: null, formattedText: `Command blocked as catastrophic: ${blocked}.`, blocked: true });
1320
+ const ev = await evaluatePermission(trimmed, agentDir(workspaceId), { canUlimit: CAN_ULIMIT });
1321
+ if (!ev.ok) return res.json({ id: null, formattedText: `$ ${trimmed}
1322
+ ${ev.message}`, blocked: true });
1323
+ const running = [...BG_JOBS.values()].filter((j) => j.status === "running").length;
1324
+ if (running >= BG_MAX_CONCURRENT) {
1325
+ return res.json({ id: null, formattedText: `Too many background commands running (max ${BG_MAX_CONCURRENT}). Kill one with kill_cmd_bg first.`, blocked: true });
1326
+ }
1327
+ let child;
1328
+ try {
1329
+ child = spawnShell(ev.ulimitMb ? `ulimit -f ${Math.max(1, Math.floor(ev.ulimitMb * 1024))}; ${trimmed}` : trimmed, agentDir(workspaceId));
1330
+ } catch (e) {
1331
+ return res.json({ id: null, formattedText: `Failed to start background command: ${e?.message || e}`, blocked: true });
1332
+ }
1333
+ const id = `bg${(++bgSeq).toString(36)}${Date.now().toString(36).slice(-4)}`;
1334
+ const job = { id, command: trimmed, child, log: "", truncated: false, status: "running", exitCode: null, startedAt: Date.now(), endedAt: null };
1335
+ BG_JOBS.set(id, job);
1336
+ child.stdout?.on("data", (d) => bgAppend(job, d.toString()));
1337
+ child.stderr?.on("data", (d) => bgAppend(job, d.toString()));
1338
+ child.on("error", (err) => {
1339
+ bgAppend(job, `
1340
+ [process error: ${err.message}]
1341
+ `);
1342
+ job.status = "error";
1343
+ job.endedAt = Date.now();
1344
+ bgEvictLater(job);
1345
+ });
1346
+ child.on("exit", (code, signal) => {
1347
+ job.exitCode = code;
1348
+ job.endedAt = Date.now();
1349
+ if (job.status === "running") job.status = "exited";
1350
+ if (signal) bgAppend(job, `
1351
+ [terminated by signal ${signal}]
1352
+ `);
1353
+ bgEvictLater(job);
1354
+ });
1355
+ return res.json({ id, formattedText: `Started background command ${id}: ${trimmed}` });
1356
+ });
1357
+ app.post("/api/kill-cmd-bg", requireLocal, (req, res) => {
1358
+ const { id } = req.body || {};
1359
+ const job = id && BG_JOBS.get(id);
1360
+ if (!job) return res.json({ formattedText: `No background command with id "${id}" (it may have finished and been cleaned up).` });
1361
+ if (job.status !== "running") return res.json({ formattedText: `${id} is not running (status: ${bgStatus(job)}).` });
1362
+ job.status = "killed";
1363
+ killTree(job.child);
1364
+ return res.json({ formattedText: `Killed ${id} (${job.command}).` });
1365
+ });
1366
+ app.post("/api/read-cmd-bg-log", requireLocal, (req, res) => {
1367
+ const { id, offset, limit } = req.body || {};
1368
+ const job = id && BG_JOBS.get(id);
1369
+ if (!job) return res.json({ formattedText: `No background command with id "${id}" (it may have finished and been cleaned up).` });
1370
+ const lines = job.log.split("\n");
1371
+ const off = Math.max(1, parseInt(offset, 10) || 1);
1372
+ const lim = Math.max(1, Math.min(2e3, parseInt(limit, 10) || 200));
1373
+ const slice = lines.slice(off - 1, off - 1 + lim);
1374
+ const header = `[${id}] status: ${bgStatus(job)} | command: ${job.command} | log lines: ${lines.length}${job.truncated ? " (older lines were trimmed)" : ""}`;
1375
+ return res.json({ formattedText: `${header}
1376
+ ${slice.join("\n") || "(nothing at this offset)"}` });
1377
+ });
1378
+ app.post("/api/list-cmd-bg", requireLocal, (_req, res) => {
1379
+ const jobs = [...BG_JOBS.values()].sort((a, b) => a.startedAt - b.startedAt);
1380
+ if (!jobs.length) return res.json({ formattedText: "No background commands.", jobs: [] });
1381
+ const rows = jobs.map((j) => ({ id: j.id, command: j.command, status: bgStatus(j), lastLogLine: lastLine(j.log) }));
1382
+ return res.json({ formattedText: rows.map((r) => `- ${r.id} | ${r.status} | ${r.command}
1383
+ last: ${r.lastLogLine}`).join("\n"), jobs: rows });
1384
+ });
1385
+ app.post("/api/agent-workspace-export", requireLocal, async (req, res) => {
1386
+ const safeId = typeof req.body?.workspaceId === "string" ? req.body.workspaceId.replace(/[^a-zA-Z0-9_-]/g, "").slice(0, 64) : "";
1387
+ if (!safeId) return res.status(400).json({ error: "Missing workspaceId" });
1388
+ const dir = import_path2.default.join(currentWorkspace, ".fanluc", "agents", safeId);
1389
+ if (!import_fs2.default.existsSync(dir)) return res.status(404).json({ error: "This agent has no workspace files yet." });
1390
+ try {
1391
+ const { default: JSZip } = await import("jszip");
1392
+ const zip = new JSZip();
1393
+ const walk = async (d, rel) => {
1394
+ for (const e of await import_fs2.default.promises.readdir(d, { withFileTypes: true })) {
1395
+ const p = import_path2.default.join(d, e.name);
1396
+ const r = rel ? `${rel}/${e.name}` : e.name;
1397
+ if (e.isDirectory()) await walk(p, r);
1398
+ else if (e.isFile()) zip.file(r, await import_fs2.default.promises.readFile(p));
1399
+ }
1400
+ };
1401
+ await walk(dir, "");
1402
+ const buf = await zip.generateAsync({ type: "nodebuffer", compression: "DEFLATE" });
1403
+ res.setHeader("Content-Type", "application/zip");
1404
+ res.setHeader("Content-Disposition", `attachment; filename="agent-${safeId}-workspace.zip"`);
1405
+ res.send(buf);
1406
+ } catch (e) {
1407
+ res.status(500).json({ error: e?.message || String(e) });
1408
+ }
1409
+ });
1410
+ app.post("/api/agent-workspace-clear", requireLocal, (req, res) => {
1411
+ const safeId = typeof req.body?.workspaceId === "string" ? req.body.workspaceId.replace(/[^a-zA-Z0-9_-]/g, "").slice(0, 64) : "";
1412
+ if (!safeId) return res.status(400).json({ error: "Missing workspaceId" });
1413
+ const dir = import_path2.default.join(currentWorkspace, ".fanluc", "agents", safeId);
1414
+ try {
1415
+ import_fs2.default.rmSync(dir, { recursive: true, force: true });
1416
+ import_fs2.default.mkdirSync(dir, { recursive: true });
1417
+ res.json({ ok: true, formattedText: `Cleared workspace for agent ${safeId}.` });
1418
+ } catch (e) {
1419
+ res.status(500).json({ error: e?.message || String(e) });
1420
+ }
1421
+ });
1422
+ app.post("/api/install-pack", (_req, res) => {
1423
+ res.json({
1424
+ ok: true,
1425
+ formattedText: "[INSTALL_PACK_V1 STATUS: SUCCESS]\nThis build runs commands directly on the user's machine. There is nothing to install automatically: use run_cmd with the tool's own installer (pip install X, npm i X, winget/brew/apt) when something is missing."
1426
+ });
1427
+ });
1428
+ app.post("/api/upload-workspace-file", requireLocal, async (req, res) => {
1429
+ try {
1430
+ const { name, content, encoding = "utf-8" } = req.body || {};
1431
+ if (!name || typeof name !== "string") return res.status(400).json({ error: "Missing file name" });
1432
+ const filePath = resolveInWorkspace(name);
1433
+ if (!filePath || filePath === currentWorkspace) return res.status(403).json({ error: "Invalid file path outside workspace" });
1434
+ await import_fs2.default.promises.mkdir(import_path2.default.dirname(filePath), { recursive: true });
1435
+ let buffer;
1436
+ if ((encoding === "base64" || name.toLowerCase().endsWith(".zip")) && typeof content === "string") {
1437
+ buffer = Buffer.from(content.includes(",") ? content.split(",")[1] : content, "base64");
1438
+ } else if (typeof content === "string") {
1439
+ buffer = Buffer.from(content, "utf-8");
1440
+ } else {
1441
+ buffer = Buffer.from("");
1442
+ }
1443
+ await import_fs2.default.promises.writeFile(filePath, buffer);
1444
+ const st = await import_fs2.default.promises.stat(filePath);
1445
+ return res.json({ success: true, path: import_path2.default.relative(currentWorkspace, filePath).split(import_path2.default.sep).join("/"), name: import_path2.default.basename(filePath), size: st.size, updatedAt: st.mtimeMs });
1446
+ } catch (err) {
1447
+ return res.status(500).json({ error: err?.message || "Failed to upload workspace file" });
1448
+ }
1449
+ });
1450
+ app.post("/api/delete-workspace-file", requireLocal, async (req, res) => {
1451
+ try {
1452
+ const p = req.body?.path;
1453
+ if (!p || typeof p !== "string") return res.status(400).json({ error: "Missing file path" });
1454
+ const target = resolveInWorkspace(p);
1455
+ if (!target || target === currentWorkspace) return res.status(403).json({ error: "Invalid file path outside workspace" });
1456
+ if (import_fs2.default.existsSync(target)) await import_fs2.default.promises.rm(target, { recursive: true, force: true });
1457
+ return res.json({ success: true, path: p });
1458
+ } catch (err) {
1459
+ return res.status(500).json({ error: err?.message || "Failed to delete workspace file" });
1460
+ }
1461
+ });
1462
+ app.post("/api/workspace/upload-raw", requireLocal, async (req, res) => {
1463
+ try {
1464
+ const rel = String(req.query.path || "");
1465
+ if (!rel) return res.status(400).json({ error: "Missing path" });
1466
+ const target = resolveInWorkspace(rel);
1467
+ if (!target || target === currentWorkspace) return res.status(403).json({ error: "Invalid file path outside workspace" });
1468
+ await import_fs2.default.promises.mkdir(import_path2.default.dirname(target), { recursive: true });
1469
+ await (0, import_promises.pipeline)(req, import_fs2.default.createWriteStream(target));
1470
+ const st = await import_fs2.default.promises.stat(target);
1471
+ return res.json({ success: true, path: import_path2.default.relative(currentWorkspace, target).split(import_path2.default.sep).join("/"), size: st.size, updatedAt: st.mtimeMs });
1472
+ } catch (err) {
1473
+ return res.status(500).json({ error: err?.message || "Upload failed" });
1474
+ }
1475
+ });
1476
+ app.post("/api/workspace/reveal", requireLocal, async (req, res) => {
1477
+ try {
1478
+ const target = resolveInWorkspace(String(req.body?.path || ""));
1479
+ if (!target || !import_fs2.default.existsSync(target)) return res.status(404).json({ error: "Not found" });
1480
+ let bin;
1481
+ let args;
1482
+ if (process.platform === "win32") {
1483
+ bin = "explorer.exe";
1484
+ args = [`/select,${target}`];
1485
+ } else if (process.platform === "darwin") {
1486
+ bin = "open";
1487
+ args = ["-R", target];
1488
+ } else {
1489
+ bin = "xdg-open";
1490
+ args = [import_path2.default.dirname(target)];
1491
+ }
1492
+ const child = (0, import_child_process.spawn)(bin, args, { detached: true, stdio: "ignore", windowsHide: false });
1493
+ child.on("error", () => {
1494
+ });
1495
+ child.unref();
1496
+ return res.json({ ok: true });
1497
+ } catch (err) {
1498
+ return res.status(500).json({ error: err?.message || "Could not open folder" });
1499
+ }
1500
+ });
1501
+ app.get("/api/workspace/file", requireLocal, async (req, res) => {
1502
+ try {
1503
+ const target = resolveInWorkspace(String(req.query.path || ""));
1504
+ if (!target || target === currentWorkspace || !import_fs2.default.existsSync(target)) return res.status(404).json({ error: "Not found" });
1505
+ const st = await import_fs2.default.promises.stat(target);
1506
+ if (!st.isFile()) return res.status(400).json({ error: "Not a file" });
1507
+ res.setHeader("Content-Type", "application/octet-stream");
1508
+ res.setHeader("Content-Length", String(st.size));
1509
+ res.setHeader("Content-Disposition", `attachment; filename*=UTF-8''${encodeURIComponent(import_path2.default.basename(target))}`);
1510
+ import_fs2.default.createReadStream(target).on("error", () => res.destroy()).pipe(res);
1511
+ } catch (err) {
1512
+ return res.status(500).json({ error: err?.message || "Download failed" });
1513
+ }
1514
+ });
1515
+ app.get("/api/kv", requireLocal, (_req, res) => res.json({ data: kvLoad() }));
1516
+ app.post("/api/kv", requireLocal, (req, res) => {
1517
+ const { set, remove } = req.body || {};
1518
+ const data = kvLoad();
1519
+ if (set && typeof set === "object") {
1520
+ for (const [k, v] of Object.entries(set)) if (typeof v === "string") data[k] = v;
1521
+ }
1522
+ if (Array.isArray(remove)) for (const k of remove) delete data[String(k)];
1523
+ kvSave(data);
1524
+ return res.json({ ok: true });
1525
+ });
1526
+ app.get("/api/workspace-files", requireLocal, async (_req, res) => {
1527
+ try {
1528
+ const { files, truncated, count } = await listWorkspaceFiles(currentWorkspace);
1529
+ return res.json({ files, truncated, count, workspace: currentWorkspace });
1530
+ } catch (err) {
1531
+ return res.status(500).json({ error: err?.message || "Failed to list workspace files" });
1532
+ }
1533
+ });
1534
+ }
1535
+
1536
+ // localSearch.ts
1537
+ var UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36";
1538
+ function detectLocale() {
1539
+ const env = process.env.SEARCH_LANG;
1540
+ if (env) return env;
1541
+ try {
1542
+ return Intl.DateTimeFormat().resolvedOptions().locale || "en-US";
1543
+ } catch {
1544
+ return "en-US";
1545
+ }
1546
+ }
1547
+ var LOCALE = detectLocale();
1548
+ var LANG = LOCALE.split("-")[0] || "en";
1549
+ var REGION = (LOCALE.split("-")[1] || "US").toUpperCase();
1550
+ var ACCEPT_LANGUAGE = `${LOCALE},${LANG};q=0.9,en;q=0.8`;
1551
+ function decodeEntities(s) {
1552
+ return s.replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&#0?39;|&apos;/g, "'").replace(/&nbsp;/g, " ").replace(/&#x([0-9a-f]+);/gi, (_m, h) => String.fromCodePoint(parseInt(h, 16))).replace(/&#(\d+);/g, (_m, d) => String.fromCodePoint(parseInt(d, 10)));
1553
+ }
1554
+ function stripTags(s) {
1555
+ return decodeEntities(s.replace(/<[^>]*>/g, " ")).replace(/\s+/g, " ").trim();
1556
+ }
1557
+ async function getText(url, ms, headers = {}) {
1558
+ const resp = await fetch(url, {
1559
+ headers: { "User-Agent": UA, "Accept-Language": ACCEPT_LANGUAGE, ...headers },
1560
+ signal: AbortSignal.timeout(ms)
1561
+ });
1562
+ if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
1563
+ return await resp.text();
1564
+ }
1565
+ async function getJson(url, ms, headers = {}) {
1566
+ const resp = await fetch(url, {
1567
+ headers: { "User-Agent": UA, Accept: "application/json", "Accept-Language": ACCEPT_LANGUAGE, ...headers },
1568
+ signal: AbortSignal.timeout(ms)
1569
+ });
1570
+ if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
1571
+ return await resp.json();
1572
+ }
1573
+ var health = /* @__PURE__ */ new Map();
1574
+ function engineStatus() {
1575
+ const out = {};
1576
+ const now = Date.now();
1577
+ for (const [k, h] of health) out[k] = { ok: h.until <= now, fails: h.fails, retryInSec: Math.max(0, Math.round((h.until - now) / 1e3)), lastError: h.lastError };
1578
+ return out;
1579
+ }
1580
+ async function runEngine(name, fn, timeoutMs = 5e3) {
1581
+ const h = health.get(name);
1582
+ if (h && h.until > Date.now()) return [];
1583
+ try {
1584
+ const res = await Promise.race([
1585
+ fn(),
1586
+ new Promise((_r, rej) => setTimeout(() => rej(new Error(`timeout ${timeoutMs}ms`)), timeoutMs).unref?.())
1587
+ ]);
1588
+ if (res.length === 0) {
1589
+ const cur = health.get(name) || { fails: 0, until: 0 };
1590
+ cur.fails += 0.5;
1591
+ if (cur.fails >= 3) cur.until = Date.now() + 2 * 60 * 1e3;
1592
+ health.set(name, cur);
1593
+ } else {
1594
+ health.delete(name);
1595
+ }
1596
+ return res;
1597
+ } catch (e) {
1598
+ const cur = health.get(name) || { fails: 0, until: 0 };
1599
+ cur.fails += 1;
1600
+ cur.lastError = e?.message || String(e);
1601
+ if (cur.fails >= 2) cur.until = Date.now() + Math.min(30, 2 ** (cur.fails - 1)) * 60 * 1e3;
1602
+ health.set(name, cur);
1603
+ return [];
1604
+ }
1605
+ }
1606
+ var cache2 = /* @__PURE__ */ new Map();
1607
+ async function cached(key, ttlMs, fn, shouldCache = () => true) {
1608
+ const hit = cache2.get(key);
1609
+ if (hit && hit.exp > Date.now()) return hit.value;
1610
+ const value = await fn();
1611
+ if (shouldCache(value)) {
1612
+ cache2.set(key, { exp: Date.now() + ttlMs, value });
1613
+ if (cache2.size > 300) cache2.delete(cache2.keys().next().value);
1614
+ }
1615
+ return value;
1616
+ }
1617
+ function dedupeKey(url) {
1618
+ try {
1619
+ const u = new URL(url);
1620
+ return (u.hostname.replace(/^www\./, "") + u.pathname.replace(/\/$/, "")).toLowerCase();
1621
+ } catch {
1622
+ return url.toLowerCase();
1623
+ }
1624
+ }
1625
+ function fuseResults(lists) {
1626
+ const K = 60;
1627
+ const map = /* @__PURE__ */ new Map();
1628
+ for (const list of lists) {
1629
+ list.forEach((hit, i) => {
1630
+ const key = dedupeKey(hit.url);
1631
+ const add = 1 / (K + i + 1);
1632
+ const cur = map.get(key);
1633
+ if (cur) {
1634
+ cur.score += add;
1635
+ if ((hit.snippet || "").length > (cur.hit.snippet || "").length) cur.hit = { ...cur.hit, snippet: hit.snippet };
1636
+ cur.hit.engine += `+${hit.engine}`;
1637
+ } else map.set(key, { hit: { ...hit }, score: add });
1638
+ });
1639
+ }
1640
+ return [...map.values()].sort((a, b) => b.score - a.score).map((x) => x.hit);
1641
+ }
1642
+ async function searchMojeek(query, count) {
1643
+ const html = await getText(`https://www.mojeek.com/search?q=${encodeURIComponent(query.trim())}&lb=${LANG}`, 5e3);
1644
+ const out = [];
1645
+ const re = /<li[^>]*>\s*<h2>\s*<a[^>]+href="(https?:\/\/[^"]+)"[^>]*class="title"[^>]*>([\s\S]*?)<\/a>[\s\S]*?<\/li>/gi;
1646
+ const re2 = /<a[^>]+class="title"[^>]+href="(https?:\/\/[^"]+)"[^>]*>([\s\S]*?)<\/a>([\s\S]*?)(?=<a[^>]+class="title"|<\/ul>)/gi;
1647
+ let m;
1648
+ while ((m = re2.exec(html)) !== null && out.length < count * 2) {
1649
+ const title = stripTags(m[2]);
1650
+ const sn = m[3].match(/<p[^>]*class="s"[^>]*>([\s\S]*?)<\/p>/i);
1651
+ const snippet = sn ? stripTags(sn[1]) : title;
1652
+ if (title) out.push({ url: decodeEntities(m[1]), title, snippet, engine: "mojeek" });
1653
+ }
1654
+ void re;
1655
+ return out;
1656
+ }
1657
+ async function searchBingLocal(query, count) {
1658
+ const html = await getText(
1659
+ `https://www.bing.com/search?q=${encodeURIComponent(query.trim())}&count=${Math.min(30, count * 3)}&setlang=${encodeURIComponent(LOCALE)}&cc=${REGION}`,
1660
+ 5e3
1661
+ );
1662
+ if (/captcha|unusual traffic|verify you are a human|\/ck\/a\?!&&p=.*challenge/i.test(html) && !/class="b_algo"/.test(html)) throw new Error("bing captcha");
1663
+ const out = [];
1664
+ const re = /<li class="b_algo"[^>]*>[\s\S]*?<h2[^>]*>[\s\S]*?<a href="([^"]+)"[^>]*>([\s\S]*?)<\/a>[\s\S]*?<\/h2>([\s\S]*?)<\/li>/gi;
1665
+ let m;
1666
+ while ((m = re.exec(html)) !== null && out.length < count * 2) {
1667
+ const url = decodeEntities(m[1]);
1668
+ if (!url.startsWith("http")) continue;
1669
+ const title = stripTags(m[2]);
1670
+ const p = m[3].match(/<p[^>]*>([\s\S]*?)<\/p>/i) || m[3].match(/<div class="b_caption"[^>]*>([\s\S]*?)<\/div>/i);
1671
+ out.push({ url, title, snippet: p ? stripTags(p[1]) : title, engine: "bing" });
1672
+ }
1673
+ return out;
1674
+ }
1675
+ async function searchWikipedia(query, count) {
1676
+ const langs = LANG === "en" ? ["en"] : [LANG, "en"];
1677
+ const lists = await Promise.all(
1678
+ langs.map(async (lg) => {
1679
+ try {
1680
+ const j = await getJson(
1681
+ `https://${lg}.wikipedia.org/w/api.php?action=query&list=search&srsearch=${encodeURIComponent(query.trim())}&srlimit=${Math.min(5, count)}&format=json&utf8=1`,
1682
+ 4e3
1683
+ );
1684
+ return (j?.query?.search || []).map((r) => ({
1685
+ url: `https://${lg}.wikipedia.org/wiki/${encodeURIComponent(String(r.title).replace(/ /g, "_"))}`,
1686
+ title: String(r.title),
1687
+ snippet: stripTags(String(r.snippet || r.title)),
1688
+ engine: "wikipedia"
1689
+ }));
1690
+ } catch {
1691
+ return [];
1692
+ }
1693
+ })
1694
+ );
1695
+ return lists.flat().slice(0, count);
1696
+ }
1697
+ async function raceUntilEnough(tasks, enough, deadlineMs = 4500, graceMs = 350) {
1698
+ const collected = tasks.map(() => []);
1699
+ return new Promise((resolve) => {
1700
+ let done = false;
1701
+ let graceTimer = null;
1702
+ let pending2 = tasks.length;
1703
+ const finish = () => {
1704
+ if (done) return;
1705
+ done = true;
1706
+ clearTimeout(deadline);
1707
+ if (graceTimer) clearTimeout(graceTimer);
1708
+ resolve(collected.map((c) => c.slice()));
1709
+ };
1710
+ const deadline = setTimeout(finish, deadlineMs);
1711
+ tasks.forEach((t, i) => {
1712
+ t.then((r) => {
1713
+ collected[i] = r;
1714
+ }).catch(() => {
1715
+ }).finally(() => {
1716
+ pending2--;
1717
+ if (pending2 === 0) return finish();
1718
+ if (!done && !graceTimer && enough(collected)) graceTimer = setTimeout(finish, graceMs);
1719
+ });
1720
+ });
1721
+ if (tasks.length === 0) finish();
1722
+ });
1723
+ }
1724
+ var STOP = /* @__PURE__ */ new Set(["a", "an", "the", "of", "and", "or", "in", "on", "for", "to", "with", "image", "images", "photo", "photos", "picture", "pictures", "pic", "anh", "hinh"]);
1725
+ function norm(s) {
1726
+ return s.normalize("NFD").replace(/[\u0300-\u036f]/g, "").replace(/\u0111/g, "d").replace(/\u0110/g, "D").toLowerCase();
1727
+ }
1728
+ function matchesAllKeywords(query, text) {
1729
+ const toks = norm(query).split(/[^a-z0-9\u0080-\uffff]+/).filter((t) => t && !STOP.has(t));
1730
+ if (toks.length === 0) return false;
1731
+ const hay = norm(text);
1732
+ return toks.every((t) => {
1733
+ if (/[^\x00-\x7f]/.test(t)) return hay.includes(t);
1734
+ return new RegExp(`(^|[^a-z0-9])${t.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(s|es)?($|[^a-z0-9])`).test(hay);
1735
+ });
1736
+ }
1737
+ async function imagesSearxng(baseUrl, query, count) {
1738
+ if (!baseUrl) return [];
1739
+ const params = new URLSearchParams({ q: query.trim(), format: "json", categories: "images", pageno: "1" });
1740
+ const j = await getJson(`${baseUrl.replace(/\/$/, "")}/search?${params}`, 4e3);
1741
+ const out = [];
1742
+ for (const x of Array.isArray(j.results) ? j.results : []) {
1743
+ const src = String(x.img_src || "");
1744
+ if (!/^https?:\/\//i.test(src)) continue;
1745
+ out.push({ title: String(x.title || x.content || query), src, thumb: String(x.thumbnail_src || x.thumbnail || src), page: String(x.url || src), engine: "searxng" });
1746
+ if (out.length >= count * 2) break;
1747
+ }
1748
+ return out;
1749
+ }
1750
+ async function imagesBing(query, count) {
1751
+ const html = await getText(
1752
+ `https://www.bing.com/images/async?q=${encodeURIComponent(query.trim())}&first=0&count=${Math.min(35, count * 3)}&mmasync=1&setlang=${encodeURIComponent(LOCALE)}`,
1753
+ 6e3
1754
+ );
1755
+ const out = [];
1756
+ const tagRe = /<a\b[^>]*\bclass="[^"]*\biusc\b[^"]*"[^>]*>/gi;
1757
+ let t;
1758
+ while ((t = tagRe.exec(html)) !== null && out.length < count * 2) {
1759
+ const mm = t[0].match(/\bm="([^"]*)"/) || t[0].match(/\bm='([^']*)'/);
1760
+ if (!mm) continue;
1761
+ try {
1762
+ const j = JSON.parse(decodeEntities(mm[1]));
1763
+ const src = String(j.murl || "");
1764
+ if (!/^https?:\/\//i.test(src)) continue;
1765
+ out.push({ title: decodeEntities(String(j.t || j.desc || query)).replace(/<[^>]*>/g, ""), src, thumb: String(j.turl || src), page: String(j.purl || src), engine: "bing-img" });
1766
+ } catch {
1767
+ }
1768
+ }
1769
+ return out;
1770
+ }
1771
+ async function imagesWikimedia(query, count) {
1772
+ const params = new URLSearchParams({
1773
+ action: "query",
1774
+ format: "json",
1775
+ generator: "search",
1776
+ gsrnamespace: "6",
1777
+ gsrlimit: String(Math.min(20, count * 2)),
1778
+ gsrsearch: query.trim(),
1779
+ prop: "imageinfo",
1780
+ iiprop: "url|mime",
1781
+ iiurlwidth: "480",
1782
+ origin: "*"
1783
+ });
1784
+ const j = await getJson(`https://commons.wikimedia.org/w/api.php?${params}`, 5e3);
1785
+ const pages = Object.values(j?.query?.pages || {});
1786
+ pages.sort((a, b) => (a.index || 0) - (b.index || 0));
1787
+ const out = [];
1788
+ for (const p of pages) {
1789
+ const ii = p.imageinfo?.[0];
1790
+ if (!ii || !/^image\/(jpeg|png|webp|gif)$/.test(ii.mime || "")) continue;
1791
+ if (!matchesAllKeywords(query, String(p.title || "").replace(/^File:/, ""))) continue;
1792
+ out.push({ title: String(p.title || query).replace(/^File:/, ""), src: ii.url, thumb: ii.thumburl || ii.url, page: ii.descriptionurl || ii.url, engine: "wikimedia" });
1793
+ }
1794
+ return out;
1795
+ }
1796
+ async function imagesOpenverse(query, count) {
1797
+ const j = await getJson(`https://api.openverse.org/v1/images/?q=${encodeURIComponent(query.trim())}&page_size=${Math.min(20, count * 2)}`, 6e3);
1798
+ const out = [];
1799
+ for (const x of Array.isArray(j.results) ? j.results : []) {
1800
+ const src = String(x.url || "");
1801
+ if (!/^https?:\/\//i.test(src)) continue;
1802
+ const text = `${x.title || ""} ${(Array.isArray(x.tags) ? x.tags : []).map((t) => t?.name || "").join(" ")}`;
1803
+ if (!matchesAllKeywords(query, text)) continue;
1804
+ out.push({ title: String(x.title || query), src, thumb: String(x.thumbnail || src), page: String(x.foreign_landing_url || src), engine: "openverse" });
1805
+ }
1806
+ return out;
1807
+ }
1808
+ async function searchImages(query, count, searxUrl = "") {
1809
+ const key = `img:${LOCALE}:${searxUrl}:${query.toLowerCase().trim()}:${count}`;
1810
+ return cached(
1811
+ key,
1812
+ 10 * 60 * 1e3,
1813
+ async () => {
1814
+ const seen = /* @__PURE__ */ new Set();
1815
+ const merged = [];
1816
+ const push = (list) => {
1817
+ for (const h of list) {
1818
+ const k = h.src.split("?")[0].toLowerCase();
1819
+ if (seen.has(k)) continue;
1820
+ seen.add(k);
1821
+ merged.push(h);
1822
+ }
1823
+ };
1824
+ const lists = await raceUntilEnough(
1825
+ [
1826
+ searxUrl ? runEngine("img:searxng", () => imagesSearxng(searxUrl, query, count), 4e3) : Promise.resolve([]),
1827
+ runEngine("img:bing", () => imagesBing(query, count), 4e3),
1828
+ runEngine("img:wikimedia", () => imagesWikimedia(query, count), 4e3),
1829
+ runEngine("img:openverse", () => imagesOpenverse(query, count), 4e3)
1830
+ ],
1831
+ (c) => c.reduce((n, a) => n + a.length, 0) >= count,
1832
+ 4500
1833
+ );
1834
+ const main = [lists[0], lists[1]];
1835
+ const maxLen = Math.max(...main.map((a) => a.length), 0);
1836
+ for (let i = 0; i < maxLen; i++) for (const a of main) if (a[i]) push([a[i]]);
1837
+ push(lists[2]);
1838
+ push(lists[3]);
1839
+ return merged.slice(0, count);
1840
+ },
1841
+ (v) => v.length > 0
1842
+ );
411
1843
  }
1844
+
1845
+ // server.ts
1846
+ var import_stdio = require("@modelcontextprotocol/sdk/client/stdio.js");
1847
+ var import_streamableHttp = require("@modelcontextprotocol/sdk/client/streamableHttp.js");
1848
+ var import_sse = require("@modelcontextprotocol/sdk/client/sse.js");
1849
+ import_dotenv.default.config();
412
1850
  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
1851
  var BLOCKED_HOST_SUFFIXES = [
414
1852
  "facebook.com",
@@ -544,68 +1982,6 @@ function stripInnerTags(str) {
544
1982
  if (!str) return "";
545
1983
  return str.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
546
1984
  }
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
1985
  async function searchYahooJP(query, count) {
610
1986
  const results = [];
611
1987
  const q = encodeURIComponent(query.trim());
@@ -614,28 +1990,33 @@ async function searchYahooJP(query, count) {
614
1990
  headers: {
615
1991
  "User-Agent": DESKTOP_UA,
616
1992
  "Accept-Language": "en-US,en;q=0.9,vi;q=0.8"
617
- }
1993
+ },
1994
+ signal: AbortSignal.timeout(5e3)
618
1995
  });
619
1996
  if (response.ok) {
620
1997
  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;
1998
+ const cardRegex = /<a\s+[^>]*href="([^"]+)"[^>]*>[\s\S]*?<h3[^>]*>([\s\S]*?)<\/h3>[\s\S]*?(?:<(?:p|div)[^>]*class="[^"]*(?:summary|Snippet|desc|text)[^"]*"[^>]*>([\s\S]*?)<\/(?:p|div)>)?/gi;
622
1999
  let match;
623
- while ((match = sectionRegex.exec(html)) !== null && results.length < count) {
2000
+ const seen = /* @__PURE__ */ new Set();
2001
+ while ((match = cardRegex.exec(html)) !== null && results.length < count) {
624
2002
  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;
2003
+ if (!url || !url.startsWith("http") || url.includes("search.yahoo.co.jp") || url === "https://www.yahoo.co.jp" || url === "https://www.yahoo.co.jp/") continue;
2004
+ if (seen.has(url)) continue;
2005
+ seen.add(url);
2006
+ const title = decodeHtmlEntities(stripInnerTags(match[2]));
2007
+ const snippet = match[3] ? decodeHtmlEntities(stripInnerTags(match[3])) : title;
2008
+ if (title) {
628
2009
  results.push({ url, title, snippet, engine: "yahoo" });
629
2010
  }
630
2011
  }
631
2012
  }
632
- } catch (err) {
633
- console.warn("Yahoo search primary endpoint error:", err);
2013
+ } catch (_err) {
634
2014
  }
635
2015
  if (results.length === 0) {
636
2016
  try {
637
2017
  const newsRes = await fetch(`https://news.search.yahoo.com/search?p=${q}`, {
638
- headers: { "User-Agent": DESKTOP_UA }
2018
+ headers: { "User-Agent": DESKTOP_UA },
2019
+ signal: AbortSignal.timeout(5e3)
639
2020
  });
640
2021
  if (newsRes.ok) {
641
2022
  const newsHtml = await newsRes.text();
@@ -653,22 +2034,11 @@ async function searchYahooJP(query, count) {
653
2034
  }
654
2035
  }
655
2036
  }
656
- } catch (err) {
657
- console.warn("Yahoo news fallback error:", err);
2037
+ } catch (_err) {
658
2038
  }
659
2039
  }
660
2040
  return results;
661
2041
  }
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
2042
  function normalizeForDedupe(url) {
673
2043
  try {
674
2044
  const u = new URL(url);
@@ -691,12 +2061,12 @@ function mergeAndFilterResults(all, count) {
691
2061
  }
692
2062
  if (isBlockedHost(hostname)) continue;
693
2063
  if (looksLikeNonHtmlFile(r.url)) continue;
694
- const dedupeKey = normalizeForDedupe(r.url);
695
- if (seen.has(dedupeKey)) continue;
2064
+ const dedupeKey2 = normalizeForDedupe(r.url);
2065
+ if (seen.has(dedupeKey2)) continue;
696
2066
  const domainKey = hostname.replace(/^www\./, "");
697
2067
  const domainCount = perDomainCount.get(domainKey) || 0;
698
2068
  if (domainCount >= 2) continue;
699
- seen.add(dedupeKey);
2069
+ seen.add(dedupeKey2);
700
2070
  perDomainCount.set(domainKey, domainCount + 1);
701
2071
  filtered.push({ ...r, url: applyDomainRewrite(r.url), score: reputationScore(hostname) });
702
2072
  }
@@ -706,7 +2076,106 @@ function mergeAndFilterResults(all, count) {
706
2076
  snippet: r.snippet && r.snippet.trim() || r.title || r.url
707
2077
  }));
708
2078
  }
709
- var MAX_FETCH_BYTES = 6 * 1024 * 1024;
2079
+ var MAX_FETCH_BYTES = Number(process.env.FANLUC_MAX_FETCH_MB) > 0 ? Number(process.env.FANLUC_MAX_FETCH_MB) * 1024 * 1024 : 30 * 1024 * 1024;
2080
+ var DOCS_VIRTUAL_HOST = "fanluc.docs";
2081
+ var DOCS_VIRTUAL_URL = `https://${DOCS_VIRTUAL_HOST}/`;
2082
+ var MONDK_MSH_VIRTUAL_HOST = "mondk-msh.docs";
2083
+ var MONDK_MSH_VIRTUAL_URL = `https://${MONDK_MSH_VIRTUAL_HOST}/`;
2084
+ var DOCS_MAX_CHARS = 4e4;
2085
+ var VIRTUAL_DOCS_SITES = [
2086
+ {
2087
+ host: DOCS_VIRTUAL_HOST,
2088
+ url: DOCS_VIRTUAL_URL,
2089
+ trigger: /fanluc/i,
2090
+ snippet: "Official FANLUC docs (detailed interface/UI description, creator mondk, msh, connection guide) - fetch this URL to read them in full."
2091
+ },
2092
+ {
2093
+ host: MONDK_MSH_VIRTUAL_HOST,
2094
+ url: MONDK_MSH_VIRTUAL_URL,
2095
+ trigger: /\b(?:mondk|msh)\b/i,
2096
+ snippet: "Official docs about the author mondk and the msh project (docs/mondk.md and docs/msh.md) - fetch this URL to read them in full."
2097
+ }
2098
+ ];
2099
+ function injectDocsResult(query, results, maxCount) {
2100
+ const sites = VIRTUAL_DOCS_SITES.filter((site) => site.trigger.test(query || ""));
2101
+ if (sites.length === 0) return results;
2102
+ const rest = results.filter((r) => !sites.some((site) => r.url.toLowerCase().includes(site.host)));
2103
+ const keepCount = Math.max(1, maxCount - sites.length);
2104
+ const kept = rest.length > keepCount && maxCount > sites.length ? rest.slice(0, keepCount) : rest;
2105
+ return [...sites.map((site) => ({ url: site.url, snippet: site.snippet })), ...kept];
2106
+ }
2107
+ async function readDocsFile(name) {
2108
+ const docsDir = import_path3.default.join(process.cwd(), "docs");
2109
+ let files = [];
2110
+ try {
2111
+ files = (await import_fs3.default.promises.readdir(docsDir)).filter((f) => /\.(md|txt)$/i.test(f));
2112
+ } catch {
2113
+ return null;
2114
+ }
2115
+ const file = files.find((f) => f.toLowerCase() === name || f.toLowerCase() === `${name}.md`) || files.find((f) => f.toLowerCase().includes(name));
2116
+ if (!file) return null;
2117
+ return { file, text: await import_fs3.default.promises.readFile(import_path3.default.join(docsDir, file), "utf-8") };
2118
+ }
2119
+ async function readMondkMshPage(pathname) {
2120
+ let name = "";
2121
+ try {
2122
+ name = decodeURIComponent(pathname || "");
2123
+ } catch {
2124
+ name = pathname || "";
2125
+ }
2126
+ name = name.replace(/^\/+|\/+$/g, "").toLowerCase().replace(/\.md$/, "");
2127
+ const pages = ["mondk", "msh"];
2128
+ if (name && name !== "index") {
2129
+ if (!pages.includes(name)) {
2130
+ return {
2131
+ title: "Not found",
2132
+ text: `Page '${name}' not found. Available: ${pages.map((n) => MONDK_MSH_VIRTUAL_URL + n).join(", ")}`
2133
+ };
2134
+ }
2135
+ const doc = await readDocsFile(name);
2136
+ if (!doc) return { title: "Not found", text: `docs/${name}.md was not found on this server.` };
2137
+ return { title: doc.file, text: doc.text.slice(0, DOCS_MAX_CHARS) };
2138
+ }
2139
+ const parts = [];
2140
+ for (const n of pages) {
2141
+ const doc = await readDocsFile(n);
2142
+ parts.push(`=== docs/${doc ? doc.file : n + ".md"} ===
2143
+ ${doc ? doc.text.trim() : "(file not found)"}`);
2144
+ }
2145
+ return { title: "MONDK and MSH docs", text: parts.join("\n\n").slice(0, DOCS_MAX_CHARS) };
2146
+ }
2147
+ async function readVirtualDocsPage(pathname) {
2148
+ const docsDir = import_path3.default.join(process.cwd(), "docs");
2149
+ let files = [];
2150
+ try {
2151
+ files = (await import_fs3.default.promises.readdir(docsDir)).filter((f) => /\.(md|txt)$/i.test(f));
2152
+ } catch {
2153
+ }
2154
+ let name = "";
2155
+ try {
2156
+ name = decodeURIComponent(pathname || "");
2157
+ } catch {
2158
+ name = pathname || "";
2159
+ }
2160
+ name = name.replace(/^\/+|\/+$/g, "").toLowerCase().replace(/\.md$/, "");
2161
+ const find = (n) => files.find((f) => f.toLowerCase() === n || f.toLowerCase() === `${n}.md`) || files.find((f) => f.toLowerCase().includes(n));
2162
+ const read = (f) => import_fs3.default.promises.readFile(import_path3.default.join(docsDir, f), "utf-8");
2163
+ if (!name || name === "index") {
2164
+ const ui = find("fanluc");
2165
+ const pages = ["fanluc", "guide", "mondk", "msh"].filter((n) => find(n));
2166
+ const index = "FANLUC docs index:\n" + pages.map((n) => `- ${DOCS_VIRTUAL_URL}${n === "fanluc" ? "" : n}`).join("\n") + "\n\n";
2167
+ const body = ui ? await read(ui) : "(fanluc ui doc not found)";
2168
+ return { title: "FANLUC docs", text: (index + body).slice(0, DOCS_MAX_CHARS) };
2169
+ }
2170
+ const file = find(name);
2171
+ if (!file) {
2172
+ return {
2173
+ title: "Not found",
2174
+ text: `Page '${name}' not found. Available: ${["fanluc", "guide", "mondk", "msh"].map((n) => DOCS_VIRTUAL_URL + (n === "fanluc" ? "" : n)).join(", ")}`
2175
+ };
2176
+ }
2177
+ return { title: file, text: (await read(file)).slice(0, DOCS_MAX_CHARS) };
2178
+ }
710
2179
  async function fetchWithByteLimit(url, signal, maxBytes) {
711
2180
  const response = await fetch(url, {
712
2181
  signal,
@@ -765,20 +2234,19 @@ function extractReadableContent(html, url) {
765
2234
  var SEARXNG_URL = (process.env.SEARXNG_URL || process.env.SEARXNG || "").replace(/\/$/, "");
766
2235
  async function searchSearXNG(query, count, categories = "general", baseUrl = SEARXNG_URL) {
767
2236
  const results = [];
768
- if (!baseUrl) return results;
2237
+ if (!baseUrl || !baseUrl.startsWith("http")) return results;
769
2238
  try {
770
2239
  const params = new URLSearchParams({ q: query.trim(), format: "json", categories, pageno: "1" });
771
2240
  const url = `${baseUrl}/search?${params.toString()}`;
772
2241
  const resp = await fetch(url, {
773
- signal: AbortSignal.timeout(6e3),
774
2242
  headers: {
775
2243
  "User-Agent": DESKTOP_UA,
776
2244
  "Accept": "application/json",
777
2245
  "Accept-Language": "en-US,en;q=0.9,vi;q=0.8"
778
- }
2246
+ },
2247
+ signal: AbortSignal.timeout(3e3)
779
2248
  });
780
2249
  if (!resp.ok) {
781
- console.warn(`SearXNG search failed ${resp.status} for "${query}"`);
782
2250
  return results;
783
2251
  }
784
2252
  const j = await resp.json();
@@ -807,8 +2275,7 @@ async function searchSearXNG(query, count, categories = "general", baseUrl = SEA
807
2275
  results.push({ url: url2, title, snippet, engine: "searxng" });
808
2276
  }
809
2277
  }
810
- } catch (err) {
811
- console.warn("SearXNG search error:", err);
2278
+ } catch (_err) {
812
2279
  }
813
2280
  return results;
814
2281
  }
@@ -824,10 +2291,10 @@ function isPrivateIp(ip) {
824
2291
  async function assertPublicUrl(raw) {
825
2292
  const u = new URL(raw);
826
2293
  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;
2294
+ if (process.env.ALLOW_PRIVATE_URLS !== "0") return u;
828
2295
  const host = u.hostname.replace(/^\[|\]$/g, "");
829
2296
  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 });
2297
+ const addrs = import_node_net.default.isIP(host) ? [{ address: host }] : await import_promises2.default.lookup(host, { all: true });
831
2298
  for (const a of addrs) if (isPrivateIp(a.address)) throw new Error("Private/local hosts are not allowed");
832
2299
  return u;
833
2300
  }
@@ -853,7 +2320,11 @@ function mcpAdminGuard(req, res, next) {
853
2320
  }
854
2321
  function resolveEnvVars(str) {
855
2322
  if (typeof str !== "string") return str;
856
- return str.replace(/\$\{([^}]+)\}/g, (_, name) => process.env[name.trim()] ?? "");
2323
+ return str.replace(/\$\{([^}]+)\}/g, (_, name) => {
2324
+ const n = String(name).trim();
2325
+ if (n === "WORKSPACE") return getWorkspace();
2326
+ return process.env[n] ?? "";
2327
+ });
857
2328
  }
858
2329
  function resolveEnvObject(obj) {
859
2330
  if (!obj || typeof obj !== "object") return obj;
@@ -879,28 +2350,66 @@ async function connectMcp(name, cfg) {
879
2350
  const entry = { client: null, tools: [], status: "connecting", config: cfg, type };
880
2351
  mcp.set(name, entry);
881
2352
  try {
882
- let transport;
2353
+ let client;
883
2354
  if (type === "http") {
884
2355
  const rawUrl = cfg.url;
885
2356
  if (!rawUrl) throw new Error("Missing url for http/remote MCP server");
886
- const resolvedUrl = resolveEnvVars(String(rawUrl));
2357
+ const resolvedUrl = new URL(resolveEnvVars(String(rawUrl)));
887
2358
  const headers = resolveEnvObject(cfg.headers) || {};
888
- transport = new import_streamableHttp.StreamableHTTPClientTransport(new URL(resolvedUrl), { requestInit: { headers } });
2359
+ const mk = () => new import_client.Client({ name: "fanluc", version: "1.0.0" }, { capabilities: {} });
2360
+ try {
2361
+ client = mk();
2362
+ await client.connect(new import_streamableHttp.StreamableHTTPClientTransport(resolvedUrl, { requestInit: { headers } }), { timeout: 3e4 });
2363
+ } catch (e1) {
2364
+ try {
2365
+ client = mk();
2366
+ await client.connect(new import_sse.SSEClientTransport(resolvedUrl, { requestInit: { headers } }), { timeout: 3e4 });
2367
+ } catch {
2368
+ throw e1;
2369
+ }
2370
+ }
889
2371
  } else {
890
2372
  let command = cfg.command;
891
2373
  let args = Array.isArray(cfg.args) ? cfg.args : [];
892
- let env = resolveEnvObject(cfg.env || cfg.environment);
2374
+ const userEnv = resolveEnvObject(cfg.env || cfg.environment) || {};
893
2375
  if (Array.isArray(command)) {
894
2376
  args = command.slice(1);
895
2377
  command = command[0];
896
2378
  }
897
2379
  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 });
2380
+ let resolvedCommand = resolveEnvVars(String(command)).trim();
2381
+ let resolvedArgs = args.map((a) => resolveEnvVars(String(a)));
2382
+ if (!resolvedArgs.length && /\s/.test(resolvedCommand)) {
2383
+ const parts = resolvedCommand.split(/\s+/);
2384
+ resolvedCommand = parts[0];
2385
+ resolvedArgs = parts.slice(1);
2386
+ }
2387
+ const baseEnv = {};
2388
+ for (const [k, v] of Object.entries(process.env)) {
2389
+ if (typeof v === "string" && !["GEMINI_API_KEY", "TOKEN", "ADMIN_TOKEN", "ACCESS_TOKEN"].includes(k)) baseEnv[k] = v;
2390
+ }
2391
+ const cwd = cfg.cwd ? resolveEnvVars(String(cfg.cwd)) : getWorkspace();
2392
+ const transport = new import_stdio.StdioClientTransport({
2393
+ command: resolvedCommand,
2394
+ args: resolvedArgs,
2395
+ env: { ...baseEnv, ...userEnv },
2396
+ cwd,
2397
+ stderr: "pipe"
2398
+ });
2399
+ let errTail = "";
2400
+ transport.stderr?.on?.("data", (d) => {
2401
+ errTail = (errTail + d.toString()).slice(-1500);
2402
+ });
2403
+ client = new import_client.Client({ name: "fanluc", version: "1.0.0" }, { capabilities: {} });
2404
+ try {
2405
+ await client.connect(transport, { timeout: 12e4 });
2406
+ } catch (e) {
2407
+ const tail = errTail.trim();
2408
+ throw new Error(`${e?.message || e}${tail ? `
2409
+ --- server stderr ---
2410
+ ${tail}` : ""}`);
2411
+ }
901
2412
  }
902
- const client = new import_client.Client({ name: "fanluc", version: "1.0.0" }, { capabilities: {} });
903
- await client.connect(transport);
904
2413
  const toolsRes = await client.listTools();
905
2414
  const tools = Array.isArray(toolsRes?.tools) ? toolsRes.tools : [];
906
2415
  const truncated = tools.map((t) => ({
@@ -915,45 +2424,15 @@ async function connectMcp(name, cfg) {
915
2424
  mcp.set(name, { client: null, tools: [], status: "failed", error: msg, config: cfg, type });
916
2425
  }
917
2426
  }
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
2427
  function loadMcpConfigSync() {
948
- const configDir = getFanlucConfigDir();
949
2428
  const candidates = [
950
- import_path.default.join(configDir, "mcp.json"),
951
- import_path.default.join(configDir, "mcp.jsonc")
2429
+ import_path3.default.join(process.cwd(), "mcp.json"),
2430
+ import_path3.default.join(process.cwd(), "mcp.jsonc")
952
2431
  ];
953
2432
  for (const p of candidates) {
954
2433
  try {
955
- if (import_fs.default.existsSync(p)) {
956
- const raw = import_fs.default.readFileSync(p, "utf-8").replace(/^\uFEFF/, "");
2434
+ if (import_fs3.default.existsSync(p)) {
2435
+ const raw = import_fs3.default.readFileSync(p, "utf-8");
957
2436
  const cleaned = stripJsonc(raw);
958
2437
  const j = JSON.parse(cleaned);
959
2438
  if (j.mcpServers && typeof j.mcpServers === "object") return j.mcpServers;
@@ -981,9 +2460,7 @@ async function initMcpFromFile() {
981
2460
  return;
982
2461
  }
983
2462
  console.log(`[MCP] loading ${entries.length} server(s) from mcp.json`);
984
- for (const [name, cfg] of entries) {
985
- await connectMcp(name, cfg);
986
- }
2463
+ await Promise.all(entries.map(([name, cfg]) => connectMcp(name, cfg)));
987
2464
  }
988
2465
  function getMcpToolsForModel() {
989
2466
  const out = [];
@@ -1003,18 +2480,26 @@ function getMcpToolsForModel() {
1003
2480
  }
1004
2481
  async function startServer() {
1005
2482
  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
- });
2483
+ const portArgIdx = process.argv.indexOf("--port");
2484
+ const cliPort = portArgIdx !== -1 && process.argv[portArgIdx + 1] ? Number(process.argv[portArgIdx + 1]) : null;
2485
+ const PORT = cliPort || (process.env.APP_PORT ? Number(process.env.APP_PORT) : 3e3);
2486
+ app.use(import_express.default.json({ limit: "2gb" }));
2487
+ app.use("/api", (req, res, next) => req.path === "/health" ? next() : requireLocal(req, res, next));
2488
+ onWorkspaceChange(() => {
2489
+ for (const [name, entry] of mcp.entries()) {
2490
+ if (entry.status === "disabled") continue;
2491
+ if (/colab-mcp|StudioMCP|Roblox.mcp\.bat/i.test(JSON.stringify(entry.config || {}))) continue;
2492
+ if (JSON.stringify(entry.config || {}).includes("${WORKSPACE}") || entry.type === "stdio" && !entry.config?.cwd) {
2493
+ const old = entry.client;
2494
+ connectMcp(name, entry.config).finally(() => {
2495
+ try {
2496
+ old?.close();
2497
+ } catch {
2498
+ }
2499
+ });
2500
+ }
1014
2501
  }
1015
- }
1016
- const PORT = Number(process.env.PORT) || 3e3;
1017
- app.use(import_express.default.json({ limit: "50mb" }));
2502
+ });
1018
2503
  initMcpFromFile().catch((e) => console.warn("[MCP] init failed:", e));
1019
2504
  app.post("/api/proxy-chat", async (req, res) => {
1020
2505
  try {
@@ -1025,7 +2510,7 @@ async function startServer() {
1025
2510
  const headers = {
1026
2511
  "Content-Type": "application/json",
1027
2512
  "ngrok-skip-browser-warning": "true",
1028
- "User-Agent": "AI-Studio-Rest-Client/1.0",
2513
+ "User-Agent": "fanluc/2.4.0",
1029
2514
  "Authorization": `Bearer ${apiKey || ""}`,
1030
2515
  ...customHeaders || {}
1031
2516
  };
@@ -1084,50 +2569,45 @@ async function startServer() {
1084
2569
  return res.status(400).json({ error: "Missing query parameter" });
1085
2570
  }
1086
2571
  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:
2572
+ const searxUrl = await resolveSearxUrl(req);
2573
+ const key = `web:${LOCALE}:${searxUrl}:${query.toLowerCase().trim()}:${maxCount}`;
2574
+ const payload = await cached(
2575
+ key,
2576
+ 10 * 60 * 1e3,
2577
+ async () => {
2578
+ const per = Math.min(20, maxCount + 6);
2579
+ const lists = await raceUntilEnough(
2580
+ [
2581
+ searxUrl ? runEngine("searxng", () => searchSearXNG(query, per, "general", searxUrl), 4e3) : Promise.resolve([]),
2582
+ runEngine("bing", () => searchBingLocal(query, per), 4e3),
2583
+ runEngine("mojeek", () => searchMojeek(query, per), 4e3),
2584
+ runEngine("wikipedia", () => searchWikipedia(query, 5), 4e3),
2585
+ runEngine("yahoo", () => searchYahooJP(query, per), 4e3)
2586
+ ],
2587
+ (c) => c.reduce((n, a) => n + a.length, 0) >= maxCount,
2588
+ 4500
2589
+ );
2590
+ const fused = fuseResults(lists);
2591
+ let results = mergeAndFilterResults(fused, maxCount);
2592
+ if (results.length === 0) results = fused.slice(0, maxCount).map((r) => ({ url: r.url, snippet: r.snippet || r.title || r.url }));
2593
+ results = injectDocsResult(query, results, maxCount);
2594
+ const names = ["searxng", "bing", "mojeek", "wikipedia", "yahoo"].filter((_, i) => lists[i].length > 0);
2595
+ const formattedText = results.length > 0 ? `${results.length} results:
1118
2596
  ` + results.map((r) => `${r.url}: ${r.snippet}`).join("\n") : `0 results:
1119
2597
  (no results found for "${query}")`;
1120
- return res.json({
1121
- count: results.length,
1122
- results,
1123
- formattedText,
1124
- engine: results.length ? "fallback" : "none"
1125
- });
2598
+ return { count: results.length, results, formattedText, engine: names.join("+") || "none" };
2599
+ },
2600
+ (v) => v.count > 0
2601
+ );
2602
+ return res.json(payload);
1126
2603
  } catch (error) {
1127
2604
  console.error("Web search error:", error);
1128
2605
  return res.status(500).json({ error: error?.message || "Web search failed" });
1129
2606
  }
1130
2607
  });
2608
+ app.get("/api/search-status", (_req, res) => {
2609
+ res.json({ locale: LOCALE, searxng: SEARXNG_URL || null, engines: engineStatus() });
2610
+ });
1131
2611
  app.post("/api/searxng-search", async (req, res) => {
1132
2612
  try {
1133
2613
  const { query, count = 7, categories = "general" } = req.body;
@@ -1162,20 +2642,9 @@ async function startServer() {
1162
2642
  const { query, count = 6 } = req.body;
1163
2643
  if (!query || typeof query !== "string") return res.status(400).json({ error: "Missing query" });
1164
2644
  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}"`;
2645
+ const hits = await searchImages(query, maxCount, await resolveSearxUrl(req));
2646
+ const images = hits.map((h) => ({ title: h.title || query, src: h.src, thumb: h.thumb, page: h.page }));
2647
+ const formattedText = images.length ? `Showing ${images.length} images for "${query}"` : `No images found for "${query}"`;
1179
2648
  return res.json({ count: images.length, images, formattedText });
1180
2649
  } catch (e) {
1181
2650
  console.error("Image search error:", e);
@@ -1193,7 +2662,7 @@ async function startServer() {
1193
2662
  for (let hop = 0; hop < 4; hop++) {
1194
2663
  const h = new URL(cur).hostname;
1195
2664
  if (isBlockedHost(h)) return res.status(403).send("Blocked host");
1196
- resp = await fetch(cur, { redirect: "manual", headers: { "User-Agent": DESKTOP_UA, "Accept": "image/*,*/*" } });
2665
+ resp = await fetch(cur, { redirect: "manual", headers: { "User-Agent": DESKTOP_UA, "Accept": "image/*,*/*", "Referer": new URL(cur).origin + "/" } });
1197
2666
  const loc = resp.headers.get("location");
1198
2667
  if (resp.status >= 300 && resp.status < 400 && loc) {
1199
2668
  cur = new URL(loc, cur).toString();
@@ -1234,7 +2703,11 @@ async function startServer() {
1234
2703
  // connected / failed / disabled / connecting
1235
2704
  tools: v.tools,
1236
2705
  toolCount: v.tools.length,
1237
- error: v.error || null
2706
+ error: v.error || null,
2707
+ command: v.config?.command || "",
2708
+ args: Array.isArray(v.config?.args) ? v.config.args.join(" ") : v.config?.args || "",
2709
+ url: v.config?.url || "",
2710
+ headers: v.config?.headers || void 0
1238
2711
  }));
1239
2712
  const modelTools = getMcpToolsForModel();
1240
2713
  res.json({ servers: list, modelTools, count: list.length });
@@ -1252,7 +2725,19 @@ async function startServer() {
1252
2725
  if (!entry) return res.status(404).json({ error: `MCP server "${server}" not found` });
1253
2726
  if (entry.status !== "connected" || !entry.client) return res.status(400).json({ error: `MCP server "${server}" not connected (${entry.status})${entry.error ? ": " + entry.error : ""}` });
1254
2727
  const actualTool = toolName.includes("_") && toolName.startsWith(server + "_") ? toolName.slice(server.length + 1) : toolName;
1255
- const result = await entry.client.callTool({ name: actualTool, arguments: toolArgs });
2728
+ const callOpts = { timeout: 18e4 };
2729
+ let result;
2730
+ try {
2731
+ result = await entry.client.callTool({ name: actualTool, arguments: toolArgs }, void 0, callOpts);
2732
+ } catch (err) {
2733
+ if (/closed|not connected|EPIPE|terminated|ECONNRESET|fetch failed/i.test(String(err?.message || err))) {
2734
+ await connectMcp(server, entry.config);
2735
+ const again = mcp.get(server);
2736
+ if (again?.status === "connected" && again.client) {
2737
+ result = await again.client.callTool({ name: actualTool, arguments: toolArgs }, void 0, callOpts);
2738
+ } else throw err;
2739
+ } else throw err;
2740
+ }
1256
2741
  return res.json(result);
1257
2742
  } catch (e) {
1258
2743
  console.error("MCP call error:", e);
@@ -1355,17 +2840,6 @@ async function startServer() {
1355
2840
  }
1356
2841
  mcp.set(server, { client: null, tools: [], status: "disabled", config: newCfg, type: normalizeMcpType(newCfg.type) });
1357
2842
  }
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
2843
  const entry = mcp.get(server);
1370
2844
  return res.json({ name: server, status: entry.status, enabled: newEnabled });
1371
2845
  } catch (e) {
@@ -1390,16 +2864,23 @@ async function startServer() {
1390
2864
  else if (typeof args === "string" && args.trim()) newCfg.args = String(args).trim().split(/\s+/);
1391
2865
  if (env && typeof env === "object") newCfg.env = env;
1392
2866
  }
1393
- const mcpPath = mcpConfigPath();
2867
+ const mcpPath = import_path3.default.join(process.cwd(), "mcp.json");
1394
2868
  let existing = {};
1395
2869
  try {
1396
- existing = readMcpServersFile(mcpPath);
2870
+ if (import_fs3.default.existsSync(mcpPath)) {
2871
+ const raw = import_fs3.default.readFileSync(mcpPath, "utf-8");
2872
+ const j = JSON.parse(stripJsonc(raw));
2873
+ if (j.mcpServers) existing = j.mcpServers;
2874
+ else if (j.mcp && j.mcp.servers) existing = j.mcp.servers;
2875
+ else if (j.mcp && typeof j.mcp === "object") existing = j.mcp;
2876
+ else existing = j;
2877
+ }
1397
2878
  } catch (e) {
1398
2879
  return res.status(500).json({ error: "mcp.json is invalid, refusing to overwrite: " + (e?.message || e) });
1399
2880
  }
1400
2881
  existing[cleanName] = newCfg;
1401
2882
  const toWrite = { mcpServers: existing };
1402
- import_fs.default.writeFileSync(mcpPath, JSON.stringify(toWrite, null, 2));
2883
+ import_fs3.default.writeFileSync(mcpPath, JSON.stringify(toWrite, null, 2));
1403
2884
  await connectMcp(cleanName, newCfg);
1404
2885
  const entry = mcp.get(cleanName);
1405
2886
  return res.json({ name: cleanName, status: entry.status, toolCount: entry.tools.length, error: entry.error || null });
@@ -1407,6 +2888,93 @@ async function startServer() {
1407
2888
  return res.status(500).json({ error: e?.message });
1408
2889
  }
1409
2890
  });
2891
+ app.post("/api/mcp/edit", mcpAdminGuard, async (req, res) => {
2892
+ try {
2893
+ const { oldName, name, type, command, args, url, headers, env, enabled = true } = req.body || {};
2894
+ if (!oldName || typeof oldName !== "string") return res.status(400).json({ error: "Missing oldName" });
2895
+ if (!name || typeof name !== "string") return res.status(400).json({ error: "Missing name" });
2896
+ const cleanOldName = oldName.trim();
2897
+ const cleanName = name.trim().replace(/[^a-zA-Z0-9_-]/g, "_");
2898
+ if (!cleanName) return res.status(400).json({ error: "Invalid name" });
2899
+ const newCfg = { type: type || (url ? "http" : "stdio"), enabled: !!enabled };
2900
+ if (newCfg.type === "http" || newCfg.type === "remote") {
2901
+ if (!url || !String(url).startsWith("http")) return res.status(400).json({ error: "Invalid url for http server" });
2902
+ newCfg.url = String(url);
2903
+ if (headers && typeof headers === "object") newCfg.headers = headers;
2904
+ } else {
2905
+ if (!command || typeof command !== "string") return res.status(400).json({ error: "Missing command for stdio server" });
2906
+ newCfg.command = String(command);
2907
+ if (Array.isArray(args) && args.length) newCfg.args = args.map(String);
2908
+ else if (typeof args === "string" && args.trim()) newCfg.args = String(args).trim().split(/\s+/);
2909
+ if (env && typeof env === "object") newCfg.env = env;
2910
+ }
2911
+ const mcpPath = import_path3.default.join(process.cwd(), "mcp.json");
2912
+ let existing = {};
2913
+ try {
2914
+ if (import_fs3.default.existsSync(mcpPath)) {
2915
+ const raw = import_fs3.default.readFileSync(mcpPath, "utf-8");
2916
+ const j = JSON.parse(stripJsonc(raw));
2917
+ if (j.mcpServers) existing = j.mcpServers;
2918
+ else if (j.mcp && j.mcp.servers) existing = j.mcp.servers;
2919
+ else if (j.mcp && typeof j.mcp === "object") existing = j.mcp;
2920
+ else existing = j;
2921
+ }
2922
+ } catch (e) {
2923
+ return res.status(500).json({ error: "mcp.json is invalid, refusing to overwrite: " + (e?.message || e) });
2924
+ }
2925
+ if (cleanOldName !== cleanName || mcp.has(cleanOldName)) {
2926
+ delete existing[cleanOldName];
2927
+ const old = mcp.get(cleanOldName);
2928
+ try {
2929
+ await old?.client?.close?.();
2930
+ } catch {
2931
+ }
2932
+ mcp.delete(cleanOldName);
2933
+ }
2934
+ existing[cleanName] = newCfg;
2935
+ import_fs3.default.writeFileSync(mcpPath, JSON.stringify({ mcpServers: existing }, null, 2));
2936
+ if (newCfg.enabled) {
2937
+ await connectMcp(cleanName, newCfg);
2938
+ } else {
2939
+ mcp.set(cleanName, { client: null, tools: [], status: "disabled", config: newCfg, type: normalizeMcpType(newCfg.type) });
2940
+ }
2941
+ const entry = mcp.get(cleanName);
2942
+ return res.json({ name: cleanName, status: entry?.status || "disabled", toolCount: entry?.tools?.length || 0, error: entry?.error || null });
2943
+ } catch (e) {
2944
+ return res.status(500).json({ error: e?.message });
2945
+ }
2946
+ });
2947
+ app.post("/api/mcp/delete", mcpAdminGuard, async (req, res) => {
2948
+ try {
2949
+ const { name } = req.body || {};
2950
+ if (!name || typeof name !== "string") return res.status(400).json({ error: "Missing name" });
2951
+ const cleanName = name.trim();
2952
+ const mcpPath = import_path3.default.join(process.cwd(), "mcp.json");
2953
+ let existing = {};
2954
+ if (import_fs3.default.existsSync(mcpPath)) {
2955
+ try {
2956
+ const raw = import_fs3.default.readFileSync(mcpPath, "utf-8");
2957
+ const j = JSON.parse(stripJsonc(raw));
2958
+ if (j.mcpServers) existing = j.mcpServers;
2959
+ else if (j.mcp && j.mcp.servers) existing = j.mcp.servers;
2960
+ else if (j.mcp && typeof j.mcp === "object") existing = j.mcp;
2961
+ else existing = j;
2962
+ } catch {
2963
+ }
2964
+ }
2965
+ delete existing[cleanName];
2966
+ import_fs3.default.writeFileSync(mcpPath, JSON.stringify({ mcpServers: existing }, null, 2));
2967
+ const old = mcp.get(cleanName);
2968
+ try {
2969
+ await old?.client?.close?.();
2970
+ } catch {
2971
+ }
2972
+ mcp.delete(cleanName);
2973
+ return res.json({ success: true });
2974
+ } catch (e) {
2975
+ return res.status(500).json({ error: e?.message });
2976
+ }
2977
+ });
1410
2978
  app.post("/api/fetch-url", async (req, res) => {
1411
2979
  const errorFallback = "you entered an incorrect URL, are missing characters, or the website no longer exists";
1412
2980
  try {
@@ -1420,6 +2988,7 @@ async function startServer() {
1420
2988
  }
1421
2989
  const urlMatch = url.match(/https?:\/\/[^\s"']+/);
1422
2990
  let targetUrl = urlMatch ? urlMatch[0] : url.trim();
2991
+ if (/^(?:fanluc|mondk-msh)\.docs(?:[\/?#]|$)/i.test(targetUrl)) targetUrl = "https://" + targetUrl;
1423
2992
  try {
1424
2993
  new URL(targetUrl);
1425
2994
  } catch {
@@ -1435,6 +3004,18 @@ async function startServer() {
1435
3004
  hostname = new URL(targetUrl).hostname.toLowerCase();
1436
3005
  } catch {
1437
3006
  }
3007
+ if (hostname === DOCS_VIRTUAL_HOST || hostname === MONDK_MSH_VIRTUAL_HOST) {
3008
+ const pathname = new URL(targetUrl).pathname;
3009
+ const doc = hostname === MONDK_MSH_VIRTUAL_HOST ? await readMondkMshPage(pathname) : await readVirtualDocsPage(pathname);
3010
+ return res.json({
3011
+ url: targetUrl,
3012
+ title: doc.title,
3013
+ text: doc.text,
3014
+ formattedText: `fetched url: ${targetUrl}
3015
+ title: ${doc.title}
3016
+ text: ${doc.text}`
3017
+ });
3018
+ }
1438
3019
  if (isBlockedHost(hostname)) {
1439
3020
  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
3021
  return res.json({
@@ -1513,9 +3094,9 @@ ${titlePart}text: ${extracted}`;
1513
3094
  return res.status(400).json({ error: "Missing name parameter" });
1514
3095
  }
1515
3096
  const cleanName = name.trim().toLowerCase().replace(/^['"`]|['"`]$/g, "");
1516
- const docsDir = import_path.default.join(process.cwd(), "docs");
3097
+ const docsDir = import_path3.default.join(process.cwd(), "docs");
1517
3098
  try {
1518
- const files = await import_fs.default.promises.readdir(docsDir);
3099
+ const files = await import_fs3.default.promises.readdir(docsDir);
1519
3100
  let targetFile = files.find(
1520
3101
  (f) => f.toLowerCase() === cleanName || f.toLowerCase() === `${cleanName}.md`
1521
3102
  );
@@ -1527,7 +3108,7 @@ ${titlePart}text: ${extracted}`;
1527
3108
  else targetFile = files.find((f) => f.toLowerCase().includes(cleanName));
1528
3109
  }
1529
3110
  if (targetFile) {
1530
- const content = await import_fs.default.promises.readFile(import_path.default.join(docsDir, targetFile), "utf-8");
3111
+ const content = await import_fs3.default.promises.readFile(import_path3.default.join(docsDir, targetFile), "utf-8");
1531
3112
  return res.json({
1532
3113
  found: true,
1533
3114
  fileName: targetFile,
@@ -1646,329 +3227,51 @@ ${content}`
1646
3227
  res.status(500).json({ error: "Failed to fetch weather" });
1647
3228
  }
1648
3229
  });
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
- });
3230
+ registerLocalRoutes(app);
1932
3231
  app.get("/api/health", (_req, res) => {
1933
- res.json({ status: "ok", workspace: WORKSPACE_DIR, permission: getPermissionMode(), platform: process.platform });
3232
+ res.json({ status: "ok" });
1934
3233
  });
1935
3234
  if (process.env.NODE_ENV !== "production") {
1936
3235
  const vite = await (0, import_vite.createServer)({
1937
- server: { middlewareMode: true },
3236
+ server: { middlewareMode: true, hmr: false },
1938
3237
  appType: "spa"
1939
3238
  });
1940
3239
  app.use(vite.middlewares);
1941
3240
  } else {
1942
- const distPath = import_path.default.join(process.cwd(), "dist");
3241
+ const distPath = import_path3.default.join(process.cwd(), "dist");
1943
3242
  app.use(import_express.default.static(distPath));
1944
3243
  app.get("*", (_req, res) => {
1945
- res.sendFile(import_path.default.join(distPath, "index.html"));
3244
+ res.sendFile(import_path3.default.join(distPath, "index.html"));
1946
3245
  });
1947
3246
  }
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)`);
3247
+ const HOST = process.env.HOST || "127.0.0.1";
1957
3248
  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`);
3249
+ console.log(`Server running on http://localhost:${PORT} (bound to ${HOST})`);
3250
+ console.log(`Workspace: ${getWorkspace()} | shell: ${SHELL.label}`);
3251
+ if (!["127.0.0.1", "localhost", "::1"].includes(HOST)) {
3252
+ console.warn("[security] HOST is not loopback. Commands run WITHOUT a sandbox: anyone who can reach this port can run commands on this PC.");
3253
+ }
1961
3254
  });
1962
3255
  }
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
- });
3256
+ var shuttingDown = false;
3257
+ function closeAllMcp() {
3258
+ if (shuttingDown) return;
3259
+ shuttingDown = true;
3260
+ for (const entry of mcp.values()) {
3261
+ try {
3262
+ entry.client?.close();
3263
+ } catch {
3264
+ }
3265
+ }
1968
3266
  }
1969
- // Annotate the CommonJS export names for ESM import in node:
1970
- 0 && (module.exports = {
1971
- WORKSPACE_DIR,
1972
- startServer
3267
+ process.on("SIGINT", () => {
3268
+ closeAllMcp();
3269
+ setTimeout(() => process.exit(0), 300);
3270
+ });
3271
+ process.on("SIGTERM", () => {
3272
+ closeAllMcp();
3273
+ setTimeout(() => process.exit(0), 300);
1973
3274
  });
3275
+ process.on("exit", closeAllMcp);
3276
+ startServer();
1974
3277
  //# sourceMappingURL=server.cjs.map