@neuralnomads/codenomad-dev 0.13.3-dev-20260402-e82e529a → 0.13.3-dev-20260403-d0a0325d

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 (51) hide show
  1. package/dist/auth/manager.js +5 -1
  2. package/dist/config/schema.js +1 -0
  3. package/dist/events/bus.js +4 -0
  4. package/dist/index.js +14 -0
  5. package/dist/server/http-server.js +281 -1
  6. package/dist/server/routes/remote-servers.js +142 -0
  7. package/dist/server/routes/sidecars.js +46 -0
  8. package/dist/settings/migrate.js +5 -0
  9. package/dist/settings/service.js +64 -4
  10. package/dist/sidecars/manager.js +193 -0
  11. package/dist/workspaces/manager.js +2 -0
  12. package/dist/workspaces/runtime.js +2 -1
  13. package/package.json +1 -1
  14. package/public/assets/{ChangesTab-CR9-DiDp.js → ChangesTab-Cga3cJ27.js} +2 -2
  15. package/public/assets/{DiffToolbar-DDj2or5F.js → DiffToolbar-ysD8YAOp.js} +1 -1
  16. package/public/assets/{FilesTab-WqNou6Ai.js → FilesTab-CNEdrGlK.js} +2 -2
  17. package/public/assets/{GitChangesTab-BwmFurGu.js → GitChangesTab-C4JCkCnw.js} +2 -2
  18. package/public/assets/{SplitFilePanel-Dh1S3Bx7.js → SplitFilePanel-DyezmCo2.js} +1 -1
  19. package/public/assets/{StatusTab-B87q8a9A.js → StatusTab-BJqHpvHN.js} +1 -1
  20. package/public/assets/{bundle-full-Cpu11oec.js → bundle-full-DwOhsFtX.js} +1 -1
  21. package/public/assets/{diff-viewer-DRA-UVaP.js → diff-viewer-B8i46pSx.js} +1 -1
  22. package/public/assets/index-4cIw-BDV.js +1 -0
  23. package/public/assets/{index-D7RpN-Kf.js → index-Bfgb6hSQ.js} +1 -1
  24. package/public/assets/index-C3ecDP5_.js +1 -0
  25. package/public/assets/{index-BjSF6wzm.js → index-C5H7aIod.js} +1 -1
  26. package/public/assets/index-C5c6mvR5.js +1 -0
  27. package/public/assets/index-CD-C2Kdk.js +1 -0
  28. package/public/assets/index-CJQRbTtv.js +2 -0
  29. package/public/assets/index-CQD0rDm8.js +1 -0
  30. package/public/assets/{index-DcG1bjdf.css → index-CRyEAEtJ.css} +1 -1
  31. package/public/assets/index-Dcj6nNO-.js +1 -0
  32. package/public/assets/{loading-D50lUPVl.js → loading-C5GsedyF.js} +1 -1
  33. package/public/assets/main-BpgxtqbO.js +56 -0
  34. package/public/assets/{markdown-BtGnYmbv.js → markdown-aU4KPoLk.js} +3 -3
  35. package/public/assets/monaco-viewer-BCffFoQZ.js +15 -0
  36. package/public/assets/{todo-CshHqPz5.js → todo-CShSQjpC.js} +1 -1
  37. package/public/assets/{tool-call-LDmo-9Rl.js → tool-call-jzprw2Fo.js} +3 -3
  38. package/public/assets/{unified-picker-C-C3Oz_t.js → unified-picker-C20-DAtu.js} +1 -1
  39. package/public/assets/{wrap-text-Dr6EjY_H.js → wrap-text-CIfNyrm2.js} +1 -1
  40. package/public/index.html +4 -4
  41. package/public/loading.html +4 -4
  42. package/public/sw.js +1 -1
  43. package/public/assets/index-BaoXz6VG.js +0 -1
  44. package/public/assets/index-BgujSDdi.js +0 -1
  45. package/public/assets/index-CRtA_Run.js +0 -2
  46. package/public/assets/index-D7BEMtle.js +0 -1
  47. package/public/assets/index-DeriVoYn.js +0 -1
  48. package/public/assets/index-Dgk0HDM-.js +0 -1
  49. package/public/assets/index-q3ssEM4m.js +0 -1
  50. package/public/assets/main-CEwS0Qyy.js +0 -56
  51. package/public/assets/monaco-viewer-UU9TfOpS.js +0 -15
@@ -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)
@@ -23,6 +23,7 @@ const PreferencesSchema = z
23
23
  showUsageMetrics: z.boolean().default(true),
24
24
  autoCleanupBlankSessions: z.boolean().default(true),
25
25
  listeningMode: z.enum(["local", "all"]).default("local"),
