@neuralnomads/codenomad-dev 0.18.0-dev-20260716-40cee071 → 0.18.0-dev-20260720-4c50829d
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 +1 -1
- package/dist/filesystem/__tests__/search-cache.test.js +5 -5
- package/dist/filesystem/search-cache.js +1 -1
- package/dist/index.js +42 -0
- package/dist/index.test.js +40 -10
- package/dist/opencode-update/service.js +150 -0
- package/dist/opencode-update/service.test.js +107 -0
- package/dist/permissions/auto-accept-manager.js +165 -0
- package/dist/permissions/auto-accept-manager.test.js +208 -0
- package/dist/permissions/auto-accept-store.js +5 -2
- package/dist/permissions/opencode-yolo-metadata.js +81 -0
- package/dist/permissions/opencode-yolo-metadata.test.js +54 -0
- package/dist/server/__tests__/remote-proxy.test.js +64 -56
- package/dist/server/http-server.js +12 -15
- package/dist/server/remote-proxy.js +68 -98
- package/dist/server/routes/opencode-update.js +37 -0
- package/dist/server/routes/opencode-update.test.js +34 -0
- package/dist/server/routes/remote-servers.js +1 -1
- package/dist/server/routes/workspaces.test.js +38 -0
- package/dist/server/routes/worktrees.js +19 -0
- package/dist/server/routes/yolo.js +2 -1
- package/dist/server/tls.js +2 -2
- package/dist/settings/binaries.test.js +2 -7
- package/dist/shutdown.js +67 -59
- package/dist/shutdown.test.js +115 -78
- package/dist/ui/remote-ui.js +2 -2
- package/dist/usage/providers/api-key.js +3 -3
- package/dist/usage/providers/google.js +1 -1
- package/dist/usage/providers/oauth.js +1 -36
- package/dist/usage/providers/special.js +3 -3
- package/dist/usage/service.js +1 -1
- package/dist/usage/service.test.js +5 -3
- package/dist/usage/shared.js +4 -4
- package/dist/usage/shared.test.js +4 -4
- package/dist/workspaces/__tests__/spawn.test.js +2 -2
- package/dist/workspaces/__tests__/workspace-identity.test.js +94 -286
- package/dist/workspaces/binary-path.test.js +10 -0
- package/dist/workspaces/instance-client.js +4 -3
- package/dist/workspaces/launch-cleanup.test.js +21 -25
- package/dist/workspaces/manager.js +300 -351
- package/dist/workspaces/manager.test.js +209 -237
- package/dist/workspaces/process-identity.darwin.test.js +88 -0
- package/dist/workspaces/process-identity.js +172 -149
- package/dist/workspaces/process-identity.test.js +86 -140
- package/dist/workspaces/runtime.js +261 -373
- package/dist/workspaces/runtime.test.js +222 -547
- package/package.json +1 -1
- package/public/assets/{FilesTab-BjztpSdj.js → FilesTab-ChfE8-8N.js} +2 -2
- package/public/assets/{GitChangesTab-B9XraSoR.js → GitChangesTab-ntRtkxIF.js} +2 -2
- package/public/assets/{SplitFilePanel-Cu_5-Z-E.js → SplitFilePanel-Dav7VCqK.js} +1 -1
- package/public/assets/StatusTab-D3toOefp.js +1 -0
- package/public/assets/{align-justify-BKS9gyRk.js → align-justify-D91sAsLc.js} +1 -1
- package/public/assets/{bundle-full-CXGXXlzR.js → bundle-full-CWkY9-v4.js} +1 -1
- package/public/assets/{diff-viewer-CSNOYoEB.js → diff-viewer-Cn_41zpc.js} +1 -1
- package/public/assets/index-3dx-hhNQ.js +1 -0
- package/public/assets/index-BKwfZLzw.js +1 -0
- package/public/assets/index-C5IioEFT.js +1 -0
- package/public/assets/index-CZYc0VcU.js +2 -0
- package/public/assets/index-CdCoXYXP.js +1 -0
- package/public/assets/index-CwAVops2.js +1 -0
- package/public/assets/index-CzvCOwKc.js +1 -0
- package/public/assets/index-D-loOWPv.js +1 -0
- package/public/assets/{index-DVc4ls8c.js → index-DM1cpVNo.js} +1 -1
- package/public/assets/{index-sLe9hEfQ.js → index-DOprq6Gt.js} +1 -1
- package/public/assets/index-L7fX3W8k.css +1 -0
- package/public/assets/index-w2KdHEME.js +1 -0
- package/public/assets/{loading-CDmt9OJU.js → loading-BfoDcWcg.js} +1 -1
- package/public/assets/main-CaTCcck1.js +68 -0
- package/public/assets/{markdown-1ZLSxCFT.js → markdown-D33YI2g2.js} +3 -3
- package/public/assets/{monaco-viewer-D6JgH76W.js → monaco-viewer-DM35aobI.js} +4 -4
- package/public/assets/{tool-call-CyXb63LH.js → tool-call-tV85Jv2i.js} +3 -3
- package/public/assets/{unified-picker-DCCdSVwI.js → unified-picker-DSRT9GMh.js} +1 -1
- package/public/assets/{wrap-text-pNMdTIq3.js → wrap-text-Dmrn2DKo.js} +1 -1
- package/public/index.html +4 -4
- package/public/loading.html +4 -4
- package/public/sw.js +1 -1
- package/dist/workspaces/launch-cleanup.js +0 -132
- package/public/assets/StatusTab-D_IE79r-.js +0 -1
- package/public/assets/index-B3YjNCh4.js +0 -1
- package/public/assets/index-B3ZD2_SU.js +0 -1
- package/public/assets/index-Bh2ofaQo.js +0 -1
- package/public/assets/index-CM0c9xRZ.js +0 -1
- package/public/assets/index-CdDxouJl.js +0 -2
- package/public/assets/index-D06Y2cFq.js +0 -1
- package/public/assets/index-PmE51vPh.js +0 -1
- package/public/assets/index-Qj63IiSj.css +0 -1
- package/public/assets/index-VY_zwmsG.js +0 -1
- package/public/assets/index-qiiH7egV.js +0 -1
- package/public/assets/main-CTN6Wwz1.js +0 -68
|
@@ -8,27 +8,20 @@ import { Agent, fetch } from "undici";
|
|
|
8
8
|
import { RemoteProxySessionManager } from "../remote-proxy";
|
|
9
9
|
import { resolveHttpsOptions } from "../tls";
|
|
10
10
|
const sharedTempDir = fs.mkdtempSync(path.join(os.tmpdir(), "codenomad-remote-proxy-test-"));
|
|
11
|
-
const sharedTls = resolveHttpsOptions({
|
|
12
|
-
|
|
13
|
-
configDir: sharedTempDir,
|
|
14
|
-
host: "127.0.0.1",
|
|
15
|
-
logger: createStubLogger(),
|
|
16
|
-
});
|
|
17
|
-
if (!sharedTls) {
|
|
11
|
+
const sharedTls = resolveHttpsOptions({ enabled: true, configDir: sharedTempDir, host: "127.0.0.1", logger: createStubLogger() });
|
|
12
|
+
if (!sharedTls)
|
|
18
13
|
throw new Error("Failed to generate HTTPS options for remote proxy tests");
|
|
19
|
-
}
|
|
20
14
|
const sharedHttpsOptions = sharedTls.httpsOptions;
|
|
21
15
|
const httpsDispatcher = new Agent({ connect: { rejectUnauthorized: false } });
|
|
22
16
|
const managers = new Set();
|
|
23
17
|
afterEach(async () => {
|
|
24
|
-
for (const manager of managers)
|
|
25
|
-
await
|
|
26
|
-
}
|
|
18
|
+
for (const manager of managers)
|
|
19
|
+
await manager.shutdown().catch(() => undefined);
|
|
27
20
|
managers.clear();
|
|
28
21
|
});
|
|
29
|
-
after(() => {
|
|
22
|
+
after(async () => {
|
|
30
23
|
fs.rmSync(sharedTempDir, { recursive: true, force: true });
|
|
31
|
-
httpsDispatcher.
|
|
24
|
+
await httpsDispatcher.destroy().catch(() => { });
|
|
32
25
|
});
|
|
33
26
|
describe("RemoteProxySessionManager", () => {
|
|
34
27
|
it("blocks proxying before activation and keeps bootstrap tokens scoped per session", async () => {
|
|
@@ -66,8 +59,7 @@ describe("RemoteProxySessionManager", () => {
|
|
|
66
59
|
const requestUrl = req.url ?? "";
|
|
67
60
|
if (requestUrl === "/base/redirect") {
|
|
68
61
|
res.writeHead(302, { location: "/base/after?ok=1" });
|
|
69
|
-
res.end();
|
|
70
|
-
return;
|
|
62
|
+
return res.end();
|
|
71
63
|
}
|
|
72
64
|
res.writeHead(200, { "content-type": "text/plain" });
|
|
73
65
|
res.end(requestUrl);
|
|
@@ -95,13 +87,11 @@ describe("RemoteProxySessionManager", () => {
|
|
|
95
87
|
"content-type": "text/plain",
|
|
96
88
|
"set-cookie": "session=abc123; Path=/; Secure; HttpOnly; Domain=127.0.0.1",
|
|
97
89
|
});
|
|
98
|
-
res.end("ok");
|
|
99
|
-
return;
|
|
90
|
+
return res.end("ok");
|
|
100
91
|
}
|
|
101
92
|
if (requestUrl === "/base/whoami") {
|
|
102
93
|
res.writeHead(200, { "content-type": "text/plain" });
|
|
103
|
-
res.end(req.headers.cookie ?? "");
|
|
104
|
-
return;
|
|
94
|
+
return res.end(req.headers.cookie ?? "");
|
|
105
95
|
}
|
|
106
96
|
res.writeHead(404, { "content-type": "text/plain" });
|
|
107
97
|
res.end(requestUrl);
|
|
@@ -116,7 +106,7 @@ describe("RemoteProxySessionManager", () => {
|
|
|
116
106
|
const session3 = await createSession(manager, `${upstreamBaseUrl}/base`);
|
|
117
107
|
const internalSessions = manager.sessions;
|
|
118
108
|
const internalCleanup = manager.cleanupExpiredSessions;
|
|
119
|
-
internalSessions.get(session3.sessionId).lastAccessAt = Date.now() - 31 *
|
|
109
|
+
internalSessions.get(session3.sessionId).lastAccessAt = Date.now() - 31 * 60_000;
|
|
120
110
|
await internalCleanup.call(manager);
|
|
121
111
|
assert.equal(internalSessions.has(session3.sessionId), false);
|
|
122
112
|
assert.equal(await manager.deleteSession(session3.sessionId), false);
|
|
@@ -139,7 +129,7 @@ describe("RemoteProxySessionManager", () => {
|
|
|
139
129
|
});
|
|
140
130
|
it("waits for in-flight idle cleanup during shutdown", async () => {
|
|
141
131
|
await withUpstreamServer(async (upstreamBaseUrl) => {
|
|
142
|
-
const manager = createSessionManager({ disposalTimeoutMs:
|
|
132
|
+
const manager = createSessionManager({ disposalTimeoutMs: 1_000 });
|
|
143
133
|
const session = await createSession(manager, `${upstreamBaseUrl}/base`);
|
|
144
134
|
const internalSession = manager.sessions.get(session.sessionId);
|
|
145
135
|
const closeGate = deferred();
|
|
@@ -148,9 +138,8 @@ describe("RemoteProxySessionManager", () => {
|
|
|
148
138
|
await closeGate.promise;
|
|
149
139
|
return originalClose();
|
|
150
140
|
};
|
|
151
|
-
internalSession.lastAccessAt = Date.now() - 31 *
|
|
141
|
+
internalSession.lastAccessAt = Date.now() - 31 * 60_000;
|
|
152
142
|
const cleanup = manager.cleanupExpiredSessions();
|
|
153
|
-
assert.equal(manager.pendingDisposals.size, 1);
|
|
154
143
|
let shutdownSettled = false;
|
|
155
144
|
const shutdown = manager.shutdown().then(() => {
|
|
156
145
|
shutdownSettled = true;
|
|
@@ -160,7 +149,7 @@ describe("RemoteProxySessionManager", () => {
|
|
|
160
149
|
closeGate.resolve();
|
|
161
150
|
await cleanup;
|
|
162
151
|
await shutdown;
|
|
163
|
-
assert.equal(manager.
|
|
152
|
+
assert.equal(manager.disposals.size, 0);
|
|
164
153
|
}, (_req, res) => {
|
|
165
154
|
res.writeHead(200).end("ok");
|
|
166
155
|
});
|
|
@@ -177,12 +166,11 @@ describe("RemoteProxySessionManager", () => {
|
|
|
177
166
|
new Promise((_resolve, reject) => setTimeout(() => reject(new Error("shutdown stalled")), 500)),
|
|
178
167
|
]);
|
|
179
168
|
assert.equal(manager.sessions.size, 0);
|
|
180
|
-
assert.equal(manager.
|
|
169
|
+
assert.equal(manager.disposals.size, 0);
|
|
181
170
|
}, (req, res) => {
|
|
182
171
|
if (req.url === "/base/events") {
|
|
183
172
|
res.writeHead(200, { "content-type": "text/event-stream" });
|
|
184
|
-
res.write("data: connected\n\n");
|
|
185
|
-
return;
|
|
173
|
+
return void res.write("data: connected\n\n");
|
|
186
174
|
}
|
|
187
175
|
res.writeHead(200).end("ok");
|
|
188
176
|
});
|
|
@@ -193,12 +181,18 @@ describe("RemoteProxySessionManager", () => {
|
|
|
193
181
|
const session = await createSession(manager, `${upstreamBaseUrl}/base`);
|
|
194
182
|
const internalSession = manager.sessions.get(session.sessionId);
|
|
195
183
|
const originalClose = internalSession.app.close.bind(internalSession.app);
|
|
184
|
+
let failClose = true;
|
|
196
185
|
internalSession.app.close = async () => {
|
|
197
186
|
await originalClose();
|
|
198
|
-
|
|
187
|
+
if (failClose) {
|
|
188
|
+
failClose = false;
|
|
189
|
+
throw new Error("close failed");
|
|
190
|
+
}
|
|
199
191
|
};
|
|
200
192
|
await assert.rejects(manager.deleteSession(session.sessionId), /Remote proxy disposal failed/);
|
|
201
|
-
|
|
193
|
+
// A completed deletion failure predating shutdown must not poison it.
|
|
194
|
+
await manager.shutdown();
|
|
195
|
+
assert.equal(manager.sessions.size, 0);
|
|
202
196
|
}, (_req, res) => {
|
|
203
197
|
res.writeHead(200).end("ok");
|
|
204
198
|
});
|
|
@@ -210,31 +204,58 @@ describe("RemoteProxySessionManager", () => {
|
|
|
210
204
|
const shutdown = manager.shutdown();
|
|
211
205
|
await assert.rejects(creation, /shutting down/);
|
|
212
206
|
await shutdown;
|
|
213
|
-
assert.equal(manager.
|
|
207
|
+
assert.equal(manager.creations.size, 0);
|
|
214
208
|
assert.equal(manager.sessions.size, 0);
|
|
215
209
|
await assert.rejects(manager.createSession(`${upstreamBaseUrl}/base`, false), /shutting down/);
|
|
216
210
|
}, (_req, res) => {
|
|
217
211
|
res.writeHead(200).end("ok");
|
|
218
212
|
});
|
|
219
213
|
});
|
|
214
|
+
it("coalesces shutdown, retains current disposal failures, and gives every session its own agent", async () => {
|
|
215
|
+
await withUpstreamServer(async (upstreamBaseUrl) => {
|
|
216
|
+
const manager = createSessionManager();
|
|
217
|
+
const verified = await manager.createSession(`${upstreamBaseUrl}/verified`, false);
|
|
218
|
+
const insecure = await manager.createSession(`${upstreamBaseUrl}/insecure`, true);
|
|
219
|
+
const sessions = manager.sessions;
|
|
220
|
+
assert.ok(sessions.get(verified.sessionId).dispatcher instanceof Agent);
|
|
221
|
+
assert.ok(sessions.get(insecure.sessionId).dispatcher instanceof Agent);
|
|
222
|
+
assert.notStrictEqual(sessions.get(verified.sessionId).dispatcher, sessions.get(insecure.sessionId).dispatcher);
|
|
223
|
+
const closeGate = deferred();
|
|
224
|
+
const originalClose = sessions.get(verified.sessionId).app.close.bind(sessions.get(verified.sessionId).app);
|
|
225
|
+
let failClose = true;
|
|
226
|
+
sessions.get(verified.sessionId).app.close = async () => {
|
|
227
|
+
await closeGate.promise;
|
|
228
|
+
await originalClose();
|
|
229
|
+
if (failClose) {
|
|
230
|
+
failClose = false;
|
|
231
|
+
throw new Error("current close failed");
|
|
232
|
+
}
|
|
233
|
+
};
|
|
234
|
+
const disposal = manager.deleteSession(verified.sessionId);
|
|
235
|
+
const first = manager.shutdown();
|
|
236
|
+
const concurrent = manager.shutdown();
|
|
237
|
+
assert.strictEqual(first, concurrent);
|
|
238
|
+
closeGate.resolve();
|
|
239
|
+
await assert.rejects(disposal, /Remote proxy disposal failed/);
|
|
240
|
+
await assert.rejects(first, (error) => error instanceof AggregateError && error.errors.some((cause) => cause instanceof AggregateError && cause.errors.some((nested) => /current close failed/.test(String(nested)))));
|
|
241
|
+
await manager.shutdown();
|
|
242
|
+
assert.equal(sessions.size, 0);
|
|
243
|
+
}, (_req, res) => {
|
|
244
|
+
res.writeHead(200).end("ok");
|
|
245
|
+
});
|
|
246
|
+
});
|
|
220
247
|
});
|
|
221
248
|
function createSessionManager(options = {}) {
|
|
222
249
|
const manager = new RemoteProxySessionManager({
|
|
223
|
-
authManager: {
|
|
224
|
-
|
|
225
|
-
},
|
|
226
|
-
logger: createStubLogger(),
|
|
227
|
-
httpsOptions: sharedHttpsOptions,
|
|
228
|
-
...options,
|
|
250
|
+
authManager: { isLoopbackRequest: () => true },
|
|
251
|
+
logger: createStubLogger(), httpsOptions: sharedHttpsOptions, ...options,
|
|
229
252
|
});
|
|
230
253
|
managers.add(manager);
|
|
231
254
|
return manager;
|
|
232
255
|
}
|
|
233
256
|
function deferred() {
|
|
234
257
|
let resolve;
|
|
235
|
-
const promise = new Promise((resolvePromise) => {
|
|
236
|
-
resolve = resolvePromise;
|
|
237
|
-
});
|
|
258
|
+
const promise = new Promise((resolvePromise) => { resolve = resolvePromise; });
|
|
238
259
|
return { promise, resolve };
|
|
239
260
|
}
|
|
240
261
|
async function createSession(manager, baseUrl) {
|
|
@@ -253,34 +274,28 @@ async function activateSession(session) {
|
|
|
253
274
|
headers: { "content-type": "application/json" },
|
|
254
275
|
body: JSON.stringify({ token: session.token }),
|
|
255
276
|
});
|
|
256
|
-
if (!response.ok)
|
|
277
|
+
if (!response.ok)
|
|
257
278
|
return false;
|
|
258
|
-
}
|
|
259
279
|
const body = (await response.json());
|
|
260
280
|
return body.ok === true;
|
|
261
281
|
}
|
|
262
282
|
function getSetCookie(response) {
|
|
263
283
|
const values = response.headers.getSetCookie?.();
|
|
264
|
-
if (Array.isArray(values) && values.length > 0)
|
|
284
|
+
if (Array.isArray(values) && values.length > 0)
|
|
265
285
|
return values;
|
|
266
|
-
}
|
|
267
286
|
const fallback = response.headers.get("set-cookie");
|
|
268
287
|
return fallback ? [fallback] : [];
|
|
269
288
|
}
|
|
270
289
|
async function proxyFetch(url, init) {
|
|
271
290
|
return fetch(url, { dispatcher: httpsDispatcher, ...init });
|
|
272
291
|
}
|
|
273
|
-
async function disposeManager(manager) {
|
|
274
|
-
await manager.shutdown().catch(() => undefined);
|
|
275
|
-
}
|
|
276
292
|
async function withUpstreamServer(callback, handler) {
|
|
277
293
|
const server = http.createServer(handler);
|
|
278
294
|
await new Promise((resolve) => server.listen(0, "127.0.0.1", () => resolve()));
|
|
279
295
|
try {
|
|
280
296
|
const address = server.address();
|
|
281
|
-
if (!address || typeof address === "string")
|
|
297
|
+
if (!address || typeof address === "string")
|
|
282
298
|
throw new Error("Failed to resolve upstream server address");
|
|
283
|
-
}
|
|
284
299
|
await callback(`http://127.0.0.1:${address.port}`);
|
|
285
300
|
}
|
|
286
301
|
finally {
|
|
@@ -288,13 +303,6 @@ async function withUpstreamServer(callback, handler) {
|
|
|
288
303
|
}
|
|
289
304
|
}
|
|
290
305
|
function createStubLogger() {
|
|
291
|
-
const logger = {
|
|
292
|
-
info() { },
|
|
293
|
-
warn() { },
|
|
294
|
-
error() { },
|
|
295
|
-
child() {
|
|
296
|
-
return logger;
|
|
297
|
-
},
|
|
298
|
-
};
|
|
306
|
+
const logger = { info() { }, warn() { }, error() { }, child() { return logger; } };
|
|
299
307
|
return logger;
|
|
300
308
|
}
|
|
@@ -19,16 +19,16 @@ import { registerBackgroundProcessRoutes } from "./routes/background-processes";
|
|
|
19
19
|
import { registerYoloRoutes } from "./routes/yolo";
|
|
20
20
|
import { registerWorktreeRoutes } from "./routes/worktrees";
|
|
21
21
|
import { registerSpeechRoutes } from "./routes/speech";
|
|
22
|
+
import { registerOpenCodeUpdateRoutes } from "./routes/opencode-update";
|
|
22
23
|
import { registerRemoteServerRoutes } from "./routes/remote-servers";
|
|
23
24
|
import { registerRemoteProxyRoutes } from "./routes/remote-proxy";
|
|
24
25
|
import { registerSideCarRoutes } from "./routes/sidecars";
|
|
25
26
|
import { registerPreviewRoutes } from "./routes/previews";
|
|
26
27
|
import { registerUsageRoutes } from "./routes/usage";
|
|
27
28
|
import { BackgroundProcessManager } from "../background-processes/manager";
|
|
28
|
-
import { AutoAcceptManager } from "../permissions/auto-accept-manager";
|
|
29
|
-
import { createOpencodePermissionReplier } from "../permissions/opencode-replier";
|
|
30
29
|
import { registerAuthRoutes } from "./routes/auth";
|
|
31
30
|
import { sendUnauthorized, wantsHtml } from "../auth/http-auth";
|
|
31
|
+
import { createOpenCodeUpdateService } from "../opencode-update/service";
|
|
32
32
|
export function createHttpServer(deps) {
|
|
33
33
|
// Fastify's type-level RawServer inference gets noisy when toggling HTTP vs HTTPS.
|
|
34
34
|
// We keep the runtime behavior correct and cast the instance to a generic FastifyInstance.
|
|
@@ -59,7 +59,7 @@ export function createHttpServer(deps) {
|
|
|
59
59
|
});
|
|
60
60
|
app.addHook("onResponse", (request, reply, done) => {
|
|
61
61
|
const meta = request.__logMeta;
|
|
62
|
-
const durationMs = meta ? Number((process.hrtime.bigint() - meta.start) / BigInt(
|
|
62
|
+
const durationMs = meta ? Number((process.hrtime.bigint() - meta.start) / BigInt(1_000_000)) : undefined;
|
|
63
63
|
const base = {
|
|
64
64
|
method: request.method,
|
|
65
65
|
url: request.url,
|
|
@@ -135,15 +135,6 @@ export function createHttpServer(deps) {
|
|
|
135
135
|
eventBus: deps.eventBus,
|
|
136
136
|
logger: deps.logger.child({ component: "background-processes" }),
|
|
137
137
|
});
|
|
138
|
-
const yoloManager = new AutoAcceptManager({
|
|
139
|
-
eventBus: deps.eventBus,
|
|
140
|
-
logger: deps.logger.child({ component: "yolo" }),
|
|
141
|
-
replier: createOpencodePermissionReplier({
|
|
142
|
-
workspaceManager: deps.workspaceManager,
|
|
143
|
-
logger: deps.logger.child({ component: "yolo" }),
|
|
144
|
-
}),
|
|
145
|
-
});
|
|
146
|
-
yoloManager.start();
|
|
147
138
|
registerAuthRoutes(app, { authManager: deps.authManager });
|
|
148
139
|
app.addHook("preHandler", (request, reply, done) => {
|
|
149
140
|
const rawUrl = request.raw.url ?? request.url;
|
|
@@ -205,6 +196,10 @@ export function createHttpServer(deps) {
|
|
|
205
196
|
});
|
|
206
197
|
registerWorkspaceRoutes(app, { workspaceManager: deps.workspaceManager });
|
|
207
198
|
registerSettingsRoutes(app, { settings: deps.settings, logger: apiLogger });
|
|
199
|
+
registerOpenCodeUpdateRoutes(app, {
|
|
200
|
+
service: createOpenCodeUpdateService(deps.settings, deps.workspaceManager),
|
|
201
|
+
logger: apiLogger,
|
|
202
|
+
});
|
|
208
203
|
registerFilesystemRoutes(app, { fileSystemBrowser: deps.fileSystemBrowser });
|
|
209
204
|
registerConfigFileRoutes(app);
|
|
210
205
|
registerMetaRoutes(app, { serverMeta: deps.serverMeta });
|
|
@@ -214,7 +209,10 @@ export function createHttpServer(deps) {
|
|
|
214
209
|
logger: sseLogger,
|
|
215
210
|
connectionManager: deps.clientConnectionManager,
|
|
216
211
|
});
|
|
217
|
-
registerWorktreeRoutes(app, {
|
|
212
|
+
registerWorktreeRoutes(app, {
|
|
213
|
+
workspaceManager: deps.workspaceManager,
|
|
214
|
+
sessionMetadataPersistence: deps.sessionMetadataPersistence,
|
|
215
|
+
});
|
|
218
216
|
registerStorageRoutes(app, {
|
|
219
217
|
instanceStore: deps.instanceStore,
|
|
220
218
|
eventBus: deps.eventBus,
|
|
@@ -246,7 +244,7 @@ export function createHttpServer(deps) {
|
|
|
246
244
|
voiceModeManager: deps.voiceModeManager,
|
|
247
245
|
});
|
|
248
246
|
registerBackgroundProcessRoutes(app, { backgroundProcessManager });
|
|
249
|
-
registerYoloRoutes(app, { yoloManager });
|
|
247
|
+
registerYoloRoutes(app, { yoloManager: deps.yoloManager });
|
|
250
248
|
registerInstanceProxyRoutes(app, { workspaceManager: deps.workspaceManager, logger: proxyLogger });
|
|
251
249
|
if (deps.uiDevServerUrl) {
|
|
252
250
|
setupDevProxy(app, deps.uiDevServerUrl, deps.authManager, deps.previewManager, proxyLogger);
|
|
@@ -302,7 +300,6 @@ export function createHttpServer(deps) {
|
|
|
302
300
|
return { port: actualPort, url: serverUrl, displayHost };
|
|
303
301
|
},
|
|
304
302
|
stop: () => {
|
|
305
|
-
yoloManager.stop();
|
|
306
303
|
closeSseClients();
|
|
307
304
|
return app.close();
|
|
308
305
|
},
|
|
@@ -6,36 +6,23 @@ import { Agent, fetch } from "undici";
|
|
|
6
6
|
const LOOPBACK_HOST = "127.0.0.1";
|
|
7
7
|
const BOOTSTRAP_PAGE_PATH = "/__codenomad/auth/token";
|
|
8
8
|
const BOOTSTRAP_EXCHANGE_PATH = "/__codenomad/api/auth/token";
|
|
9
|
-
const SESSION_IDLE_TTL_MS = 30 *
|
|
10
|
-
const SESSION_DISPOSAL_TIMEOUT_MS =
|
|
9
|
+
const SESSION_IDLE_TTL_MS = 30 * 60_000;
|
|
10
|
+
const SESSION_DISPOSAL_TIMEOUT_MS = 5_000;
|
|
11
11
|
export class RemoteProxySessionManager {
|
|
12
12
|
constructor(options) {
|
|
13
13
|
this.options = options;
|
|
14
14
|
this.sessions = new Map();
|
|
15
|
-
this.
|
|
16
|
-
this.
|
|
17
|
-
this.
|
|
15
|
+
this.creations = new Set();
|
|
16
|
+
this.disposals = new Set();
|
|
17
|
+
this.sessionDisposals = new Map();
|
|
18
18
|
this.shuttingDown = false;
|
|
19
|
-
this.
|
|
20
|
-
this.cleanupTimer = setInterval(() => {
|
|
21
|
-
void this.cleanupExpiredSessions().catch((error) => {
|
|
22
|
-
this.options.logger.error({ err: error }, "Failed to dispose expired remote proxy session");
|
|
23
|
-
});
|
|
24
|
-
}, 60000);
|
|
19
|
+
this.cleanupTimer = setInterval(() => void this.cleanupExpiredSessions().catch((error) => this.options.logger.error({ err: error }, "Failed to dispose expired remote proxy session")), 60_000);
|
|
25
20
|
this.cleanupTimer.unref();
|
|
26
21
|
}
|
|
27
22
|
async createSession(baseUrl, skipTlsVerify) {
|
|
28
|
-
if (this.shuttingDown)
|
|
23
|
+
if (this.shuttingDown)
|
|
29
24
|
throw new Error("Remote proxy session manager is shutting down");
|
|
30
|
-
|
|
31
|
-
const creation = this.createSessionInternal(baseUrl, skipTlsVerify);
|
|
32
|
-
this.pendingCreations.add(creation);
|
|
33
|
-
try {
|
|
34
|
-
return await creation;
|
|
35
|
-
}
|
|
36
|
-
finally {
|
|
37
|
-
this.pendingCreations.delete(creation);
|
|
38
|
-
}
|
|
25
|
+
return this.track(this.creations, this.createSessionInternal(baseUrl, skipTlsVerify));
|
|
39
26
|
}
|
|
40
27
|
async createSessionInternal(baseUrl, skipTlsVerify) {
|
|
41
28
|
if (!this.options.httpsOptions) {
|
|
@@ -79,7 +66,7 @@ export class RemoteProxySessionManager {
|
|
|
79
66
|
session.lastAccessAt = Date.now();
|
|
80
67
|
reply.send({ ok: true });
|
|
81
68
|
});
|
|
82
|
-
|
|
69
|
+
const handleProxyRequest = async (request, reply) => {
|
|
83
70
|
if (!session) {
|
|
84
71
|
reply.code(503).send({ error: "Remote proxy session is unavailable" });
|
|
85
72
|
return;
|
|
@@ -90,78 +77,64 @@ export class RemoteProxySessionManager {
|
|
|
90
77
|
}
|
|
91
78
|
session.lastAccessAt = Date.now();
|
|
92
79
|
await proxyRequest({ request, reply, session, logger: this.options.logger });
|
|
93
|
-
}
|
|
94
|
-
app.
|
|
95
|
-
|
|
96
|
-
reply.code(503).send({ error: "Remote proxy session is unavailable" });
|
|
97
|
-
return;
|
|
98
|
-
}
|
|
99
|
-
if (!session.activated) {
|
|
100
|
-
reply.code(403).send({ error: "Remote proxy session is not activated" });
|
|
101
|
-
return;
|
|
102
|
-
}
|
|
103
|
-
session.lastAccessAt = Date.now();
|
|
104
|
-
await proxyRequest({ request, reply, session, logger: this.options.logger });
|
|
105
|
-
});
|
|
80
|
+
};
|
|
81
|
+
app.all("/*", handleProxyRequest);
|
|
82
|
+
app.setNotFoundHandler(handleProxyRequest);
|
|
106
83
|
const addressInfo = await app.listen({ host: LOOPBACK_HOST, port: 0 });
|
|
107
|
-
if (this.shuttingDown) {
|
|
108
|
-
abortController.abort();
|
|
109
|
-
try {
|
|
110
|
-
await this.disposeResources(app, dispatcher);
|
|
111
|
-
}
|
|
112
|
-
catch (error) {
|
|
113
|
-
this.disposalFailures.push(error);
|
|
114
|
-
throw error;
|
|
115
|
-
}
|
|
116
|
-
throw new Error("Remote proxy session manager is shutting down");
|
|
117
|
-
}
|
|
118
84
|
const address = new URL(addressInfo);
|
|
119
85
|
const localBaseUrl = new URL(`https://${LOOPBACK_HOST}:${address.port}`);
|
|
120
86
|
const entryUrl = new URL(targetBaseUrl.pathname || "/", localBaseUrl);
|
|
121
87
|
const returnTo = buildReturnToTarget(entryUrl);
|
|
88
|
+
const bootstrapUrl = `${localBaseUrl.origin}${BOOTSTRAP_PAGE_PATH}?returnTo=${encodeURIComponent(returnTo)}#${encodeURIComponent(bootstrapToken)}`;
|
|
122
89
|
session = {
|
|
123
90
|
id: sessionId,
|
|
124
91
|
bootstrapToken,
|
|
125
92
|
targetBaseUrl,
|
|
126
|
-
skipTlsVerify,
|
|
127
93
|
localBaseUrl,
|
|
128
|
-
entryUrl,
|
|
129
|
-
bootstrapUrl: `${localBaseUrl.origin}${BOOTSTRAP_PAGE_PATH}?returnTo=${encodeURIComponent(returnTo)}#${encodeURIComponent(bootstrapToken)}`,
|
|
130
94
|
activated: false,
|
|
131
95
|
cookiePrefix: `cnrp_${randomBytes(6).toString("hex")}_`,
|
|
132
96
|
app,
|
|
133
97
|
dispatcher,
|
|
134
98
|
abortController,
|
|
135
|
-
createdAt: Date.now(),
|
|
136
99
|
lastAccessAt: Date.now(),
|
|
137
100
|
};
|
|
138
101
|
this.sessions.set(sessionId, session);
|
|
102
|
+
if (this.shuttingDown) {
|
|
103
|
+
await this.disposeSession(sessionId);
|
|
104
|
+
throw new Error("Remote proxy session manager is shutting down");
|
|
105
|
+
}
|
|
139
106
|
this.options.logger.info({ sessionId, targetBaseUrl: targetBaseUrl.toString(), localBaseUrl: localBaseUrl.toString() }, "Created remote proxy session");
|
|
140
|
-
return { sessionId, windowUrl:
|
|
107
|
+
return { sessionId, windowUrl: bootstrapUrl };
|
|
141
108
|
}
|
|
142
109
|
async deleteSession(sessionId) {
|
|
143
110
|
return this.disposeSession(sessionId);
|
|
144
111
|
}
|
|
145
|
-
|
|
112
|
+
shutdown() {
|
|
113
|
+
if (this.shutdownPromise)
|
|
114
|
+
return this.shutdownPromise;
|
|
146
115
|
this.shuttingDown = true;
|
|
147
116
|
clearInterval(this.cleanupTimer);
|
|
148
|
-
const
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
117
|
+
const shutdown = this.drainShutdown();
|
|
118
|
+
this.shutdownPromise = shutdown;
|
|
119
|
+
void shutdown.finally(() => {
|
|
120
|
+
if (this.shutdownPromise === shutdown)
|
|
121
|
+
this.shutdownPromise = undefined;
|
|
122
|
+
}).catch(() => undefined);
|
|
123
|
+
return shutdown;
|
|
124
|
+
}
|
|
125
|
+
async drainShutdown() {
|
|
126
|
+
const disposals = new Set(this.disposals);
|
|
127
|
+
while (this.creations.size > 0) {
|
|
128
|
+
await Promise.allSettled([...this.creations]);
|
|
129
|
+
for (const disposal of this.disposals)
|
|
155
130
|
disposals.add(disposal);
|
|
156
|
-
await Promise.allSettled(pending);
|
|
157
131
|
}
|
|
158
|
-
const
|
|
159
|
-
const
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
throw createDisposalError("Remote proxy shutdown failed", failures);
|
|
132
|
+
const pendingResults = await Promise.allSettled(disposals);
|
|
133
|
+
const results = await Promise.allSettled(Array.from(this.sessions.keys(), (id) => this.disposeSession(id)));
|
|
134
|
+
const failures = [...pendingResults, ...results]
|
|
135
|
+
.flatMap((result) => result.status === "rejected" ? [result.reason] : []);
|
|
136
|
+
if (failures.length)
|
|
137
|
+
throw new AggregateError(failures, "Remote proxy shutdown failed");
|
|
165
138
|
}
|
|
166
139
|
async cleanupExpiredSessions() {
|
|
167
140
|
const now = Date.now();
|
|
@@ -173,48 +146,45 @@ export class RemoteProxySessionManager {
|
|
|
173
146
|
}
|
|
174
147
|
}
|
|
175
148
|
disposeSession(sessionId) {
|
|
149
|
+
const pending = this.sessionDisposals.get(sessionId);
|
|
150
|
+
if (pending)
|
|
151
|
+
return pending;
|
|
176
152
|
const session = this.sessions.get(sessionId);
|
|
177
|
-
if (!session)
|
|
153
|
+
if (!session)
|
|
178
154
|
return Promise.resolve(false);
|
|
179
|
-
}
|
|
180
|
-
this.sessions.delete(sessionId);
|
|
181
155
|
session.abortController.abort();
|
|
182
|
-
const disposal = this.disposeResources(session.app, session.dispatcher)
|
|
183
|
-
.
|
|
156
|
+
const disposal = this.trackDisposal(this.disposeResources(session.app, session.dispatcher).then(() => {
|
|
157
|
+
if (this.sessions.get(sessionId) === session)
|
|
158
|
+
this.sessions.delete(sessionId);
|
|
184
159
|
this.options.logger.info({ sessionId }, "Disposed remote proxy session");
|
|
185
160
|
return true;
|
|
186
|
-
})
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
void disposal.then(() => this.pendingDisposals.delete(disposal), () => this.pendingDisposals.delete(disposal));
|
|
161
|
+
}));
|
|
162
|
+
this.sessionDisposals.set(sessionId, disposal);
|
|
163
|
+
void disposal.finally(() => {
|
|
164
|
+
if (this.sessionDisposals.get(sessionId) === disposal)
|
|
165
|
+
this.sessionDisposals.delete(sessionId);
|
|
166
|
+
}).catch(() => undefined);
|
|
193
167
|
return disposal;
|
|
194
168
|
}
|
|
195
169
|
async disposeResources(app, dispatcher) {
|
|
196
170
|
app.server.closeAllConnections?.();
|
|
197
|
-
const
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
}
|
|
171
|
+
const results = await Promise.race([
|
|
172
|
+
Promise.allSettled([app.close(), dispatcher?.destroy()]),
|
|
173
|
+
new Promise((_resolve, reject) => AbortSignal.timeout(Math.max(1, this.options.disposalTimeoutMs ?? SESSION_DISPOSAL_TIMEOUT_MS)).addEventListener("abort", () => reject(new Error("Remote proxy disposal timed out")))),
|
|
174
|
+
]);
|
|
175
|
+
const failures = results.flatMap((result) => result.status === "rejected" ? [result.reason] : []);
|
|
176
|
+
if (failures.length)
|
|
177
|
+
throw new AggregateError(failures, "Remote proxy disposal failed");
|
|
178
|
+
}
|
|
179
|
+
track(operations, operation) {
|
|
180
|
+
operations.add(operation);
|
|
181
|
+
void operation.finally(() => operations.delete(operation)).catch(() => undefined);
|
|
182
|
+
return operation;
|
|
183
|
+
}
|
|
184
|
+
trackDisposal(operation) {
|
|
185
|
+
return this.track(this.disposals, operation);
|
|
213
186
|
}
|
|
214
187
|
}
|
|
215
|
-
function createDisposalError(message, failures) {
|
|
216
|
-
return Object.assign(new Error(message), { failures });
|
|
217
|
-
}
|
|
218
188
|
function normalizeBaseUrl(input) {
|
|
219
189
|
const parsed = new URL(input.trim());
|
|
220
190
|
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { OpenCodeUpdateError } from "../../opencode-update/service";
|
|
2
|
+
function statusCode(error) {
|
|
3
|
+
if (error.code === "no_ready_instance")
|
|
4
|
+
return 409;
|
|
5
|
+
if (error.code === "binary_unavailable")
|
|
6
|
+
return 422;
|
|
7
|
+
return 502;
|
|
8
|
+
}
|
|
9
|
+
function requestError(error, fallback) {
|
|
10
|
+
if (error instanceof OpenCodeUpdateError)
|
|
11
|
+
return { status: statusCode(error), code: error.code };
|
|
12
|
+
return { status: 500, code: fallback };
|
|
13
|
+
}
|
|
14
|
+
export function registerOpenCodeUpdateRoutes(app, deps) {
|
|
15
|
+
app.get("/api/opencode/update", async (_request, reply) => {
|
|
16
|
+
try {
|
|
17
|
+
return await deps.service.getStatus();
|
|
18
|
+
}
|
|
19
|
+
catch (error) {
|
|
20
|
+
deps.logger.warn({ err: error }, "Failed to check OpenCode update status");
|
|
21
|
+
const failure = requestError(error, "update_check_failed");
|
|
22
|
+
reply.code(failure.status);
|
|
23
|
+
return { error: failure.code };
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
app.post("/api/opencode/update", async (_request, reply) => {
|
|
27
|
+
try {
|
|
28
|
+
return await deps.service.upgrade();
|
|
29
|
+
}
|
|
30
|
+
catch (error) {
|
|
31
|
+
deps.logger.warn({ err: error }, "Failed to update OpenCode");
|
|
32
|
+
const failure = requestError(error, "upgrade_failed");
|
|
33
|
+
reply.code(failure.status);
|
|
34
|
+
return { success: false, error: failure.code };
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import Fastify from "fastify";
|
|
4
|
+
import { registerOpenCodeUpdateRoutes } from "./opencode-update";
|
|
5
|
+
test("does not pass request-controlled binary paths to the update service", async () => {
|
|
6
|
+
const calls = [];
|
|
7
|
+
const service = {
|
|
8
|
+
getStatus: async (...args) => {
|
|
9
|
+
calls.push({ method: "status", args });
|
|
10
|
+
return {
|
|
11
|
+
currentVersion: "1.0.0",
|
|
12
|
+
latestVersion: "1.1.0",
|
|
13
|
+
updateAvailable: true,
|
|
14
|
+
canUpgrade: true,
|
|
15
|
+
};
|
|
16
|
+
},
|
|
17
|
+
upgrade: async (...args) => {
|
|
18
|
+
calls.push({ method: "upgrade", args });
|
|
19
|
+
return { success: true, version: "1.1.0" };
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
const app = Fastify();
|
|
23
|
+
registerOpenCodeUpdateRoutes(app, {
|
|
24
|
+
service,
|
|
25
|
+
logger: { warn() { } },
|
|
26
|
+
});
|
|
27
|
+
await app.inject({ method: "GET", url: "/api/opencode/update?binary=untrusted.cmd" });
|
|
28
|
+
await app.inject({ method: "POST", url: "/api/opencode/update", payload: { binary: "untrusted.cmd" } });
|
|
29
|
+
assert.deepEqual(calls, [
|
|
30
|
+
{ method: "status", args: [] },
|
|
31
|
+
{ method: "upgrade", args: [] },
|
|
32
|
+
]);
|
|
33
|
+
await app.close();
|
|
34
|
+
});
|
|
@@ -4,7 +4,7 @@ const ProbeSchema = z.object({
|
|
|
4
4
|
baseUrl: z.string().min(1),
|
|
5
5
|
skipTlsVerify: z.boolean().optional(),
|
|
6
6
|
});
|
|
7
|
-
const PROBE_TIMEOUT_MS =
|
|
7
|
+
const PROBE_TIMEOUT_MS = 8_000;
|
|
8
8
|
export function registerRemoteServerRoutes(app, deps) {
|
|
9
9
|
app.post("/api/remote-servers/probe", async (request, reply) => {
|
|
10
10
|
try {
|