@neuralnomads/codenomad-dev 0.14.0-dev-20260418-e022a158 → 0.14.0-dev-20260420-04fc28c4

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 (35) hide show
  1. package/dist/index.js +8 -3
  2. package/dist/server/__tests__/remote-proxy.test.js +204 -0
  3. package/dist/server/http-server.js +6 -1
  4. package/dist/server/remote-proxy.js +466 -0
  5. package/dist/server/routes/remote-proxy.js +42 -0
  6. package/package.json +1 -1
  7. package/public/assets/{ChangesTab-DUZlwLiK.js → ChangesTab-eUJ7HXjk.js} +2 -2
  8. package/public/assets/{DiffToolbar-C32dRoEE.js → DiffToolbar-C2gN4-DU.js} +1 -1
  9. package/public/assets/{FilesTab-BHR_yoxs.js → FilesTab-BtxCG1pv.js} +2 -2
  10. package/public/assets/{GitChangesTab-glvFfIqf.js → GitChangesTab-BVOgrozk.js} +2 -2
  11. package/public/assets/{SplitFilePanel-c1Cyzd2Q.js → SplitFilePanel-vPBNqijZ.js} +1 -1
  12. package/public/assets/{StatusTab-D_y6grlM.js → StatusTab-BLnPWyWk.js} +1 -1
  13. package/public/assets/{bundle-full-xErn6pK2.js → bundle-full-Ddu6qye3.js} +1 -1
  14. package/public/assets/{diff-viewer-Ti4vD-Wf.js → diff-viewer-BZeFsDG4.js} +1 -1
  15. package/public/assets/{index-D5K3x-5q.js → index-BGjy4SXE.js} +1 -1
  16. package/public/assets/{index-KGa2MZ03.js → index-BWikFX-R.js} +1 -1
  17. package/public/assets/{index-DNjsUvJP.js → index-C72ltV-E.js} +1 -1
  18. package/public/assets/{index-MN-xsoRk.js → index-C_TDxjfc.js} +2 -2
  19. package/public/assets/{index-RbYx88R2.js → index-CcYvh3Uc.js} +1 -1
  20. package/public/assets/{index-Buu9pQM-.js → index-CmAwr57o.js} +1 -1
  21. package/public/assets/{index-DAUGrHIr.js → index-DL8_CQ8W.js} +1 -1
  22. package/public/assets/{index-Vi334d28.js → index-DoLglUyU.js} +1 -1
  23. package/public/assets/{index-DCKsvmLK.js → index-lqTLZJ_z.js} +1 -1
  24. package/public/assets/{loading-8z6r7QGU.js → loading-CXx1HTRq.js} +1 -1
  25. package/public/assets/main-DAirHb0u.js +48 -0
  26. package/public/assets/{markdown-CN__9g8I.js → markdown-CB_rJhcV.js} +3 -3
  27. package/public/assets/{monaco-viewer-C8arwHE9.js → monaco-viewer-BAhYjr-N.js} +4 -4
  28. package/public/assets/{todo-CGjayFGz.js → todo-CAxz-1Tz.js} +1 -1
  29. package/public/assets/{tool-call-Wyw279OP.js → tool-call-ZqXDG9uG.js} +3 -3
  30. package/public/assets/{unified-picker-DqT__5EQ.js → unified-picker-d0K5E9QI.js} +1 -1
  31. package/public/assets/{wrap-text-DJEP0aF1.js → wrap-text-CPc0o1wr.js} +1 -1
  32. package/public/index.html +3 -3
  33. package/public/loading.html +3 -3
  34. package/public/sw.js +1 -1
  35. package/public/assets/main-BL6QgWzz.js +0 -48
package/dist/index.js CHANGED
@@ -20,6 +20,7 @@ import { launchInBrowser } from "./launcher";
20
20
  import { resolveUi } from "./ui/remote-ui";
21
21
  import { AuthManager, BOOTSTRAP_TOKEN_STDOUT_PREFIX, DEFAULT_AUTH_COOKIE_NAME, DEFAULT_AUTH_USERNAME } from "./auth/manager";
22
22
  import { resolveHttpsOptions } from "./server/tls";
23
+ import { RemoteProxySessionManager } from "./server/remote-proxy";
23
24
  import { resolveNetworkAddresses, resolveRemoteAddresses } from "./server/network-addresses";
24
25
  import { startDevReleaseMonitor } from "./releases/dev-release-monitor";
25
26
  import { SpeechService } from "./speech/service";