26
+ logLevel: z.enum(["DEBUG", "INFO", "WARN", "ERROR"]).default("DEBUG"),
26
27
  // OS notifications
27
28
  osNotificationsEnabled: z.boolean().default(false),
28
29
  osNotificationsAllowWhenVisible: z.boolean().default(false),
@@ -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";
@@ -16,6 +18,8 @@ import { registerPluginRoutes } from "./routes/plugin";
16
18
  import { registerBackgroundProcessRoutes } from "./routes/background-processes";
17
19
  import { registerWorktreeRoutes } from "./routes/worktrees";
18
20
  import { registerSpeechRoutes } from "./routes/speech";
21
+ import { registerRemoteServerRoutes } from "./routes/remote-servers";
22
+ import { registerSideCarRoutes } from "./routes/sidecars";
19
23
  import { BackgroundProcessManager } from "../background-processes/manager";
20
24
  import { registerAuthRoutes } from "./routes/auth";
21
25
  import { sendUnauthorized, wantsHtml } from "../auth/http-auth";
@@ -149,7 +153,7 @@ export function createHttpServer(deps) {
149
153
  return;
150
154
  }
151
155
  const session = deps.authManager.getSessionFromRequest(request);
152
- const requiresAuthForApi = pathname.startsWith("/api/") || pathname.startsWith("/workspaces/");
156
+ const requiresAuthForApi = pathname.startsWith("/api/") || pathname.startsWith("/workspaces/") || pathname.startsWith("/sidecars/");
153
157
  if (requiresAuthForApi && !session) {
154
158
  // Allow OpenCode plugin -> CodeNomad calls with per-instance basic auth.
155
159
  const pluginMatch = pathname.match(/^\/workspaces\/([^/]+)\/plugin(?:\/|$)/);
@@ -207,7 +211,15 @@ export function createHttpServer(deps) {
207
211
  eventBus: deps.eventBus,
208
212
  workspaceManager: deps.workspaceManager,
209
213
  });
