osborn 0.9.151 → 0.9.153

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 (2) hide show
  1. package/dist/index.js +60 -41
  2. package/package.json +3 -1
package/dist/index.js CHANGED
@@ -15,7 +15,7 @@ import { dirname, join } from 'node:path';
15
15
  import { fileURLToPath } from 'node:url';
16
16
  import { spawn } from 'node:child_process';
17
17
  import { randomUUID, randomBytes } from 'node:crypto';
18
- import { createConnection as netConnect } from 'node:net';
18
+ import httpProxy from 'http-proxy';
19
19
  import { createRequire } from 'node:module';
20
20
  // 0.9.71: createRequire for resolving package.json versions inside ESM
21
21
  const __sdkVersionRequire = createRequire(import.meta.url);
@@ -282,11 +282,28 @@ let ideProxyEnabled = false;
282
282
  // Bumped on every proxied HTTP request and WS upgrade so the idle watcher sees
283
283
  // real editor activity rather than code-server log noise.
284
284
  let ideLastProxiedActivity = 0;
285
+ // WebSocket proxy for code-server upgrades. Using http-proxy instead of a raw
286
+ // net.createConnection splice: the raw byte-splice causes an immediate 1006
287
+ // disconnect because it doesn't properly handle the HTTP/1.1 101 framing that
288
+ // code-server expects (confirmed: cloudflared works, raw splice does not).
289
+ const wsProxy = httpProxy.createProxyServer({ target: IDE_TARGET, ws: true, changeOrigin: true });
290
+ wsProxy.on('error', (err, _req, socket) => {
291
+ try {
292
+ socket?.destroy();
293
+ }
294
+ catch { }
295
+ });
285
296
  // Per-session cookie token minted when code-server becomes ready. The proxy
286
297
  // gate checks this token via the osborn_ide cookie — only requests carrying a
287
298
  // valid cookie are forwarded, so a forgotten agent route can never be silently
288
299
  // swallowed by the IDE proxy. Cleared in stopIde().
289
300
  let ideSessionToken = null;
301
+ // Cancellation-generation guard for startCodeServer(). Incremented by stopIde()
302
+ // every time the IDE is torn down. startCodeServer() captures myGen at entry;
303
+ // if ideStartGeneration !== myGen when the poll resolves, the start has been
304
+ // superseded (stopIde ran mid-flight) and the result is discarded instead of
305
+ // re-enabling the proxy with no participant present.
306
+ let ideStartGeneration = 0;
290
307
  // Helper: parse the osborn_ide cookie from a request and return true iff it
291
308
  // matches the current ideSessionToken (and a token exists).
