@hachej/boring-workspace 0.1.33 → 0.1.35

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/dist/server.js CHANGED
@@ -1019,13 +1019,19 @@ function buildBoringSystemPrompt(opts) {
1019
1019
  "- Imperative method names: `registerComponent`, `addPanel`, `registerCommand` (no `Panel`), `registerTab` \u2014 the actual names are `registerPanel`, `registerPanelCommand`, `registerLeftTab`, `registerSurfaceResolver` (and you usually express these declaratively, not as method calls).",
1020
1020
  "- Import paths: `@hachej/boring-pi` (it's a skills package, not for code), `@boring-ui/*`, `@hachej/pi-sdk` \u2014 use `@hachej/boring-workspace/plugin` for front and `@hachej/boring-workspace/server` for server.",
1021
1021
  '- File visualizers: import `WORKSPACE_OPEN_PATH_SURFACE_KIND`/`PaneProps` from `@hachej/boring-workspace/plugin`; import `useApiBaseUrl`/`useWorkspaceRequestId` from `@hachej/boring-workspace`; read `request.target`; fetch `${apiBaseUrl}/api/v1/files/raw?...` with `credentials: "include"` and `x-boring-workspace-id` when present. Never use `/workspace/read` or string kind `"WORKSPACE_OPEN_PATH_SURFACE_KIND"`.',
1022
- "- Pi extension tools: `defineTool` and `export const tools` do NOT exist. Export `default function (pi) { pi.registerTool({ name, description, execute }) }`.",
1022
+ '- Pi extension tools: `defineTool` and `export const tools` do NOT exist. Export `default function (pi) { pi.registerTool({ name, description, parameters: { type: "object", properties: {} }, execute }) }`. `parameters` is mandatory even for no-arg tools; omitting it breaks tool execution.',
1023
1023
  '- Server/Pi tool method: `handler` \u2014 use `execute`. Return shape: `{ content: [{ type: "text", text }] }` (NEVER a bare string).',
1024
1024
  "- Manifest values: `boring.server: true` \u2014 use `false`/omit for hot-reload user plugins, or a relative path string only for advanced boot-time/static server integration.",
1025
1025
  "- File layout: files at the package root, or `src/` / `dist/` / `lib/` subdirectories \u2014 the scaffold's hot-reload layout (`front/index.tsx`, optional `agent/index.ts` declared in `pi.extensions`) is the one the workspace refreshes on `/reload`.",
1026
1026
  "- Dependency installs: do NOT install plugin UI dependencies at the workspace root. Install them inside `.pi/extensions/<name>/` and keep React/workspace/boring-ui-kit imports as host singletons, not plugin dependencies.",
1027
1027
  "- Hot-reload agent tools: do NOT put them in `.pi/extensions/<name>/server/index.ts`; use `pi.extensions` instead. `boring.server` requires static composition plus process restart."
1028
1028
  ].join("\n"),
1029
+ [
1030
+ "## Installing an existing or published plugin",
1031
+ "To ADD an existing or published plugin (not author a new one), use `boring-ui-plugin install <source>` via bash \u2014 `<source>` is `npm:<package>`, `git:<repo>`, `github:<owner>/<repo>`, an `http(s)` git URL, or a local path; add `--global` for all workspaces (default is this workspace).",
1032
+ "A bare `npm install <package>` does NOT register it as a plugin (no `.pi/settings.json` package source), so it will NOT load \u2014 always use `boring-ui-plugin install`, then ask the user to `/reload` (a `boring.server` backend also needs a process restart).",
1033
+ "Inspect with `boring-ui-plugin list [--json]`; remove with `boring-ui-plugin remove <id-or-source>`."
1034
+ ].join("\n"),
1029
1035
  docsBlock
1030
1036
  ].join("\n\n");
1031
1037
  }
@@ -1227,6 +1233,14 @@ function resolveContainedPluginPath(rootDir, value, options = {}) {
1227
1233
  }
1228
1234
 
1229
1235
  // src/server/agentPlugins/scan.ts