214
+ registerRemoteServerRoutes(app, { logger: apiLogger });
210
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
+ });
211
223
  registerPluginRoutes(app, {
212
224
  workspaceManager: deps.workspaceManager,
213
225
  eventBus: deps.eventBus,
@@ -277,6 +289,48 @@ export function createHttpServer(deps) {
277
289
  },
278
290
  };
279
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
+ }
280
334
  function registerInstanceProxyRoutes(app, deps) {
281
335
  app.register(async (instance) => {
282
336
  instance.removeAllContentTypeParsers();
@@ -679,3 +733,229 @@ function buildProxyHeaders(headers) {
679
733
  }
680
734
  return result;
681
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,142 @@
1
+ import { Agent, fetch } from "undici";
2
+ import { z } from "zod";
3
+ const ProbeSchema = z.object({
4
+ baseUrl: z.string().min(1),
5
+ skipTlsVerify: z.boolean().optional(),
6
+ });
7
+ const PROBE_TIMEOUT_MS = 8000;
8
+ export function registerRemoteServerRoutes(app, deps) {
9
+ app.post("/api/remote-servers/probe", async (request, reply) => {
10
+ try {
11
+ const body = ProbeSchema.parse(request.body ?? {});
12
+ return await probeRemoteServer(body.baseUrl, Boolean(body.skipTlsVerify));
13
+ }
14
+ catch (error) {
15
+ deps.logger.warn({ err: error }, "Failed to probe remote server");
16
+ reply.code(400);
17
+ return { error: error instanceof Error ? error.message : "Invalid request" };
18
+ }
19
+ });
20
+ }
21
+ async function probeRemoteServer(baseUrl, skipTlsVerify) {
22
+ const normalizedUrl = normalizeBaseUrl(baseUrl);
23
+ const probeUrl = new URL("./api/auth/status", `${normalizedUrl}/`);
24
+ const controller = new AbortController();
25
+ const timeout = setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS);
26
+ const dispatcher = skipTlsVerify ? new Agent({ connect: { rejectUnauthorized: false } }) : undefined;
27
+ try {
28
+ const response = await fetch(probeUrl, {
29
+ method: "GET",
30
+ dispatcher,
31
+ signal: controller.signal,
32
+ headers: {
33
+ Accept: "application/json",
34
+ },
35
+ });
36
+ if (!response.ok) {
37
+ return {
38
+ ok: false,
39
+ reachable: true,
40
+ normalizedUrl,
41
+ skipTlsVerify,
42
+ requiresAuth: false,
43
+ authenticated: false,
44
+ error: `Remote server returned HTTP ${response.status}`,
45
+ errorCode: "http_error",
46
+ };
47
+ }
48
+ const payload = (await response.json());
49
+ if (typeof payload?.authenticated !== "boolean") {
50
+ return {
51
+ ok: false,
52
+ reachable: true,
53
+ normalizedUrl,
54
+ skipTlsVerify,
55
+ requiresAuth: false,
56
+ authenticated: false,
57
+ error: "Remote server did not return a valid CodeNomad auth response",
58
+ errorCode: "invalid_server",
59
+ };
60
+ }
61
+ return {
62
+ ok: true,
63
+ reachable: true,
64
+ normalizedUrl,
65
+ skipTlsVerify,
66
+ requiresAuth: !payload.authenticated,
67
+ authenticated: payload.authenticated,
68
+ };
69
+ }
70
+ catch (error) {
71
+ const message = describeProbeError(error);
72
+ return {
73
+ ok: false,
74
+ reachable: false,
75
+ normalizedUrl,
76
+ skipTlsVerify,
77
+ requiresAuth: false,
78
+ authenticated: false,
79
+ error: message.message,
80
+ errorCode: message.code,
81
+ };
82
+ }
83
+ finally {
84
+ clearTimeout(timeout);
85
+ await dispatcher?.close().catch(() => { });
86
+ }
87
+ }
88
+ function normalizeBaseUrl(input) {
89
+ const parsed = new URL(input.trim());
90
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
91
+ throw new Error("Server URL must use http:// or https://");
92
+ }
93
+ parsed.hash = "";
94
+ parsed.search = "";
95
+ parsed.pathname = parsed.pathname === "/" ? "/" : parsed.pathname.replace(/\/+$/, "") || "/";
96
+ const value = parsed.toString();
97
+ return parsed.pathname === "/" ? value.replace(/\/$/, "") : value.replace(/\/$/, "");
98
+ }
99
+ function describeProbeError(error) {
100
+ const chain = unwrapErrorChain(error);
101
+ const detailed = chain.find((entry) => {
102
+ const code = (entry?.code ?? "").toString();
103
+ return Boolean(code) && code !== "UND_ERR_RESPONSE_STATUS_CODE";
104
+ }) ?? chain[0];
105
+ const code = (detailed?.code ?? "").toString();
106
+ const exactMessage = detailed?.message?.trim() || chain.find((entry) => entry.message?.trim())?.message?.trim();
107
+ if (code === "DEPTH_ZERO_SELF_SIGNED_CERT" || code === "SELF_SIGNED_CERT_IN_CHAIN" || code === "CERT_HAS_EXPIRED") {
108
+ return {
109
+ code: "tls_error",
110
+ message: "Certificate check failed while connecting to the remote server.",
111
+ };
112
+ }
113
+ return {
114
+ code: code === "ERR_INVALID_URL"
115
+ ? "invalid_url"
116
+ : code === "ECONNREFUSED"
117
+ ? "connection_refused"
118
+ : code === "ENOTFOUND"
119
+ ? "dns_error"
120
+ : code === "UND_ERR_CONNECT_TIMEOUT" || code === "ABORT_ERR"
121
+ ? "timeout"
122
+ : code
123
+ ? code.toLowerCase()
124
+ : "probe_failed",
125
+ message: exactMessage || "Failed to connect to the remote server.",
126
+ };
127
+ }
128
+ function unwrapErrorChain(error) {
129
+ const results = [];
130
+ let current = error;
131
+ const seen = new Set();
132
+ while (current && typeof current === "object" && !seen.has(current)) {
133
+ seen.add(current);
134
+ const entry = current;
135
+ results.push({ code: entry.code, message: entry.message });
136
+ current = entry.cause;
137
+ }
138
+ if (results.length === 0 && error instanceof Error) {
139
+ results.push({ message: error.message });
140
+ }
141
+ return results;
142
+ }
@@ -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
+ }
@@ -93,6 +93,10 @@ function mapLegacyToOwnerDocs(legacyConfig, legacyState) {
93
93
  if (typeof listeningMode === "string") {
94
94
  serverConfig.listeningMode = listeningMode;
95
95
  }
96
+ const logLevel = preferences.logLevel;
97
+ if (typeof logLevel === "string") {
98
+ serverConfig.logLevel = logLevel;
99
+ }
96
100
  const lastUsedBinary = preferences.lastUsedBinary;
97
101
  if (typeof lastUsedBinary === "string") {
98
102
  serverConfig.opencodeBinary = lastUsedBinary;
@@ -118,6 +122,7 @@ function mapLegacyToOwnerDocs(legacyConfig, legacyState) {
118
122
  const moved = new Set([
119
123
  "environmentVariables",
120
124
  "listeningMode",
125
+ "logLevel",
121
126
  "lastUsedBinary",
122
127
  "modelRecents",
123
128
  "modelFavorites",