@frumu/tandem-panel 0.4.31 → 0.4.32

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.env.example CHANGED
@@ -94,3 +94,16 @@ ACA_PROBE_TIMEOUT_MS=5000
94
94
  # How long to cache capability probe results, in milliseconds (default: 45000 = 45 s).
95
95
  # Set lower for faster ACA availability detection, higher to reduce probe frequency.
96
96
  ACA_CAPABILITY_CACHE_TTL_MS=45000
97
+
98
+ # ─── Knowledgebase MCP Proxy ─────────────────────────────────────────
99
+ # Local KB admin endpoint exposed by the hosted/provisioned bundle.
100
+ TANDEM_KB_ADMIN_URL=
101
+
102
+ # Optional path to a file containing the KB admin API key.
103
+ TANDEM_KB_ADMIN_API_KEY_FILE=
104
+
105
+ # Optional KB admin API key value. The file value wins if both are set.
106
+ TANDEM_KB_ADMIN_API_KEY=
107
+
108
+ # Default KB collection id for provisioned servers.
109
+ TANDEM_KB_DEFAULT_COLLECTION_ID=
package/bin/setup.js CHANGED
@@ -3,7 +3,7 @@
3
3
  import { spawn } from "child_process";
4
4
  import { createServer } from "http";
5
5
  import { readFileSync, existsSync, createReadStream, createWriteStream } from "fs";
6
- import { mkdir, readdir, stat, rm, readFile, writeFile } from "fs/promises";
6
+ import { mkdir, readdir, stat, rm, readFile, rename, writeFile } from "fs/promises";
7
7
  import { createHash, randomBytes } from "crypto";
8
8
  import { join, dirname, extname, normalize, resolve, basename, relative } from "path";
9
9
  import { Transform } from "stream";
@@ -24,6 +24,7 @@ import { createSwarmApiHandler, getOrchestratorMetrics } from "../server/routes/
24
24
  import { createAcaApiHandler } from "../server/routes/aca.js";
25
25
  import { createCapabilitiesHandler, getCapabilitiesMetrics } from "../server/routes/capabilities.js";
26
26
  import { createControlPanelConfigHandler } from "../server/routes/control-panel-config.js";
27
+ import { createKnowledgebaseApiHandler } from "../server/routes/knowledgebase.js";
27
28
  import { createControlPanelPreferencesHandler } from "../server/routes/control-panel-preferences.js";
28
29
 
