@frumu/tandem-panel 0.4.31 → 0.4.33

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,
@@ -1286,6 +1332,16 @@ async function getInstallProfile({ acaAvailable = false, acaReason = "" } = {})
1286
1332
  control_panel_config_ready: summary.ready,
1287
1333
  control_panel_config_missing: summary.missing,
1288
1334
  control_panel_compact_nav: !!summary.control_panel?.aca_compact_nav,
1335
+ hosted_managed: summary.hosted?.managed === true,
1336
+ hosted_provider: String(summary.hosted?.provider || "").trim(),
1337
+ hosted_deployment_id: String(summary.hosted?.deployment_id || "").trim(),
1338
+ hosted_deployment_slug: String(summary.hosted?.deployment_slug || "").trim(),
1339
+ hosted_hostname: String(summary.hosted?.hostname || "").trim(),
1340
+ hosted_public_url: String(summary.hosted?.public_url || "").trim(),
1341
+ hosted_control_plane_url: String(summary.hosted?.control_plane_url || "").trim(),
1342
+ hosted_release_version: String(summary.hosted?.release_version || "").trim(),
1343
+ hosted_release_channel: String(summary.hosted?.release_channel || "").trim(),
1344
+ hosted_update_policy: String(summary.hosted?.update_policy || "").trim(),
1289
1345
  aca_integration: !!acaAvailable,
1290
1346
  aca_reason: acaReason || "",
1291
1347
  };
@@ -1336,6 +1392,38 @@ async function engineHealth(token = "") {
1336
1392
  }
1337
1393
  }
1338
1394
 