1236
+ function normalizeBoringPluginSource(input) {
1237
+ if (typeof input === "string") return { rootDir: resolve3(input), kind: "internal" };
1238
+ return {
1239
+ rootDir: resolve3(input.rootDir),
1240
+ kind: input.kind,
1241
+ ...input.workspaceId ? { workspaceId: input.workspaceId } : {}
1242
+ };
1243
+ }
1230
1244
  function pluginIdFromPackageJson(pkg, rootDir) {
1231
1245
  const explicitId = typeof pkg.boring?.id === "string" && pkg.boring.id.trim() ? pkg.boring.id.trim() : void 0;
1232
1246
  if (explicitId) return explicitId;
@@ -1285,10 +1299,11 @@ function packagePathContainmentIssues(rootDir, pkg) {
1285
1299
  return issues;
1286
1300
  }
1287
1301
  function discoverBoringPluginDirs(pluginDirs) {
1288
- const out = /* @__PURE__ */ new Set();
1302
+ const out = /* @__PURE__ */ new Map();
1289
1303
  const missingPackageJson = [];
1290
1304
  for (const raw of pluginDirs) {
1291
- const dir = resolve3(raw);
1305
+ const source = normalizeBoringPluginSource(raw);
1306
+ const dir = source.rootDir;
1292
1307
  if (!existsSync2(dir)) continue;
1293
1308
  const info = statSync(dir);
1294
1309
  if (!info.isDirectory()) continue;
@@ -1299,13 +1314,19 @@ function discoverBoringPluginDirs(pluginDirs) {
1299
1314
  const child = join3(dir, entry.name);
1300
1315
  if (existsSync2(join3(child, "package.json"))) childPackageDirs.push(child);
1301
1316
  }
1302
- if (hasPackageJson) out.add(dir);
1303
- for (const child of childPackageDirs) out.add(child);
1304
- if (!hasPackageJson && childPackageDirs.length === 0 && basename(dir) !== "extensions") {
1317
+ if (hasPackageJson && !out.has(dir)) out.set(dir, source);
1318
+ for (const child of childPackageDirs) {
1319
+ if (!out.has(child)) out.set(child, { ...source, rootDir: child });
1320
+ }
1321
+ const collectionDirNames = /* @__PURE__ */ new Set(["extensions", "npm", "git"]);
1322
+ if (!hasPackageJson && childPackageDirs.length === 0 && !collectionDirNames.has(basename(dir))) {
1305
1323
  missingPackageJson.push(dir);
1306
1324
  }
1307
1325
  }
1308
- return { dirs: [...out].sort(), missingPackageJson: [...new Set(missingPackageJson)].sort() };
1326
+ return {
1327
+ sources: [...out.values()].sort((a, b) => a.rootDir.localeCompare(b.rootDir)),
1328
+ missingPackageJson: [...new Set(missingPackageJson)].sort()
1329
+ };
1309
1330
  }
1310
1331
  function scanBoringPlugins(pluginDirs) {
1311
1332
  const errors = [];
@@ -1315,7 +1336,8 @@ function scanBoringPlugins(pluginDirs) {
1315
1336
  for (const pluginDir of discovered.missingPackageJson) {
1316
1337
  errors.push({ pluginDir, code: "MISSING_PACKAGE_JSON", message: "package.json is missing" });
1317
1338
  }
1318
- for (const rootDir of discovered.dirs) {
1339
+ for (const source of discovered.sources) {
1340
+ const rootDir = source.rootDir;
1319
1341
  let raw;
1320
1342
  try {
1321
1343
  raw = parsePackageJson(rootDir);
@@ -1353,15 +1375,26 @@ function scanBoringPlugins(pluginDirs) {
1353
1375
  } else {
1354
1376
  const previous = seenIds.get(id);
1355
1377
  if (previous) {
1356
- errors.push({
1357
- pluginDir: rootDir,
1358
- pluginId: id,
1359
- code: "INVALID_PLUGIN_METADATA",
1360
- message: `duplicate plugin id "${id}" also declared by ${previous}`
1361
- });
1362
1378
  const previousPluginIndex = plugins.findIndex((plugin) => plugin.id === id);
1363
- if (previousPluginIndex >= 0) plugins.splice(previousPluginIndex, 1);
1364
- canAddPlugin = false;
1379
+ const previousPlugin = previousPluginIndex >= 0 ? plugins[previousPluginIndex] : void 0;
1380
+ const currentIsWorkspaceLocal = Boolean(source.workspaceId);
1381
+ const previousIsWorkspaceLocal = Boolean(previousPlugin?.source.workspaceId);
1382
+ const currentMayShadowPrevious = source.kind === "external" && currentIsWorkspaceLocal && previousPlugin?.source.kind === "external" && !previousIsWorkspaceLocal;
1383
+ if (currentMayShadowPrevious) {
1384
+ if (previousPluginIndex >= 0) plugins.splice(previousPluginIndex, 1);
1385
+ seenIds.set(id, rootDir);
1386
+ } else if (!currentIsWorkspaceLocal && previousIsWorkspaceLocal) {
1387
+ canAddPlugin = false;
1388
+ } else {
1389
+ errors.push({
1390
+ pluginDir: rootDir,
1391
+ pluginId: id,
1392
+ code: "INVALID_PLUGIN_METADATA",
1393
+ message: `duplicate plugin id "${id}" also declared by ${previous}`
1394
+ });
1395
+ if (previousPluginIndex >= 0) plugins.splice(previousPluginIndex, 1);
1396
+ canAddPlugin = false;
1397
+ }
1365
1398
  } else {
1366
1399
  seenIds.set(id, rootDir);
1367
1400
  }
@@ -1373,6 +1406,7 @@ function scanBoringPlugins(pluginDirs) {
1373
1406
  }
1374
1407
  if (!canAddPlugin) continue;
1375
1408
  const pkg = result.packageJson;
1409
+ const hasBoring = pkg.boring !== void 0;
1376
1410
  const boring = pkg.boring ?? {};
1377
1411
  const pi = pkg.pi;
1378
1412
  const frontPath = resolvePluginPath(rootDir, boring.front, { mustExist: true });
@@ -1385,11 +1419,13 @@ function scanBoringPlugins(pluginDirs) {
1385
1419
  rootDir,
1386
1420
  version,
1387
1421
  boring,
1422
+ hasBoring,
1388
1423
  ...pi ? { pi } : {},
1389
1424
  ...frontPath ? { frontPath, frontUrl: `/@fs/${frontPath}` } : {},
1390
1425
  ...serverPath ? { serverPath } : {},
1391
1426
  ...extensionPaths.length > 0 ? { extensionPaths } : {},
1392
- ...skillPaths.length > 0 ? { skillPaths } : {}
1427
+ ...skillPaths.length > 0 ? { skillPaths } : {},
1428
+ source
1393
1429
  });
1394
1430
  }
1395
1431
  const preflight = { ok: errors.length === 0, errors };
@@ -1400,7 +1436,7 @@ function preflightBoringPlugins(pluginDirs) {
1400
1436
  }
1401
1437
  function readBoringPlugins(pluginDirs) {
1402
1438
  const scan = scanBoringPlugins(pluginDirs);
1403
- return scan.preflight.ok ? scan.plugins : [];
1439
+ return scan.preflight.ok ? scan.plugins.filter((plugin) => plugin.hasBoring) : [];
1404
1440
  }
1405
1441
 
1406
1442
  // src/server/agentPlugins/signatureCache.ts
@@ -1564,10 +1600,11 @@ function frontSignatureRoot(plugin) {
1564
1600
  return rel === "" || !rel.startsWith("..") && !isAbsolute3(rel) ? frontRoot : dirname6(plugin.frontPath);
1565
1601
  }
1566
1602
  function pluginSignature(plugin) {
1567
- return createHash("sha256").update(JSON.stringify(plugin.boring)).update(JSON.stringify(plugin.pi ?? {})).update(plugin.version).update(plugin.frontPath ?? "").update(pluginFileSignature(plugin.frontPath)).update(directorySignature(frontSignatureRoot(plugin))).update(directorySignature(join5(plugin.rootDir, "shared"))).update(plugin.serverPath ?? "").update(pluginFileSignature(plugin.serverPath)).update(directorySignature(plugin.serverPath ? dirname6(plugin.serverPath) : void 0)).update((plugin.extensionPaths ?? []).join("\0")).update((plugin.skillPaths ?? []).join("\0")).digest("hex");
1603
+ return createHash("sha256").update(JSON.stringify(plugin.boring)).update(JSON.stringify(plugin.pi ?? {})).update(plugin.version).update(JSON.stringify(plugin.source)).update(plugin.frontPath ?? "").update(pluginFileSignature(plugin.frontPath)).update(directorySignature(frontSignatureRoot(plugin))).update(directorySignature(join5(plugin.rootDir, "shared"))).update(plugin.serverPath ?? "").update(pluginFileSignature(plugin.serverPath)).update(directorySignature(plugin.serverPath ? dirname6(plugin.serverPath) : void 0)).update((plugin.extensionPaths ?? []).join("\0")).update((plugin.skillPaths ?? []).join("\0")).digest("hex");
1568
1604
  }
1569
1605
  function computeRequiresRestart(previous, next) {
1570
1606
  if (!previous) return [];
1607
+ if (previous.source.kind === "external" && next.source.kind === "external") return [];
1571
1608
  const prevHasServer = !!previous.serverPath;
1572
1609
  const nextHasServer = !!next.serverPath;
1573
1610
  if (!prevHasServer && !nextHasServer) return [];
@@ -1613,8 +1650,10 @@ var BoringPluginAssetManager = class {
1613
1650
  version: plugin.version,
1614
1651
  revision: plugin.revision,
1615
1652
  rootDir: plugin.rootDir,
1653
+ source: plugin.source,
1616
1654
  ...plugin.frontPath ? { frontPath: plugin.frontPath } : {},
1617
- ...plugin.frontTarget ? { frontTarget: plugin.frontTarget } : {}
1655
+ ...plugin.frontTarget ? { frontTarget: plugin.frontTarget } : {},
1656
+ ...plugin.serverPath ? { serverPath: plugin.serverPath } : {}
1618
1657
  }));
1619
1658
  }
1620
1659
  inspectLoadedPiSnapshot() {
@@ -1654,7 +1693,7 @@ ${prompts.join("\n\n")}` } : {}
1654
1693
  async doLoadOnce() {
1655
1694
  this.lastErrors.clear();
1656
1695
  const scan = scanBoringPlugins(this.pluginDirs);
1657
- const nextPlugins = scan.plugins;
1696
+ const nextPlugins = scan.plugins.filter((plugin) => plugin.hasBoring);
1658
1697
  const nextIds = new Set(nextPlugins.map((plugin) => plugin.id));
1659
1698
  const invalidPluginDirs = new Set(scan.preflight.errors.map((error) => resolve5(error.pluginDir)));
1660
1699
  const events = [];
@@ -1818,31 +1857,7 @@ function collectRestartWarnings(events) {
1818
1857
  return warnings;
1819
1858
  }
1820
1859
  async function boringPluginRoutes(app, opts) {
1821
- const { manager, rebuildPlugins, enableReloadRoute = true } = opts;
1822
- if (enableReloadRoute) {
1823
- app.post("/api/boring.reload", async (_request, reply) => {
1824
- const scan = await manager.load();
1825
- const rebuild = rebuildPlugins ? await rebuildPlugins() : { ok: true, diagnostics: [] };
1826
- const restart_warnings = collectRestartWarnings(scan.events);
1827
- const hasFailures = scan.errors.length > 0 || rebuild.diagnostics.length > 0;
1828
- if (hasFailures) {
1829
- return reply.status(422).send({
1830
- ok: false,
1831
- errors: scan.errors,
1832
- diagnostics: rebuild.diagnostics,
1833
- plugins: scan.loaded,
1834
- // Even on failure, emit warnings for plugins that DID reload
1835
- // — partial-failure tolerance means some loaded successfully.
1836
- ...restart_warnings.length > 0 ? { restart_warnings } : {}
1837
- });
1838
- }
1839
- return reply.send({
1840
- ok: true,
1841
- plugins: scan.loaded,
1842
- ...restart_warnings.length > 0 ? { restart_warnings } : {}
1843
- });
1844
- });
1845
- }
1860
+ const { manager } = opts;
1846
1861
  const listPlugins = async () => manager.list();
1847
1862
  app.get("/api/v1/agent-plugins", listPlugins);
1848
1863
  const getPluginError = async (request, reply) => {
@@ -1919,8 +1934,499 @@ function aggregatePluginPrompts(manager) {
1919
1934
 
1920
1935
  ${prompts.join("\n\n")}`;
1921
1936
  }
1937
+
1938
+ // src/server/runtimeBackend/defineRuntimeServerPlugin.ts
1939
+ function defineRuntimeServerPlugin(plugin) {
1940
+ return plugin;
1941
+ }
1942
+ function isPlainObject(value) {
1943
+ if (typeof value !== "object" || value === null) return false;
1944
+ const proto = Object.getPrototypeOf(value);
1945
+ return proto === Object.prototype || proto === null;
1946
+ }
1947
+ function validateRuntimeServerPlugin(value) {
1948
+ if (!isPlainObject(value)) {
1949
+ throw new Error("runtime server plugin default export must be a plain object");
1950
+ }
1951
+ if ("id" in value) {
1952
+ throw new Error("runtime server plugin must not declare id; the host supplies plugin id from package metadata");
1953
+ }
1954
+ if (typeof value.routes !== "function") {
1955
+ throw new Error("runtime server plugin default export must define routes(router)");
1956
+ }
1957
+ if (value.dispose !== void 0 && typeof value.dispose !== "function") {
1958
+ throw new Error("runtime server plugin dispose must be a function when provided");
1959
+ }
1960
+ return value;
1961
+ }
1962
+ function isRuntimePluginResponse(value) {
1963
+ return isPlainObject(value) && value.kind === "response";
1964
+ }
1965
+
1966
+ // src/server/runtimeBackend/runtimePathSegments.ts
1967
+ function findUnsafeRuntimePathSegment(path) {
1968
+ if (path.includes("\\")) return "\\";
1969
+ for (const segment of path.split("/")) {
1970
+ if (segment === "." || segment === "..") return segment;
1971
+ }
1972
+ return null;
1973
+ }
1974
+ function describeUnsafeRuntimePathSegment(segment) {
1975
+ if (segment === "\\") return "backslashes";
1976
+ return `${segment} segments`;
1977
+ }
1978
+
1979
+ // src/server/runtimeBackend/routerCapture.ts
1980
+ var METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS", "ALL"];
1981
+ function routeKey(method, path) {
1982
+ return `${method} ${path}`;
1983
+ }
1984
+ function runtimeRouteKey(method, path) {
1985
+ return routeKey(method.toUpperCase(), path);
1986
+ }
1987
+ function validateRuntimeRoutePath(path) {
1988
+ if (typeof path !== "string" || path.length === 0) {
1989
+ throw new Error("runtime route path must be a non-empty string");
1990
+ }
1991
+ if (!path.startsWith("/")) {
1992
+ throw new Error(`runtime route path must start with /: ${path}`);
1993
+ }
1994
+ if (path.includes("?") || path.includes("#")) {
1995
+ throw new Error(`runtime route path must not include query strings or fragments: ${path}`);
1996
+ }
1997
+ let decodedPath;
1998
+ try {
1999
+ decodedPath = decodeURIComponent(path);
2000
+ } catch {
2001
+ throw new Error(`runtime route path must be valid percent-encoding: ${path}`);
2002
+ }
2003
+ const unsafeSegment = findUnsafeRuntimePathSegment(decodedPath);
2004
+ if (unsafeSegment) {
2005
+ throw new Error(`runtime route path must not contain ${describeUnsafeRuntimePathSegment(unsafeSegment)}: ${path}`);
2006
+ }
2007
+ if (path.includes(":")) {
2008
+ throw new Error(`runtime route path must be exact and must not contain params: ${path}`);
2009
+ }
2010
+ if (path.includes("*")) {
2011
+ throw new Error(`runtime route path must be exact and must not contain wildcards: ${path}`);
2012
+ }
2013
+ return path;
2014
+ }
2015
+ async function captureRuntimeRoutes(register) {
2016
+ const routes = [];
2017
+ const seen = /* @__PURE__ */ new Set();
2018
+ const add = (method, path, handler) => {
2019
+ if (typeof handler !== "function") {
2020
+ throw new Error(`runtime route ${method} ${path} handler must be a function`);
2021
+ }
2022
+ const normalizedPath = validateRuntimeRoutePath(path);
2023
+ const key = routeKey(method, normalizedPath);
2024
+ if (seen.has(key)) throw new Error(`duplicate runtime route: ${key}`);
2025
+ seen.add(key);
2026
+ routes.push({ method, path: normalizedPath, handler });
2027
+ };
2028
+ const router = Object.fromEntries(
2029
+ METHODS.map((method) => [method.toLowerCase(), (path, handler) => add(method, path, handler)])
2030
+ );
2031
+ await register(router);
2032
+ return routes;
2033
+ }
2034
+
2035
+ // src/server/runtimeBackend/runtimeBackendRegistry.ts
2036
+ import { ErrorCode } from "@hachej/boring-agent/shared";
2037
+
2038
+ // src/server/pluginImports/importServerModule.ts
2039
+ import { createRequire as createRequire2 } from "module";
2040
+ import { pathToFileURL } from "url";
2041
+ var require3 = createRequire2(import.meta.url);
2042
+ var warnedJitiMissing = false;
2043
+ function warnJitiUnavailable(serverPath, reason) {
2044
+ if (warnedJitiMissing) return;
2045
+ warnedJitiMissing = true;
2046
+ console.warn(
2047
+ `[boring-workspace] hotReload requested but jiti is unavailable (${reason}). Falling back to native import() for ${serverPath}; subsequent reloads will NOT pick up source changes because Node's module cache will return the same module. Install jiti or set hotReload: false.`
2048
+ );
2049
+ }
2050
+ function jitiImport(serverPath) {
2051
+ try {
2052
+ const jitiModule = require3("jiti");
2053
+ const create = jitiModule.createJiti;
2054
+ if (!create) {
2055
+ warnJitiUnavailable(serverPath, "createJiti not exported");
2056
+ return null;
2057
+ }
2058
+ return create(import.meta.url, { moduleCache: false }).import(serverPath);
2059
+ } catch (err) {
2060
+ warnJitiUnavailable(serverPath, err instanceof Error ? err.message : String(err));
2061
+ return null;
2062
+ }
2063
+ }
2064
+ async function importServerModule(serverPath, hotReload) {
2065
+ if (hotReload) {
2066
+ const jiti = jitiImport(serverPath);
2067
+ if (jiti) return await jiti;
2068
+ }
2069
+ const href = pathToFileURL(serverPath).href;
2070
+ return await import(
2071
+ /* @vite-ignore */
2072
+ href
2073
+ );
2074
+ }
2075
+
2076
+ // src/server/runtimeBackend/runtimeBackendRegistry.ts
2077
+ var RuntimeBackendError = class extends Error {
2078
+ constructor(code, statusCode, message, details) {
2079
+ super(message);
2080
+ this.code = code;
2081
+ this.statusCode = statusCode;
2082
+ this.details = details;
2083
+ this.name = "RuntimeBackendError";
2084
+ }
2085
+ code;
2086
+ statusCode;
2087
+ details;
2088
+ };
2089
+ function errorMessage(error) {
2090
+ return error instanceof Error ? error.stack ?? error.message : String(error);
2091
+ }
2092
+ function moduleValue(mod) {
2093
+ return typeof mod === "object" && mod !== null && "default" in mod ? mod.default : mod;
2094
+ }
2095
+ function toRouteMap(routes) {
2096
+ const map = /* @__PURE__ */ new Map();
2097
+ for (const route of routes) map.set(runtimeRouteKey(route.method, route.path), route);
2098
+ return map;
2099
+ }
2100
+ function assertJsonSerializable(value) {
2101
+ if (value === void 0 || value === null) return;
2102
+ if (typeof value === "function" || typeof value === "symbol" || typeof value === "bigint") {
2103
+ throw new RuntimeBackendError(
2104
+ ErrorCode.enum.RUNTIME_PLUGIN_RESPONSE_UNSUPPORTED,
2105
+ 500,
2106
+ "runtime plugin response is not JSON-serializable"
2107
+ );
2108
+ }
2109
+ try {
2110
+ JSON.stringify(value);
2111
+ } catch (error) {
2112
+ throw new RuntimeBackendError(
2113
+ ErrorCode.enum.RUNTIME_PLUGIN_RESPONSE_UNSUPPORTED,
2114
+ 500,
2115
+ `runtime plugin response is not JSON-serializable: ${error instanceof Error ? error.message : String(error)}`
2116
+ );
2117
+ }
2118
+ }
2119
+ function normalizeResponse(value) {
2120
+ if (value === void 0 || value === null) return { status: 204, headers: {} };
2121
+ if (isRuntimePluginResponse(value)) return normalizeExplicitResponse(value);
2122
+ assertJsonSerializable(value);
2123
+ return { status: 200, headers: { "content-type": "application/json; charset=utf-8" }, body: value };
2124
+ }
2125
+ function normalizeExplicitResponse(value) {
2126
+ const status = value.status ?? (value.body === void 0 || value.body === null ? 204 : 200);
2127
+ if (!Number.isInteger(status) || status < 100 || status > 599) {
2128
+ throw new RuntimeBackendError(
2129
+ ErrorCode.enum.RUNTIME_PLUGIN_RESPONSE_UNSUPPORTED,
2130
+ 500,
2131
+ "runtime plugin response status must be an integer HTTP status code"
2132
+ );
2133
+ }
2134
+ const headers = {};
2135
+ if (value.headers !== void 0) {
2136
+ for (const [name, headerValue] of Object.entries(value.headers)) {
2137
+ if (typeof headerValue !== "string") {
2138
+ throw new RuntimeBackendError(
2139
+ ErrorCode.enum.RUNTIME_PLUGIN_RESPONSE_UNSUPPORTED,
2140
+ 500,
2141
+ "runtime plugin response headers must be strings"
2142
+ );
2143
+ }
2144
+ headers[name] = headerValue;
2145
+ }
2146
+ }
2147
+ if (value.body === void 0 || value.body === null) return { status, headers };
2148
+ assertJsonSerializable(value.body);
2149
+ if (!Object.keys(headers).some((name) => name.toLowerCase() === "content-type")) {
2150
+ headers["content-type"] = "application/json; charset=utf-8";
2151
+ }
2152
+ return { status, headers, body: value.body };
2153
+ }
2154
+ async function disposeSnapshot(snapshot) {
2155
+ if (!snapshot.module.dispose) return [];
2156
+ try {
2157
+ await snapshot.module.dispose();
2158
+ return [];
2159
+ } catch (error) {
2160
+ return [{
2161
+ pluginId: snapshot.pluginId,
2162
+ source: `runtime backend dispose (${snapshot.pluginId})`,
2163
+ code: ErrorCode.enum.RUNTIME_PLUGIN_LOAD_FAILED,
2164
+ message: errorMessage(error)
2165
+ }];
2166
+ }
2167
+ }
2168
+ var RuntimeBackendRegistry = class {
2169
+ snapshots = /* @__PURE__ */ new Map();
2170
+ lastDiagnostics = [];
2171
+ reloadQueue = Promise.resolve({ ok: true, diagnostics: [] });
2172
+ getDiagnostics() {
2173
+ return [...this.lastDiagnostics];
2174
+ }
2175
+ listPluginIds() {
2176
+ return [...this.snapshots.keys()].sort();
2177
+ }
2178
+ async reloadFromLoadedPlugins(plugins) {
2179
+ const run = this.reloadQueue.then(() => this.reloadOnce(plugins), () => this.reloadOnce(plugins));
2180
+ this.reloadQueue = run.then(() => ({ ok: true, diagnostics: [] }), () => ({ ok: false, diagnostics: [] }));
2181
+ return run;
2182
+ }
2183
+ async close() {
2184
+ const run = this.reloadQueue.then(() => this.closeOnce(), () => this.closeOnce());
2185
+ this.reloadQueue = run.then(() => ({ ok: true, diagnostics: [] }), () => ({ ok: false, diagnostics: [] }));
2186
+ return run;
2187
+ }
2188
+ async dispatch(request) {
2189
+ const snapshot = this.snapshots.get(request.pluginId);
2190
+ if (!snapshot) {
2191
+ throw new RuntimeBackendError(
2192
+ ErrorCode.enum.RUNTIME_PLUGIN_NOT_FOUND,
2193
+ 404,
2194
+ `runtime backend plugin not found: ${request.pluginId}`
2195
+ );
2196
+ }
2197
+ if (snapshot.source.workspaceId && snapshot.source.workspaceId !== request.workspaceId) {
2198
+ throw new RuntimeBackendError(
2199
+ ErrorCode.enum.RUNTIME_PLUGIN_NOT_FOUND,
2200
+ 404,
2201
+ `runtime backend plugin not found in workspace: ${request.pluginId}`
2202
+ );
2203
+ }
2204
+ const route = snapshot.routes.get(runtimeRouteKey(request.method, request.path)) ?? snapshot.routes.get(runtimeRouteKey("ALL", request.path));
2205
+ if (!route) {
2206
+ throw new RuntimeBackendError(
2207
+ ErrorCode.enum.RUNTIME_PLUGIN_ROUTE_NOT_FOUND,
2208
+ 404,
2209
+ `runtime backend route not found: ${request.method.toUpperCase()} ${request.path}`
2210
+ );
2211
+ }
2212
+ const ctx = {
2213
+ pluginId: request.pluginId,
2214
+ method: request.method.toUpperCase(),
2215
+ path: request.path,
2216
+ query: request.query,
2217
+ headers: request.headers,
2218
+ signal: request.signal,
2219
+ body: request.body,
2220
+ logger: request.logger
2221
+ };
2222
+ try {
2223
+ return normalizeResponse(await route.handler(ctx));
2224
+ } catch (error) {
2225
+ if (error instanceof RuntimeBackendError) throw error;
2226
+ throw new RuntimeBackendError(
2227
+ ErrorCode.enum.RUNTIME_PLUGIN_HANDLER_FAILED,
2228
+ 500,
2229
+ error instanceof Error ? error.message : String(error)
2230
+ );
2231
+ }
2232
+ }
2233
+ async reloadOnce(plugins) {
2234
+ const diagnostics = [];
2235
+ const externalRuntimePlugins = plugins.filter((plugin) => plugin.source.kind === "external" && plugin.serverPath);
2236
+ const nextIds = new Set(externalRuntimePlugins.map((plugin) => plugin.id));
2237
+ for (const id of [...this.snapshots.keys()]) {
2238
+ if (nextIds.has(id)) continue;
2239
+ const previous = this.snapshots.get(id);
2240
+ if (!previous) continue;
2241
+ this.snapshots.delete(id);
2242
+ diagnostics.push(...await disposeSnapshot(previous));
2243
+ }
2244
+ for (const plugin of externalRuntimePlugins) {
2245
+ const serverPath = plugin.serverPath;
2246
+ if (!serverPath) continue;
2247
+ try {
2248
+ const mod = await importServerModule(serverPath, true);
2249
+ const runtimePlugin = validateRuntimeServerPlugin(moduleValue(mod));
2250
+ const routes = await captureRuntimeRoutes((router) => runtimePlugin.routes(router));
2251
+ const nextSnapshot = {
2252
+ pluginId: plugin.id,
2253
+ source: plugin.source,
2254
+ module: runtimePlugin,
2255
+ routes: toRouteMap(routes)
2256
+ };
2257
+ const previous = this.snapshots.get(plugin.id);
2258
+ this.snapshots.set(plugin.id, nextSnapshot);
2259
+ if (previous) diagnostics.push(...await disposeSnapshot(previous));
2260
+ } catch (error) {
2261
+ diagnostics.push({
2262
+ pluginId: plugin.id,
2263
+ source: `runtime backend (${plugin.id})`,
2264
+ code: ErrorCode.enum.RUNTIME_PLUGIN_LOAD_FAILED,
2265
+ message: errorMessage(error)
2266
+ });
2267
+ }
2268
+ }
2269
+ this.lastDiagnostics = diagnostics;
2270
+ return { ok: diagnostics.length === 0, diagnostics };
2271
+ }
2272
+ async closeOnce() {
2273
+ const diagnostics = [];
2274
+ for (const snapshot of this.snapshots.values()) {
2275
+ diagnostics.push(...await disposeSnapshot(snapshot));
2276
+ }
2277
+ this.snapshots.clear();
2278
+ this.lastDiagnostics = diagnostics;
2279
+ return { ok: diagnostics.length === 0, diagnostics };
2280
+ }
2281
+ };
2282
+
2283
+ // src/server/runtimeBackend/runtimeBackendGateway.ts
2284
+ import { ErrorCode as ErrorCode2 } from "@hachej/boring-agent/shared";
2285
+ var GATEWAY_PREFIX = "/api/v1/plugins/";
2286
+ function rawPathFromRequest(request) {
2287
+ const url = request.raw.url ?? request.url;
2288
+ const queryIndex = url.indexOf("?");
2289
+ return queryIndex === -1 ? url : url.slice(0, queryIndex);
2290
+ }
2291
+ function rawGatewayTail(request, pluginId) {
2292
+ const rawPath = rawPathFromRequest(request);
2293
+ const prefix = `${GATEWAY_PREFIX}${pluginId}`;
2294
+ if (rawPath === prefix || rawPath === `${prefix}/`) return "/";
2295
+ if (!rawPath.startsWith(`${prefix}/`)) {
2296
+ throw new RuntimeBackendError(
2297
+ ErrorCode2.enum.RUNTIME_PLUGIN_ROUTE_NOT_FOUND,
2298
+ 404,
2299
+ "runtime backend route not found"
2300
+ );
2301
+ }
2302
+ return rawPath.slice(prefix.length);
2303
+ }
2304
+ function normalizeGatewayPath(rawTail) {
2305
+ let path;
2306
+ try {
2307
+ path = decodeURIComponent(rawTail);
2308
+ } catch {
2309
+ throw new RuntimeBackendError(
2310
+ ErrorCode2.enum.RUNTIME_PLUGIN_ROUTE_NOT_FOUND,
2311
+ 404,
2312
+ "runtime backend route path is not valid percent-encoding"
2313
+ );
2314
+ }
2315
+ if (path.length === 0) path = "/";
2316
+ const unsafeSegment = findUnsafeRuntimePathSegment(path);
2317
+ if (unsafeSegment) {
2318
+ throw new RuntimeBackendError(
2319
+ ErrorCode2.enum.RUNTIME_PLUGIN_ROUTE_NOT_FOUND,
2320
+ 404,
2321
+ `runtime backend route path must not contain ${describeUnsafeRuntimePathSegment(unsafeSegment)}`
2322
+ );
2323
+ }
2324
+ return path;
2325
+ }
2326
+ function firstString(value) {
2327
+ if (Array.isArray(value)) return value[0];
2328
+ return value;
2329
+ }
2330
+ function headersFromRequest(request) {
2331
+ const headers = new Headers();
2332
+ for (const [name, value] of Object.entries(request.headers)) {
2333
+ if (value === void 0) continue;
2334
+ if (Array.isArray(value)) {
2335
+ for (const item of value) headers.append(name, item);
2336
+ } else {
2337
+ headers.set(name, String(value));
2338
+ }
2339
+ }
2340
+ return headers;
2341
+ }
2342
+ function loggerFromRequest(request) {
2343
+ return {
2344
+ debug: (arg, message) => {
2345
+ if (message === void 0) request.log.debug(arg);
2346
+ else request.log.debug(arg, message);
2347
+ },
2348
+ info: (arg, message) => {
2349
+ if (message === void 0) request.log.info(arg);
2350
+ else request.log.info(arg, message);
2351
+ },
2352
+ warn: (arg, message) => {
2353
+ if (message === void 0) request.log.warn(arg);
2354
+ else request.log.warn(arg, message);
2355
+ },
2356
+ error: (arg, message) => {
2357
+ if (message === void 0) request.log.error(arg);
2358
+ else request.log.error(arg, message);
2359
+ }
2360
+ };
2361
+ }
2362
+ function sendDispatchResponse(reply, response) {
2363
+ reply.status(response.status);
2364
+ for (const [name, value] of Object.entries(response.headers)) reply.header(name, value);
2365
+ if (response.body === void 0 || response.body === null) return reply.send();
2366
+ return reply.send(response.body);
2367
+ }
2368
+ function sendError(reply, error) {
2369
+ if (error instanceof RuntimeBackendError) {
2370
+ return reply.status(error.statusCode).send({
2371
+ error: {
2372
+ code: error.code,
2373
+ message: error.message,
2374
+ ...error.details ? { details: error.details } : {}
2375
+ }
2376
+ });
2377
+ }
2378
+ return reply.status(500).send({
2379
+ error: {
2380
+ code: ErrorCode2.enum.INTERNAL_ERROR,
2381
+ message: error instanceof Error ? error.message : String(error)
2382
+ }
2383
+ });
2384
+ }
2385
+ async function runtimeBackendGateway(app, opts) {
2386
+ const handle = async (request, reply) => {
2387
+ const { pluginId } = request.params;
2388
+ if (!isValidBoringPluginId(pluginId)) {
2389
+ return sendError(reply, new RuntimeBackendError(
2390
+ ErrorCode2.enum.RUNTIME_PLUGIN_NOT_FOUND,
2391
+ 404,
2392
+ "runtime backend plugin not found"
2393
+ ));
2394
+ }
2395
+ let path;
2396
+ try {
2397
+ path = normalizeGatewayPath(rawGatewayTail(request, pluginId));
2398
+ } catch (error) {
2399
+ return sendError(reply, error);
2400
+ }
2401
+ const abort = new AbortController();
2402
+ const close = () => abort.abort();
2403
+ request.raw.on("close", close);
2404
+ try {
2405
+ const response = await opts.registry.dispatch({
2406
+ pluginId,
2407
+ method: request.method,
2408
+ path,
2409
+ query: new URLSearchParams(request.query),
2410
+ headers: headersFromRequest(request),
2411
+ signal: abort.signal,
2412
+ body: request.body,
2413
+ logger: loggerFromRequest(request),
2414
+ ...firstString(request.headers["x-boring-workspace-id"]) ?? opts.defaultWorkspaceId ? { workspaceId: firstString(request.headers["x-boring-workspace-id"]) ?? opts.defaultWorkspaceId } : {}
2415
+ });
2416
+ return sendDispatchResponse(reply, response);
2417
+ } catch (error) {
2418
+ return sendError(reply, error);
2419
+ } finally {
2420
+ request.raw.off("close", close);
2421
+ }
2422
+ };
2423
+ app.all("/api/v1/plugins/:pluginId", handle);
2424
+ app.all("/api/v1/plugins/:pluginId/*", handle);
2425
+ }
1922
2426
  export {
1923
2427
  BoringPluginAssetManager,
2428
+ RuntimeBackendError,
2429
+ RuntimeBackendRegistry,
1924
2430
  aggregatePluginPrompts,
1925
2431
  bootstrapServer,
1926
2432
  boringPluginRoutes,
@@ -1931,14 +2437,17 @@ export {
1931
2437
  createInMemoryBridge,
1932
2438
  createWorkspaceUiTools,
1933
2439
  definePluginAsset,
2440
+ defineRuntimeServerPlugin,
1934
2441
  defineServerPlugin,
1935
2442
  pluginFileSignature,
1936
2443
  preflightBoringPlugins,
1937
2444
  readBoringPlugins,
1938
2445
  readPluginSignatureCache,
1939
2446
  resolvePluginAssetPath,
2447
+ runtimeBackendGateway,
1940
2448
  scanBoringPlugins,
1941
2449
  uiRoutes,
2450
+ validateRuntimeServerPlugin,
1942
2451
  validateServerPlugin,
1943
2452
  writePluginSignatureCache
1944
2453
  };