@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.
Files changed (89) hide show
  1. package/dist/auth/manager.js +1 -1
  2. package/dist/filesystem/__tests__/search-cache.test.js +5 -5
  3. package/dist/filesystem/search-cache.js +1 -1
  4. package/dist/index.js +42 -0
  5. package/dist/index.test.js +40 -10
  6. package/dist/opencode-update/service.js +150 -0
  7. package/dist/opencode-update/service.test.js +107 -0
  8. package/dist/permissions/auto-accept-manager.js +165 -0
  9. package/dist/permissions/auto-accept-manager.test.js +208 -0
  10. package/dist/permissions/auto-accept-store.js +5 -2
  11. package/dist/permissions/opencode-yolo-metadata.js +81 -0
  12. package/dist/permissions/opencode-yolo-metadata.test.js +54 -0
  13. package/dist/server/__tests__/remote-proxy.test.js +64 -56
  14. package/dist/server/http-server.js +12 -15
  15. package/dist/server/remote-proxy.js +68 -98
  16. package/dist/server/routes/opencode-update.js +37 -0
  17. package/dist/server/routes/opencode-update.test.js +34 -0
  18. package/dist/server/routes/remote-servers.js +1 -1
  19. package/dist/server/routes/workspaces.test.js +38 -0
  20. package/dist/server/routes/worktrees.js +19 -0
  21. package/dist/server/routes/yolo.js +2 -1
  22. package/dist/server/tls.js +2 -2
  23. package/dist/settings/binaries.test.js +2 -7
  24. package/dist/shutdown.js +67 -59
  25. package/dist/shutdown.test.js +115 -78
  26. package/dist/ui/remote-ui.js +2 -2
  27. package/dist/usage/providers/api-key.js +3 -3
  28. package/dist/usage/providers/google.js +1 -1
  29. package/dist/usage/providers/oauth.js +1 -36
  30. package/dist/usage/providers/special.js +3 -3
  31. package/dist/usage/service.js +1 -1
  32. package/dist/usage/service.test.js +5 -3
  33. package/dist/usage/shared.js +4 -4
  34. package/dist/usage/shared.test.js +4 -4
  35. package/dist/workspaces/__tests__/spawn.test.js +2 -2
  36. package/dist/workspaces/__tests__/workspace-identity.test.js +94 -286
  37. package/dist/workspaces/binary-path.test.js +10 -0
  38. package/dist/workspaces/instance-client.js +4 -3
  39. package/dist/workspaces/launch-cleanup.test.js +21 -25
  40. package/dist/workspaces/manager.js +300 -351
  41. package/dist/workspaces/manager.test.js +209 -237
  42. package/dist/workspaces/process-identity.darwin.test.js +88 -0
  43. package/dist/workspaces/process-identity.js +172 -149
  44. package/dist/workspaces/process-identity.test.js +86 -140
  45. package/dist/workspaces/runtime.js +261 -373
  46. package/dist/workspaces/runtime.test.js +222 -547
  47. package/package.json +1 -1
  48. package/public/assets/{FilesTab-BjztpSdj.js → FilesTab-ChfE8-8N.js} +2 -2
  49. package/public/assets/{GitChangesTab-B9XraSoR.js → GitChangesTab-ntRtkxIF.js} +2 -2
  50. package/public/assets/{SplitFilePanel-Cu_5-Z-E.js → SplitFilePanel-Dav7VCqK.js} +1 -1
  51. package/public/assets/StatusTab-D3toOefp.js +1 -0
  52. package/public/assets/{align-justify-BKS9gyRk.js → align-justify-D91sAsLc.js} +1 -1
  53. package/public/assets/{bundle-full-CXGXXlzR.js → bundle-full-CWkY9-v4.js} +1 -1
  54. package/public/assets/{diff-viewer-CSNOYoEB.js → diff-viewer-Cn_41zpc.js} +1 -1
  55. package/public/assets/index-3dx-hhNQ.js +1 -0
  56. package/public/assets/index-BKwfZLzw.js +1 -0
  57. package/public/assets/index-C5IioEFT.js +1 -0
  58. package/public/assets/index-CZYc0VcU.js +2 -0
  59. package/public/assets/index-CdCoXYXP.js +1 -0
  60. package/public/assets/index-CwAVops2.js +1 -0
  61. package/public/assets/index-CzvCOwKc.js +1 -0
  62. package/public/assets/index-D-loOWPv.js +1 -0
  63. package/public/assets/{index-DVc4ls8c.js → index-DM1cpVNo.js} +1 -1
  64. package/public/assets/{index-sLe9hEfQ.js → index-DOprq6Gt.js} +1 -1
  65. package/public/assets/index-L7fX3W8k.css +1 -0
  66. package/public/assets/index-w2KdHEME.js +1 -0
  67. package/public/assets/{loading-CDmt9OJU.js → loading-BfoDcWcg.js} +1 -1
  68. package/public/assets/main-CaTCcck1.js +68 -0
  69. package/public/assets/{markdown-1ZLSxCFT.js → markdown-D33YI2g2.js} +3 -3
  70. package/public/assets/{monaco-viewer-D6JgH76W.js → monaco-viewer-DM35aobI.js} +4 -4
  71. package/public/assets/{tool-call-CyXb63LH.js → tool-call-tV85Jv2i.js} +3 -3
  72. package/public/assets/{unified-picker-DCCdSVwI.js → unified-picker-DSRT9GMh.js} +1 -1
  73. package/public/assets/{wrap-text-pNMdTIq3.js → wrap-text-Dmrn2DKo.js} +1 -1
  74. package/public/index.html +4 -4
  75. package/public/loading.html +4 -4
  76. package/public/sw.js +1 -1
  77. package/dist/workspaces/launch-cleanup.js +0 -132
  78. package/public/assets/StatusTab-D_IE79r-.js +0 -1
  79. package/public/assets/index-B3YjNCh4.js +0 -1
  80. package/public/assets/index-B3ZD2_SU.js +0 -1
  81. package/public/assets/index-Bh2ofaQo.js +0 -1
  82. package/public/assets/index-CM0c9xRZ.js +0 -1
  83. package/public/assets/index-CdDxouJl.js +0 -2
  84. package/public/assets/index-D06Y2cFq.js +0 -1
  85. package/public/assets/index-PmE51vPh.js +0 -1
  86. package/public/assets/index-Qj63IiSj.css +0 -1
  87. package/public/assets/index-VY_zwmsG.js +0 -1
  88. package/public/assets/index-qiiH7egV.js +0 -1
  89. 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