1395
+ async function probeEngineHealth(token = "") {
1396
+ try {
1397
+ const response = await fetch(`${ENGINE_URL}/global/health`, {
1398
+ headers: token
1399
+ ? {
1400
+ authorization: `Bearer ${token}`,
1401
+ "x-tandem-token": token,
1402
+ }
1403
+ : {},
1404
+ signal: AbortSignal.timeout(1800),
1405
+ });
1406
+ const text = await response.text().catch(() => "");
1407
+ let payload = null;
1408
+ try {
1409
+ payload = text ? JSON.parse(text) : null;
1410
+ } catch {
1411
+ payload = null;
1412
+ }
1413
+ return {
1414
+ ok: response.ok,
1415
+ status: response.status,
1416
+ payload,
1417
+ };
1418
+ } catch {
1419
+ return {
1420
+ ok: false,
1421
+ status: 0,
1422
+ payload: null,
1423
+ };
1424
+ }
1425
+ }
1426
+
1339
1427
  async function executeEngineTool(token, tool, args = {}) {
1340
1428
  const response = await fetch(`${ENGINE_URL}/tool/execute`, {
1341
1429
  method: "POST",
@@ -1507,18 +1595,58 @@ function sanitizeStaticPath(rawUrl) {
1507
1595
  return full;
1508
1596
  }
1509
1597
 
1510
- function toSafeRelPath(raw) {
1598
+ function normalizeVisibleFilesPath(raw, allowEmpty = true) {
1511
1599
  const normalized = String(raw || "")
1512
1600
  .trim()
1513
1601
  .replace(/\\/g, "/")
1514
- .replace(/^\/+/, "");
1515
- if (!normalized) return "";
1602
+ .replace(/^\/+/, "")
1603
+ .replace(/\/+$/, "");
1604
+ if (!normalized) return allowEmpty ? "" : null;
1516
1605
  if (normalized.includes("\0")) return null;
1517
- const full = resolve(FILES_ROOT, normalized);
1606
+ const parts = normalized.split("/").filter(Boolean);
1607
+ if (!parts.length) return allowEmpty ? "" : null;
1608
+ if (parts.some((part) => part === "." || part === "..")) return null;
1609
+ const [first, ...rest] = parts;
1610
+ if (first === LEGACY_UPLOAD_BUCKET) return ["uploads", ...rest].join("/");
1611
+ if (!FILE_BUCKETS.includes(first)) return null;
1612
+ return [first, ...rest].join("/");
1613
+ }
1614
+
1615
+ function visibleFilesPathToPhysicalPath(raw) {
1616
+ const normalized = String(raw || "")
1617
+ .trim()
1618
+ .replace(/\\/g, "/")
1619
+ .replace(/^\/+/, "")
1620
+ .replace(/\/+$/, "");
1621
+ if (!normalized) return "";
1622
+ const parts = normalized.split("/").filter(Boolean);
1623
+ if (!parts.length) return "";
1624
+ const [first, ...rest] = parts;
1625
+ const physicalBucket = FILE_BUCKET_PHYSICAL_NAMES[first] || first;
1626
+ return [physicalBucket, ...rest].filter(Boolean).join("/");
1627
+ }
1628
+
1629
+ function physicalFilesPathToVisiblePath(raw) {
1630
+ const normalized = String(raw || "")
1631
+ .trim()
1632
+ .replace(/\\/g, "/")
1633
+ .replace(/^\/+/, "")
1634
+ .replace(/\/+$/, "");
1635
+ if (!normalized) return "";
1636
+ const parts = normalized.split("/").filter(Boolean);
1637
+ if (!parts.length) return "";
1638
+ const [first, ...rest] = parts;
1639
+ if (first === LEGACY_UPLOAD_BUCKET) return ["uploads", ...rest].join("/");
1640
+ return [first, ...rest].join("/");
1641
+ }
1642
+
1643
+ function toSafeRelPath(raw, allowEmpty = true) {
1644
+ const visible = normalizeVisibleFilesPath(raw, allowEmpty);
1645
+ if (visible === null) return null;
1646
+ if (!visible) return "";
1647
+ const full = resolve(FILES_ROOT, visibleFilesPathToPhysicalPath(visible));
1518
1648
  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;
1649
+ return visible;
1522
1650
  }
1523
1651
 
1524
1652
  function toSafeRelFileName(rawName) {
@@ -1527,19 +1655,48 @@ function toSafeRelFileName(rawName) {
1527
1655
  return cleaned;
1528
1656
  }
1529
1657
 
1530
- async function ensureUniqueRelPath(relativePath) {
1531
- const ext = extname(relativePath);
1532
- const stem = ext ? relativePath.slice(0, -ext.length) : relativePath;
1533
- let candidate = relativePath;
1658
+ function parentVisiblePath(raw) {
1659
+ const normalized = String(raw || "")
1660
+ .trim()
1661
+ .replace(/\\/g, "/")
1662
+ .replace(/^\/+/, "")
1663
+ .replace(/\/+$/, "");
1664
+ if (!normalized) return null;
1665
+ const idx = normalized.lastIndexOf("/");
1666
+ if (idx < 0) return null;
1667
+ return normalized.slice(0, idx);
1668
+ }
1669
+
1670
+ function inferFileMime(pathname = "") {
1671
+ const ext = extname(String(pathname || "")).toLowerCase();
1672
+ return MIME_TYPES[ext] || "application/octet-stream";
1673
+ }
1674
+
1675
+ function inferFilePreviewKind(pathname = "", mime = "") {
1676
+ const ext = extname(String(pathname || "")).toLowerCase();
1677
+ if (mime === "application/pdf" || ext === ".pdf") return "pdf";
1678
+ if (String(mime || "").startsWith("image/")) return "image";
1679
+ if (ext === ".md" || ext === ".markdown" || mime === "text/markdown") return "markdown";
1680
+ if (ext === ".json" || mime === "application/json") return "json";
1681
+ if (ext === ".yaml" || ext === ".yml" || mime === "application/yaml") return "yaml";
1682
+ if (String(mime || "").startsWith("text/") || mime === "application/xml") return "text";
1683
+ return "binary";
1684
+ }
1685
+
1686
+ async function ensureUniqueVisibleRelPath(visiblePath) {
1687
+ const ext = extname(visiblePath);
1688
+ const stem = ext ? visiblePath.slice(0, -ext.length) : visiblePath;
1689
+ let candidate = visibleFilesPathToPhysicalPath(visiblePath);
1534
1690
  let counter = 1;
1535
1691
  while (true) {
1536
1692
  const full = resolve(FILES_ROOT, candidate);
1537
1693
  try {
1538
1694
  await stat(full);
1539
1695
  counter += 1;
1540
- candidate = `${stem}-${counter}${ext}`;
1696
+ const nextVisible = `${stem}-${counter}${ext}`;
1697
+ candidate = visibleFilesPathToPhysicalPath(nextVisible);
1541
1698
  } catch {
1542
- return candidate;
1699
+ return physicalFilesPathToVisiblePath(candidate);
1543
1700
  }
1544
1701
  }
1545
1702
  }
@@ -1550,31 +1707,77 @@ async function handleFilesApi(req, res, _session) {
1550
1707
 
1551
1708
  if (pathname === "/api/files/list" && req.method === "GET") {
1552
1709
  const incomingDir = url.searchParams.get("dir") || "";
1553
- const defaultDir = FILES_SCOPE || "";
1554
- const dirRelRaw = toSafeRelPath(incomingDir || defaultDir);
1710
+ const dirRelRaw = toSafeRelPath(incomingDir, true);
1555
1711
  if (dirRelRaw === null) {
1556
1712
  sendJson(res, 400, { ok: false, error: "Invalid directory path." });
1557
1713
  return true;
1558
1714
  }
1559
1715
  const dirRel = dirRelRaw || "";
1560
- const dirFull = resolve(FILES_ROOT, dirRel);
1561
1716
  try {
1717
+ if (!dirRel) {
1718
+ const directories = await Promise.all(
1719
+ FILE_BUCKETS.map(async (bucket) => {
1720
+ const physicalRel = visibleFilesPathToPhysicalPath(bucket);
1721
+ const info = await stat(resolve(FILES_ROOT, physicalRel)).catch(() => null);
1722
+ return {
1723
+ name: bucket,
1724
+ path: bucket,
1725
+ updatedAt: info?.mtimeMs || 0,
1726
+ previewKind: "directory",
1727
+ };
1728
+ })
1729
+ );
1730
+ sendJson(res, 200, {
1731
+ ok: true,
1732
+ root: FILES_ROOT,
1733
+ dir: "",
1734
+ parent: null,
1735
+ directories,
1736
+ files: [],
1737
+ });
1738
+ return true;
1739
+ }
1740
+
1741
+ const physicalDirRel = visibleFilesPathToPhysicalPath(dirRel);
1742
+ const dirFull = resolve(FILES_ROOT, physicalDirRel);
1562
1743
  await mkdir(dirFull, { recursive: true });
1563
1744
  const entries = await readdir(dirFull, { withFileTypes: true });
1745
+ const directories = [];
1564
1746
  const files = [];
1565
1747
  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
- });
1748
+ const physicalChildRel = physicalDirRel ? `${physicalDirRel}/${entry.name}` : entry.name;
1749
+ const visibleChildRel = physicalFilesPathToVisiblePath(physicalChildRel);
1750
+ const info = await stat(resolve(FILES_ROOT, physicalChildRel)).catch(() => null);
1751
+ if (entry.isDirectory()) {
1752
+ directories.push({
1753
+ name: entry.name,
1754
+ path: visibleChildRel,
1755
+ updatedAt: info?.mtimeMs || 0,
1756
+ previewKind: "directory",
1757
+ });
1758
+ } else if (entry.isFile()) {
1759
+ const mime = inferFileMime(visibleChildRel || entry.name);
1760
+ files.push({
1761
+ name: entry.name,
1762
+ path: visibleChildRel,
1763
+ size: info?.size || 0,
1764
+ updatedAt: info?.mtimeMs || 0,
1765
+ mime,
1766
+ previewKind: inferFilePreviewKind(visibleChildRel || entry.name, mime),
1767
+ downloadUrl: `/api/files/download?path=${encodeURIComponent(visibleChildRel)}`,
1768
+ });
1769
+ }
1575
1770
  }
1576
- files.sort((a, b) => b.updatedAt - a.updatedAt);
1577
- sendJson(res, 200, { ok: true, root: FILES_ROOT, files });
1771
+ directories.sort((a, b) => String(a.name).localeCompare(String(b.name)));
1772
+ files.sort((a, b) => b.updatedAt - a.updatedAt || String(a.name).localeCompare(String(b.name)));
1773
+ sendJson(res, 200, {
1774
+ ok: true,
1775
+ root: FILES_ROOT,
1776
+ dir: dirRel,
1777
+ parent: parentVisiblePath(dirRel),
1778
+ directories,
1779
+ files,
1780
+ });
1578
1781
  } catch (e) {
1579
1782
  sendJson(res, 500, { ok: false, error: e instanceof Error ? e.message : String(e) });
1580
1783
  }
@@ -1593,16 +1796,16 @@ async function handleFilesApi(req, res, _session) {
1593
1796
  }
1594
1797
 
1595
1798
  const incomingDir = url.searchParams.get("dir") || "";
1596
- const defaultDir = FILES_SCOPE || "";
1597
- const dirRelRaw = toSafeRelPath(incomingDir || defaultDir);
1799
+ const defaultDir = "uploads";
1800
+ const dirRelRaw = toSafeRelPath(incomingDir || defaultDir, true);
1598
1801
  if (dirRelRaw === null) {
1599
1802
  sendJson(res, 400, { ok: false, error: "Invalid upload directory." });
1600
1803
  return true;
1601
1804
  }
1602
1805
  const dirRel = dirRelRaw || "";
1603
1806
  let relPath = dirRel ? `${dirRel}/${safeName}` : safeName;
1604
- relPath = await ensureUniqueRelPath(relPath);
1605
- const fullPath = resolve(FILES_ROOT, relPath);
1807
+ relPath = await ensureUniqueVisibleRelPath(relPath);
1808
+ const fullPath = resolve(FILES_ROOT, visibleFilesPathToPhysicalPath(relPath));
1606
1809
  const folder = dirname(fullPath);
1607
1810
 
1608
1811
  try {
@@ -1620,6 +1823,8 @@ async function handleFilesApi(req, res, _session) {
1620
1823
  });
1621
1824
  await pipeline(req, guard, createWriteStream(fullPath, { flags: "wx" }));
1622
1825
  const meta = await stat(fullPath);
1826
+ const mime = inferFileMime(relPath);
1827
+ const previewKind = inferFilePreviewKind(relPath, mime);
1623
1828
  sendJson(res, 200, {
1624
1829
  ok: true,
1625
1830
  root: FILES_ROOT,
@@ -1627,6 +1832,8 @@ async function handleFilesApi(req, res, _session) {
1627
1832
  path: relPath,
1628
1833
  absPath: fullPath,
1629
1834
  size: meta.size,
1835
+ mime,
1836
+ previewKind,
1630
1837
  downloadUrl: `/api/files/download?path=${encodeURIComponent(relPath)}`,
1631
1838
  });
1632
1839
  } catch (e) {
@@ -1640,17 +1847,33 @@ async function handleFilesApi(req, res, _session) {
1640
1847
  }
1641
1848
 
1642
1849
  if (pathname === "/api/files/read" && req.method === "GET") {
1643
- const rel = toSafeRelPath(url.searchParams.get("path") || "");
1850
+ const rel = toSafeRelPath(url.searchParams.get("path") || "", false);
1644
1851
  if (!rel) {
1645
1852
  sendJson(res, 400, { ok: false, error: "Missing or invalid file path." });
1646
1853
  return true;
1647
1854
  }
1648
- const full = resolve(FILES_ROOT, rel);
1855
+ const physicalRel = visibleFilesPathToPhysicalPath(rel);
1856
+ const full = resolve(FILES_ROOT, physicalRel);
1649
1857
  try {
1650
1858
  const info = await stat(full);
1651
1859
  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." });
1860
+ const mime = inferFileMime(rel);
1861
+ const previewKind = inferFilePreviewKind(rel, mime);
1862
+ const previewable = ["text", "markdown", "json", "yaml"].includes(previewKind);
1863
+ if (!previewable || info.size > MAX_PREVIEW_BYTES) {
1864
+ sendJson(res, 200, {
1865
+ ok: true,
1866
+ root: FILES_ROOT,
1867
+ path: rel,
1868
+ absPath: full,
1869
+ name: basename(full),
1870
+ size: info.size,
1871
+ mime,
1872
+ previewKind,
1873
+ previewable: false,
1874
+ reason: !previewable ? "not_previewable" : "too_large",
1875
+ downloadUrl: `/api/files/download?path=${encodeURIComponent(rel)}`,
1876
+ });
1654
1877
  return true;
1655
1878
  }
1656
1879
  const text = await readFile(full, "utf8");
@@ -1659,7 +1882,12 @@ async function handleFilesApi(req, res, _session) {
1659
1882
  root: FILES_ROOT,
1660
1883
  path: rel,
1661
1884
  absPath: full,
1885
+ name: basename(full),
1662
1886
  size: info.size,
1887
+ mime,
1888
+ previewKind,
1889
+ previewable: true,
1890
+ downloadUrl: `/api/files/download?path=${encodeURIComponent(rel)}`,
1663
1891
  text,
1664
1892
  });
1665
1893
  } catch {
@@ -1671,7 +1899,7 @@ async function handleFilesApi(req, res, _session) {
1671
1899
  if (pathname === "/api/files/write" && req.method === "POST") {
1672
1900
  try {
1673
1901
  const body = await readJsonBody(req);
1674
- const rel = toSafeRelPath(body?.path || "");
1902
+ const rel = toSafeRelPath(body?.path || "", false);
1675
1903
  const text = String(body?.text ?? "");
1676
1904
  const overwrite = body?.overwrite !== false;
1677
1905
  if (!rel) {
@@ -1682,7 +1910,7 @@ async function handleFilesApi(req, res, _session) {
1682
1910
  sendJson(res, 413, { ok: false, error: "Text payload exceeds max upload bytes limit." });
1683
1911
  return true;
1684
1912
  }
1685
- const full = resolve(FILES_ROOT, rel);
1913
+ const full = resolve(FILES_ROOT, visibleFilesPathToPhysicalPath(rel));
1686
1914
  await mkdir(dirname(full), { recursive: true });
1687
1915
  await writeFile(full, text, { encoding: "utf8", flag: overwrite ? "w" : "wx" });
1688
1916
  const info = await stat(full);
@@ -1704,17 +1932,16 @@ async function handleFilesApi(req, res, _session) {
1704
1932
  }
1705
1933
 
1706
1934
  if (pathname === "/api/files/download" && req.method === "GET") {
1707
- const rel = toSafeRelPath(url.searchParams.get("path") || "");
1935
+ const rel = toSafeRelPath(url.searchParams.get("path") || "", false);
1708
1936
  if (!rel) {
1709
1937
  sendJson(res, 400, { ok: false, error: "Missing or invalid file path." });
1710
1938
  return true;
1711
1939
  }
1712
- const full = resolve(FILES_ROOT, rel);
1940
+ const full = resolve(FILES_ROOT, visibleFilesPathToPhysicalPath(rel));
1713
1941
  try {
1714
1942
  const info = await stat(full);
1715
1943
  if (!info.isFile()) throw new Error("Not a file");
1716
- const ext = extname(full);
1717
- const mime = MIME_TYPES[ext] || "application/octet-stream";
1944
+ const mime = inferFileMime(rel);
1718
1945
  res.writeHead(200, {
1719
1946
  "content-type": mime,
1720
1947
  "content-length": String(info.size),
@@ -1730,12 +1957,12 @@ async function handleFilesApi(req, res, _session) {
1730
1957
  if (pathname === "/api/files/delete" && req.method === "POST") {
1731
1958
  try {
1732
1959
  const body = await readJsonBody(req);
1733
- const rel = toSafeRelPath(body?.path || "");
1960
+ const rel = toSafeRelPath(body?.path || "", false);
1734
1961
  if (!rel) {
1735
1962
  sendJson(res, 400, { ok: false, error: "Missing or invalid file path." });
1736
1963
  return true;
1737
1964
  }
1738
- await rm(resolve(FILES_ROOT, rel), { force: true });
1965
+ await rm(resolve(FILES_ROOT, visibleFilesPathToPhysicalPath(rel)), { force: true });
1739
1966
  sendJson(res, 200, { ok: true, path: rel });
1740
1967
  } catch (e) {
1741
1968
  sendJson(res, 500, { ok: false, error: e instanceof Error ? e.message : String(e) });
@@ -4784,6 +5011,14 @@ const handleControlPanelPreferences = createControlPanelPreferencesHandler({
4784
5011
  readJsonBody,
4785
5012
  });
4786
5013
 
5014
+ const handleKnowledgebaseApi = createKnowledgebaseApiHandler({
5015
+ PORTAL_PORT,
5016
+ TANDEM_KB_ADMIN_URL: KB_ADMIN_URL,
5017
+ KB_ADMIN_API_KEY_FILE,
5018
+ KB_DEFAULT_COLLECTION_ID,
5019
+ sendJson,
5020
+ });
5021
+
4787
5022
  async function handleApi(req, res) {
4788
5023
  const pathname = new URL(req.url, `http://127.0.0.1:${PORTAL_PORT}`).pathname;
4789
5024
 
@@ -4909,11 +5144,13 @@ async function handleApi(req, res) {
4909
5144
  }
4910
5145
 
4911
5146
  if (pathname === "/api/auth/login" && req.method === "POST") {
5147
+ res.setHeader("cache-control", "no-store, max-age=0");
4912
5148
  await handleAuthLogin(req, res);
4913
5149
  return true;
4914
5150
  }
4915
5151
 
4916
5152
  if (pathname === "/api/auth/logout" && req.method === "POST") {
5153
+ res.setHeader("cache-control", "no-store, max-age=0");
4917
5154
  const current = getSession(req);
4918
5155
  if (current?.sid) sessions.delete(current.sid);
4919
5156
  clearSessionCookie(res);
@@ -4922,10 +5159,28 @@ async function handleApi(req, res) {
4922
5159
  }
4923
5160
 
4924
5161
  if (pathname === "/api/auth/me" && req.method === "GET") {
5162
+ res.setHeader("cache-control", "no-store, max-age=0");
4925
5163
  const session = requireSession(req, res);
4926
5164
  if (!session) return true;
4927
- const health = await engineHealth(session.token);
4928
- if (!health) {
5165
+ const probe = await probeEngineHealth(session.token);
5166
+ if (!probe.ok) {
5167
+ if (probe.status === 401 || probe.status === 403) {
5168
+ sessions.delete(session.sid);
5169
+ clearSessionCookie(res);
5170
+ sendJson(res, 401, {
5171
+ ok: false,
5172
+ error: "Session token is no longer valid for the configured engine.",
5173
+ });
5174
+ return true;
5175
+ }
5176
+ sendJson(res, 503, {
5177
+ ok: false,
5178
+ error: "Engine is temporarily unavailable while restoring your session.",
5179
+ });
5180
+ return true;
5181
+ }
5182
+ const health = probe.payload;
5183
+ if (!health || typeof health !== "object") {
4929
5184
  sessions.delete(session.sid);
4930
5185
  clearSessionCookie(res);
4931
5186
  sendJson(res, 401, {
@@ -4961,6 +5216,12 @@ async function handleApi(req, res) {
4961
5216
  return handleControlPanelConfig(req, res);
4962
5217
  }
4963
5218
 
5219
+ if (pathname.startsWith("/api/knowledgebase")) {
5220
+ const session = requireSession(req, res);
5221
+ if (!session) return true;
5222
+ return handleKnowledgebaseApi(req, res);
5223
+ }
5224
+
4964
5225
  if (pathname.startsWith("/api/swarm") || pathname.startsWith("/api/orchestrator")) {
4965
5226
  const session = requireSession(req, res);
4966
5227
  if (!session) return true;
@@ -5085,7 +5346,7 @@ async function main() {
5085
5346
  log(`Engine URL: ${ENGINE_URL}`);
5086
5347
  log(`Engine mode: ${isLocalEngineUrl(ENGINE_URL) ? "local" : "remote"}`);
5087
5348
  log(`Files root: ${FILES_ROOT}`);
5088
- log(`Files scope: ${FILES_SCOPE || "(full root)"}`);
5349
+ log(`Files buckets: uploads, artifacts, exports`);
5089
5350
  log(`Build: ${CONTROL_PANEL_BUILD_FINGERPRINT}`);
5090
5351
  log("=========================================");
5091
5352
  });