@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.
- package/dist/auth/manager.js +5 -1
- package/dist/config/schema.js +1 -0
- package/dist/events/bus.js +4 -0
- package/dist/index.js +14 -0
- package/dist/server/http-server.js +281 -1
- package/dist/server/routes/remote-servers.js +142 -0
- package/dist/server/routes/sidecars.js +46 -0
- package/dist/settings/migrate.js +5 -0
- package/dist/settings/service.js +64 -4
- package/dist/sidecars/manager.js +193 -0
- package/dist/workspaces/manager.js +2 -0
- package/dist/workspaces/runtime.js +2 -1
- package/package.json +1 -1
- package/public/assets/{ChangesTab-CR9-DiDp.js → ChangesTab-Cga3cJ27.js} +2 -2
- package/public/assets/{DiffToolbar-DDj2or5F.js → DiffToolbar-ysD8YAOp.js} +1 -1
- package/public/assets/{FilesTab-WqNou6Ai.js → FilesTab-CNEdrGlK.js} +2 -2
- package/public/assets/{GitChangesTab-BwmFurGu.js → GitChangesTab-C4JCkCnw.js} +2 -2
- package/public/assets/{SplitFilePanel-Dh1S3Bx7.js → SplitFilePanel-DyezmCo2.js} +1 -1
- package/public/assets/{StatusTab-B87q8a9A.js → StatusTab-BJqHpvHN.js} +1 -1
- package/public/assets/{bundle-full-Cpu11oec.js → bundle-full-DwOhsFtX.js} +1 -1
- package/public/assets/{diff-viewer-DRA-UVaP.js → diff-viewer-B8i46pSx.js} +1 -1
- package/public/assets/index-4cIw-BDV.js +1 -0
- package/public/assets/{index-D7RpN-Kf.js → index-Bfgb6hSQ.js} +1 -1
- package/public/assets/index-C3ecDP5_.js +1 -0
- package/public/assets/{index-BjSF6wzm.js → index-C5H7aIod.js} +1 -1
- package/public/assets/index-C5c6mvR5.js +1 -0
- package/public/assets/index-CD-C2Kdk.js +1 -0
- package/public/assets/index-CJQRbTtv.js +2 -0
- package/public/assets/index-CQD0rDm8.js +1 -0
- package/public/assets/{index-DcG1bjdf.css → index-CRyEAEtJ.css} +1 -1
- package/public/assets/index-Dcj6nNO-.js +1 -0
- package/public/assets/{loading-D50lUPVl.js → loading-C5GsedyF.js} +1 -1
- package/public/assets/main-BpgxtqbO.js +56 -0
- package/public/assets/{markdown-BtGnYmbv.js → markdown-aU4KPoLk.js} +3 -3
- package/public/assets/monaco-viewer-BCffFoQZ.js +15 -0
- package/public/assets/{todo-CshHqPz5.js → todo-CShSQjpC.js} +1 -1
- package/public/assets/{tool-call-LDmo-9Rl.js → tool-call-jzprw2Fo.js} +3 -3
- package/public/assets/{unified-picker-C-C3Oz_t.js → unified-picker-C20-DAtu.js} +1 -1
- package/public/assets/{wrap-text-Dr6EjY_H.js → wrap-text-CIfNyrm2.js} +1 -1
- package/public/index.html +4 -4
- package/public/loading.html +4 -4
- package/public/sw.js +1 -1
- package/public/assets/index-BaoXz6VG.js +0 -1
- package/public/assets/index-BgujSDdi.js +0 -1
- package/public/assets/index-CRtA_Run.js +0 -2
- package/public/assets/index-D7BEMtle.js +0 -1
- package/public/assets/index-DeriVoYn.js +0 -1
- package/public/assets/index-Dgk0HDM-.js +0 -1
- package/public/assets/index-q3ssEM4m.js +0 -1
- package/public/assets/main-CEwS0Qyy.js +0 -56
- package/public/assets/monaco-viewer-UU9TfOpS.js +0 -15
package/dist/auth/manager.js
CHANGED
|
@@ -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
|
|
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)
|
package/dist/config/schema.js
CHANGED
|
@@ -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),
|
package/dist/events/bus.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/settings/migrate.js
CHANGED
|
@@ -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",
|