@@ -262,12 +263,14 @@ async function main() {
262
263
  },
263
264
  })
264
265
  : null;
265
- if (uiResolution.uiDevServerUrl && options.https) {
266
- throw new InvalidArgumentError("UI dev proxy is only supported with --https=false --http=true");
267
- }
268
266
  const remoteAccessEnabled = options.host === "0.0.0.0" || !isLoopbackHost(options.host);
269
267
  const clientConnectionManager = new ClientConnectionManager(logger.child({ component: "client-connections" }));
270
268
  const pluginChannel = new PluginChannelManager(logger.child({ component: "plugin-channel" }));
269
+ const remoteProxySessionManager = new RemoteProxySessionManager({
270
+ authManager,
271
+ logger: logger.child({ component: "remote-proxy" }),
272
+ httpsOptions: tlsResolution?.httpsOptions,
273
+ });
271
274
  const voiceModeManager = new VoiceModeManager({
272
275
  connections: clientConnectionManager,
273
276
  channel: pluginChannel,
@@ -302,6 +305,7 @@ async function main() {
302
305
  clientConnectionManager,
303
306
  pluginChannel,
304
307
  voiceModeManager,
308
+ remoteProxySessionManager,
305
309
  uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR,
306
310
  uiDevServerUrl: uiResolution.uiDevServerUrl,
307
311
  logger,
@@ -326,6 +330,7 @@ async function main() {
326
330
  clientConnectionManager,
327
331
  pluginChannel,
328
332
  voiceModeManager,
333
+ remoteProxySessionManager,
329
334
  uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR,
330
335
  uiDevServerUrl: undefined,
331
336
  logger,
@@ -0,0 +1,204 @@
1
+ import assert from "node:assert/strict";
2
+ import { after, afterEach, describe, it } from "node:test";
3
+ import fs from "node:fs";
4
+ import http from "node:http";
5
+ import os from "node:os";
6
+ import path from "node:path";
7
+ import { Agent, fetch } from "undici";
8
+ import { RemoteProxySessionManager } from "../remote-proxy";
9
+ import { resolveHttpsOptions } from "../tls";
10
+ const sharedTempDir = fs.mkdtempSync(path.join(os.tmpdir(), "codenomad-remote-proxy-test-"));
11
+ const sharedTls = resolveHttpsOptions({
12
+ enabled: true,
13
+ configDir: sharedTempDir,
14
+ host: "127.0.0.1",
15
+ logger: createStubLogger(),
16
+ });
17
+ if (!sharedTls) {
18
+ throw new Error("Failed to generate HTTPS options for remote proxy tests");
19
+ }
20
+ const sharedHttpsOptions = sharedTls.httpsOptions;
21
+ const httpsDispatcher = new Agent({ connect: { rejectUnauthorized: false } });
22
+ const managers = new Set();
23
+ afterEach(async () => {
24
+ for (const manager of managers) {
25
+ await disposeManager(manager);
26
+ }
27
+ managers.clear();
28
+ });
29
+ after(() => {
30
+ fs.rmSync(sharedTempDir, { recursive: true, force: true });
31
+ httpsDispatcher.close().catch(() => { });
32
+ });
33
+ describe("RemoteProxySessionManager", () => {
34
+ it("blocks proxying before activation and keeps bootstrap tokens scoped per session", async () => {
35
+ await withUpstreamServer(async (upstreamBaseUrl) => {
36
+ const manager = createSessionManager();
37
+ const session1 = await createSession(manager, `${upstreamBaseUrl}/base`);
38
+ const session2 = await createSession(manager, `${upstreamBaseUrl}/base`);
39
+ const blocked = await proxyFetch(`${session1.proxyOrigin}/status`);
40
+ assert.equal(blocked.status, 403);
41
+ const wrongTokenResponse = await proxyFetch(`${session1.proxyOrigin}/__codenomad/api/auth/token`, {
42
+ method: "POST",
43
+ headers: { "content-type": "application/json" },
44
+ body: JSON.stringify({ token: session2.token }),
45
+ });
46
+ assert.equal(wrongTokenResponse.status, 401);
47
+ assert.equal(await activateSession(session1), true);
48
+ assert.equal(await activateSession(session2), true);
49
+ }, (req, res) => {
50
+ res.writeHead(200, { "content-type": "text/plain" });
51
+ res.end(req.url ?? "");
52
+ });
53
+ });
54
+ it("preserves remote base paths and rewrites same-origin redirects to the local proxy origin", async () => {
55
+ await withUpstreamServer(async (upstreamBaseUrl) => {
56
+ const manager = createSessionManager();
57
+ const session = await createSession(manager, `${upstreamBaseUrl}/base`);
58
+ await activateSession(session);
59
+ const apiResponse = await proxyFetch(`${session.proxyOrigin}/api/auth/status?foo=bar`);
60
+ assert.equal(apiResponse.status, 200);
61
+ assert.equal(await apiResponse.text(), "/base/api/auth/status?foo=bar");
62
+ const redirectResponse = await proxyFetch(`${session.proxyOrigin}/redirect`, { redirect: "manual" });
63
+ assert.equal(redirectResponse.status, 302);
64
+ assert.equal(redirectResponse.headers.get("location"), `${session.proxyOrigin}/base/after?ok=1`);
65
+ }, (req, res) => {
66
+ const requestUrl = req.url ?? "";
67
+ if (requestUrl === "/base/redirect") {
68
+ res.writeHead(302, { location: "/base/after?ok=1" });
69
+ res.end();
70
+ return;
71
+ }
72
+ res.writeHead(200, { "content-type": "text/plain" });
73
+ res.end(requestUrl);
74
+ });
75
+ });
76
+ it("rewrites set-cookie names for the proxy and restores cookie names on proxied requests", async () => {
77
+ await withUpstreamServer(async (upstreamBaseUrl) => {
78
+ const manager = createSessionManager();
79
+ const session = await createSession(manager, `${upstreamBaseUrl}/base`);
80
+ await activateSession(session);
81
+ const loginResponse = await proxyFetch(`${session.proxyOrigin}/login`);
82
+ assert.equal(loginResponse.status, 200);
83
+ const setCookie = getSetCookie(loginResponse)[0];
84
+ assert.match(setCookie, /^cnrp_[0-9a-f]+_session=abc123/i);
85
+ assert.doesNotMatch(setCookie, /domain=/i);
86
+ const cookieHeader = setCookie.split(";", 1)[0];
87
+ const whoamiResponse = await proxyFetch(`${session.proxyOrigin}/whoami`, {
88
+ headers: { cookie: cookieHeader },
89
+ });
90
+ assert.equal(await whoamiResponse.text(), "session=abc123");
91
+ }, (req, res) => {
92
+ const requestUrl = req.url ?? "";
93
+ if (requestUrl === "/base/login") {
94
+ res.writeHead(200, {
95
+ "content-type": "text/plain",
96
+ "set-cookie": "session=abc123; Path=/; Secure; HttpOnly; Domain=127.0.0.1",
97
+ });
98
+ res.end("ok");
99
+ return;
100
+ }
101
+ if (requestUrl === "/base/whoami") {
102
+ res.writeHead(200, { "content-type": "text/plain" });
103
+ res.end(req.headers.cookie ?? "");
104
+ return;
105
+ }
106
+ res.writeHead(404, { "content-type": "text/plain" });
107
+ res.end(requestUrl);
108
+ });
109
+ });
110
+ it("supports explicit deletion and idle cleanup of sessions", async () => {
111
+ await withUpstreamServer(async (upstreamBaseUrl) => {
112
+ const manager = createSessionManager();
113
+ const session = await createSession(manager, `${upstreamBaseUrl}/base`);
114
+ assert.equal(await manager.deleteSession(session.sessionId), true);
115
+ assert.equal(await manager.deleteSession(session.sessionId), false);
116
+ const session3 = await createSession(manager, `${upstreamBaseUrl}/base`);
117
+ const internalSessions = manager.sessions;
118
+ const internalCleanup = manager.cleanupExpiredSessions;
119
+ internalSessions.get(session3.sessionId).lastAccessAt = Date.now() - 31 * 60000;
120
+ await internalCleanup.call(manager);
121
+ assert.equal(internalSessions.has(session3.sessionId), false);
122
+ assert.equal(await manager.deleteSession(session3.sessionId), false);
123
+ }, (_req, res) => {
124
+ res.writeHead(200, { "content-type": "text/plain" });
125
+ res.end("ok");
126
+ });
127
+ });
128
+ });
129
+ function createSessionManager() {
130
+ const manager = new RemoteProxySessionManager({
131
+ authManager: {
132
+ isLoopbackRequest: () => true,
133
+ },
134
+ logger: createStubLogger(),
135
+ httpsOptions: sharedHttpsOptions,
136
+ });
137
+ managers.add(manager);
138
+ return manager;
139
+ }
140
+ async function createSession(manager, baseUrl) {
141
+ const created = await manager.createSession(baseUrl, false);
142
+ const windowUrl = new URL(created.windowUrl);
143
+ return {
144
+ sessionId: created.sessionId,
145
+ windowUrl,
146
+ proxyOrigin: windowUrl.origin,
147
+ token: decodeURIComponent(windowUrl.hash.replace(/^#/, "")),
148
+ };
149
+ }
150
+ async function activateSession(session) {
151
+ const response = await proxyFetch(`${session.proxyOrigin}/__codenomad/api/auth/token`, {
152
+ method: "POST",
153
+ headers: { "content-type": "application/json" },
154
+ body: JSON.stringify({ token: session.token }),
155
+ });
156
+ if (!response.ok) {
157
+ return false;
158
+ }
159
+ const body = (await response.json());
160
+ return body.ok === true;
161
+ }
162
+ function getSetCookie(response) {
163
+ const values = response.headers.getSetCookie?.();
164
+ if (Array.isArray(values) && values.length > 0) {
165
+ return values;
166
+ }
167
+ const fallback = response.headers.get("set-cookie");
168
+ return fallback ? [fallback] : [];
169
+ }
170
+ async function proxyFetch(url, init) {
171
+ return fetch(url, { dispatcher: httpsDispatcher, ...init });
172
+ }
173
+ async function disposeManager(manager) {
174
+ const sessions = Array.from(manager.sessions.keys());
175
+ for (const sessionId of sessions) {
176
+ await manager.deleteSession(sessionId);
177
+ }
178
+ clearInterval(manager.cleanupTimer);
179
+ }
180
+ async function withUpstreamServer(callback, handler) {
181
+ const server = http.createServer(handler);
182
+ await new Promise((resolve) => server.listen(0, "127.0.0.1", () => resolve()));
183
+ try {
184
+ const address = server.address();
185
+ if (!address || typeof address === "string") {
186
+ throw new Error("Failed to resolve upstream server address");
187
+ }
188
+ await callback(`http://127.0.0.1:${address.port}`);
189
+ }
190
+ finally {
191
+ await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));
192
+ }
193
+ }
194
+ function createStubLogger() {
195
+ const logger = {
196
+ info() { },
197
+ warn() { },
198
+ error() { },
199
+ child() {
200
+ return logger;
201
+ },
202
+ };
203
+ return logger;
204
+ }
@@ -20,6 +20,7 @@ import { registerBackgroundProcessRoutes } from "./routes/background-processes";
20
20
  import { registerWorktreeRoutes } from "./routes/worktrees";
21
21
  import { registerSpeechRoutes } from "./routes/speech";
22
22
  import { registerRemoteServerRoutes } from "./routes/remote-servers";
23
+ import { registerRemoteProxyRoutes } from "./routes/remote-proxy";
23
24
  import { registerSideCarRoutes } from "./routes/sidecars";
24
25
  import { BackgroundProcessManager } from "../background-processes/manager";
25
26
  import { registerAuthRoutes } from "./routes/auth";
@@ -139,7 +140,10 @@ export function createHttpServer(deps) {
139
140
  if (deps.authManager.isTokenBootstrapEnabled()) {
140
141
  publicPagePaths.add("/auth/token");
141
142
  }
142
- if (publicApiPaths.has(pathname) || publicPagePaths.has(pathname)) {
143
+ const isLoopbackRemoteProxyDelete = request.method === "DELETE" &&
144
+ pathname.startsWith("/api/remote-proxy/sessions/") &&
145
+ deps.authManager.isLoopbackRequest(request);
146
+ if (publicApiPaths.has(pathname) || publicPagePaths.has(pathname) || isLoopbackRemoteProxyDelete) {
143
147
  done();
144
148
  return;
145
149
  }
@@ -203,6 +207,7 @@ export function createHttpServer(deps) {
203
207
  workspaceManager: deps.workspaceManager,
204
208
  });
205
209
  registerRemoteServerRoutes(app, { logger: apiLogger });
210
+ registerRemoteProxyRoutes(app, { logger: proxyLogger, sessionManager: deps.remoteProxySessionManager });
206
211
  registerSpeechRoutes(app, { speechService: deps.speechService });
207
212
  registerSideCarRoutes(app, { sidecarManager: deps.sidecarManager });
208
213
  registerSideCarProxyRoutes(app, { sidecarManager: deps.sidecarManager, logger: proxyLogger });