292
309
  function hasValidIdeCookie(req) {
@@ -1528,7 +1545,7 @@ function startApiServer(workingDir, port) {
1528
1545
  port: 8300,
1529
1546
  path: targetUrl.pathname + (targetUrl.search || ''),
1530
1547
  method: req.method,
1531
- headers: { ...req.headers, host: '127.0.0.1:8300' },
1548
+ headers: { ...req.headers, host: '127.0.0.1:8300', 'x-forwarded-host': req.headers.host },
1532
1549
  };
1533
1550
  const proxyReq = httpRequest(options, (proxyRes) => {
1534
1551
  res.writeHead(proxyRes.statusCode || 502, proxyRes.headers);
@@ -1591,41 +1608,14 @@ function startApiServer(workingDir, port) {
1591
1608
  const isAgentRoute = AGENT_ROUTE_PREFIXES.some(p => upgradePath === p || upgradePath.startsWith(p + '/'));
1592
1609
  if (ideProxyEnabled && hasValidIdeCookie(req) && !isAgentRoute) {
1593
1610
  ideLastProxiedActivity = Date.now();
1594
- // Raw TCP tunnel: connect to code-server and splice the socket.
1595
- // Pause the client socket before initiating the connect so that any
1596
- // bytes the browser sends before the connect callback fires are buffered
1597
- // by Node rather than silently dropped — the classic silent-hang shape
1598
- // for a websocket terminal on a slow connect.
1599
- socket.pause();
1600
- const conn = netConnect(8300, '127.0.0.1', () => {
1601
- // Forward the original HTTP upgrade request headers.
1602
- const headerLines = [`${req.method} ${req.url} HTTP/1.1`];
1603
- for (const [k, v] of Object.entries(req.headers)) {
1604
- if (k.toLowerCase() === 'host')
1605
- continue;
1606
- if (Array.isArray(v)) {
1607
- for (const vv of v)
1608
- headerLines.push(`${k}: ${vv}`);
1609
- }
1610
- else if (v != null)
1611
- headerLines.push(`${k}: ${v}`);
1612
- }
1613
- headerLines.push('host: 127.0.0.1:8300');
1614
- conn.write(headerLines.join('\r\n') + '\r\n\r\n');
1615
- if (head && head.length)
1616
- conn.write(head);
1617
- conn.pipe(socket);
1618
- socket.pipe(conn);
1619
- socket.resume();
1620
- });
1621
- conn.on('error', () => { try {
1622
- socket.destroy();
1623
- }
1624
- catch { } });
1625
- socket.on('error', () => { try {
1626
- conn.destroy();
1627
- }
1628
- catch { } });
1611
+ // code-server's authenticateOrigin() compares Origin against the effective host
1612
+ // (X-Forwarded-Host first). changeOrigin:true rewrites Host→127.0.0.1:8300 while the
1613
+ // browser's Origin stays the public hostname, causing a 403 → WS 1006. Preserve the
1614
+ // real public host so the origin check matches.
1615
+ if (!req.headers['x-forwarded-host'] && req.headers.host) {
1616
+ req.headers['x-forwarded-host'] = req.headers.host;
1617
+ }
1618
+ wsProxy.ws(req, socket, head, { target: IDE_TARGET });
1629
1619
  return;
1630
1620
  }
1631
1621
  socket.destroy();
@@ -2362,6 +2352,9 @@ async function main() {
2362
2352
  };
2363
2353
  // Kill code-server, disable proxy, clear interval, reset IDE state.
2364
2354
  const stopIde = (reason) => {
2355
+ // Bump the generation counter so any in-flight startCodeServer() knows it
2356
+ // has been cancelled and must not re-enable the proxy or mint a new token.
2357
+ ideStartGeneration++;
2365
2358
  ideProxyEnabled = false;
2366
2359
  ideSessionToken = null;
2367
2360
  ideStartInProgress = null;
@@ -2435,6 +2428,11 @@ async function main() {
2435
2428
  // Fresh start: kill any stale proc (shouldn't normally exist here, but be safe).
2436
2429
  if (ideCodeServerProc)
2437
2430
  stopIde();
2431
+ // Capture the current generation AFTER any preceding stopIde() call so that
2432
+ // if stopIde() already ran (above), myGen reflects the incremented counter.
2433
+ // A concurrent stopIde() after this point will increment ideStartGeneration
2434
+ // beyond myGen, signalling cancellation to the poll loop and success branch.
2435
+ const myGen = ideStartGeneration;
2438
2436
  let resolveFn;
2439
2437
  let rejectFn;
2440
2438
  ideStartInProgress = new Promise((res, rej) => { resolveFn = res; rejectFn = rej; });
@@ -2485,11 +2483,13 @@ async function main() {
2485
2483
  await new Promise((res, rej) => {
2486
2484
  let attempts = 0;
2487
2485
  const poll = setInterval(() => {
2488
- // Early-exit: the proc we spawned is no longer the active one (stopIde
2489
- // was called, or a concurrent path replaced it).
2490
- if (ideCodeServerProc !== codeServerProc) {
2486
+ // Early-exit: stopIde() was called while we were waiting (generation bumped),
2487
+ // OR the proc we spawned is no longer the active one for another reason.
2488
+ // Either way, cancel the poll immediately so we don't keep hitting 8300
2489
+ // for a boot that has already been invalidated.
2490
+ if (ideStartGeneration !== myGen || ideCodeServerProc !== codeServerProc) {
2491
2491
  clearInterval(poll);
2492
- rej(new Error('code-server process was replaced or stopped mid-poll'));
2492
+ rej(new Error('code-server start cancelled (stopIde ran mid-poll)'));
2493
2493
  return;
2494
2494
  }
2495
2495
  attempts++;
@@ -2510,6 +2510,25 @@ async function main() {
2510
2510
  }, 500);
2511
2511
  });
2512
2512
  console.log('✅ code-server ready on 8300');
2513
+ // Generation guard: check again right before enabling the proxy. The poll
2514
+ // already checks this on each tick, but there is a narrow window between the
2515
+ // last tick resolving and reaching here. If stopIde() ran in that window,
2516
+ // ideStartGeneration will have been incremented beyond myGen — we must NOT
2517
+ // enable the proxy or mint a token, and we must kill the orphan process so
2518
+ // no zombie code-server is left running with no participant present.
2519
+ if (ideStartGeneration !== myGen) {
2520
+ console.log('🖥️ IDE start cancelled (stopIde ran while code-server was booting) — killing orphan proc');
2521
+ try {
2522
+ codeServerProc.kill();
2523
+ }
2524
+ catch { }
2525
+ // ideCodeServerProc may already be null (stopIde cleared it); only null it
2526
+ // if it still points to our proc to avoid clobbering a newer start.
2527
+ if (ideCodeServerProc === codeServerProc)
2528
+ ideCodeServerProc = null;
2529
+ resolveFn(); // resolve (not reject) so the promise chain exits cleanly
2530
+ return;
2531
+ }
2513
2532
  // Mint the session cookie token and enable the proxy. From this point the
2514
2533
  // /editor route will 302+Set-Cookie and the fall-through proxy will only
2515
2534
  // forward requests that carry the valid osborn_ide cookie.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "osborn",
3
- "version": "0.9.151",
3
+ "version": "0.9.153",
4
4
  "description": "Voice AI coding assistant - local agent that connects to Osborn frontend",
5
5
  "type": "module",
6
6
  "bin": {
@@ -48,6 +48,7 @@
48
48
  "@vscode/ripgrep": "^1.17.1",
49
49
  "diff": "^8.0.4",
50
50
  "dotenv": "^16.4.0",
51
+ "http-proxy": "^1.18.1",
51
52
  "livekit-server-sdk": "^2.15.0",
52
53
  "minisearch": "^7.2.0",
53
54
  "node-pty": "^1.1.0",
@@ -57,6 +58,7 @@
57
58
  "zod": "^4.3.6"
58
59
  },
59
60
  "devDependencies": {
61
+ "@types/http-proxy": "^1.17.17",
60
62
  "@types/node": "^20.0.0",
61
63
  "@vitest/coverage-v8": "^4.0.18",
62
64
  "typescript": "^5.0.0",