@neuralnomads/codenomad-dev 0.13.3-dev-20260402-19a4c3df → 0.13.3-dev-20260404-7996e514

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 (45) hide show
  1. package/dist/auth/manager.js +5 -1
  2. package/dist/events/bus.js +4 -0
  3. package/dist/index.js +14 -0
  4. package/dist/server/http-server.js +279 -1
  5. package/dist/server/routes/sidecars.js +46 -0
  6. package/dist/sidecars/manager.js +193 -0
  7. package/package.json +1 -1
  8. package/public/assets/{ChangesTab-CmUh1lD6.js → ChangesTab-DvItgv-K.js} +2 -2
  9. package/public/assets/{DiffToolbar-BO2mraG6.js → DiffToolbar-Cu7psKR5.js} +1 -1
  10. package/public/assets/{FilesTab-eqP9iJFz.js → FilesTab-BRgtKHwp.js} +2 -2
  11. package/public/assets/{GitChangesTab-DyQyRmoO.js → GitChangesTab-BeiJf1yp.js} +2 -2
  12. package/public/assets/{SplitFilePanel-DvXBnKhO.js → SplitFilePanel-DyezmCo2.js} +1 -1
  13. package/public/assets/{StatusTab-B2B6v-Eg.js → StatusTab-BI7MfzHL.js} +1 -1
  14. package/public/assets/{bundle-full-CWbff1l0.js → bundle-full-DwOhsFtX.js} +1 -1
  15. package/public/assets/{diff-viewer-D0a6LOK6.js → diff-viewer-D8057mmx.js} +1 -1
  16. package/public/assets/index-4cIw-BDV.js +1 -0
  17. package/public/assets/{index-C9VkLCt-.js → index-Bfgb6hSQ.js} +1 -1
  18. package/public/assets/index-C3ecDP5_.js +1 -0
  19. package/public/assets/{index-Dl_aI0rQ.js → index-C5H7aIod.js} +1 -1
  20. package/public/assets/index-C5c6mvR5.js +1 -0
  21. package/public/assets/index-CD-C2Kdk.js +1 -0
  22. package/public/assets/index-CJQRbTtv.js +2 -0
  23. package/public/assets/index-CQD0rDm8.js +1 -0
  24. package/public/assets/{index-Y07FhVRK.css → index-CRyEAEtJ.css} +1 -1
  25. package/public/assets/index-Dcj6nNO-.js +1 -0
  26. package/public/assets/{loading-BMaruoBO.js → loading-C5GsedyF.js} +1 -1
  27. package/public/assets/main-C35SKI2Q.js +56 -0
  28. package/public/assets/{markdown-2ayBrByq.js → markdown-B0aobgPK.js} +3 -3
  29. package/public/assets/{monaco-viewer-DUhWC7BI.js → monaco-viewer-BCffFoQZ.js} +1 -1
  30. package/public/assets/{todo-D5pIlYqO.js → todo-CGOKkpdI.js} +1 -1
  31. package/public/assets/{tool-call-BuEF2-g9.js → tool-call-CpqnhOn0.js} +3 -3
  32. package/public/assets/unified-picker-8j7yVuRo.js +1 -0
  33. package/public/assets/{wrap-text-DD_eCVvu.js → wrap-text-CzY37NHS.js} +1 -1
  34. package/public/index.html +4 -4
  35. package/public/loading.html +4 -4
  36. package/public/sw.js +1 -1
  37. package/public/assets/index-BTg3IYTI.js +0 -1
  38. package/public/assets/index-CY6GNG9A.js +0 -1
  39. package/public/assets/index-CfoVNw2d.js +0 -2
  40. package/public/assets/index-DHqgtrcH.js +0 -1
  41. package/public/assets/index-DPhIvE8c.js +0 -1
  42. package/public/assets/index-Wj66Yen3.js +0 -1
  43. package/public/assets/index-vxahkr1I.js +0 -1
  44. package/public/assets/main-CH4aDUD4.js +0 -56
  45. package/public/assets/unified-picker-C-OVvqSd.js +0 -1
@@ -75,12 +75,16 @@ export class AuthManager {
75
75
  return isLoopbackAddress(request.socket.remoteAddress);
76
76
  }