- enabled: true,
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 disposeManager(manager);
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.close().catch(() => { });
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 * 60000;
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: 1000 });
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 * 60000;
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.pendingDisposals.size, 0);
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.pendingDisposals.size, 0);
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
- throw new Error("close failed");
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
- await assert.rejects(manager.shutdown(), /Remote proxy shutdown failed/);
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.pendingCreations.size, 0);
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
- isLoopbackRequest: () => true,
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(1000000)) : undefined;
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, { workspaceManager: deps.workspaceManager });
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 * 60000;
10
- const SESSION_DISPOSAL_TIMEOUT_MS = 5000;
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.pendingCreations = new Set();
16
- this.pendingDisposals = new Set();
17
- this.disposalFailures = [];
15
+ this.creations = new Set();
16
+ this.disposals = new Set();
17
+ this.sessionDisposals = new Map();
18
18
  this.shuttingDown = false;
19
- this.disposalTimeoutMs = Math.max(1, options.disposalTimeoutMs ?? SESSION_DISPOSAL_TIMEOUT_MS);
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
- app.all("/*", async (request, reply) => {
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.setNotFoundHandler(async (request, reply) => {
95
- if (!session) {
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: session.bootstrapUrl };
107
+ return { sessionId, windowUrl: bootstrapUrl };
141
108
  }
142
109
  async deleteSession(sessionId) {
143
110
  return this.disposeSession(sessionId);
144
111
  }
145
- async shutdown() {
112
+ shutdown() {
113
+ if (this.shutdownPromise)
114
+ return this.shutdownPromise;
146
115
  this.shuttingDown = true;
147
116
  clearInterval(this.cleanupTimer);
148
- const disposals = new Set(this.pendingDisposals);
149
- await Promise.allSettled(Array.from(this.pendingCreations));
150
- for (const sessionId of this.sessions.keys())
151
- disposals.add(this.disposeSession(sessionId));
152
- while (this.pendingDisposals.size > 0) {
153
- const pending = Array.from(this.pendingDisposals);
154
- for (const disposal of pending)
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 results = await Promise.allSettled(disposals);
159
- const failures = [
160
- ...this.disposalFailures,
161
- ...results.flatMap((result) => result.status === "rejected" ? [result.reason] : []),
162
- ];
163
- if (failures.length > 0)
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
- .then(() => {
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
- .catch((error) => {
188
- this.disposalFailures.push(error);
189
- throw error;
190
- });
191
- this.pendingDisposals.add(disposal);
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 disposal = Promise.allSettled([app.close(), dispatcher.destroy()]);
198
- let timeout;
199
- const deadline = new Promise((_resolve, reject) => {
200
- timeout = setTimeout(() => reject(new Error(`Remote proxy disposal timed out after ${this.disposalTimeoutMs}ms`)), this.disposalTimeoutMs);
201
- timeout.unref();
202
- });
203
- try {
204
- const results = await Promise.race([disposal, deadline]);
205
- const failures = results.flatMap((result) => result.status === "rejected" ? [result.reason] : []);
206
- if (failures.length > 0)
207
- throw createDisposalError("Remote proxy disposal failed", failures);
208
- }
209
- finally {
210
- if (timeout)
211
- clearTimeout(timeout);
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 = 8000;
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 {