29
30
  function parseDotEnv(content) {
@@ -50,6 +51,28 @@ function serializeEnv(entries) {
50
51
  return `${entries.map(([key, value]) => `${key}=${value}`).join("\n")}\n`;
51
52
  }
52
53
 
54
+ async function writeTextFileAtomic(pathname, content) {
55
+ const targetPath = String(pathname || "").trim();
56
+ if (!targetPath) throw new Error("Missing path for atomic write");
57
+ const targetDir = dirname(targetPath);
58
+ await mkdir(targetDir, { recursive: true });
59
+ let mode = 0o640;
60
+ try {
61
+ mode = (await stat(targetPath)).mode & 0o777;
62
+ } catch {}
63
+ const tempPath = join(
64
+ targetDir,
65
+ `${basename(targetPath)}.${process.pid}.${Date.now()}.${randomBytes(6).toString("hex")}.tmp`
66
+ );
67
+ await writeFile(tempPath, content, { encoding: "utf8", mode });
68
+ try {
69
+ await rename(tempPath, targetPath);
70
+ } catch (error) {
71
+ await rm(tempPath, { force: true }).catch(() => {});
72
+ throw error;
73
+ }
74
+ }
75
+
53
76
  function loadDotEnvFile(pathname) {
54
77
  if (!existsSync(pathname)) return false;
55
78
  const parsed = parseDotEnv(readFileSync(pathname, "utf8"));
@@ -192,6 +215,15 @@ const ENGINE_URL = (
192
215
  const ACA_BASE_URL = String(process.env.ACA_BASE_URL || "")
193
216
  .trim()
194
217
  .replace(/\/+$/, "");
218
+ const KB_ADMIN_URL = String(process.env.TANDEM_KB_ADMIN_URL || process.env.KB_ADMIN_URL || "")
219
+ .trim()
220
+ .replace(/\/+$/, "");
221
+ const KB_ADMIN_API_KEY_FILE = String(
222
+ process.env.TANDEM_KB_ADMIN_API_KEY_FILE || process.env.KB_ADMIN_API_KEY_FILE || ""
223
+ ).trim();
224
+ const KB_DEFAULT_COLLECTION_ID = String(
225
+ process.env.TANDEM_KB_DEFAULT_COLLECTION_ID || process.env.KB_DEFAULT_COLLECTION_ID || ""
226
+ ).trim();
195
227
  const CONTROL_PANEL_CONFIG_FILE = String(process.env.TANDEM_CONTROL_PANEL_CONFIG_FILE || "").trim();
196
228
  const CONTROL_PANEL_PREFERENCES_FILE = resolveControlPanelPreferencesPath({
197
229
  env: process.env,
@@ -229,11 +261,6 @@ const SESSION_TTL_MS =
229
261
  const FILES_ROOT = resolve(
230
262
  process.env.TANDEM_CONTROL_PANEL_FILES_ROOT || resolveDefaultChannelUploadsRoot()
231
263
  );
232
- const FILES_SCOPE = String(process.env.TANDEM_CONTROL_PANEL_FILES_SCOPE || "control-panel")
233
- .trim()
234
- .replace(/\\/g, "/")
235
- .replace(/^\/+/, "")
236
- .replace(/\/+$/, "");
237
264
  const MAX_UPLOAD_BYTES = Math.max(
238
265
  1,
239
266
  Number.parseInt(
@@ -277,6 +304,16 @@ const MIME_TYPES = {
277
304
  ".html": "text/html",
278
305
  ".js": "text/javascript",
279
306
  ".css": "text/css",
307
+ ".md": "text/markdown",
308
+ ".markdown": "text/markdown",
309
+ ".csv": "text/csv",
310
+ ".yml": "application/yaml",
311
+ ".yaml": "application/yaml",
312
+ ".pdf": "application/pdf",
313
+ ".jpg": "image/jpeg",
314
+ ".jpeg": "image/jpeg",
315
+ ".gif": "image/gif",
316
+ ".webp": "image/webp",
280
317
  ".png": "image/png",
281
318
  ".svg": "image/svg+xml",
282
319
  ".json": "application/json",
@@ -284,6 +321,15 @@ const MIME_TYPES = {
284
321
  ".txt": "text/plain",
285
322
  };
286
323
 
324
+ const FILE_BUCKETS = ["uploads", "artifacts", "exports"];
325
+ const LEGACY_UPLOAD_BUCKET = "control-panel";
326
+ const FILE_BUCKET_PHYSICAL_NAMES = {
327
+ uploads: LEGACY_UPLOAD_BUCKET,
328
+ artifacts: "artifacts",
329
+ exports: "exports",
330
+ };
331
+ const MAX_PREVIEW_BYTES = Math.max(1, Math.min(MAX_UPLOAD_BYTES, 2 * 1024 * 1024));
332
+
287
333
  const sessions = new Map();
288
334
  let engineProcess = null;
289
335
  let server = null;
@@ -841,7 +887,7 @@ async function installServices() {
841
887
  const engineEnvBody = Object.entries(engineEnv)
842
888
  .map(([k, v]) => `${k}=${v}`)
843
889
  .join("\n");
844
- await writeFile(engineEnvPath, `${engineEnvBody}\n`, "utf8");
890
+ await writeTextFileAtomic(engineEnvPath, `${engineEnvBody}\n`);
845
891
  await runCmd("chmod", ["640", engineEnvPath]);
846
892
 
847
893
  const panelAutoStart = serviceMode === "panel" ? "1" : "0";
@@ -858,7 +904,7 @@ async function installServices() {
858
904
  const panelEnvBody = Object.entries(panelEnv)
859
905
  .map(([k, v]) => `${k}=${v}`)
860
906
  .join("\n");
861
- await writeFile(panelEnvPath, `${panelEnvBody}\n`, "utf8");
907
+ await writeTextFileAtomic(panelEnvPath, `${panelEnvBody}\n`);
862
908
  await runCmd("chmod", ["640", panelEnvPath]);
863
909
 
864
910
  if (installEngine) {
@@ -1083,7 +1129,7 @@ function readManagedSearchSettings() {
1083
1129
  writable: localEngine,
1084
1130
  managed_env_path: envPath,
1085
1131
  restart_required: false,
1086
- restart_hint: "Restart tandem-engine after saving search settings.",
1132
+ restart_hint: "Changes apply immediately.",
1087
1133
  settings: {
1088
1134
  backend: normalizeSearchBackend(env.TANDEM_SEARCH_BACKEND || "auto"),
1089
1135
  tandem_url: normalizeSearchUrl(env.TANDEM_SEARCH_URL || ""),
@@ -1162,10 +1208,10 @@ async function writeManagedSearchSettings(payload = {}) {
1162
1208
  for (const [key, value] of Object.entries(nextEnv)) {
1163
1209
  if (!preferredKeys.includes(key)) ordered.push([key, value]);
1164
1210
  }
1165
- await writeFile(envPath, serializeEnv(ordered), "utf8");
1211
+ await writeTextFileAtomic(envPath, serializeEnv(ordered));
1166
1212
  return {
1167
1213
  ...readManagedSearchSettings(),
1168
- restart_required: true,
1214
+ restart_required: false,
1169
1215
  };
1170
1216
  }
1171
1217
 
@@ -1226,7 +1272,7 @@ async function writeManagedSchedulerSettings(payload = {}) {
1226
1272
  for (const [key, value] of Object.entries(nextEnv)) {
1227
1273
  if (!preferredKeys.includes(key)) ordered.push([key, value]);
1228
1274
  }
1229
- await writeFile(envPath, serializeEnv(ordered), "utf8");
1275
+ await writeTextFileAtomic(envPath, serializeEnv(ordered));
1230
1276
  return {
1231
1277
  ...getManagedSchedulerSettings(),
1232
1278
  restart_required: true,
@@ -1336,6 +1382,38 @@ async function engineHealth(token = "") {
1336
1382
  }
1337
1383
  }
1338
1384
 
1385
+ async function probeEngineHealth(token = "") {
1386
+ try {
1387
+ const response = await fetch(`${ENGINE_URL}/global/health`, {
1388
+ headers: token
1389
+ ? {
1390
+ authorization: `Bearer ${token}`,
1391
+ "x-tandem-token": token,
1392
+ }
1393
+ : {},
1394
+ signal: AbortSignal.timeout(1800),
1395
+ });
1396
+ const text = await response.text().catch(() => "");
1397
+ let payload = null;
1398
+ try {
1399
+ payload = text ? JSON.parse(text) : null;
1400
+ } catch {
1401
+ payload = null;
1402
+ }
1403
+ return {
1404
+ ok: response.ok,
1405
+ status: response.status,
1406
+ payload,
1407
+ };
1408
+ } catch {
1409
+ return {
1410
+ ok: false,
1411
+ status: 0,
1412
+ payload: null,
1413
+ };
1414
+ }
1415
+ }
1416
+
1339
1417
  async function executeEngineTool(token, tool, args = {}) {
1340
1418
  const response = await fetch(`${ENGINE_URL}/tool/execute`, {
1341
1419
  method: "POST",
@@ -1507,18 +1585,58 @@ function sanitizeStaticPath(rawUrl) {
1507
1585
  return full;
1508
1586
  }
1509
1587
 
1510
- function toSafeRelPath(raw) {
1588
+ function normalizeVisibleFilesPath(raw, allowEmpty = true) {
1511
1589
  const normalized = String(raw || "")
1512
1590
  .trim()
1513
1591
  .replace(/\\/g, "/")
1514
- .replace(/^\/+/, "");
1515
- if (!normalized) return "";
1592
+ .replace(/^\/+/, "")
1593
+ .replace(/\/+$/, "");
1594
+ if (!normalized) return allowEmpty ? "" : null;
1516
1595
  if (normalized.includes("\0")) return null;
1517
- const full = resolve(FILES_ROOT, normalized);
1596
+ const parts = normalized.split("/").filter(Boolean);
1597
+ if (!parts.length) return allowEmpty ? "" : null;
1598
+ if (parts.some((part) => part === "." || part === "..")) return null;
1599
+ const [first, ...rest] = parts;
1600
+ if (first === LEGACY_UPLOAD_BUCKET) return ["uploads", ...rest].join("/");
1601
+ if (!FILE_BUCKETS.includes(first)) return null;
1602
+ return [first, ...rest].join("/");
1603
+ }
1604
+
1605
+ function visibleFilesPathToPhysicalPath(raw) {
1606
+ const normalized = String(raw || "")
1607
+ .trim()
1608
+ .replace(/\\/g, "/")
1609
+ .replace(/^\/+/, "")
1610
+ .replace(/\/+$/, "");
1611
+ if (!normalized) return "";
1612
+ const parts = normalized.split("/").filter(Boolean);
1613
+ if (!parts.length) return "";
1614
+ const [first, ...rest] = parts;
1615
+ const physicalBucket = FILE_BUCKET_PHYSICAL_NAMES[first] || first;
1616
+ return [physicalBucket, ...rest].filter(Boolean).join("/");
1617
+ }
1618
+
1619
+ function physicalFilesPathToVisiblePath(raw) {
1620
+ const normalized = String(raw || "")
1621
+ .trim()
1622
+ .replace(/\\/g, "/")
1623
+ .replace(/^\/+/, "")
1624
+ .replace(/\/+$/, "");
1625
+ if (!normalized) return "";
1626
+ const parts = normalized.split("/").filter(Boolean);
1627
+ if (!parts.length) return "";
1628
+ const [first, ...rest] = parts;
1629
+ if (first === LEGACY_UPLOAD_BUCKET) return ["uploads", ...rest].join("/");
1630
+ return [first, ...rest].join("/");
1631
+ }
1632
+
1633
+ function toSafeRelPath(raw, allowEmpty = true) {
1634
+ const visible = normalizeVisibleFilesPath(raw, allowEmpty);
1635
+ if (visible === null) return null;
1636
+ if (!visible) return "";
1637
+ const full = resolve(FILES_ROOT, visibleFilesPathToPhysicalPath(visible));
1518
1638
  if (full !== FILES_ROOT && !full.startsWith(`${FILES_ROOT}/`)) return null;
1519
- if (FILES_SCOPE && normalized !== FILES_SCOPE && !normalized.startsWith(`${FILES_SCOPE}/`))
1520
- return null;
1521
- return normalized;
1639
+ return visible;
1522
1640
  }
1523
1641
 
1524
1642
  function toSafeRelFileName(rawName) {
@@ -1527,19 +1645,48 @@ function toSafeRelFileName(rawName) {
1527
1645
  return cleaned;
1528
1646
  }
1529
1647
 
1530
- async function ensureUniqueRelPath(relativePath) {
1531
- const ext = extname(relativePath);
1532
- const stem = ext ? relativePath.slice(0, -ext.length) : relativePath;
1533
- let candidate = relativePath;
1648
+ function parentVisiblePath(raw) {
1649
+ const normalized = String(raw || "")
1650
+ .trim()
1651
+ .replace(/\\/g, "/")
1652
+ .replace(/^\/+/, "")
1653
+ .replace(/\/+$/, "");
1654
+ if (!normalized) return null;
1655
+ const idx = normalized.lastIndexOf("/");
1656
+ if (idx < 0) return null;
1657
+ return normalized.slice(0, idx);
1658
+ }
1659
+
1660
+ function inferFileMime(pathname = "") {
1661
+ const ext = extname(String(pathname || "")).toLowerCase();
1662
+ return MIME_TYPES[ext] || "application/octet-stream";
1663
+ }
1664
+
1665
+ function inferFilePreviewKind(pathname = "", mime = "") {
1666
+ const ext = extname(String(pathname || "")).toLowerCase();
1667
+ if (mime === "application/pdf" || ext === ".pdf") return "pdf";
1668
+ if (String(mime || "").startsWith("image/")) return "image";
1669
+ if (ext === ".md" || ext === ".markdown" || mime === "text/markdown") return "markdown";
1670
+ if (ext === ".json" || mime === "application/json") return "json";
1671
+ if (ext === ".yaml" || ext === ".yml" || mime === "application/yaml") return "yaml";
1672
+ if (String(mime || "").startsWith("text/") || mime === "application/xml") return "text";
1673
+ return "binary";
1674
+ }
1675
+
1676
+ async function ensureUniqueVisibleRelPath(visiblePath) {
1677
+ const ext = extname(visiblePath);
1678
+ const stem = ext ? visiblePath.slice(0, -ext.length) : visiblePath;
1679
+ let candidate = visibleFilesPathToPhysicalPath(visiblePath);
1534
1680
  let counter = 1;
1535
1681
  while (true) {
1536
1682
  const full = resolve(FILES_ROOT, candidate);
1537
1683
  try {
1538
1684
  await stat(full);
1539
1685
  counter += 1;
1540
- candidate = `${stem}-${counter}${ext}`;
1686
+ const nextVisible = `${stem}-${counter}${ext}`;
1687
+ candidate = visibleFilesPathToPhysicalPath(nextVisible);
1541
1688
  } catch {
1542
- return candidate;
1689
+ return physicalFilesPathToVisiblePath(candidate);
1543
1690
  }
1544
1691
  }
1545
1692
  }
@@ -1550,31 +1697,77 @@ async function handleFilesApi(req, res, _session) {
1550
1697
 
1551
1698
  if (pathname === "/api/files/list" && req.method === "GET") {
1552
1699
  const incomingDir = url.searchParams.get("dir") || "";
1553
- const defaultDir = FILES_SCOPE || "";
1554
- const dirRelRaw = toSafeRelPath(incomingDir || defaultDir);
1700
+ const dirRelRaw = toSafeRelPath(incomingDir, true);
1555
1701
  if (dirRelRaw === null) {
1556
1702
  sendJson(res, 400, { ok: false, error: "Invalid directory path." });
1557
1703
  return true;
1558
1704
  }
1559
1705
  const dirRel = dirRelRaw || "";
1560
- const dirFull = resolve(FILES_ROOT, dirRel);
1561
1706
  try {
1707
+ if (!dirRel) {
1708
+ const directories = await Promise.all(
1709
+ FILE_BUCKETS.map(async (bucket) => {
1710
+ const physicalRel = visibleFilesPathToPhysicalPath(bucket);
1711
+ const info = await stat(resolve(FILES_ROOT, physicalRel)).catch(() => null);
1712
+ return {
1713
+ name: bucket,
1714
+ path: bucket,
1715
+ updatedAt: info?.mtimeMs || 0,
1716
+ previewKind: "directory",
1717
+ };
1718
+ })
1719
+ );
1720
+ sendJson(res, 200, {
1721
+ ok: true,
1722
+ root: FILES_ROOT,
1723
+ dir: "",
1724
+ parent: null,
1725
+ directories,
1726
+ files: [],
1727
+ });
1728
+ return true;
1729
+ }
1730
+
1731
+ const physicalDirRel = visibleFilesPathToPhysicalPath(dirRel);
1732
+ const dirFull = resolve(FILES_ROOT, physicalDirRel);
1562
1733
  await mkdir(dirFull, { recursive: true });
1563
1734
  const entries = await readdir(dirFull, { withFileTypes: true });
1735
+ const directories = [];
1564
1736
  const files = [];
1565
1737
  for (const entry of entries) {
1566
- const childRel = dirRel ? `${dirRel}/${entry.name}` : entry.name;
1567
- if (!entry.isFile()) continue;
1568
- const info = await stat(resolve(FILES_ROOT, childRel)).catch(() => null);
1569
- files.push({
1570
- name: entry.name,
1571
- path: childRel,
1572
- size: info?.size || 0,
1573
- updatedAt: info?.mtimeMs || 0,
1574
- });
1738
+ const physicalChildRel = physicalDirRel ? `${physicalDirRel}/${entry.name}` : entry.name;
1739
+ const visibleChildRel = physicalFilesPathToVisiblePath(physicalChildRel);
1740
+ const info = await stat(resolve(FILES_ROOT, physicalChildRel)).catch(() => null);
1741
+ if (entry.isDirectory()) {
1742
+ directories.push({
1743
+ name: entry.name,
1744
+ path: visibleChildRel,
1745
+ updatedAt: info?.mtimeMs || 0,
1746
+ previewKind: "directory",
1747
+ });
1748
+ } else if (entry.isFile()) {
1749
+ const mime = inferFileMime(visibleChildRel || entry.name);
1750
+ files.push({
1751
+ name: entry.name,
1752
+ path: visibleChildRel,
1753
+ size: info?.size || 0,
1754
+ updatedAt: info?.mtimeMs || 0,
1755
+ mime,
1756
+ previewKind: inferFilePreviewKind(visibleChildRel || entry.name, mime),
1757
+ downloadUrl: `/api/files/download?path=${encodeURIComponent(visibleChildRel)}`,
1758
+ });
1759
+ }
1575
1760
  }
1576
- files.sort((a, b) => b.updatedAt - a.updatedAt);
1577
- sendJson(res, 200, { ok: true, root: FILES_ROOT, files });
1761
+ directories.sort((a, b) => String(a.name).localeCompare(String(b.name)));
1762
+ files.sort((a, b) => b.updatedAt - a.updatedAt || String(a.name).localeCompare(String(b.name)));
1763
+ sendJson(res, 200, {
1764
+ ok: true,
1765
+ root: FILES_ROOT,
1766
+ dir: dirRel,
1767
+ parent: parentVisiblePath(dirRel),
1768
+ directories,
1769
+ files,
1770
+ });
1578
1771
  } catch (e) {
1579
1772
  sendJson(res, 500, { ok: false, error: e instanceof Error ? e.message : String(e) });
1580
1773
  }
@@ -1593,16 +1786,16 @@ async function handleFilesApi(req, res, _session) {
1593
1786
  }
1594
1787
 
1595
1788
  const incomingDir = url.searchParams.get("dir") || "";
1596
- const defaultDir = FILES_SCOPE || "";
1597
- const dirRelRaw = toSafeRelPath(incomingDir || defaultDir);
1789
+ const defaultDir = "uploads";
1790
+ const dirRelRaw = toSafeRelPath(incomingDir || defaultDir, true);
1598
1791
  if (dirRelRaw === null) {
1599
1792
  sendJson(res, 400, { ok: false, error: "Invalid upload directory." });
1600
1793
  return true;
1601
1794
  }
1602
1795
  const dirRel = dirRelRaw || "";
1603
1796
  let relPath = dirRel ? `${dirRel}/${safeName}` : safeName;
1604
- relPath = await ensureUniqueRelPath(relPath);
1605
- const fullPath = resolve(FILES_ROOT, relPath);
1797
+ relPath = await ensureUniqueVisibleRelPath(relPath);
1798
+ const fullPath = resolve(FILES_ROOT, visibleFilesPathToPhysicalPath(relPath));
1606
1799
  const folder = dirname(fullPath);
1607
1800
 
1608
1801
  try {
@@ -1620,6 +1813,8 @@ async function handleFilesApi(req, res, _session) {
1620
1813
  });
1621
1814
  await pipeline(req, guard, createWriteStream(fullPath, { flags: "wx" }));
1622
1815
  const meta = await stat(fullPath);
1816
+ const mime = inferFileMime(relPath);
1817
+ const previewKind = inferFilePreviewKind(relPath, mime);
1623
1818
  sendJson(res, 200, {
1624
1819
  ok: true,
1625
1820
  root: FILES_ROOT,
@@ -1627,6 +1822,8 @@ async function handleFilesApi(req, res, _session) {
1627
1822
  path: relPath,
1628
1823
  absPath: fullPath,
1629
1824
  size: meta.size,
1825
+ mime,
1826
+ previewKind,
1630
1827
  downloadUrl: `/api/files/download?path=${encodeURIComponent(relPath)}`,
1631
1828
  });
1632
1829
  } catch (e) {
@@ -1640,17 +1837,33 @@ async function handleFilesApi(req, res, _session) {
1640
1837
  }
1641
1838
 
1642
1839
  if (pathname === "/api/files/read" && req.method === "GET") {
1643
- const rel = toSafeRelPath(url.searchParams.get("path") || "");
1840
+ const rel = toSafeRelPath(url.searchParams.get("path") || "", false);
1644
1841
  if (!rel) {
1645
1842
  sendJson(res, 400, { ok: false, error: "Missing or invalid file path." });
1646
1843
  return true;
1647
1844
  }
1648
- const full = resolve(FILES_ROOT, rel);
1845
+ const physicalRel = visibleFilesPathToPhysicalPath(rel);
1846
+ const full = resolve(FILES_ROOT, physicalRel);
1649
1847
  try {
1650
1848
  const info = await stat(full);
1651
1849
  if (!info.isFile()) throw new Error("Not a file");
1652
- if (info.size > MAX_UPLOAD_BYTES) {
1653
- sendJson(res, 413, { ok: false, error: "File too large to read through API." });
1850
+ const mime = inferFileMime(rel);
1851
+ const previewKind = inferFilePreviewKind(rel, mime);
1852
+ const previewable = ["text", "markdown", "json", "yaml"].includes(previewKind);
1853
+ if (!previewable || info.size > MAX_PREVIEW_BYTES) {
1854
+ sendJson(res, 200, {
1855
+ ok: true,
1856
+ root: FILES_ROOT,
1857
+ path: rel,
1858
+ absPath: full,
1859
+ name: basename(full),
1860
+ size: info.size,
1861
+ mime,
1862
+ previewKind,
1863
+ previewable: false,
1864
+ reason: !previewable ? "not_previewable" : "too_large",
1865
+ downloadUrl: `/api/files/download?path=${encodeURIComponent(rel)}`,
1866
+ });
1654
1867
  return true;
1655
1868
  }
1656
1869
  const text = await readFile(full, "utf8");
@@ -1659,7 +1872,12 @@ async function handleFilesApi(req, res, _session) {
1659
1872
  root: FILES_ROOT,
1660
1873
  path: rel,
1661
1874
  absPath: full,
1875
+ name: basename(full),
1662
1876
  size: info.size,
1877
+ mime,
1878
+ previewKind,
1879
+ previewable: true,
1880
+ downloadUrl: `/api/files/download?path=${encodeURIComponent(rel)}`,
1663
1881
  text,
1664
1882
  });
1665
1883
  } catch {
@@ -1671,7 +1889,7 @@ async function handleFilesApi(req, res, _session) {
1671
1889
  if (pathname === "/api/files/write" && req.method === "POST") {
1672
1890
  try {
1673
1891
  const body = await readJsonBody(req);
1674
- const rel = toSafeRelPath(body?.path || "");
1892
+ const rel = toSafeRelPath(body?.path || "", false);
1675
1893
  const text = String(body?.text ?? "");
1676
1894
  const overwrite = body?.overwrite !== false;
1677
1895
  if (!rel) {
@@ -1682,7 +1900,7 @@ async function handleFilesApi(req, res, _session) {
1682
1900
  sendJson(res, 413, { ok: false, error: "Text payload exceeds max upload bytes limit." });
1683
1901
  return true;
1684
1902
  }
1685
- const full = resolve(FILES_ROOT, rel);
1903
+ const full = resolve(FILES_ROOT, visibleFilesPathToPhysicalPath(rel));
1686
1904
  await mkdir(dirname(full), { recursive: true });
1687
1905
  await writeFile(full, text, { encoding: "utf8", flag: overwrite ? "w" : "wx" });
1688
1906
  const info = await stat(full);
@@ -1704,17 +1922,16 @@ async function handleFilesApi(req, res, _session) {
1704
1922
  }
1705
1923
 
1706
1924
  if (pathname === "/api/files/download" && req.method === "GET") {
1707
- const rel = toSafeRelPath(url.searchParams.get("path") || "");
1925
+ const rel = toSafeRelPath(url.searchParams.get("path") || "", false);
1708
1926
  if (!rel) {
1709
1927
  sendJson(res, 400, { ok: false, error: "Missing or invalid file path." });
1710
1928
  return true;
1711
1929
  }
1712
- const full = resolve(FILES_ROOT, rel);
1930
+ const full = resolve(FILES_ROOT, visibleFilesPathToPhysicalPath(rel));
1713
1931
  try {
1714
1932
  const info = await stat(full);
1715
1933
  if (!info.isFile()) throw new Error("Not a file");
1716
- const ext = extname(full);
1717
- const mime = MIME_TYPES[ext] || "application/octet-stream";
1934
+ const mime = inferFileMime(rel);
1718
1935
  res.writeHead(200, {
1719
1936
  "content-type": mime,
1720
1937
  "content-length": String(info.size),
@@ -1730,12 +1947,12 @@ async function handleFilesApi(req, res, _session) {
1730
1947
  if (pathname === "/api/files/delete" && req.method === "POST") {
1731
1948
  try {
1732
1949
  const body = await readJsonBody(req);
1733
- const rel = toSafeRelPath(body?.path || "");
1950
+ const rel = toSafeRelPath(body?.path || "", false);
1734
1951
  if (!rel) {
1735
1952
  sendJson(res, 400, { ok: false, error: "Missing or invalid file path." });
1736
1953
  return true;
1737
1954
  }
1738
- await rm(resolve(FILES_ROOT, rel), { force: true });
1955
+ await rm(resolve(FILES_ROOT, visibleFilesPathToPhysicalPath(rel)), { force: true });
1739
1956
  sendJson(res, 200, { ok: true, path: rel });
1740
1957
  } catch (e) {
1741
1958
  sendJson(res, 500, { ok: false, error: e instanceof Error ? e.message : String(e) });
@@ -4784,6 +5001,14 @@ const handleControlPanelPreferences = createControlPanelPreferencesHandler({
4784
5001
  readJsonBody,
4785
5002
  });
4786
5003
 
5004
+ const handleKnowledgebaseApi = createKnowledgebaseApiHandler({
5005
+ PORTAL_PORT,
5006
+ TANDEM_KB_ADMIN_URL: KB_ADMIN_URL,
5007
+ KB_ADMIN_API_KEY_FILE,
5008
+ KB_DEFAULT_COLLECTION_ID,
5009
+ sendJson,
5010
+ });
5011
+
4787
5012
  async function handleApi(req, res) {
4788
5013
  const pathname = new URL(req.url, `http://127.0.0.1:${PORTAL_PORT}`).pathname;
4789
5014
 
@@ -4909,11 +5134,13 @@ async function handleApi(req, res) {
4909
5134
  }
4910
5135
 
4911
5136
  if (pathname === "/api/auth/login" && req.method === "POST") {
5137
+ res.setHeader("cache-control", "no-store, max-age=0");
4912
5138
  await handleAuthLogin(req, res);
4913
5139
  return true;
4914
5140
  }
4915
5141
 
4916
5142
  if (pathname === "/api/auth/logout" && req.method === "POST") {
5143
+ res.setHeader("cache-control", "no-store, max-age=0");
4917
5144
  const current = getSession(req);
4918
5145
  if (current?.sid) sessions.delete(current.sid);
4919
5146
  clearSessionCookie(res);
@@ -4922,10 +5149,28 @@ async function handleApi(req, res) {
4922
5149
  }
4923
5150
 
4924
5151
  if (pathname === "/api/auth/me" && req.method === "GET") {
5152
+ res.setHeader("cache-control", "no-store, max-age=0");
4925
5153
  const session = requireSession(req, res);
4926
5154
  if (!session) return true;
4927
- const health = await engineHealth(session.token);
4928
- if (!health) {
5155
+ const probe = await probeEngineHealth(session.token);
5156
+ if (!probe.ok) {
5157
+ if (probe.status === 401 || probe.status === 403) {
5158
+ sessions.delete(session.sid);
5159
+ clearSessionCookie(res);
5160
+ sendJson(res, 401, {
5161
+ ok: false,
5162
+ error: "Session token is no longer valid for the configured engine.",
5163
+ });
5164
+ return true;
5165
+ }
5166
+ sendJson(res, 503, {
5167
+ ok: false,
5168
+ error: "Engine is temporarily unavailable while restoring your session.",
5169
+ });
5170
+ return true;
5171
+ }
5172
+ const health = probe.payload;
5173
+ if (!health || typeof health !== "object") {
4929
5174
  sessions.delete(session.sid);
4930
5175
  clearSessionCookie(res);
4931
5176
  sendJson(res, 401, {
@@ -4961,6 +5206,12 @@ async function handleApi(req, res) {
4961
5206
  return handleControlPanelConfig(req, res);
4962
5207
  }
4963
5208
 
5209
+ if (pathname.startsWith("/api/knowledgebase")) {
5210
+ const session = requireSession(req, res);
5211
+ if (!session) return true;
5212
+ return handleKnowledgebaseApi(req, res);
5213
+ }
5214
+
4964
5215
  if (pathname.startsWith("/api/swarm") || pathname.startsWith("/api/orchestrator")) {
4965
5216
  const session = requireSession(req, res);
4966
5217
  if (!session) return true;
@@ -5085,7 +5336,7 @@ async function main() {
5085
5336
  log(`Engine URL: ${ENGINE_URL}`);
5086
5337
  log(`Engine mode: ${isLocalEngineUrl(ENGINE_URL) ? "local" : "remote"}`);
5087
5338
  log(`Files root: ${FILES_ROOT}`);
5088
- log(`Files scope: ${FILES_SCOPE || "(full root)"}`);
5339
+ log(`Files buckets: uploads, artifacts, exports`);
5089
5340
  log(`Build: ${CONTROL_PANEL_BUILD_FINGERPRINT}`);
5090
5341
  log("=========================================");
5091
5342
  });