77
77
  getSessionFromRequest(request) {
78
+ return this.getSessionFromHeaders(request.headers);
79
+ }
80
+ getSessionFromHeaders(headers) {
78
81
  if (!this.authEnabled) {
79
82
  // When auth is disabled, treat all requests as authenticated.
80
83
  // We still return a stable username so callers can display it.
81
84
  return { username: this.init.username, sessionId: "auth-disabled" };
82
85
  }
83
- const cookies = parseCookies(request.headers.cookie);
86
+ const cookieHeader = Array.isArray(headers.cookie) ? headers.cookie.join("; ") : headers.cookie;
87
+ const cookies = parseCookies(cookieHeader);
84
88
  const sessionId = cookies[this.cookieName];
85
89
  const session = this.sessionManager.getSession(sessionId);
86
90
  if (!session)
@@ -20,6 +20,8 @@ export class EventBus extends EventEmitter {
20
20
  this.on("workspace.error", handler);
21
21
  this.on("workspace.stopped", handler);
22
22
  this.on("workspace.log", handler);
23
+ this.on("sidecar.updated", handler);
24
+ this.on("sidecar.removed", handler);
23
25
  this.on("storage.configChanged", handler);
24
26
  this.on("storage.stateChanged", handler);
25
27
  this.on("instance.dataChanged", handler);
@@ -31,6 +33,8 @@ export class EventBus extends EventEmitter {
31
33
  this.off("workspace.error", handler);
32
34
  this.off("workspace.stopped", handler);
33
35
  this.off("workspace.log", handler);
36
+ this.off("sidecar.updated", handler);
37
+ this.off("sidecar.removed", handler);
34
38
  this.off("storage.configChanged", handler);
35
39
  this.off("storage.stateChanged", handler);
36
40
  this.off("instance.dataChanged", handler);
package/dist/index.js CHANGED
@@ -23,6 +23,7 @@ import { resolveHttpsOptions } from "./server/tls";
23
23
  import { resolveNetworkAddresses, resolveRemoteAddresses } from "./server/network-addresses";
24
24
  import { startDevReleaseMonitor } from "./releases/dev-release-monitor";
25
25
  import { SpeechService } from "./speech/service";
26
+ import { SideCarManager } from "./sidecars/manager";
26
27
  const require = createRequire(import.meta.url);
27
28
  const packageJson = require("../package.json");
28
29
  const __filename = fileURLToPath(import.meta.url);
@@ -208,6 +209,11 @@ async function main() {
208
209
  const fileSystemBrowser = new FileSystemBrowser({ rootDir: options.rootDir, unrestricted: options.unrestrictedRoot });
209
210
  const instanceStore = new InstanceStore(configLocation.instancesDir);
210
211
  const speechService = new SpeechService(settings, logger.child({ component: "speech" }));
212
+ const sidecarManager = new SideCarManager({
213
+ settings,
214
+ eventBus,
215
+ logger: logger.child({ component: "sidecars" }),
216
+ });
211
217
  const instanceEventBridge = new InstanceEventBridge({
212
218
  workspaceManager,
213
219
  eventBus,
@@ -281,6 +287,7 @@ async function main() {
281
287
  serverMeta,
282
288
  instanceStore,
283
289
  speechService,
290
+ sidecarManager,
284
291
  authManager,
285
292
  uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR,
286
293
  uiDevServerUrl: uiResolution.uiDevServerUrl,
@@ -301,6 +308,7 @@ async function main() {
301
308
  serverMeta,
302
309
  instanceStore,
303
310
  speechService,
311
+ sidecarManager,
304
312
  authManager,
305
313
  uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR,
306
314
  uiDevServerUrl: undefined,
@@ -391,6 +399,12 @@ async function main() {
391
399
  catch (error) {
392
400
  logger.warn({ err: error }, "Instance event bridge shutdown failed");
393
401
  }
402
+ try {
403
+ await sidecarManager.shutdown();
404
+ }
405
+ catch (error) {
406
+ logger.error({ err: error }, "SideCar manager shutdown failed");
407
+ }
394
408
  try {
395
409
  await workspaceManager.shutdown();
396
410
  logger.info("Workspace manager shutdown complete");
@@ -3,7 +3,9 @@ import cors from "@fastify/cors";
3
3
  import fastifyStatic from "@fastify/static";
4
4
  import replyFrom from "@fastify/reply-from";
5
5
  import fs from "fs";
6
+ import { connect as connectTcp } from "net";
6
7
  import path from "path";
8
+ import { connect as connectTls } from "tls";
7
9
  import { fetch } from "undici";
8
10
  import { isValidWorktreeSlug, listWorktrees, resolveRepoRoot } from "../workspaces/git-worktrees";
9
11
  import { registerWorkspaceRoutes } from "./routes/workspaces";
@@ -17,6 +19,7 @@ import { registerBackgroundProcessRoutes } from "./routes/background-processes";
17
19
  import { registerWorktreeRoutes } from "./routes/worktrees";
18
20
  import { registerSpeechRoutes } from "./routes/speech";
19
21
  import { registerRemoteServerRoutes } from "./routes/remote-servers";
22
+ import { registerSideCarRoutes } from "./routes/sidecars";
20
23
  import { BackgroundProcessManager } from "../background-processes/manager";
21
24
  import { registerAuthRoutes } from "./routes/auth";
22
25
  import { sendUnauthorized, wantsHtml } from "../auth/http-auth";
@@ -150,7 +153,7 @@ export function createHttpServer(deps) {
150
153
  return;
151
154
  }
152
155
  const session = deps.authManager.getSessionFromRequest(request);
153
- const requiresAuthForApi = pathname.startsWith("/api/") || pathname.startsWith("/workspaces/");
156
+ const requiresAuthForApi = pathname.startsWith("/api/") || pathname.startsWith("/workspaces/") || pathname.startsWith("/sidecars/");
154
157
  if (requiresAuthForApi && !session) {
155
158
  // Allow OpenCode plugin -> CodeNomad calls with per-instance basic auth.
156
159
  const pluginMatch = pathname.match(/^\/workspaces\/([^/]+)\/plugin(?:\/|$)/);
@@ -210,6 +213,13 @@ export function createHttpServer(deps) {
210
213
  });
211
214
  registerRemoteServerRoutes(app, { logger: apiLogger });
212
215
  registerSpeechRoutes(app, { speechService: deps.speechService });
216
+ registerSideCarRoutes(app, { sidecarManager: deps.sidecarManager });
217
+ registerSideCarProxyRoutes(app, { sidecarManager: deps.sidecarManager, logger: proxyLogger });
218
+ setupSideCarWebSocketProxy(app, {
219
+ sidecarManager: deps.sidecarManager,
220
+ authManager: deps.authManager,
221
+ logger: proxyLogger,
222
+ });
213
223
  registerPluginRoutes(app, {
214
224
  workspaceManager: deps.workspaceManager,
215
225
  eventBus: deps.eventBus,
@@ -279,6 +289,48 @@ export function createHttpServer(deps) {
279
289
  },
280
290
  };
281
291
  }
292
+ function registerSideCarProxyRoutes(app, deps) {
293
+ const proxyBaseHandler = async (request, reply) => {
294
+ await proxySideCarRequest({
295
+ request,
296
+ reply,
297
+ sidecarManager: deps.sidecarManager,
298
+ logger: deps.logger,
299
+ pathSuffix: "",
300
+ });
301
+ };
302
+ const proxyWildcardHandler = async (request, reply) => {
303
+ await proxySideCarRequest({
304
+ request,
305
+ reply,
306
+ sidecarManager: deps.sidecarManager,
307
+ logger: deps.logger,
308
+ pathSuffix: request.params["*"] ?? "",
309
+ });
310
+ };
311
+ app.all("/sidecars/:id", proxyBaseHandler);
312
+ app.all("/sidecars/:id/*", proxyWildcardHandler);
313
+ }
314
+ function setupSideCarWebSocketProxy(app, deps) {
315
+ app.server.on("upgrade", (request, socket, head) => {
316
+ const rawUrl = request.url ?? "/";
317
+ const parsed = parseSideCarUpgradePath(rawUrl);
318
+ if (!parsed) {
319
+ return;
320
+ }
321
+ void proxySideCarWebSocketUpgrade({
322
+ request,
323
+ socket: socket,
324
+ head,
325
+ sidecarId: parsed.sidecarId,
326
+ incomingPath: parsed.pathname,
327
+ search: parsed.search,
328
+ sidecarManager: deps.sidecarManager,
329
+ authManager: deps.authManager,
330
+ logger: deps.logger,
331
+ });
332
+ });
333
+ }
282
334
  function registerInstanceProxyRoutes(app, deps) {
283
335
  app.register(async (instance) => {
284
336
  instance.removeAllContentTypeParsers();
@@ -681,3 +733,229 @@ function buildProxyHeaders(headers) {
681
733
  }
682
734
  return result;
683
735
  }
736
+ async function proxySideCarRequest(args) {
737
+ const sidecarId = args.request.params.id ?? "";
738
+ const sidecar = await args.sidecarManager.get(sidecarId);
739
+ if (!sidecar) {
740
+ args.reply.code(404).send({ error: "SideCar not found" });
741
+ return;
742
+ }
743
+ const pathname = (args.request.raw.url ?? args.request.url ?? "").split("?")[0] ?? "";
744
+ const queryIndex = (args.request.raw.url ?? args.request.url ?? "").indexOf("?");
745
+ const search = queryIndex >= 0 ? (args.request.raw.url ?? args.request.url ?? "").slice(queryIndex) : "";
746
+ const pathSuffix = args.pathSuffix ?? "";
747
+ const requestPath = pathSuffix ? `${args.sidecarManager.buildProxyBasePath(sidecarId)}/${pathSuffix.replace(/^\/+/, "")}` : args.sidecarManager.buildProxyBasePath(sidecarId);
748
+ const targetPath = args.sidecarManager.buildTargetPath(sidecarId, requestPath, search);
749
+ const targetOrigin = args.sidecarManager.buildTargetOrigin(sidecar);
750
+ const targetUrl = `${targetOrigin}${targetPath}`;
751
+ args.logger.debug({ sidecarId: sidecar.id, targetUrl, pathname, prefixMode: sidecar.prefixMode }, "Proxying request to SideCar");
752
+ await args.reply.from(targetUrl, {
753
+ rewriteRequestHeaders: (_originalRequest, headers) => sanitizeSideCarProxyRequestHeaders(headers, targetOrigin),
754
+ rewriteHeaders: (headers) => rewriteSideCarResponseHeaders(headers, sidecarId, targetOrigin, sidecar.prefixMode),
755
+ onError: (reply, { error }) => {
756
+ args.logger.error({ sidecarId: sidecar.id, err: error, targetUrl }, "Failed to proxy SideCar request");
757
+ if (!reply.sent) {
758
+ reply.code(502).send({ error: "SideCar proxy failed" });
759
+ }
760
+ },
761
+ });
762
+ }
763
+ function parseSideCarUpgradePath(rawUrl) {
764
+ let parsed;
765
+ try {
766
+ parsed = new URL(rawUrl, "http://localhost");
767
+ }
768
+ catch {
769
+ return null;
770
+ }
771
+ const match = parsed.pathname.match(/^\/sidecars\/([^/]+)(?:\/.*)?$/);
772
+ if (!match) {
773
+ return null;
774
+ }
775
+ try {
776
+ return {
777
+ sidecarId: decodeURIComponent(match[1] ?? ""),
778
+ pathname: parsed.pathname,
779
+ search: parsed.search,
780
+ };
781
+ }
782
+ catch {
783
+ return null;
784
+ }
785
+ }
786
+ async function proxySideCarWebSocketUpgrade(args) {
787
+ const { request, socket, head, sidecarId, incomingPath, search, sidecarManager, authManager, logger } = args;
788
+ if (!isWebSocketUpgradeRequest(request)) {
789
+ rejectUpgrade(socket, 400, "Bad Request");
790
+ return;
791
+ }
792
+ const session = authManager.getSessionFromHeaders(request.headers);
793
+ if (!session) {
794
+ rejectUpgrade(socket, 401, "Unauthorized");
795
+ return;
796
+ }
797
+ const sidecar = await sidecarManager.get(sidecarId);
798
+ if (!sidecar) {
799
+ rejectUpgrade(socket, 404, "Not Found");
800
+ return;
801
+ }
802
+ const targetOrigin = sidecarManager.buildTargetOrigin(sidecar);
803
+ const targetPath = sidecarManager.buildTargetPath(sidecarId, incomingPath, search);
804
+ const targetUrl = new URL(`${targetOrigin}${targetPath}`);
805
+ logger.debug({ sidecarId, targetUrl: targetUrl.toString(), prefixMode: sidecar.prefixMode }, "Proxying websocket to SideCar");
806
+ const { socket: upstream, readyEvent } = createSideCarUpstreamSocket(targetUrl);
807
+ const closeBoth = () => {
808
+ if (!socket.destroyed) {
809
+ socket.destroy();
810
+ }
811
+ if (!upstream.destroyed) {
812
+ upstream.destroy();
813
+ }
814
+ };
815
+ upstream.once("error", (error) => {
816
+ logger.error({ sidecarId, err: error, targetUrl: targetUrl.toString() }, "Failed to proxy SideCar websocket");
817
+ rejectUpgrade(socket, 502, "Bad Gateway");
818
+ if (!upstream.destroyed) {
819
+ upstream.destroy();
820
+ }
821
+ });
822
+ socket.once("error", (error) => {
823
+ logger.debug({ sidecarId, err: error }, "SideCar websocket client socket errored");
824
+ if (!upstream.destroyed) {
825
+ upstream.destroy();
826
+ }
827
+ });
828
+ upstream.once(readyEvent, () => {
829
+ try {
830
+ upstream.write(buildSideCarWebSocketRequest(request, targetUrl));
831
+ if (head.length > 0) {
832
+ upstream.write(head);
833
+ }
834
+ upstream.pipe(socket);
835
+ socket.pipe(upstream);
836
+ }
837
+ catch (error) {
838
+ logger.error({ sidecarId, err: error, targetUrl: targetUrl.toString() }, "Failed to forward SideCar websocket upgrade");
839
+ closeBoth();
840
+ }
841
+ });
842
+ upstream.once("close", () => {
843
+ if (!socket.destroyed) {
844
+ socket.end();
845
+ }
846
+ });
847
+ socket.once("close", () => {
848
+ if (!upstream.destroyed) {
849
+ upstream.end();
850
+ }
851
+ });
852
+ }
853
+ function createSideCarUpstreamSocket(targetUrl) {
854
+ const port = Number(targetUrl.port || (targetUrl.protocol === "https:" ? 443 : 80));
855
+ if (targetUrl.protocol === "https:") {
856
+ return {
857
+ socket: connectTls({
858
+ host: targetUrl.hostname,
859
+ port,
860
+ servername: targetUrl.hostname,
861
+ }),
862
+ readyEvent: "secureConnect",
863
+ };
864
+ }
865
+ return {
866
+ socket: connectTcp(port, targetUrl.hostname),
867
+ readyEvent: "connect",
868
+ };
869
+ }
870
+ function buildSideCarWebSocketRequest(request, targetUrl) {
871
+ const pathWithQuery = `${targetUrl.pathname}${targetUrl.search}`;
872
+ const requestLine = `${request.method ?? "GET"} ${pathWithQuery} HTTP/${request.httpVersion}\r\n`;
873
+ const headerLines = [];
874
+ const rawHeaders = request.rawHeaders ?? [];
875
+ const blockedHeaders = getBlockedSideCarRequestHeaders();
876
+ for (let index = 0; index < rawHeaders.length; index += 2) {
877
+ const key = rawHeaders[index];
878
+ const value = rawHeaders[index + 1];
879
+ if (!key || value === undefined)
880
+ continue;
881
+ const lower = key.toLowerCase();
882
+ if (blockedHeaders.has(lower))
883
+ continue;
884
+ if (lower === "origin") {
885
+ headerLines.push(`Origin: ${targetUrl.origin}\r\n`);
886
+ continue;
887
+ }
888
+ headerLines.push(`${key}: ${value}\r\n`);
889
+ }
890
+ const hostValue = targetUrl.port ? `${targetUrl.hostname}:${targetUrl.port}` : targetUrl.hostname;
891
+ headerLines.push(`Host: ${hostValue}\r\n`);
892
+ headerLines.push("\r\n");
893
+ return requestLine + headerLines.join("");
894
+ }
895
+ function isWebSocketUpgradeRequest(request) {
896
+ const upgrade = request.headers.upgrade;
897
+ if (typeof upgrade !== "string" || upgrade.toLowerCase() !== "websocket") {
898
+ return false;
899
+ }
900
+ const connection = request.headers.connection;
901
+ const connectionValue = Array.isArray(connection) ? connection.join(",") : connection ?? "";
902
+ return connectionValue.toLowerCase().split(",").map((part) => part.trim()).includes("upgrade");
903
+ }
904
+ function rejectUpgrade(socket, statusCode, statusText) {
905
+ if (socket.destroyed) {
906
+ return;
907
+ }
908
+ socket.write(`HTTP/1.1 ${statusCode} ${statusText}\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`);
909
+ socket.destroy();
910
+ }
911
+ function rewriteSideCarResponseHeaders(headers, sidecarId, targetOrigin, prefixMode) {
912
+ if (prefixMode === "preserve") {
913
+ return headers;
914
+ }
915
+ const next = { ...headers };
916
+ const locationHeader = next.location;
917
+ const location = Array.isArray(locationHeader) ? locationHeader[0] : locationHeader;
918
+ if (!location) {
919
+ return next;
920
+ }
921
+ const publicBase = `/sidecars/${encodeURIComponent(sidecarId)}`;
922
+ if (location.startsWith("/")) {
923
+ next.location = `${publicBase}${location}`;
924
+ return next;
925
+ }
926
+ try {
927
+ const parsed = new URL(location);
928
+ if (parsed.origin === targetOrigin) {
929
+ next.location = `${publicBase}${parsed.pathname}${parsed.search}${parsed.hash}`;
930
+ }
931
+ }
932
+ catch {
933
+ // Relative redirects should continue to resolve against the public sidecar path.
934
+ }
935
+ return next;
936
+ }
937
+ function sanitizeSideCarProxyRequestHeaders(headers, targetOrigin) {
938
+ const blockedHeaders = getBlockedSideCarRequestHeaders();
939
+ const next = {};
940
+ for (const [key, value] of Object.entries(headers)) {
941
+ if (!value)
942
+ continue;
943
+ if (blockedHeaders.has(key.toLowerCase()))
944
+ continue;
945
+ next[key] = value;
946
+ }
947
+ next.origin = targetOrigin;
948
+ return next;
949
+ }
950
+ function getBlockedSideCarRequestHeaders() {
951
+ return new Set([
952
+ "host",
953
+ "authorization",
954
+ "proxy-authorization",
955
+ "forwarded",
956
+ "x-forwarded-for",
957
+ "x-forwarded-host",
958
+ "x-forwarded-port",
959
+ "x-forwarded-proto",
960
+ ]);
961
+ }
@@ -0,0 +1,46 @@
1
+ import { z } from "zod";
2
+ const SideCarCreateSchema = z.object({
3
+ kind: z.literal("port").default("port"),
4
+ name: z.string().trim().min(1),
5
+ port: z.number().int().min(1).max(65535),
6
+ insecure: z.boolean().default(false),
7
+ prefixMode: z.enum(["strip", "preserve"]).default("strip"),
8
+ });
9
+ const SideCarUpdateSchema = SideCarCreateSchema.omit({ kind: true }).partial().refine((value) => Object.keys(value).length > 0, {
10
+ message: "At least one field is required",
11
+ });
12
+ export function registerSideCarRoutes(app, deps) {
13
+ app.get("/api/sidecars", async () => {
14
+ return { sidecars: await deps.sidecarManager.list() };
15
+ });
16
+ app.post("/api/sidecars", async (request, reply) => {
17
+ try {
18
+ const body = SideCarCreateSchema.parse(request.body ?? {});
19
+ const sidecar = await deps.sidecarManager.create(body);
20
+ reply.code(201);
21
+ return sidecar;
22
+ }
23
+ catch (error) {
24
+ reply.code(400);
25
+ return { error: error instanceof Error ? error.message : "Failed to create SideCar" };
26
+ }
27
+ });
28
+ app.put("/api/sidecars/:id", async (request, reply) => {
29
+ try {
30
+ const body = SideCarUpdateSchema.parse(request.body ?? {});
31
+ return await deps.sidecarManager.update(request.params.id, body);
32
+ }
33
+ catch (error) {
34
+ reply.code(400);
35
+ return { error: error instanceof Error ? error.message : "Failed to update SideCar" };
36
+ }
37
+ });
38
+ app.delete("/api/sidecars/:id", async (request, reply) => {
39
+ const removed = await deps.sidecarManager.delete(request.params.id);
40
+ if (!removed) {
41
+ reply.code(404);
42
+ return { error: "SideCar not found" };
43
+ }
44
+ reply.code(204);
45
+ });
46
+ }
@@ -0,0 +1,193 @@
1
+ import { connect } from "net";
2
+ export class SideCarManager {
3
+ constructor(options) {
4
+ this.options = options;
5
+ this.configs = new Map();
6
+ this.runtime = new Map();
7
+ for (const record of this.loadConfiguredSideCars()) {
8
+ this.configs.set(record.id, record);
9
+ this.runtime.set(record.id, { status: "stopped" });
10
+ }
11
+ queueMicrotask(() => {
12
+ for (const record of this.configs.values()) {
13
+ void this.refreshPortSideCar(record.id).catch((error) => {
14
+ this.options.logger.warn({ sidecarId: record.id, err: error }, "Failed to probe sidecar port");
15
+ });
16
+ }
17
+ });
18
+ }
19
+ async list() {
20
+ await this.refreshPortStatuses();
21
+ return Array.from(this.configs.values()).map((record) => this.toSideCar(record));
22
+ }
23
+ async get(id) {
24
+ if (!this.configs.has(id))
25
+ return undefined;
26
+ await this.refreshPortSideCar(id);
27
+ return this.toSideCar(this.requireConfig(id));
28
+ }
29
+ async create(input) {
30
+ const normalizedName = input.name.trim();
31
+ const id = this.buildSideCarId(normalizedName);
32
+ if (this.configs.has(id)) {
33
+ throw new Error(`SideCar '${id}' already exists`);
34
+ }
35
+ const now = new Date().toISOString();
36
+ const record = {
37
+ id,
38
+ kind: input.kind,
39
+ name: normalizedName,
40
+ port: input.port,
41
+ insecure: input.insecure,
42
+ prefixMode: input.prefixMode,
43
+ createdAt: now,
44
+ updatedAt: now,
45
+ };
46
+ this.configs.set(record.id, record);
47
+ this.runtime.set(record.id, { status: "stopped" });
48
+ this.persistConfigs();
49
+ await this.refreshPortSideCar(record.id);
50
+ return this.toSideCar(record);
51
+ }
52
+ async update(id, input) {
53
+ const record = this.requireConfig(id);
54
+ record.name = typeof input.name === "string" ? input.name.trim() : record.name;
55
+ record.port = typeof input.port === "number" ? input.port : record.port;
56
+ record.insecure = typeof input.insecure === "boolean" ? input.insecure : record.insecure;
57
+ record.prefixMode = typeof input.prefixMode === "string" ? input.prefixMode : record.prefixMode;
58
+ record.updatedAt = new Date().toISOString();
59
+ this.persistConfigs();
60
+ await this.refreshPortSideCar(id);
61
+ return this.toSideCar(record);
62
+ }
63
+ async delete(id) {
64
+ const record = this.configs.get(id);
65
+ if (!record)
66
+ return false;
67
+ this.configs.delete(id);
68
+ this.runtime.delete(id);
69
+ this.persistConfigs();
70
+ this.options.eventBus.publish({ type: "sidecar.removed", sidecarId: id });
71
+ return true;
72
+ }
73
+ async shutdown() {
74
+ return;
75
+ }
76
+ buildTargetOrigin(sidecar) {
77
+ const protocol = sidecar.insecure ? "http" : "https";
78
+ return `${protocol}://127.0.0.1:${sidecar.port}`;
79
+ }
80
+ buildProxyBasePath(id) {
81
+ return `/sidecars/${encodeURIComponent(id)}`;
82
+ }
83
+ buildTargetPath(id, incomingPath, search = "") {
84
+ const record = this.requireConfig(id);
85
+ const publicBase = this.buildProxyBasePath(id);
86
+ const normalizedPath = incomingPath || publicBase;
87
+ if (record.prefixMode === "preserve") {
88
+ return `${normalizedPath}${search}`;
89
+ }
90
+ let stripped = normalizedPath.startsWith(publicBase) ? normalizedPath.slice(publicBase.length) : normalizedPath;
91
+ if (!stripped || stripped === "/") {
92
+ stripped = "/";
93
+ }
94
+ else if (!stripped.startsWith("/")) {
95
+ stripped = `/${stripped}`;
96
+ }
97
+ return `${stripped}${search}`;
98
+ }
99
+ async refreshPortStatuses() {
100
+ await Promise.all(Array.from(this.configs.values()).map((record) => this.refreshPortSideCar(record.id)));
101
+ }
102
+ async refreshPortSideCar(id) {
103
+ const record = this.configs.get(id);
104
+ if (!record)
105
+ return;
106
+ const isAvailable = await this.isPortAvailable(record.port);
107
+ const current = this.runtime.get(id);
108
+ const nextStatus = isAvailable ? "running" : "stopped";
109
+ if (current?.status === nextStatus) {
110
+ return;
111
+ }
112
+ this.runtime.set(id, { status: nextStatus });
113
+ record.updatedAt = new Date().toISOString();
114
+ this.publish(id);
115
+ }
116
+ publish(id) {
117
+ const record = this.configs.get(id);
118
+ if (!record)
119
+ return;
120
+ this.options.eventBus.publish({ type: "sidecar.updated", sidecar: this.toSideCar(record) });
121
+ }
122
+ toSideCar(record) {
123
+ const runtime = this.runtime.get(record.id);
124
+ return {
125
+ id: record.id,
126
+ kind: record.kind,
127
+ name: record.name,
128
+ port: record.port,
129
+ insecure: record.insecure,
130
+ prefixMode: record.prefixMode,
131
+ status: runtime?.status ?? "stopped",
132
+ createdAt: record.createdAt,
133
+ updatedAt: record.updatedAt,
134
+ };
135
+ }
136
+ requireConfig(id) {
137
+ const record = this.configs.get(id);
138
+ if (!record) {
139
+ throw new Error("SideCar not found");
140
+ }
141
+ return record;
142
+ }
143
+ persistConfigs() {
144
+ const sidecars = Array.from(this.configs.values()).map((record) => ({ ...record }));
145
+ this.options.settings.mergePatchOwner("config", "server", { sidecars });
146
+ }
147
+ loadConfiguredSideCars() {
148
+ const serverConfig = this.options.settings.getOwner("config", "server");
149
+ const list = Array.isArray(serverConfig?.sidecars) ? serverConfig.sidecars : [];
150
+ const records = [];
151
+ for (const item of list) {
152
+ if (!item || typeof item !== "object")
153
+ continue;
154
+ const record = item;
155
+ const kind = record.kind === "port" ? "port" : null;
156
+ const id = typeof record.id === "string" && record.id.trim() ? record.id.trim() : null;
157
+ const name = typeof record.name === "string" && record.name.trim() ? record.name.trim() : null;
158
+ const port = typeof record.port === "number" && Number.isInteger(record.port) ? record.port : null;
159
+ if (!kind || !id || !name || !port)
160
+ continue;
161
+ const insecure = record.insecure === true;
162
+ const prefixMode = record.prefixMode === "preserve" ? "preserve" : "strip";
163
+ const createdAt = typeof record.createdAt === "string" && record.createdAt ? record.createdAt : new Date().toISOString();
164
+ const updatedAt = typeof record.updatedAt === "string" && record.updatedAt ? record.updatedAt : createdAt;
165
+ records.push({ id, kind, name, port, insecure, prefixMode, createdAt, updatedAt });
166
+ }
167
+ return records;
168
+ }
169
+ isPortAvailable(port) {
170
+ return new Promise((resolve) => {
171
+ const socket = connect({ port, host: "127.0.0.1" }, () => {
172
+ socket.end();
173
+ resolve(true);
174
+ });
175
+ socket.once("error", () => {
176
+ socket.destroy();
177
+ resolve(false);
178
+ });
179
+ });
180
+ }
181
+ buildSideCarId(name) {
182
+ const normalized = name
183
+ .trim()
184
+ .toLowerCase()
185
+ .replace(/[^a-z0-9]+/g, "-")
186
+ .replace(/-{2,}/g, "-")
187
+ .replace(/^-|-$/g, "");
188
+ if (!normalized) {
189
+ throw new Error("SideCar name must include letters or numbers");
190
+ }
191
+ return normalized;
192
+ }
193
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@neuralnomads/codenomad-dev",
3
- "version": "0.13.3-dev-20260402-19a4c3df",
3
+ "version": "0.13.3-dev-20260404-7996e514",
4
4
  "description": "CodeNomad Server",
5
5
  "license": "MIT",
6
6
  "author": {