sliccy 4.3.0 → 4.4.0

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 (85) hide show
  1. package/dist/node-server/index.js +645 -1550
  2. package/dist/node-server/routes/fetch-proxy.d.ts +12 -0
  3. package/dist/node-server/routes/fetch-proxy.js +316 -0
  4. package/dist/node-server/routes/handoff.d.ts +48 -0
  5. package/dist/node-server/routes/handoff.js +60 -0
  6. package/dist/node-server/routes/lick-api.d.ts +7 -0
  7. package/dist/node-server/routes/lick-api.js +129 -0
  8. package/dist/node-server/routes/lick-bridge.d.ts +16 -0
  9. package/dist/node-server/routes/lick-bridge.js +73 -0
  10. package/dist/node-server/routes/oauth-callback.d.ts +11 -0
  11. package/dist/node-server/routes/oauth-callback.js +62 -0
  12. package/dist/node-server/routes/secrets.d.ts +17 -0
  13. package/dist/node-server/routes/secrets.js +204 -0
  14. package/dist/node-server/ui-serving.d.ts +18 -0
  15. package/dist/node-server/ui-serving.js +91 -0
  16. package/dist/ui/assets/{account-store-B7QAqqi3.js → account-store-DDmdZHbw.js} +2 -2
  17. package/dist/ui/assets/{account-store-C8na8kHM.js → account-store-DYwow2TA.js} +2 -2
  18. package/dist/ui/assets/{adobe-CLAtsbgy.js → adobe-Yy9fISE-.js} +1 -1
  19. package/dist/ui/assets/{adobe-B3FAPCSl.js → adobe-hTPIFNJv.js} +1 -1
  20. package/dist/ui/assets/{agent-message-to-chat-u5bAg3Dv.js → agent-message-to-chat-9I5BpCy9.js} +1 -1
  21. package/dist/ui/assets/{apps-BrQb6D54.js → apps-D8Gdju73.js} +1 -1
  22. package/dist/ui/assets/{azure-openai-DdRohXjQ.js → azure-openai-C2W9g3IJ.js} +1 -1
  23. package/dist/ui/assets/{azure-openai-BfAHTf5F.js → azure-openai-DRB4IDfJ.js} +1 -1
  24. package/dist/ui/assets/{bsh-watchdog-Bc7J2Bk7.js → bsh-watchdog-Bt9CqKVQ.js} +1 -1
  25. package/dist/ui/assets/{connect-surface-FrHkI2dH.js → connect-surface-DQzchqkg.js} +1 -1
  26. package/dist/ui/assets/dip-DFaUkk4F.js +1 -0
  27. package/dist/ui/assets/{dist-eq0qrLpe.js → dist-CO9fGFcy.js} +1 -1
  28. package/dist/ui/assets/{dist-DOo6fKGj.js → dist-Doek6FYy.js} +1 -1
  29. package/dist/ui/assets/{es-CRDAPvPn.js → es-DIdFAR_x.js} +1 -1
  30. package/dist/ui/assets/{fs-Bd1RYTR9.js → fs-B11hxMFT.js} +2 -2
  31. package/dist/ui/assets/{fs-BlUrfzJM.js → fs-Tyk_pbIn.js} +1 -1
  32. package/dist/ui/assets/{github-Dik4gFKy.js → github-B71DA8E3.js} +2 -2
  33. package/dist/ui/assets/{github-CqzS1etP.js → github-CPiFDsp3.js} +1 -1
  34. package/dist/ui/assets/{github-copilot-yPQNbALb.js → github-copilot-BfiOICFw.js} +1 -1
  35. package/dist/ui/assets/{github-copilot-B2O232DQ.js → github-copilot-uEeOT_7K.js} +1 -1
  36. package/dist/ui/assets/{hear-UQAn2f9B.js → hear-CfpqqXo5.js} +1 -1
  37. package/dist/ui/assets/{kernel-worker-BMe--kS1.js → kernel-worker-CJBmf2_H.js} +631 -631
  38. package/dist/ui/assets/{kokoro-engine-Cu1pI-kR.js → kokoro-engine-C6AgVvUa.js} +1 -1
  39. package/dist/ui/assets/{lick-ws-bridge-DAuwCMQO.js → lick-ws-bridge-BMBeGHRK.js} +1 -1
  40. package/dist/ui/assets/{local-llm-l4-XQFY8.js → local-llm-CEaARq5R.js} +1 -1
  41. package/dist/ui/assets/{main-BDx9HQkN.js → main-BHrstcdQ.js} +3 -3
  42. package/dist/ui/assets/{mount-CWCvNUcA.js → mount-BwDyizK9.js} +1 -1
  43. package/dist/ui/assets/{mount-D5vdZ9ZD.js → mount-CyEIOV30.js} +2 -2
  44. package/dist/ui/assets/{new-session-Gr9WMwtw.js → new-session-CfhcCQAp.js} +1 -1
  45. package/dist/ui/assets/{oauth-bootstrap-BsfJ9QeQ.js → oauth-bootstrap-Tcg85q8p.js} +2 -2
  46. package/dist/ui/assets/{openai-codex-kuA3-9C7.js → openai-codex-BDz5Fxit.js} +1 -1
  47. package/dist/ui/assets/{openai-codex-BaNE798j.js → openai-codex-CVcod1ia.js} +1 -1
  48. package/dist/ui/assets/{panel-rpc-handlers-BcpEgTGa.js → panel-rpc-handlers-DPUizpgv.js} +1 -1
  49. package/dist/ui/assets/{provider-CEn9M9_r.js → provider-DcMNUxfM.js} +1 -1
  50. package/dist/ui/assets/{provider-BmbE_rtX.js → provider-DdXgyWQC.js} +2 -2
  51. package/dist/ui/assets/provider-store-access-BwZ-Ogkc.js +1 -0
  52. package/dist/ui/assets/provider-store-access-gZjBUTYS.js +1 -0
  53. package/dist/ui/assets/{providers-MUCDnWww.js → providers-CcWtOmn0.js} +1 -1
  54. package/dist/ui/assets/{quick-llm-DB--outF.js → quick-llm-BKbreZXe.js} +1 -1
  55. package/dist/ui/assets/session-freezer-DMAACMXD.js +1 -0
  56. package/dist/ui/assets/setup-sudo-CsL0Y3UH.js +1 -0
  57. package/dist/ui/assets/{speak-BHHbBLx8.js → speak-Bv-b3EVQ.js} +1 -1
  58. package/dist/ui/assets/{sprinkle-manager-COo-zwx8.js → sprinkle-manager-CBrsHbU3.js} +1 -1
  59. package/dist/ui/assets/{store-CqhogTXq.js → store-BwHhL-tv.js} +1 -1
  60. package/dist/ui/assets/{sudo-IiXNo0Z2.js → sudo-DZ9_0JRP.js} +1 -1
  61. package/dist/ui/assets/{transformers-env-d7AMEyAX.js → transformers-env-XuyWgfSl.js} +1 -1
  62. package/dist/ui/assets/{tray-leave-runtime-CQ20HzV5.js → tray-leave-runtime-Ww3km3lu.js} +1 -1
  63. package/dist/ui/assets/{upgrade-detection-CqAg8yKN.js → upgrade-detection-_YQCwW8c.js} +1 -1
  64. package/dist/ui/assets/{wc-attach-NtXmSLiR.js → wc-attach-BGApOJUg.js} +2 -2
  65. package/dist/ui/assets/{wc-detached-TITxuKki.js → wc-detached-mQvIrRCJ.js} +1 -1
  66. package/dist/ui/assets/{wc-extension-Dy7sBRt4.js → wc-extension-DmWG-O_B.js} +2 -2
  67. package/dist/ui/assets/{wc-live-CKs-VTsl.js → wc-live-CA1EDWiu.js} +5 -5
  68. package/dist/ui/assets/wc-nav-BF5SsYYe.js +2 -0
  69. package/dist/ui/assets/{wc-onboarding-D4j2oDHI.js → wc-onboarding-CVMo_Eqf.js} +2 -2
  70. package/dist/ui/assets/{wc-placeholder-Cg0ggHwo.js → wc-placeholder-BUbREW79.js} +2 -2
  71. package/dist/ui/assets/{wc-settings-BlGVWM4o.js → wc-settings-MQTTeP3O.js} +2 -2
  72. package/dist/ui/assets/{wc-shell-RljPbgFJ.js → wc-shell-CZSwcbtB.js} +9 -5
  73. package/dist/ui/assets/{wc-sprinkles-B791LgwY.js → wc-sprinkles-BUD5Miwy.js} +2 -2
  74. package/dist/ui/assets/{wc-tray-DCscOwKN.js → wc-tray-iifyjNN6.js} +3 -3
  75. package/dist/ui/assets/{xai-grok-Dh1WJ9QS.js → xai-grok-BrM3dm3w.js} +1 -1
  76. package/dist/ui/assets/{xai-grok-Z0dPXLgr.js → xai-grok-CPiFjFiB.js} +1 -1
  77. package/dist/ui/index.html +2 -2
  78. package/dist/ui/packages/webapp/index.html +2 -2
  79. package/package.json +6 -5
  80. package/dist/ui/assets/dip-CBvyzPyQ.js +0 -1
  81. package/dist/ui/assets/provider-store-access-BSVI8UyE.js +0 -1
  82. package/dist/ui/assets/provider-store-access-CWZqsjgW.js +0 -1
  83. package/dist/ui/assets/session-freezer-DUHrs9By.js +0 -1
  84. package/dist/ui/assets/setup-sudo-B5JoDQ4W.js +0 -1
  85. package/dist/ui/assets/wc-nav-BSmxk4Wx.js +0 -2
@@ -1,16 +1,13 @@
1
1
  #!/usr/bin/env node
2
2
  import { promises as fsPromises } from 'node:fs';
3
3
  import { createSubstrate } from './_cloud_core/src/index.js';
4
- import { previewSecret } from './_shared/index.js';
5
4
  import { spawn } from 'child_process';
6
5
  import express from 'express';
7
6
  import { existsSync, readFileSync } from 'fs';
8
7
  import { createServer } from 'http';
9
8
  import { createServer as createNetServer } from 'net';
10
9
  import { homedir } from 'os';
11
- import { basename, dirname, join, resolve, sep } from 'path';
12
- import { Readable, Transform } from 'stream';
13
- import { StringDecoder } from 'string_decoder';
10
+ import { basename, dirname, join, resolve } from 'path';
14
11
  import { fileURLToPath } from 'url';
15
12
  import { WebSocket, WebSocketServer } from 'ws';
16
13
  import { applyCdpUnmask } from './cdp-proxy/cdp-unmask.js';
@@ -27,20 +24,25 @@ import { runStart } from './cloud/start.js';
27
24
  import { registerCloudStatusEndpoint } from './cloud-status.js';
28
25
  import { ElectronAppAlreadyRunningError, ElectronOverlayInjector, launchElectronApp, } from './electron-controller.js';
29
26
  import { getElectronAppPorts } from './electron-runtime.js';
30
- import { FETCH_PROXY_SKIP_HEADERS } from './fetch-proxy-headers.js';
31
27
  import { FileLogger } from './file-logger.js';
32
28
  import { registerHostedBootstrapEndpoint } from './hosted-bootstrap.js';
33
29
  import { resolveCliBrowserLaunchUrl } from './launch-url.js';
34
30
  import { createHttpCdp, registerLeaderRestartEndpoint } from './leader-restart.js';
35
31
  import { buildLocalApiDescriptor, sliccLinksMiddleware } from './links-middleware.js';
32
+ import { registerFetchProxyRoute } from './routes/fetch-proxy.js';
33
+ import { registerHandoffRoute } from './routes/handoff.js';
34
+ import { registerLickApiRoutes } from './routes/lick-api.js';
35
+ import { createLickBridge } from './routes/lick-bridge.js';
36
+ import { registerOAuthCallbackRoutes } from './routes/oauth-callback.js';
37
+ import { registerSecretRoutes } from './routes/secrets.js';
36
38
  import { parseCliRuntimeFlags } from './runtime-flags.js';
37
39
  import { EnvSecretStore } from './secrets/env-secret-store.js';
38
40
  import { OauthSecretStore } from './secrets/oauth-secret-store.js';
39
41
  import { SecretProxyManager } from './secrets/proxy-manager.js';
40
42
  import { readOrCreateSessionId } from './secrets/session-id-file.js';
41
- import { handleDaSignAndForward, handleS3SignAndForward } from './secrets/sign-and-forward.js';
42
43
  import { registerSecretsReloadEndpoint } from './secrets-reload-endpoint.js';
43
44
  import { registerSudoApproveEndpoint } from './sudo/endpoint.js';
45
+ import { attachUiServing } from './ui-serving.js';
44
46
  const Dirname = fileURLToPath(new URL('.', import.meta.url));
45
47
  const PROJECT_ROOT = resolve(Dirname, '..', '..');
46
48
  // ---------------------------------------------------------------------------
@@ -287,279 +289,624 @@ async function attachConsoleForwarder(cdpPort, pageUrl) {
287
289
  // ---------------------------------------------------------------------------
288
290
  const PREFERRED_SERVE_PORT = parseInt(process.env['PORT'] ?? '5710', 10);
289
291
  const PREFERRED_CDP_PORT = RUNTIME_FLAGS.cdpPort;
290
- async function main() {
291
- // Resolve available ports before anything else — serve port must be known
292
- // before Chrome launches (the launch URL contains it).
293
- let SERVE_PORT;
294
- let CDP_PORT;
295
- let REQUESTED_CDP_PORT;
292
+ function createServerState() {
293
+ return {
294
+ servePort: 0,
295
+ cdpPort: 0,
296
+ requestedCdpPort: 0,
297
+ serveOrigin: '',
298
+ launchedBrowserProcess: null,
299
+ launchedBrowserLabel: 'Browser',
300
+ overlayInjector: null,
301
+ shuttingDown: false,
302
+ discoveredTrayJoinUrl: RUNTIME_FLAGS.joinUrl ?? null,
303
+ cdpUrl: null,
304
+ chromeWs: null,
305
+ activeClientWs: null,
306
+ messageBuffer: null,
307
+ };
308
+ }
309
+ /**
310
+ * Resolve the serve + CDP ports before anything else — the serve port must be
311
+ * known before Chrome launches (the launch URL embeds it). Electron apps may
312
+ * use a hash-derived dynamic port pair; otherwise we probe the preferred serve
313
+ * port and let Chrome pick its own CDP port (requestedCdpPort=0).
314
+ */
315
+ async function resolvePorts(state) {
296
316
  let usingDynamicElectronPorts = false;
297
317
  if (ELECTRON_MODE && ELECTRON_APP && !RUNTIME_FLAGS.explicitCdpPort) {
298
- // Dynamic port allocation for Electron apps (hash-based with fallback)
299
318
  const ports = await getElectronAppPorts(ELECTRON_APP);
300
- CDP_PORT = ports.cdpPort;
301
- SERVE_PORT = ports.servePort;
302
- REQUESTED_CDP_PORT = CDP_PORT;
319
+ state.cdpPort = ports.cdpPort;
320
+ state.servePort = ports.servePort;
321
+ state.requestedCdpPort = ports.cdpPort;
303
322
  usingDynamicElectronPorts = true;
304
323
  }
305
324
  else {
306
- SERVE_PORT = await findAvailablePort(PREFERRED_SERVE_PORT);
307
- // For Chrome CDP, we pass port 0 to let Chrome pick any available port,
308
- // then parse the actual port from its stderr. This avoids race conditions
309
- // where Node's port probe succeeds but Chrome still can't bind the port.
310
- // Electron mode keeps the preferred port (external CDP, not launched by us).
311
- REQUESTED_CDP_PORT = ELECTRON_MODE ? PREFERRED_CDP_PORT : 0;
312
- CDP_PORT = ELECTRON_MODE ? PREFERRED_CDP_PORT : 0;
325
+ state.servePort = await findAvailablePort(PREFERRED_SERVE_PORT);
326
+ // Pass 0 for Chrome CDP so Chrome picks an available port (parsed from its
327
+ // stderr) avoids a race where Node's probe succeeds but Chrome can't bind.
328
+ // Electron keeps the preferred port (external CDP, not launched by us).
329
+ state.requestedCdpPort = ELECTRON_MODE ? PREFERRED_CDP_PORT : 0;
330
+ state.cdpPort = ELECTRON_MODE ? PREFERRED_CDP_PORT : 0;
313
331
  }
314
- const SERVE_ORIGIN = `http://localhost:${SERVE_PORT}`;
332
+ state.serveOrigin = `http://localhost:${state.servePort}`;
315
333
  if (usingDynamicElectronPorts) {
316
- console.log(`Dynamic port allocation for Electron app: CDP=${CDP_PORT}, serve=${SERVE_PORT}`);
334
+ console.log(`Dynamic port allocation for Electron app: CDP=${state.cdpPort}, serve=${state.servePort}`);
317
335
  }
318
- else if (SERVE_PORT !== PREFERRED_SERVE_PORT) {
319
- console.log(`Port ${PREFERRED_SERVE_PORT} in use, serving on port ${SERVE_PORT}`);
336
+ else if (state.servePort !== PREFERRED_SERVE_PORT) {
337
+ console.log(`Port ${PREFERRED_SERVE_PORT} in use, serving on port ${state.servePort}`);
320
338
  }
321
- if (DEV_MODE) {
339
+ if (DEV_MODE)
322
340
  console.log('Starting in dev mode (Vite HMR enabled)');
323
- }
324
341
  if (SERVE_ONLY) {
325
- console.log(`Starting in serve-only mode (reusing external CDP on port ${CDP_PORT})`);
342
+ console.log(`Starting in serve-only mode (reusing external CDP on port ${state.cdpPort})`);
326
343
  }
327
- if (ELECTRON_MODE) {
344
+ if (ELECTRON_MODE)
328
345
  console.log('Starting in Electron mode');
329
- }
330
- let launchedBrowserProcess = null;
331
- let launchedBrowserLabel = 'Browser';
332
- let overlayInjector = null;
333
- let shuttingDown = false;
334
- // Tray join URL discovered from an existing leader on the preferred port.
335
- // Populated in Electron mode when auto-discovering the leader's tray.
336
- let discoveredTrayJoinUrl = RUNTIME_FLAGS.joinUrl ?? null;
337
- // 1. Launch Chrome unless an external CDP provider is already running.
346
+ }
347
+ /** Launch Chrome / Electron unless an external CDP provider is already running. */
348
+ async function launchBrowser(state) {
338
349
  if (ELECTRON_MODE && !SERVE_ONLY) {
339
- if (!ELECTRON_APP) {
340
- console.error('Electron mode requires an app path. Pass --electron <path> or --electron-app=<path>.');
341
- process.exit(1);
342
- }
350
+ await launchElectronTarget(state);
351
+ }
352
+ else if (!SERVE_ONLY) {
353
+ await launchChromeTarget(state);
354
+ }
355
+ }
356
+ /**
357
+ * Poll an existing leader on the preferred port for its tray join URL. The
358
+ * leader may still be minting its tray session, so retry a few times.
359
+ */
360
+ async function discoverLeaderTrayJoinUrl() {
361
+ const leaderOrigin = `http://localhost:${PREFERRED_SERVE_PORT}`;
362
+ for (let attempt = 0; attempt < 5; attempt++) {
343
363
  try {
344
- const { child, displayName } = await launchElectronApp({
345
- appPath: ELECTRON_APP,
346
- cdpPort: CDP_PORT,
347
- kill: KILL_EXISTING_ELECTRON_APP,
364
+ const resp = await fetch(`${leaderOrigin}/api/tray-status`, {
365
+ signal: AbortSignal.timeout(3000),
348
366
  });
349
- launchedBrowserProcess = child;
350
- launchedBrowserLabel = displayName;
351
- pipeChildOutput(child, 'electron-app');
352
- // Track when app exits - quick exits before CDP connects indicate a problem
353
- let cdpConnected = false;
354
- let exitCode = null;
355
- let exitResolve = null;
356
- const exitPromise = new Promise((resolve) => {
357
- exitResolve = resolve;
358
- });
359
- child.on('exit', (code) => {
360
- exitCode = code;
361
- exitResolve?.();
362
- if (shuttingDown)
363
- return;
364
- if (cdpConnected) {
365
- // Normal exit after we connected
366
- console.log(`${displayName} exited with code ${code}`);
367
- process.exit(0);
368
- }
369
- // If CDP not yet connected, don't exit - let waitForCDP handle it
370
- });
371
- console.log(`Waiting for ${displayName} CDP on port ${CDP_PORT}...`);
372
- try {
373
- // Race between CDP connection and app exit
374
- await Promise.race([
375
- waitForCDP(CDP_PORT, 40, 500).then(() => {
376
- cdpConnected = true;
377
- }),
378
- exitPromise.then(() => {
379
- if (!cdpConnected) {
380
- throw new Error('app-exited');
381
- }
382
- }),
383
- ]);
367
+ if (!resp.ok)
368
+ break;
369
+ const status = (await resp.json());
370
+ if (status.joinUrl) {
371
+ console.log(`Discovered leader tray join URL: ${status.joinUrl}`);
372
+ return status.joinUrl;
384
373
  }
385
- catch (_err) {
386
- // Check if app exited quickly (likely due to disabled remote debugging fuse)
387
- if (exitCode !== null) {
388
- console.error(`\n${displayName} exited with code ${exitCode} before remote debugging was available.`);
389
- console.error('This usually means the app has disabled remote debugging (EnableNodeCliInspectArguments fuse).');
390
- console.error('Some Electron apps disable this for security. Check if there is a developer/debug build available.\n');
391
- process.exit(1);
392
- }
393
- throw new Error(`Could not connect to ${displayName} CDP on port ${CDP_PORT}`);
374
+ if (status.state === 'connecting') {
375
+ // Leader is still setting up wait and retry.
376
+ await new Promise((r) => setTimeout(r, 2000));
394
377
  }
395
- console.log(`Connected to ${displayName} on CDP port ${CDP_PORT}`);
396
- // Auto-discover leader's tray join URL when another instance runs on the preferred port.
397
- // The leader may still be creating its tray session, so retry a few times.
398
- if (!discoveredTrayJoinUrl && SERVE_PORT !== PREFERRED_SERVE_PORT) {
399
- const leaderOrigin = `http://localhost:${PREFERRED_SERVE_PORT}`;
400
- for (let attempt = 0; attempt < 5 && !discoveredTrayJoinUrl; attempt++) {
401
- try {
402
- const resp = await fetch(`${leaderOrigin}/api/tray-status`, {
403
- signal: AbortSignal.timeout(3000),
404
- });
405
- if (resp.ok) {
406
- const status = (await resp.json());
407
- if (status.joinUrl) {
408
- discoveredTrayJoinUrl = status.joinUrl;
409
- console.log(`Discovered leader tray join URL: ${status.joinUrl}`);
410
- }
411
- else if (status.state === 'connecting') {
412
- // Leader is still setting up — wait and retry
413
- await new Promise((r) => setTimeout(r, 2000));
414
- }
415
- else {
416
- console.log(`Leader on port ${PREFERRED_SERVE_PORT} has no active tray (state: ${status.state ?? 'unknown'})`);
417
- break;
418
- }
419
- }
420
- else {
421
- break;
422
- }
423
- }
424
- catch {
425
- // Leader not reachable or no tray status endpoint — continue without tray
426
- break;
427
- }
428
- }
378
+ else {
379
+ console.log(`Leader on port ${PREFERRED_SERVE_PORT} has no active tray (state: ${status.state ?? 'unknown'})`);
380
+ break;
429
381
  }
430
382
  }
431
- catch (error) {
432
- if (error instanceof ElectronAppAlreadyRunningError) {
433
- console.error(error.message);
434
- process.exit(1);
435
- }
436
- throw error;
383
+ catch {
384
+ // Leader not reachable or no tray status endpoint — continue without tray.
385
+ break;
437
386
  }
438
387
  }
439
- else if (!SERVE_ONLY) {
440
- let browserLaunchUrl = resolveCliBrowserLaunchUrl({
441
- serveOrigin: SERVE_ORIGIN,
442
- lead: RUNTIME_FLAGS.lead,
443
- leadWorkerBaseUrl: RUNTIME_FLAGS.leadWorkerBaseUrl,
444
- envWorkerBaseUrl: process.env['WORKER_BASE_URL'] ?? null,
445
- join: RUNTIME_FLAGS.join,
446
- joinUrl: RUNTIME_FLAGS.joinUrl,
447
- });
448
- // Append runtime parameter for hosted mode
449
- if (RUNTIME_FLAGS.hosted) {
450
- const sep = browserLaunchUrl.includes('?') ? '&' : '?';
451
- browserLaunchUrl += `${sep}runtime=hosted-leader`;
388
+ return null;
389
+ }
390
+ /**
391
+ * Race the Electron app's CDP becoming available against the app exiting. A
392
+ * quick exit before CDP connects usually means remote debugging is fused off.
393
+ */
394
+ async function waitForElectronCdp(state, displayName) {
395
+ const child = state.launchedBrowserProcess;
396
+ let cdpConnected = false;
397
+ let exitCode = null;
398
+ let exitResolve = null;
399
+ const exitPromise = new Promise((resolve) => {
400
+ exitResolve = resolve;
401
+ });
402
+ child.on('exit', (code) => {
403
+ exitCode = code;
404
+ exitResolve?.();
405
+ if (state.shuttingDown)
406
+ return;
407
+ if (cdpConnected) {
408
+ console.log(`${displayName} exited with code ${code}`);
409
+ process.exit(0);
452
410
  }
453
- // Append optional prompt parameter
454
- if (RUNTIME_FLAGS.prompt) {
455
- const sep = browserLaunchUrl.includes('?') ? '&' : '?';
456
- browserLaunchUrl += `${sep}prompt=${encodeURIComponent(RUNTIME_FLAGS.prompt)}`;
411
+ // CDP not yet connected — let waitForCDP handle it.
412
+ });
413
+ console.log(`Waiting for ${displayName} CDP on port ${state.cdpPort}...`);
414
+ try {
415
+ await Promise.race([
416
+ waitForCDP(state.cdpPort, 40, 500).then(() => {
417
+ cdpConnected = true;
418
+ }),
419
+ exitPromise.then(() => {
420
+ if (!cdpConnected)
421
+ throw new Error('app-exited');
422
+ }),
423
+ ]);
424
+ }
425
+ catch (_err) {
426
+ if (exitCode !== null) {
427
+ console.error(`\n${displayName} exited with code ${exitCode} before remote debugging was available.`);
428
+ console.error('This usually means the app has disabled remote debugging (EnableNodeCliInspectArguments fuse).');
429
+ console.error('Some Electron apps disable this for security. Check if there is a developer/debug build available.\n');
430
+ process.exit(1);
457
431
  }
458
- if (RUNTIME_FLAGS.join) {
459
- console.log(`Join launch URL: ${browserLaunchUrl}`);
432
+ throw new Error(`Could not connect to ${displayName} CDP on port ${state.cdpPort}`);
433
+ }
434
+ }
435
+ async function launchElectronTarget(state) {
436
+ if (!ELECTRON_APP) {
437
+ console.error('Electron mode requires an app path. Pass --electron <path> or --electron-app=<path>.');
438
+ process.exit(1);
439
+ }
440
+ try {
441
+ const { child, displayName } = await launchElectronApp({
442
+ appPath: ELECTRON_APP,
443
+ cdpPort: state.cdpPort,
444
+ kill: KILL_EXISTING_ELECTRON_APP,
445
+ });
446
+ state.launchedBrowserProcess = child;
447
+ state.launchedBrowserLabel = displayName;
448
+ pipeChildOutput(child, 'electron-app');
449
+ await waitForElectronCdp(state, displayName);
450
+ console.log(`Connected to ${displayName} on CDP port ${state.cdpPort}`);
451
+ // Auto-discover the leader's tray join URL when another instance is on the preferred port.
452
+ if (!state.discoveredTrayJoinUrl && state.servePort !== PREFERRED_SERVE_PORT) {
453
+ state.discoveredTrayJoinUrl = await discoverLeaderTrayJoinUrl();
460
454
  }
461
- else if (RUNTIME_FLAGS.lead) {
462
- console.log(`Lead launch URL: ${browserLaunchUrl}`);
455
+ }
456
+ catch (error) {
457
+ if (error instanceof ElectronAppAlreadyRunningError) {
458
+ console.error(error.message);
459
+ process.exit(1);
463
460
  }
464
- const chromeProfile = (() => {
465
- try {
466
- const resolved = resolveChromeLaunchProfile({
467
- projectRoot: PROJECT_ROOT,
468
- profile: RUNTIME_FLAGS.profile,
469
- servePort: SERVE_PORT,
470
- });
471
- // Override user data dir in hosted mode to use persistent profile
472
- if (RUNTIME_FLAGS.hosted) {
473
- resolved.userDataDir = process.env['CHROME_USER_DATA_DIR'] ?? '/data/profile';
474
- }
475
- return resolved;
476
- }
477
- catch (error) {
478
- console.error(error instanceof Error ? error.message : String(error));
479
- process.exit(1);
480
- }
481
- })();
482
- const chromePath = findChromeExecutable({
483
- executablePreference: !DEV_MODE && !chromeProfile.id ? 'installed' : 'chrome-for-testing',
461
+ throw error;
462
+ }
463
+ }
464
+ /** Build the Chrome launch URL, appending hosted-runtime + prompt query params. */
465
+ function buildBrowserLaunchUrl(state) {
466
+ let url = resolveCliBrowserLaunchUrl({
467
+ serveOrigin: state.serveOrigin,
468
+ lead: RUNTIME_FLAGS.lead,
469
+ leadWorkerBaseUrl: RUNTIME_FLAGS.leadWorkerBaseUrl,
470
+ envWorkerBaseUrl: process.env['WORKER_BASE_URL'] ?? null,
471
+ join: RUNTIME_FLAGS.join,
472
+ joinUrl: RUNTIME_FLAGS.joinUrl,
473
+ });
474
+ if (RUNTIME_FLAGS.hosted) {
475
+ url += `${url.includes('?') ? '&' : '?'}runtime=hosted-leader`;
476
+ }
477
+ if (RUNTIME_FLAGS.prompt) {
478
+ url += `${url.includes('?') ? '&' : '?'}prompt=${encodeURIComponent(RUNTIME_FLAGS.prompt)}`;
479
+ }
480
+ if (RUNTIME_FLAGS.join) {
481
+ console.log(`Join launch URL: ${url}`);
482
+ }
483
+ else if (RUNTIME_FLAGS.lead) {
484
+ console.log(`Lead launch URL: ${url}`);
485
+ }
486
+ return url;
487
+ }
488
+ function resolveChromeProfileOrExit(state) {
489
+ try {
490
+ const resolved = resolveChromeLaunchProfile({
491
+ projectRoot: PROJECT_ROOT,
492
+ profile: RUNTIME_FLAGS.profile,
493
+ servePort: state.servePort,
484
494
  });
485
- if (!chromePath) {
486
- console.error('Could not find Chrome/Chromium. Please install Chrome or set CHROME_PATH.');
487
- process.exit(1);
495
+ // Override the user data dir in hosted mode to use a persistent profile.
496
+ if (RUNTIME_FLAGS.hosted) {
497
+ resolved.userDataDir = process.env['CHROME_USER_DATA_DIR'] ?? '/data/profile';
488
498
  }
489
- console.log(`Found Chrome: ${chromePath}`);
490
- if (chromeProfile.id) {
491
- await ensureQaProfileScaffold(PROJECT_ROOT);
499
+ return resolved;
500
+ }
501
+ catch (error) {
502
+ console.error(error instanceof Error ? error.message : String(error));
503
+ process.exit(1);
504
+ }
505
+ }
506
+ async function launchChromeTarget(state) {
507
+ const browserLaunchUrl = buildBrowserLaunchUrl(state);
508
+ const chromeProfile = resolveChromeProfileOrExit(state);
509
+ const chromePath = findChromeExecutable({
510
+ executablePreference: !DEV_MODE && !chromeProfile.id ? 'installed' : 'chrome-for-testing',
511
+ });
512
+ if (!chromePath) {
513
+ console.error('Could not find Chrome/Chromium. Please install Chrome or set CHROME_PATH.');
514
+ process.exit(1);
515
+ }
516
+ console.log(`Found Chrome: ${chromePath}`);
517
+ if (chromeProfile.id) {
518
+ await ensureQaProfileScaffold(PROJECT_ROOT);
519
+ }
520
+ else if (!RUNTIME_FLAGS.hosted) {
521
+ const profileDirName = basename(chromeProfile.userDataDir);
522
+ await migrateLegacyDefaultChromeProfile(chromeProfile.userDataDir, legacyChromeCandidates(profileDirName));
523
+ }
524
+ if (chromeProfile.extensionPath && !existsSync(chromeProfile.extensionPath)) {
525
+ console.error(`Extension profile requires ${chromeProfile.extensionPath}. Run \`npm run build -w @slicc/chrome-extension\` first.`);
526
+ process.exit(1);
527
+ }
528
+ if (chromeProfile.id) {
529
+ console.log(`Using QA Chrome profile: ${chromeProfile.id}`);
530
+ console.log(`Profile directory: ${chromeProfile.userDataDir}`);
531
+ if (chromeProfile.extensionPath) {
532
+ console.log(`Auto-loading unpacked extension from ${chromeProfile.extensionPath}`);
492
533
  }
493
- else if (!RUNTIME_FLAGS.hosted) {
494
- const profileDirName = basename(chromeProfile.userDataDir);
495
- await migrateLegacyDefaultChromeProfile(chromeProfile.userDataDir, legacyChromeCandidates(profileDirName));
534
+ }
535
+ const chromeArgs = buildChromeLaunchArgs({
536
+ cdpPort: state.requestedCdpPort,
537
+ launchUrl: browserLaunchUrl,
538
+ profile: chromeProfile,
539
+ hosted: RUNTIME_FLAGS.hosted,
540
+ });
541
+ // Chrome never clears DevToolsActivePort on shutdown, so a stale file from a
542
+ // prior crash/SIGKILL would let our active-port poller win the race with the
543
+ // wrong port. Clear it before spawn.
544
+ await clearStaleDevToolsActivePort(chromeProfile.userDataDir);
545
+ // On macOS, route through `/usr/bin/open` so LaunchServices owns the new Chrome
546
+ // process — otherwise the launching terminal stays in Chrome's TCC responsibility
547
+ // chain and silently breaks getUserMedia() camera/mic grants.
548
+ const spawnPlan = planChromeSpawn({ executablePath: chromePath, chromeArgs });
549
+ state.launchedBrowserProcess = spawn(spawnPlan.command, spawnPlan.args, {
550
+ stdio: ['ignore', 'pipe', 'pipe'],
551
+ detached: false,
552
+ env: { ...process.env, GOOGLE_CRASHPAD_DISABLE: '1' },
553
+ });
554
+ state.launchedBrowserLabel = chromeProfile.displayName;
555
+ // Use the stderr-vs-DevToolsActivePort race so this works in both direct-exec
556
+ // mode (Linux/Windows stderr banner) and LaunchServices mode (macOS active-port file).
557
+ state.cdpPort = await waitForCdpPort(state.launchedBrowserProcess, {
558
+ userDataDir: chromeProfile.userDataDir,
559
+ });
560
+ console.log(`Chrome CDP listening on port ${state.cdpPort}`);
561
+ pipeChildOutput(state.launchedBrowserProcess, 'chrome');
562
+ state.launchedBrowserProcess.on('exit', (code) => {
563
+ if (state.shuttingDown)
564
+ return;
565
+ console.log(`Chrome exited with code ${code}`);
566
+ process.exit(0);
567
+ });
568
+ }
569
+ const CDP_PROXY_INSPECT_BYTES = 256 * 1024;
570
+ const CDP_PROXY_HARD_FRAME_CAP = 64 * 1024 * 1024;
571
+ const CDP_LOOP_EVENT_PREFIXES = [
572
+ '{"method":"Network.webSocketFrameReceived"',
573
+ '{"method":"Network.webSocketFrameSent"',
574
+ ];
575
+ /**
576
+ * Normalise the `ws` library's polymorphic message payload into a single Buffer
577
+ * we can peek at and forward. Without this, a later `String(data)` would coerce
578
+ * an `ArrayBuffer` to `"[object ArrayBuffer]"` and a `Buffer[]` to comma-joined
579
+ * fragments, corrupting the CDP frame.
580
+ */
581
+ function cdpFrameToBuffer(data) {
582
+ if (Buffer.isBuffer(data))
583
+ return data;
584
+ if (data instanceof ArrayBuffer)
585
+ return Buffer.from(data);
586
+ if (Array.isArray(data))
587
+ return Buffer.concat(data);
588
+ // Rare fallback — string frames in text mode. Keep bytes faithful.
589
+ return Buffer.from(String(data));
590
+ }
591
+ function closeWebSocketQuietly(ws) {
592
+ if (!ws)
593
+ return;
594
+ try {
595
+ ws.close();
596
+ }
597
+ catch {
598
+ /* ignore */
599
+ }
600
+ }
601
+ /** Route `/cdp` and `/licks-ws` upgrades to their servers; leave others for Vite HMR. */
602
+ function attachCdpUpgradeRouting(server, wss, lickWss) {
603
+ server.on('upgrade', (request, socket, head) => {
604
+ const { pathname } = new URL(request.url, `http://${request.headers.host}`);
605
+ if (pathname === '/cdp') {
606
+ wss.handleUpgrade(request, socket, head, (ws) => {
607
+ wss.emit('connection', ws, request);
608
+ });
496
609
  }
497
- if (chromeProfile.extensionPath && !existsSync(chromeProfile.extensionPath)) {
498
- console.error(`Extension profile requires ${chromeProfile.extensionPath}. Run \`npm run build -w @slicc/chrome-extension\` first.`);
499
- process.exit(1);
610
+ else if (pathname === '/licks-ws') {
611
+ lickWss.handleUpgrade(request, socket, head, (ws) => {
612
+ lickWss.emit('connection', ws, request);
613
+ });
500
614
  }
501
- if (chromeProfile.id) {
502
- console.log(`Using QA Chrome profile: ${chromeProfile.id}`);
503
- console.log(`Profile directory: ${chromeProfile.userDataDir}`);
504
- if (chromeProfile.extensionPath) {
505
- console.log(`Auto-loading unpacked extension from ${chromeProfile.extensionPath}`);
506
- }
615
+ });
616
+ }
617
+ /**
618
+ * Apply the Client→Chrome unmask gate to a buffered frame on flush. Shared by
619
+ * both buffer-drain sites (ensureChromeConnection already-open path + the
620
+ * chromeWs 'open' handler); falls back to the original bytes on any error.
621
+ */
622
+ function flushClientFrame(target, raw, ctx) {
623
+ const original = String(raw);
624
+ const { output } = applyCdpUnmask(original, {
625
+ tracker: ctx.cdpSessionUrls,
626
+ pipeline: ctx.secretProxy.rawPipeline,
627
+ });
628
+ target.send(output);
629
+ }
630
+ function flushBufferedClientFrames(state, target, ctx) {
631
+ if (!state.messageBuffer)
632
+ return;
633
+ for (const msg of state.messageBuffer) {
634
+ flushClientFrame(target, msg, ctx);
635
+ }
636
+ state.messageBuffer = null;
637
+ }
638
+ /**
639
+ * Forward one Chrome→Client frame, dropping the self-amplifying
640
+ * `Network.webSocketFrame*` feedback-loop events (the slicc UI runs inside the
641
+ * Chrome it debugs, so those embed prior frames and blow past V8's string cap)
642
+ * and any frame over the hard cap.
643
+ */
644
+ function forwardChromeFrame(state, buf, ctx) {
645
+ const byteLen = buf.length;
646
+ // Peek at the first 256 KiB only — enough to identify the event type cheaply.
647
+ const head = buf.subarray(0, CDP_PROXY_INSPECT_BYTES).toString();
648
+ if (CDP_LOOP_EVENT_PREFIXES.some((p) => head.startsWith(p))) {
649
+ const msg = `[cdp-proxy] Dropping Chrome feedback-loop event (${byteLen} bytes, ${head.slice(1, 60)}…)`;
650
+ if (ctx.cdpDedup.shouldLog(msg))
651
+ console.debug(msg);
652
+ return;
653
+ }
654
+ if (byteLen > CDP_PROXY_HARD_FRAME_CAP) {
655
+ const msg = `[cdp-proxy] Dropping oversized Chrome→Client frame (${byteLen} bytes)`;
656
+ if (ctx.cdpDedup.shouldLog(msg))
657
+ console.debug(msg);
658
+ return;
659
+ }
660
+ const str = buf.toString();
661
+ const msg = `[cdp-proxy] Chrome→Client: ${str.slice(0, 200)}`;
662
+ if (ctx.cdpDedup.shouldLog(msg))
663
+ console.debug(msg);
664
+ // Sniff Target.attachedToTarget / targetInfoChanged / Page.frameNavigated so
665
+ // the Client→Chrome unmask gate can resolve per-session hostnames.
666
+ ctx.cdpSessionUrls.observeChromeToClient(str);
667
+ if (state.activeClientWs && state.activeClientWs.readyState === WebSocket.OPEN) {
668
+ state.activeClientWs.send(str);
669
+ }
670
+ }
671
+ function ensureChromeConnection(state, url, ctx) {
672
+ return new Promise((resolve, reject) => {
673
+ if (state.chromeWs && state.chromeWs.readyState === WebSocket.OPEN) {
674
+ // Already connected — flush any buffered messages and go direct.
675
+ flushBufferedClientFrames(state, state.chromeWs, ctx);
676
+ resolve();
677
+ return;
507
678
  }
508
- const chromeArgs = buildChromeLaunchArgs({
509
- cdpPort: REQUESTED_CDP_PORT,
510
- launchUrl: browserLaunchUrl,
511
- profile: chromeProfile,
512
- hosted: RUNTIME_FLAGS.hosted,
679
+ closeWebSocketQuietly(state.chromeWs);
680
+ state.messageBuffer = [];
681
+ // Disable the ws library's per-message size cap (default 100 MiB). The slicc
682
+ // UI runs INSIDE the Chrome it debugs, so Chrome's Network domain reports
683
+ // every CDP frame back as `Network.webSocketFrame*` events embedding prior
684
+ // payloads — an exponential loop that would trip the cap and close the
685
+ // socket (code 1006). forwardChromeFrame drops those events by method.
686
+ const chromeWs = new WebSocket(url, { maxPayload: 0 });
687
+ state.chromeWs = chromeWs;
688
+ chromeWs.on('open', () => {
689
+ console.log('[cdp-proxy] chromeWs open');
690
+ flushBufferedClientFrames(state, chromeWs, ctx);
691
+ resolve();
513
692
  });
514
- // Profile directories are reused across runs (both the dev
515
- // `/tmp/browser-coding-agent-chrome` profile and the persistent
516
- // `.qa/chrome/<profile>` QA profiles). Chrome never proactively
517
- // clears `DevToolsActivePort` on shutdown, so a stale file from a
518
- // previous crash/SIGKILL would let our active-port-file poller win
519
- // the race instantly with the wrong port. Clear it before spawn.
520
- await clearStaleDevToolsActivePort(chromeProfile.userDataDir);
521
- // On macOS, route through `/usr/bin/open` so LaunchServices owns the
522
- // new Chrome process. Without this hop the terminal that started
523
- // `node` stays in Chrome's TCC responsibility chain, which silently
524
- // breaks `getUserMedia()` (camera/mic in Google Meet, Zoom, etc.)
525
- // whenever the terminal hasn't already been granted camera/microphone
526
- // access. With LaunchServices in the loop, Chrome becomes its own
527
- // TCC responsible process and the user's
528
- // `/Applications/Google Chrome.app` privacy grant applies as expected.
529
- const spawnPlan = planChromeSpawn({ executablePath: chromePath, chromeArgs });
530
- launchedBrowserProcess = spawn(spawnPlan.command, spawnPlan.args, {
531
- stdio: ['ignore', 'pipe', 'pipe'],
532
- detached: false,
533
- env: { ...process.env, GOOGLE_CRASHPAD_DISABLE: '1' },
693
+ chromeWs.on('message', (data) => {
694
+ forwardChromeFrame(state, cdpFrameToBuffer(data), ctx);
534
695
  });
535
- launchedBrowserLabel = chromeProfile.displayName;
536
- // Use the stderr-vs-DevToolsActivePort race so we work in both
537
- // direct-exec mode (Linux/Windows, or bare-binary fallbacks where
538
- // stderr carries Chrome's banner) and LaunchServices mode (macOS,
539
- // where stderr belongs to `open` and only the active-port file
540
- // surfaces the real CDP port).
541
- const actualCdpPort = await waitForCdpPort(launchedBrowserProcess, {
542
- userDataDir: chromeProfile.userDataDir,
696
+ chromeWs.on('close', (code, reason) => {
697
+ console.log(`[cdp-proxy] Chrome WS closed. code=${code}, reason=${String(reason)}`);
698
+ state.chromeWs = null;
543
699
  });
544
- CDP_PORT = actualCdpPort;
545
- console.log(`Chrome CDP listening on port ${CDP_PORT}`);
546
- pipeChildOutput(launchedBrowserProcess, 'chrome');
547
- launchedBrowserProcess.on('exit', (code) => {
548
- if (shuttingDown)
549
- return;
550
- console.log(`Chrome exited with code ${code}`);
551
- process.exit(0);
700
+ chromeWs.on('error', (err) => {
701
+ console.log(`[cdp-proxy] Chrome WS error: ${err}`);
702
+ state.chromeWs = null;
703
+ reject(err);
704
+ });
705
+ });
706
+ }
707
+ /** Forward one Client→Chrome frame, buffering it when Chrome isn't ready yet. */
708
+ function forwardClientFrame(state, data, ctx) {
709
+ const original = String(data);
710
+ const preview = original.slice(0, 200);
711
+ if (state.chromeWs &&
712
+ state.chromeWs.readyState === WebSocket.OPEN &&
713
+ state.messageBuffer === null) {
714
+ const msg = `[cdp-proxy] Client→Chrome: ${preview}`;
715
+ if (ctx.cdpDedup.shouldLog(msg))
716
+ console.debug(msg);
717
+ const { output } = applyCdpUnmask(original, {
718
+ tracker: ctx.cdpSessionUrls,
719
+ pipeline: ctx.secretProxy.rawPipeline,
552
720
  });
721
+ state.chromeWs.send(output);
553
722
  }
554
- // 3. Set up express app with request logging
723
+ else if (state.messageBuffer !== null) {
724
+ // Buffer the ORIGINAL bytes; unmask runs on flush so the hostname tracker
725
+ // reflects the state at send time.
726
+ state.messageBuffer.push(data);
727
+ const msg = `[cdp-proxy] Client→Chrome (buffered): ${preview}`;
728
+ if (ctx.cdpDedup.shouldLog(msg))
729
+ console.debug(msg);
730
+ }
731
+ else {
732
+ console.log(`[cdp-proxy] Client→Chrome (DROPPED — no connection): ${preview}`);
733
+ }
734
+ }
735
+ async function handleCdpClient(state, clientWs, ctx, cdpPort) {
736
+ try {
737
+ // Only one client active at a time — close the previous one.
738
+ if (state.activeClientWs && state.activeClientWs.readyState === WebSocket.OPEN) {
739
+ console.log('[cdp-proxy] Closing previous client connection');
740
+ state.activeClientWs.close();
741
+ }
742
+ state.activeClientWs = clientWs;
743
+ console.log('[cdp-proxy] New client connected');
744
+ // Initialise the buffer BEFORE any await so messages arriving during
745
+ // waitForCDP / ensureChromeConnection are captured, not dropped.
746
+ if (state.messageBuffer === null)
747
+ state.messageBuffer = [];
748
+ // Register ALL handlers BEFORE any async work so no messages are lost.
749
+ clientWs.on('message', (data) => {
750
+ forwardClientFrame(state, data, ctx);
751
+ });
752
+ clientWs.on('close', () => {
753
+ console.log('[cdp-proxy] Client disconnected');
754
+ if (state.activeClientWs === clientWs)
755
+ state.activeClientWs = null;
756
+ // Don't close chromeWs — keep it alive for the next client.
757
+ });
758
+ clientWs.on('error', (err) => {
759
+ console.log(`[cdp-proxy] Client WS error: ${err}`);
760
+ if (state.activeClientWs === clientWs)
761
+ state.activeClientWs = null;
762
+ });
763
+ // NOW do async work — messages arriving during these awaits are buffered.
764
+ if (!state.cdpUrl) {
765
+ state.cdpUrl = await waitForCDP(cdpPort);
766
+ console.log(`[cdp-proxy] CDP available at: ${state.cdpUrl}`);
767
+ }
768
+ await ensureChromeConnection(state, state.cdpUrl, ctx);
769
+ }
770
+ catch (err) {
771
+ console.error('[cdp-proxy] Connection error:', err);
772
+ clientWs.close();
773
+ }
774
+ }
775
+ /** Best-effort graceful close of the launched browser, escalating to SIGKILL. */
776
+ async function closeLaunchedBrowserGracefully(state, cdpPort) {
777
+ const browser = state.launchedBrowserProcess;
778
+ if (!browser)
779
+ return;
780
+ let browserExited = false;
781
+ browser.on('exit', () => {
782
+ browserExited = true;
783
+ });
784
+ try {
785
+ const res = await fetch(`http://127.0.0.1:${cdpPort}/json/version`);
786
+ const json = (await res.json());
787
+ const browserWs = new WebSocket(json.webSocketDebuggerUrl);
788
+ await new Promise((resolve, reject) => {
789
+ browserWs.on('open', () => {
790
+ browserWs.send(JSON.stringify({ id: 1, method: 'Browser.close' }));
791
+ resolve();
792
+ });
793
+ browserWs.on('error', reject);
794
+ });
795
+ }
796
+ catch {
797
+ // CDP not available — the launched browser may still be starting up; fall through to kill.
798
+ }
799
+ const deadline = Date.now() + 3000;
800
+ while (!browserExited && Date.now() < deadline) {
801
+ await new Promise((r) => setTimeout(r, 100));
802
+ }
803
+ if (!browserExited) {
804
+ try {
805
+ browser.kill('SIGKILL');
806
+ }
807
+ catch {
808
+ /* ignore */
809
+ }
810
+ }
811
+ console.log(`${state.launchedBrowserLabel} closed`);
812
+ }
813
+ /** Build the idempotent graceful-shutdown handler wired to the process signals. */
814
+ function createGracefulShutdown(state, deps) {
815
+ return async () => {
816
+ if (state.shuttingDown)
817
+ return;
818
+ state.shuttingDown = true;
819
+ console.log('\nShutting down...');
820
+ deps.fileLogger.close();
821
+ state.overlayInjector?.stop();
822
+ state.overlayInjector = null;
823
+ closeWebSocketQuietly(state.chromeWs);
824
+ state.chromeWs = null;
825
+ closeWebSocketQuietly(state.activeClientWs);
826
+ state.activeClientWs = null;
827
+ for (const client of deps.wss.clients) {
828
+ client.close();
829
+ }
830
+ deps.wss.close();
831
+ // Stop accepting new HTTP connections.
832
+ deps.server.close();
833
+ await closeLaunchedBrowserGracefully(state, deps.cdpPort);
834
+ process.exit(0);
835
+ };
836
+ }
837
+ /** Pre-connect to Chrome so the proxy is warm before the first client; hosted mode registers leader-restart once CDP is ready. */
838
+ async function preconnectCdp(state, ctx, app, servePort, cdpPort) {
839
+ try {
840
+ state.cdpUrl = await waitForCDP(cdpPort);
841
+ console.log(`[cdp-proxy] Pre-connected: CDP available at ${state.cdpUrl}`);
842
+ await ensureChromeConnection(state, state.cdpUrl, ctx);
843
+ console.log('[cdp-proxy] Chrome WebSocket ready (pre-warmed)');
844
+ if (RUNTIME_FLAGS.hosted) {
845
+ registerLeaderRestartEndpoint(app, {
846
+ cdp: createHttpCdp(cdpPort),
847
+ localUrlPrefix: `http://localhost:${servePort}/`,
848
+ });
849
+ console.log('[hosted] /api/leader-restart endpoint registered');
850
+ }
851
+ }
852
+ catch (err) {
853
+ console.log('[cdp-proxy] Pre-connect failed (will retry on first client):', err);
854
+ }
855
+ }
856
+ async function startOverlayInjector(state, cdpPort, servePort) {
857
+ try {
858
+ state.overlayInjector = await ElectronOverlayInjector.create({
859
+ cdpPort,
860
+ servePort,
861
+ dev: DEV_MODE,
862
+ projectRoot: PROJECT_ROOT,
863
+ });
864
+ await state.overlayInjector.start();
865
+ console.log('[electron-float] Overlay injector is watching Electron page targets');
866
+ }
867
+ catch (error) {
868
+ const message = error instanceof Error ? error.message : String(error);
869
+ console.error('[electron-float] Failed to start overlay injector:', message);
870
+ }
871
+ }
872
+ /** Start listening and warm up the CDP proxy, overlay injector, and console forwarder. */
873
+ function startCdpServer(state, deps) {
874
+ const { app, server, ctx, fileLogger, servePort, serveOrigin, cdpPort } = deps;
875
+ server.listen(servePort, '127.0.0.1', () => {
876
+ console.log(`Serving UI at ${serveOrigin}`);
877
+ console.log(`CDP proxy at ws://localhost:${servePort}/cdp`);
878
+ fileLogger.log('info', 'CLI server started', {
879
+ port: servePort,
880
+ cdpPort,
881
+ devMode: DEV_MODE,
882
+ electronMode: ELECTRON_MODE,
883
+ });
884
+ void preconnectCdp(state, ctx, app, servePort, cdpPort);
885
+ if (ELECTRON_MODE) {
886
+ void startOverlayInjector(state, cdpPort, servePort);
887
+ }
888
+ else {
889
+ setTimeout(() => {
890
+ attachConsoleForwarder(cdpPort, String(servePort)).catch((err) => {
891
+ console.error('[page] Console forwarder error:', err);
892
+ });
893
+ }, 2500);
894
+ }
895
+ });
896
+ }
897
+ /**
898
+ * Build the secret stores + masking pipeline shared by the fetch-proxy, the
899
+ * /api/secrets routes, and the S3 sign-and-forward handler. Secret-load
900
+ * failures are logged, not fatal.
901
+ */
902
+ async function bootstrapSecrets() {
555
903
  const sessionDir = RUNTIME_FLAGS.envFile
556
904
  ? dirname(RUNTIME_FLAGS.envFile)
557
905
  : join(homedir(), '.slicc');
558
906
  const sessionId = readOrCreateSessionId(sessionDir);
559
907
  const oauthStore = new OauthSecretStore();
560
- // Env-file secrets (~/.slicc/secrets.env) feed the fetch-proxy mask
561
- // pipeline alongside OAuth tokens. The same instance is reused below
562
- // for /api/secrets and handleS3SignAndForward.
908
+ // Env-file secrets (~/.slicc/secrets.env) feed the fetch-proxy mask pipeline
909
+ // alongside OAuth tokens.
563
910
  const secretStore = new EnvSecretStore(RUNTIME_FLAGS.envFile ?? undefined);
564
911
  const secretProxy = new SecretProxyManager(secretStore, sessionId, oauthStore);
565
912
  try {
@@ -571,6 +918,18 @@ async function main() {
571
918
  catch (err) {
572
919
  console.warn('Failed to load secrets:', err instanceof Error ? err.message : err);
573
920
  }
921
+ return { secretStore, secretProxy, oauthStore };
922
+ }
923
+ async function main() {
924
+ // Resolve ports, launch the browser, then snapshot the now-final values that
925
+ // the rest of boot reads. Mutable lifecycle fields stay on `state`.
926
+ const state = createServerState();
927
+ await resolvePorts(state);
928
+ await launchBrowser(state);
929
+ const { servePort: SERVE_PORT, cdpPort: CDP_PORT, serveOrigin: SERVE_ORIGIN } = state;
930
+ const discoveredTrayJoinUrl = state.discoveredTrayJoinUrl;
931
+ // 3. Set up express app with request logging
932
+ const { secretStore, secretProxy, oauthStore } = await bootstrapSecrets();
574
933
  const app = express();
575
934
  app.use(requestLogger);
576
935
  // Append SLICC's standard RFC 8288 Link header set on every /api/* response.
@@ -578,134 +937,10 @@ async function main() {
578
937
  // ---------------------------------------------------------------------------
579
938
  // Lick system — WebSocket bridge for webhooks/crontasks (all logic in browser)
580
939
  // ---------------------------------------------------------------------------
581
- // WebSocket for bidirectional communication with browser
582
- const lickWss = new WebSocketServer({ noServer: true });
583
- const lickClients = new Set();
584
- const pendingRequests = new Map();
585
- let requestIdCounter = 0;
586
- lickWss.on('connection', (ws) => {
587
- lickClients.add(ws);
588
- console.log('[licks] Browser client connected');
589
- ws.on('message', (data) => {
590
- try {
591
- const msg = JSON.parse(data.toString());
592
- // Handle responses to pending requests
593
- if (msg.type === 'response' && msg.requestId) {
594
- const pending = pendingRequests.get(msg.requestId);
595
- if (pending) {
596
- pendingRequests.delete(msg.requestId);
597
- if (msg.error) {
598
- pending.reject(new Error(msg.error));
599
- }
600
- else {
601
- pending.resolve(msg.data);
602
- }
603
- }
604
- }
605
- }
606
- catch {
607
- // Ignore invalid messages
608
- }
609
- });
610
- ws.on('close', () => {
611
- lickClients.delete(ws);
612
- console.log('[licks] Browser client disconnected');
613
- });
614
- });
615
- /** Send a request to the browser and wait for response */
616
- function sendLickRequest(type, data, timeout = 5000) {
617
- return new Promise((resolve, reject) => {
618
- const requestId = `req_${++requestIdCounter}`;
619
- const msg = JSON.stringify({ type, requestId, ...data });
620
- // Find a connected client
621
- const client = Array.from(lickClients).find((c) => c.readyState === WebSocket.OPEN);
622
- if (!client) {
623
- reject(new Error('No browser connected'));
624
- return;
625
- }
626
- // Set up timeout
627
- const timer = setTimeout(() => {
628
- pendingRequests.delete(requestId);
629
- reject(new Error('Request timeout'));
630
- }, timeout);
631
- pendingRequests.set(requestId, {
632
- resolve: (data) => {
633
- clearTimeout(timer);
634
- resolve(data);
635
- },
636
- reject: (err) => {
637
- clearTimeout(timer);
638
- reject(err);
639
- },
640
- });
641
- client.send(msg);
642
- });
643
- }
644
- /** Broadcast an event to all connected browsers (no response expected) */
645
- function broadcastLickEvent(event) {
646
- const msg = JSON.stringify(event);
647
- for (const client of lickClients) {
648
- if (client.readyState === WebSocket.OPEN) {
649
- client.send(msg);
650
- }
651
- }
652
- }
653
- // ---------------------------------------------------------------------------
940
+ const lickBridge = createLickBridge();
941
+ const { lickWss, broadcastLickEvent } = lickBridge;
654
942
  // OAuth callback — generic redirect target for OAuth providers (implicit + PKCE)
655
- // ---------------------------------------------------------------------------
656
- // Pending OAuth result for server-side relay (Electron overlay can't use window.opener)
657
- let pendingOAuthResult = null;
658
- app.get('/auth/callback', (_req, res) => {
659
- // The callback page tries window.opener.postMessage first (works in CLI popup mode).
660
- // If window.opener is null (Electron overlay — opens system browser), it falls back
661
- // to POSTing the result to /api/oauth-result for the UI to poll.
662
- res.send(`<!DOCTYPE html><html><body><script>
663
- var q = new URLSearchParams(location.search);
664
- var h = new URLSearchParams(location.hash.replace(/^#/, ''));
665
- var payload = {
666
- type: 'oauth-callback',
667
- redirectUrl: location.href,
668
- code: q.get('code'),
669
- state: q.get('state') || h.get('state'),
670
- error: q.get('error') || h.get('error'),
671
- access_token: h.get('access_token'),
672
- expires_in: h.get('expires_in'),
673
- token_type: h.get('token_type')
674
- };
675
- if (window.opener) {
676
- window.opener.postMessage(payload, '*');
677
- } else {
678
- fetch('/api/oauth-result', {
679
- method: 'POST',
680
- headers: { 'Content-Type': 'application/json' },
681
- body: JSON.stringify(payload)
682
- }).catch(function(err) { console.error('[oauth-callback] Failed to relay result to server:', err); });
683
- }
684
- window.close();
685
- </script><p>Completing login... you can close this window.</p></body></html>`);
686
- });
687
- app.post('/api/oauth-result', express.json(), (req, res) => {
688
- const body = req.body;
689
- const redirectUrl = typeof body.redirectUrl === 'string' ? body.redirectUrl : '';
690
- if (!redirectUrl) {
691
- console.warn('[oauth-result] Received callback with empty redirectUrl');
692
- }
693
- pendingOAuthResult = {
694
- redirectUrl,
695
- error: typeof body.error === 'string' ? body.error : undefined,
696
- };
697
- res.json({ ok: true });
698
- });
699
- app.get('/api/oauth-result', (_req, res) => {
700
- if (pendingOAuthResult) {
701
- const result = pendingOAuthResult;
702
- pendingOAuthResult = null;
703
- res.json(result);
704
- }
705
- else {
706
- res.status(204).end();
707
- }
708
- });
943
+ registerOAuthCallbackRoutes(app);
709
944
  // Global JSON body parser. Skipped when the request carries
710
945
  // `X-Slicc-Raw-Body: 1`, so SigV4-signed bodies survive into the
711
946
  // /api/fetch-proxy handler byte-for-byte (the parser would otherwise
@@ -746,399 +981,15 @@ async function main() {
746
981
  timestamp: new Date().toISOString(),
747
982
  });
748
983
  });
749
- // Tray status API forwards to browser to get leader tray join info
750
- app.get('/api/tray-status', async (_req, res) => {
751
- try {
752
- const data = await sendLickRequest('tray_status', {});
753
- res.json(data);
754
- }
755
- catch (err) {
756
- res.status(503).json({ error: err instanceof Error ? err.message : 'Browser not connected' });
757
- }
758
- });
759
- // Webhook management API — forwards to browser
760
- app.get('/api/webhooks', async (_req, res) => {
761
- try {
762
- const data = await sendLickRequest('list_webhooks', {});
763
- res.json(data);
764
- }
765
- catch (err) {
766
- res.status(503).json({ error: err instanceof Error ? err.message : 'Browser not connected' });
767
- }
768
- });
769
- app.post('/api/webhooks', async (req, res) => {
770
- try {
771
- const data = await sendLickRequest('create_webhook', req.body);
772
- res.json(data);
773
- }
774
- catch (err) {
775
- const msg = err instanceof Error ? err.message : String(err);
776
- res.status(msg.includes('Invalid') ? 400 : 503).json({ error: msg });
777
- }
778
- });
779
- app.delete('/api/webhooks/:id', async (req, res) => {
780
- try {
781
- const data = (await sendLickRequest('delete_webhook', { id: req.params.id }));
782
- if (data.error) {
783
- res.status(404).json({ error: data.error });
784
- }
785
- else {
786
- res.json(data);
787
- }
788
- }
789
- catch (err) {
790
- res.status(503).json({ error: err instanceof Error ? err.message : 'Browser not connected' });
791
- }
792
- });
793
- // Webhook receiver — handle CORS preflight
794
- app.options('/webhooks/:id', (_req, res) => {
795
- res.set({
796
- 'Access-Control-Allow-Origin': '*',
797
- 'Access-Control-Allow-Methods': 'POST, OPTIONS',
798
- 'Access-Control-Allow-Headers': 'Content-Type',
799
- });
800
- res.sendStatus(204);
801
- });
802
- // Webhook receiver — forwards POST to browser for processing
803
- app.post('/webhooks/:id', async (req, res) => {
804
- res.set({ 'Access-Control-Allow-Origin': '*' });
805
- const { id } = req.params;
806
- // Collect body
807
- let body = req.body;
808
- if (!body || Object.keys(body).length === 0) {
809
- const chunks = [];
810
- for await (const chunk of req) {
811
- chunks.push(Buffer.from(chunk));
812
- }
813
- const raw = Buffer.concat(chunks).toString('utf-8');
814
- try {
815
- body = JSON.parse(raw);
816
- }
817
- catch {
818
- body = { raw };
819
- }
820
- }
821
- // Forward to browser for processing
822
- broadcastLickEvent({
823
- type: 'webhook_event',
824
- webhookId: id,
825
- timestamp: new Date().toISOString(),
826
- headers: req.headers,
827
- body,
828
- });
829
- res.json({ ok: true, received: true });
830
- });
831
- // Cron task management API — forwards to browser
832
- app.get('/api/crontasks', async (_req, res) => {
833
- try {
834
- const data = await sendLickRequest('list_crontasks', {});
835
- res.json(data);
836
- }
837
- catch (err) {
838
- res.status(503).json({ error: err instanceof Error ? err.message : 'Browser not connected' });
839
- }
840
- });
841
- app.post('/api/crontasks', async (req, res) => {
842
- try {
843
- const data = await sendLickRequest('create_crontask', req.body);
844
- res.json(data);
845
- }
846
- catch (err) {
847
- const msg = err instanceof Error ? err.message : String(err);
848
- res
849
- .status(msg.includes('Invalid') || msg.includes('required') ? 400 : 503)
850
- .json({ error: msg });
851
- }
852
- });
853
- app.delete('/api/crontasks/:id', async (req, res) => {
854
- try {
855
- const data = (await sendLickRequest('delete_crontask', { id: req.params.id }));
856
- if (data.error) {
857
- res.status(404).json({ error: data.error });
858
- }
859
- else {
860
- res.json(data);
861
- }
862
- }
863
- catch (err) {
864
- res.status(503).json({ error: err instanceof Error ? err.message : 'Browser not connected' });
865
- }
866
- });
867
- // Profile-independent handoff injection.
868
- //
869
- // The CDP navigation-watcher only sees tabs inside the Chrome instance
870
- // SLICC launched (isolated profile keyed by port); similarly the
871
- // extension's webRequest observer only fires inside the profile where it
872
- // is installed. External tools (e.g. the slicc-handoff helper) post here
873
- // so a handoff reaches the cone regardless of which browser profile the
874
- // user is currently driving.
875
- //
876
- // The payload mirrors the parsed RFC 8288 `Link` form used by the
877
- // observers: `verb` ∈ {handoff, upskill}, `target` is the resolved URL,
878
- // `instruction` is optional free-form prose (handoff verb).
879
- app.post('/api/handoff', (req, res) => {
880
- const payload = req.body;
881
- if (typeof payload?.sliccHeader === 'string') {
882
- res.status(400).json({
883
- error: 'The legacy `sliccHeader` payload was removed; post `{ verb, target, instruction? }` instead. See docs/slicc-handoff.md.',
884
- });
885
- return;
886
- }
887
- if (payload?.verb !== 'handoff' && payload?.verb !== 'upskill') {
888
- res.status(400).json({ error: 'verb must be "handoff" or "upskill"' });
889
- return;
890
- }
891
- if (typeof payload.target !== 'string' || payload.target.length === 0) {
892
- res.status(400).json({ error: 'target is required (non-empty string)' });
893
- return;
894
- }
895
- if (payload.instruction != null && typeof payload.instruction !== 'string') {
896
- res.status(400).json({ error: 'instruction must be a string when provided' });
897
- return;
898
- }
899
- // `branch` / `path` mirror the upskill rel's Link params and are
900
- // ignored on the handoff verb (its target is the page itself, not a
901
- // repo). Reject the wrong-shape combo loudly so emitters notice
902
- // rather than silently dropping the scope.
903
- if (payload.branch != null && typeof payload.branch !== 'string') {
904
- res.status(400).json({ error: 'branch must be a string when provided' });
905
- return;
906
- }
907
- if (payload.path != null && typeof payload.path !== 'string') {
908
- res.status(400).json({ error: 'path must be a string when provided' });
909
- return;
910
- }
911
- if (payload.verb === 'handoff' && (payload.branch != null || payload.path != null)) {
912
- res.status(400).json({ error: 'branch and path are only valid with verb="upskill"' });
913
- return;
914
- }
915
- broadcastLickEvent({
916
- type: 'navigate_event',
917
- verb: payload.verb,
918
- target: payload.target,
919
- instruction: typeof payload.instruction === 'string' ? payload.instruction : undefined,
920
- url: typeof payload.url === 'string' && payload.url.length > 0 ? payload.url : 'about:handoff',
921
- title: typeof payload.title === 'string' ? payload.title : undefined,
922
- branch: typeof payload.branch === 'string' && payload.branch.length > 0
923
- ? payload.branch
924
- : undefined,
925
- path: typeof payload.path === 'string' && payload.path.length > 0 ? payload.path : undefined,
926
- timestamp: new Date().toISOString(),
927
- });
928
- res.json({ ok: true });
929
- });
930
- // Secret management API — direct .env file access (no browser needed).
931
- // `secretStore` was created above and wired into `secretProxy` so the
932
- // fetch-proxy and the management API share one source of truth.
933
- app.get('/api/secrets', (_req, res) => {
934
- try {
935
- const entries = secretStore.list();
936
- res.json(entries);
937
- }
938
- catch (err) {
939
- res
940
- .status(500)
941
- .json({ error: err instanceof Error ? err.message : 'Failed to list secrets' });
942
- }
943
- });
944
- // Persisted-set — write a secret to ~/.slicc/secrets.env. Gated by the agent's
945
- // intrinsic sudo prompt before the request is ever sent.
946
- app.post('/api/secrets', express.json(), async (req, res) => {
947
- const { name, value, domains } = req.body ?? {};
948
- if (typeof name !== 'string' ||
949
- typeof value !== 'string' ||
950
- !Array.isArray(domains) ||
951
- domains.some((d) => typeof d !== 'string')) {
952
- return res.status(400).json({ error: 'bad-request' });
953
- }
954
- try {
955
- secretStore.set(name, value, domains);
956
- await secretProxy.reload();
957
- res.json({ ok: true });
958
- }
959
- catch (err) {
960
- res.status(500).json({ error: err instanceof Error ? err.message : 'Failed to set secret' });
961
- }
962
- });
963
- // Scope edit — update the allowed domains of an existing secret (persisted or
964
- // session), preserving the value. Gated by the agent before sending.
965
- app.post('/api/secrets/scope', express.json(), async (req, res) => {
966
- const { name, domains } = req.body ?? {};
967
- if (typeof name !== 'string' ||
968
- !Array.isArray(domains) ||
969
- domains.some((d) => typeof d !== 'string')) {
970
- return res.status(400).json({ error: 'bad-request' });
971
- }
972
- try {
973
- if (secretProxy.sessionStore.has(name)) {
974
- secretProxy.sessionStore.setDomains(name, domains);
975
- }
976
- else {
977
- const existing = secretStore.get(name);
978
- if (!existing)
979
- return res.status(404).json({ error: `no secret named "${name}"` });
980
- secretStore.set(name, existing.value, domains);
981
- }
982
- await secretProxy.reload();
983
- res.json({ ok: true });
984
- }
985
- catch (err) {
986
- res
987
- .status(500)
988
- .json({ error: err instanceof Error ? err.message : 'Failed to update scope' });
989
- }
990
- });
991
- // Session secrets — in-memory only, never written to disk. Free for the agent
992
- // to create; the masking pipeline picks them up on the reload below.
993
- app.get('/api/secrets/session', (_req, res) => {
994
- res.json(secretProxy.sessionStore.list());
995
- });
996
- app.post('/api/secrets/session', express.json(), async (req, res) => {
997
- const { name, value, domains } = req.body ?? {};
998
- if (typeof name !== 'string' ||
999
- typeof value !== 'string' ||
1000
- (domains !== undefined &&
1001
- (!Array.isArray(domains) || domains.some((d) => typeof d !== 'string')))) {
1002
- return res.status(400).json({ error: 'bad-request' });
1003
- }
1004
- secretProxy.sessionStore.set(name, value, Array.isArray(domains) ? domains : []);
1005
- await secretProxy.reload();
1006
- res.json({ ok: true });
1007
- });
1008
- // Peek — elided preview of the unmasked value (session or persisted). The
1009
- // full value never leaves the server.
1010
- app.get('/api/secrets/peek', (req, res) => {
1011
- const name = typeof req.query.name === 'string' ? req.query.name : '';
1012
- if (!name)
1013
- return res.status(400).json({ error: 'bad-request' });
1014
- const session = secretProxy.sessionStore.getRecord(name);
1015
- if (session) {
1016
- return res.json({ name, preview: previewSecret(session.value), domains: session.domains });
1017
- }
1018
- const persisted = secretStore.get(name);
1019
- if (persisted) {
1020
- return res.json({
1021
- name,
1022
- preview: previewSecret(persisted.value),
1023
- domains: persisted.domains,
1024
- });
1025
- }
1026
- return res.status(404).json({ error: `no secret named "${name}"` });
1027
- });
1028
- // S3 sign-and-forward — browser-side mount backend posts envelopes here;
1029
- // server resolves the s3.<profile>.* secrets, signs SigV4 v4, forwards to
1030
- // the upstream, returns the response as a JSON envelope. The browser
1031
- // never sees access_key_id / secret_access_key. See sign-and-forward.ts
1032
- // for the envelope contract.
1033
- app.post('/api/s3-sign-and-forward', async (req, res) => {
1034
- try {
1035
- await handleS3SignAndForward(req, res, secretStore);
1036
- }
1037
- catch (err) {
1038
- // Generic log line + trace id only. Avoid logging the err.message
1039
- // because TypeError stack frames or signing errors can include
1040
- // profile names, bucket names, or partial URLs — operational secrets
1041
- // we don't want in shared log aggregators (Sentry, Datadog, etc.).
1042
- // The trace id lets users correlate a server-side log with the
1043
- // 500 the client got; the detailed message goes only to the local
1044
- // file logger above DEBUG, where it's bounded to the operator.
1045
- const traceId = (globalThis.crypto?.randomUUID?.() ?? Math.random().toString(36).slice(2, 10)).slice(0, 8);
1046
- console.error(`S3 sign-and-forward error [trace=${traceId}]`);
1047
- if (DEV_MODE) {
1048
- console.error(err);
1049
- }
1050
- if (!res.headersSent) {
1051
- res.status(500).json({
1052
- ok: false,
1053
- error: `internal sign-and-forward error [trace=${traceId}]`,
1054
- errorCode: 'internal',
1055
- });
1056
- }
1057
- }
1058
- });
1059
- // DA sign-and-forward — same pattern as S3, but for Adobe da.live. The
1060
- // IMS bearer token is passed transiently in the envelope (browser holds
1061
- // it via the existing Adobe LLM provider). v2 will move OAuth server-side
1062
- // to remove the browser exposure entirely.
1063
- app.post('/api/da-sign-and-forward', async (req, res) => {
1064
- try {
1065
- await handleDaSignAndForward(req, res);
1066
- }
1067
- catch (err) {
1068
- const traceId = (globalThis.crypto?.randomUUID?.() ?? Math.random().toString(36).slice(2, 10)).slice(0, 8);
1069
- console.error(`DA sign-and-forward error [trace=${traceId}]`);
1070
- if (DEV_MODE) {
1071
- console.error(err);
1072
- }
1073
- if (!res.headersSent) {
1074
- res.status(500).json({
1075
- ok: false,
1076
- error: `internal sign-and-forward error [trace=${traceId}]`,
1077
- errorCode: 'internal',
1078
- });
1079
- }
1080
- }
1081
- });
1082
- // Tool-output real→masked scrub. The browser-side agent realm
1083
- // never holds real secret values, so the defense-in-depth scrub of
1084
- // bash / read_file / other tool results runs here against the
1085
- // node-server-owned `SecretProxyManager`. Direction is real→masked
1086
- // ONLY (`scrubResponse`), so it is always safe and is idempotent
1087
- // for already-masked tokens and secret-free output. The caller
1088
- // (`packages/webapp/src/core/secret-scrub.ts`) treats any non-2xx
1089
- // or malformed response as "return input unchanged" — the scrub is
1090
- // defense-in-depth, not the primary defense.
1091
- app.post('/api/secrets/scrub', express.json({ limit: '32mb' }), (req, res) => {
1092
- const text = req.body?.text;
1093
- if (typeof text !== 'string') {
1094
- return res.status(400).json({ error: 'bad-request' });
1095
- }
1096
- try {
1097
- res.json({ text: secretProxy.scrubResponse(text) });
1098
- }
1099
- catch (err) {
1100
- res.status(500).json({ error: err instanceof Error ? err.message : 'scrub failed', text });
1101
- }
1102
- });
1103
- // Masked secrets endpoint — returns name + maskedValue pairs for shell env population.
1104
- // The browser fetches this at shell init to populate env vars with masked values.
1105
- // Real values are never exposed; only deterministic session-scoped masks.
1106
- app.get('/api/secrets/masked', (_req, res) => {
1107
- try {
1108
- const entries = secretProxy.getMaskedEntries();
1109
- res.json(entries);
1110
- }
1111
- catch (err) {
1112
- res
1113
- .status(500)
1114
- .json({ error: err instanceof Error ? err.message : 'Failed to get masked secrets' });
1115
- }
1116
- });
1117
- // OAuth secret update — stores access token from OAuth login flow
1118
- app.post('/api/secrets/oauth-update', express.json(), async (req, res) => {
1119
- const { providerId, accessToken, domains } = req.body ?? {};
1120
- if (typeof providerId !== 'string' ||
1121
- typeof accessToken !== 'string' ||
1122
- !Array.isArray(domains) ||
1123
- domains.length === 0) {
1124
- return res.status(400).json({ error: 'bad-request' });
1125
- }
1126
- const name = `oauth.${providerId}.token`;
1127
- oauthStore.set(name, accessToken, domains);
1128
- await secretProxy.reload();
1129
- const masked = secretProxy.getMaskedEntries().find((e) => e.name === name)?.maskedValue;
1130
- res.json({ providerId, name, maskedValue: masked, domains });
1131
- });
1132
- // OAuth secret deletion — removes access token on logout
1133
- app.delete('/api/secrets/oauth/:providerId', async (req, res) => {
1134
- const name = `oauth.${req.params.providerId}.token`;
1135
- if (!oauthStore.list().some((e) => e.name === name)) {
1136
- return res.status(404).json({ error: 'not-found' });
1137
- }
1138
- oauthStore.delete(name);
1139
- await secretProxy.reload();
1140
- res.status(204).end();
1141
- });
984
+ // Tray status, webhook management + receiver, and cron task routes all
985
+ // forward to the browser over the lick bridge.
986
+ registerLickApiRoutes(app, lickBridge);
987
+ // Profile-independent handoff injection external tools post here so a
988
+ // handoff reaches the cone regardless of which browser profile is active.
989
+ registerHandoffRoute(app, { broadcastLickEvent });
990
+ // Secret management API — direct .env file access (no browser needed),
991
+ // plus the S3 / DA sign-and-forward and masked-secret endpoints.
992
+ registerSecretRoutes(app, { secretStore, secretProxy, oauthStore, devMode: DEV_MODE });
1142
993
  // Cloud status endpoint (hosted-only) — writes join info to /tmp/slicc-join.json
1143
994
  // Register BEFORE Chromium launches. The webapp's first action after
1144
995
  // ?runtime=hosted-leader boot is to mint a tray and POST /api/cloud-status.
@@ -1152,539 +1003,34 @@ async function main() {
1152
1003
  // trusted process so the in-browser agent can request, but never fabricate,
1153
1004
  // an approval. Loopback-only; selects a backend by environment at call time.
1154
1005
  registerSudoApproveEndpoint(app);
1155
- // Fetch proxy — forwards cross-origin requests from the browser to bypass CORS.
1156
- // Used by just-bash's curl which calls the browser's fetch() API.
1157
- // Note: express.json() may have already parsed the body, so we check req.body first.
1158
- app.all('/api/fetch-proxy', async (req, res) => {
1159
- // Get the body - either from express.json() parsed body or collect raw chunks
1160
- let rawBody;
1161
- if (req.body && typeof req.body === 'object' && Object.keys(req.body).length > 0) {
1162
- // Body was already parsed by express.json() - re-serialize it
1163
- rawBody = Buffer.from(JSON.stringify(req.body), 'utf-8');
1164
- }
1165
- else {
1166
- // Collect raw body manually (for non-JSON content types)
1167
- const chunks = [];
1168
- for await (const chunk of req) {
1169
- chunks.push(Buffer.from(chunk));
1170
- }
1171
- rawBody = Buffer.concat(chunks);
1172
- }
1173
- const targetUrl = req.headers['x-target-url'];
1174
- if (!targetUrl) {
1175
- res.setHeader('X-Proxy-Error', '1');
1176
- res.status(400).json({ error: 'Missing X-Target-URL header' });
1177
- return;
1178
- }
1179
- // Hoisted so the catch handler below can detach it on early
1180
- // failures (e.g. fetch threw before the success-path detach
1181
- // could run).
1182
- let onClientClose = null;
1183
- try {
1184
- const fetchInit = {
1185
- method: req.method,
1186
- redirect: 'follow', // Follow redirects for git protocol compatibility
1187
- };
1188
- // Forward relevant headers (excluding hop-by-hop and proxy headers).
1189
- // Set lives at module scope as FETCH_PROXY_SKIP_HEADERS so tests can
1190
- // verify the contract without copying it.
1191
- const headers = {};
1192
- for (const [key, value] of Object.entries(req.headers)) {
1193
- if (!FETCH_PROXY_SKIP_HEADERS.has(key) && typeof value === 'string') {
1194
- headers[key] = value;
1195
- }
1196
- }
1197
- // Forbidden-header transport: browser cannot send Cookie via fetch(),
1198
- // so the client encodes it as X-Proxy-Cookie. Restore it here.
1199
- const proxyCookie = req.headers['x-proxy-cookie'];
1200
- if (proxyCookie) {
1201
- headers['cookie'] = Array.isArray(proxyCookie) ? proxyCookie[0] : proxyCookie;
1202
- }
1203
- // Helper: check if an origin/referer value is a localhost URL
1204
- function isLocalhostOrigin(origin) {
1205
- if (!origin)
1206
- return false;
1207
- try {
1208
- const url = new URL(origin);
1209
- return (url.hostname === 'localhost' ||
1210
- url.hostname === '127.0.0.1' ||
1211
- url.hostname === '::1' ||
1212
- url.hostname === '[::1]');
1213
- }
1214
- catch {
1215
- return false;
1216
- }
1217
- }
1218
- // Forbidden-header transport: restore X-Proxy-Origin → Origin
1219
- const proxyOrigin = req.headers['x-proxy-origin'];
1220
- if (proxyOrigin) {
1221
- headers['origin'] = Array.isArray(proxyOrigin) ? proxyOrigin[0] : proxyOrigin;
1222
- }
1223
- else if (isLocalhostOrigin(headers['origin'])) {
1224
- // Only strip browser's auto-added localhost origin, preserve legitimate origins
1225
- delete headers['origin'];
1226
- }
1227
- // Default-Origin fallback: when no explicit caller Origin survives, synthesize
1228
- // one from the target URL so upstream CORS-protected APIs see a real Origin
1229
- // instead of nothing. Caller-supplied X-Proxy-Origin (handled above) still wins.
1230
- if (!headers['origin']) {
1231
- try {
1232
- headers['origin'] = new URL(targetUrl).origin;
1233
- }
1234
- catch {
1235
- // Malformed targetUrl — leave origin unset; the upstream fetch will fail anyway.
1236
- }
1237
- }
1238
- // Forbidden-header transport: restore X-Proxy-Referer → Referer
1239
- const proxyReferer = req.headers['x-proxy-referer'];
1240
- if (proxyReferer) {
1241
- headers['referer'] = Array.isArray(proxyReferer) ? proxyReferer[0] : proxyReferer;
1242
- }
1243
- else if (isLocalhostOrigin(headers['referer'])) {
1244
- // Only strip browser's auto-added localhost referer, preserve legitimate referers
1245
- delete headers['referer'];
1246
- }
1247
- // Restore any X-Proxy-Proxy-* transport headers as Proxy-* headers
1248
- for (const [key, value] of Object.entries(req.headers)) {
1249
- if (key.startsWith('x-proxy-proxy-') && typeof value === 'string') {
1250
- const restored = key.replace(/^x-proxy-/, '');
1251
- headers[restored] = value;
1252
- delete headers[key];
1253
- }
1254
- }
1255
- // Always request uncompressed responses from upstream — the proxy doesn't
1256
- // decompress, and the browser→proxy link is localhost (no benefit to compression).
1257
- // Without this, Cloudflare may Brotli-compress the response, the proxy strips
1258
- // Content-Encoding (line below), and the browser receives compressed garbage.
1259
- headers['accept-encoding'] = 'identity';
1260
- // --- Secret injection: unmask headers ---
1261
- let targetHostname;
1262
- try {
1263
- targetHostname = new URL(targetUrl).hostname;
1264
- }
1265
- catch {
1266
- targetHostname = '';
1267
- }
1268
- if (secretProxy.hasSecrets()) {
1269
- // Unmask request headers (replace masked values with real, validate domain)
1270
- const headerResult = secretProxy.unmaskHeaders(headers, targetHostname);
1271
- if (headerResult.forbidden) {
1272
- res.setHeader('X-Proxy-Error', '1');
1273
- res.status(403).json({
1274
- error: `Secret "${headerResult.forbidden.secretName}" is not allowed for domain "${headerResult.forbidden.hostname}"`,
1275
- });
1276
- return;
1277
- }
1278
- }
1279
- // --- Secret injection: unmask URL-embedded credentials ---
1280
- let cleanedUrl = targetUrl;
1281
- if (secretProxy.hasSecrets()) {
1282
- const credsResult = secretProxy.extractAndUnmaskUrlCredentials(targetUrl);
1283
- if (credsResult.forbidden) {
1284
- res.setHeader('X-Proxy-Error', '1');
1285
- res.status(403).json({
1286
- error: `Secret "${credsResult.forbidden.secretName}" is not allowed for domain "${credsResult.forbidden.hostname}"`,
1287
- });
1288
- return;
1289
- }
1290
- cleanedUrl = credsResult.url;
1291
- // Attach synthetic Authorization if the URL had credentials and the header isn't already set
1292
- if (credsResult.syntheticAuthorization && !('authorization' in headers)) {
1293
- headers.authorization = credsResult.syntheticAuthorization;
1294
- }
1295
- }
1296
- if (Object.keys(headers).length > 0)
1297
- fetchInit.headers = headers;
1298
- if (rawBody.length > 0 && !['GET', 'HEAD'].includes(req.method)) {
1299
- // --- Secret injection: unmask request body ---
1300
- // Body uses unmaskBody: domain mismatches leave the masked value as-is
1301
- // (safe/meaningless) rather than rejecting. This avoids false 403s when
1302
- // LLM conversation context contains masked secrets sent to non-matching
1303
- // domains like Bedrock.
1304
- //
1305
- // Skip the unmask for non-text content (git packfiles, octet-stream,
1306
- // ZIPs, images, …) — `Buffer.toString('utf-8')` on arbitrary bytes
1307
- // replaces invalid sequences with U+FFFD, silently corrupting the
1308
- // payload. Masked values are hex strings with known prefixes; they
1309
- // do not appear in deflated git packfiles or other compressed binary,
1310
- // so skipping is safe.
1311
- const reqCt = (headers['content-type'] ?? headers['Content-Type'] ?? '').toLowerCase();
1312
- const reqIsText = !reqCt ||
1313
- reqCt.startsWith('text/') ||
1314
- reqCt.includes('json') ||
1315
- reqCt.includes('xml') ||
1316
- reqCt.includes('javascript') ||
1317
- reqCt.includes('ecmascript') ||
1318
- reqCt.includes('html') ||
1319
- reqCt.includes('css') ||
1320
- reqCt.includes('svg');
1321
- if (reqIsText && secretProxy.hasSecrets()) {
1322
- const bodyStr = rawBody.toString('utf-8');
1323
- const bodyResult = secretProxy.unmaskBody(bodyStr, targetHostname);
1324
- rawBody = Buffer.from(bodyResult.text, 'utf-8');
1325
- }
1326
- // Buffer extends Uint8Array which is a valid fetch body at runtime.
1327
- fetchInit.body = rawBody;
1328
- }
1329
- // Propagate client disconnect to the upstream request so that
1330
- // long-lived streams (LLM SSE completions) are torn down promptly
1331
- // when the SW or the page aborts. Listen on `res.on('close')` —
1332
- // not `req.on('close')` — because Node fires `req` close as soon
1333
- // as the readable side of the request is consumed (right after
1334
- // express.json() parses the body), which would abort the upstream
1335
- // fetch before it could even start. `res` close only fires when
1336
- // the response is sent OR the connection is killed mid-stream;
1337
- // in the first case the abort is harmless (the fetch already
1338
- // settled), in the second it's exactly what we want. Guard with
1339
- // `res.writableEnded` to be safe.
1340
- const abortController = new AbortController();
1341
- onClientClose = () => {
1342
- if (!res.writableEnded)
1343
- abortController.abort();
1344
- };
1345
- res.on('close', onClientClose);
1346
- fetchInit.signal = abortController.signal;
1347
- const upstream = await fetch(cleanedUrl, fetchInit);
1348
- // Forward status, prevent browser caching of proxy responses
1349
- res.status(upstream.status);
1350
- res.setHeader('Cache-Control', 'no-store, no-cache');
1351
- // Forward response headers (strip www-authenticate to prevent
1352
- // the browser from showing a native Basic Auth dialog — isomorphic-git
1353
- // handles 401s through its own onAuth callback). Drop Content-Length
1354
- // so the response can be chunk-encoded transparently.
1355
- const setCookieValues = upstream.headers.getSetCookie();
1356
- upstream.headers.forEach((v, k) => {
1357
- const lower = k.toLowerCase();
1358
- if (lower !== 'transfer-encoding' &&
1359
- lower !== 'content-encoding' &&
1360
- lower !== 'content-length' &&
1361
- lower !== 'www-authenticate' &&
1362
- lower !== 'set-cookie' &&
1363
- !lower.startsWith('x-proxy-')) {
1364
- // Scrub real secret values from response headers (one-shot,
1365
- // headers are always small so per-chunk semantics don't apply).
1366
- res.setHeader(k, secretProxy.scrubResponse(v));
1367
- }
1368
- });
1369
- if (setCookieValues.length > 0) {
1370
- res.setHeader('X-Proxy-Set-Cookie', secretProxy.scrubResponse(JSON.stringify(setCookieValues)));
1371
- }
1372
- // Stream the upstream body straight through to the client so that
1373
- // LLM SSE completions reach the browser token-by-token instead of
1374
- // arriving in one giant burst at the end. Per-chunk secret-scrub
1375
- // runs on text responses; secrets that span a chunk boundary slip
1376
- // through unscrubbed (documented limitation — the scrub primitive
1377
- // is best-effort on streamed bodies).
1378
- if (!upstream.body) {
1379
- res.end();
1380
- if (onClientClose)
1381
- res.off('close', onClientClose);
1382
- return;
1383
- }
1384
- const ct = (upstream.headers.get('content-type') ?? '').toLowerCase();
1385
- const isText = ct.startsWith('text/') ||
1386
- ct.startsWith('application/json') ||
1387
- ct.includes('charset=') ||
1388
- ct.includes('event-stream');
1389
- const upstreamStream = Readable.fromWeb(upstream.body);
1390
- // Buffer-aware UTF-8 scrubber. Naive `Buffer.from(chunk).toString('utf-8')`
1391
- // corrupts multi-byte codepoints whenever a sequence straddles a chunk
1392
- // boundary — Node replaces the partial bytes with U+FFFD, which is fatal
1393
- // for any non-ASCII model output (CJK, emoji, even some accented Latin).
1394
- // `StringDecoder` keeps the trailing partial bytes in a private buffer
1395
- // and prepends them to the next chunk, guaranteeing valid UTF-8 every
1396
- // call. The flush() in `flush(cb)` releases any tail bytes (replacing
1397
- // truly invalid trailing bytes with U+FFFD, same as before but only at
1398
- // EOF where it can't span a real codepoint).
1399
- const utf8Decoder = new StringDecoder('utf8');
1400
- const scrubChunk = new Transform({
1401
- transform(chunk, _enc, cb) {
1402
- if (!isText || !secretProxy.hasSecrets()) {
1403
- cb(null, chunk);
1404
- return;
1405
- }
1406
- try {
1407
- const decoded = utf8Decoder.write(chunk);
1408
- if (decoded.length === 0) {
1409
- // All bytes were buffered as a partial codepoint — no output yet.
1410
- cb(null, Buffer.alloc(0));
1411
- return;
1412
- }
1413
- const scrubbed = secretProxy.scrubResponse(decoded);
1414
- cb(null, Buffer.from(scrubbed, 'utf-8'));
1415
- }
1416
- catch (err) {
1417
- cb(err);
1418
- }
1419
- },
1420
- flush(cb) {
1421
- if (!isText || !secretProxy.hasSecrets()) {
1422
- cb();
1423
- return;
1424
- }
1425
- try {
1426
- const tail = utf8Decoder.end();
1427
- if (tail.length === 0) {
1428
- cb();
1429
- return;
1430
- }
1431
- const scrubbed = secretProxy.scrubResponse(tail);
1432
- cb(null, Buffer.from(scrubbed, 'utf-8'));
1433
- }
1434
- catch (err) {
1435
- cb(err);
1436
- }
1437
- },
1438
- });
1439
- const cleanup = () => {
1440
- if (onClientClose) {
1441
- res.off('close', onClientClose);
1442
- onClientClose = null;
1443
- }
1444
- };
1445
- upstreamStream.on('error', (err) => {
1446
- cleanup();
1447
- if (!res.headersSent) {
1448
- res.setHeader('X-Proxy-Error', '1');
1449
- res
1450
- .status(502)
1451
- .json({ error: `Proxy stream failed: ${err instanceof Error ? err.message : err}` });
1452
- }
1453
- else {
1454
- res.destroy(err);
1455
- }
1456
- });
1457
- // Belt-and-braces cleanup: 'finish' fires once the response is fully
1458
- // flushed; 'close' fires regardless of how the response ended (success,
1459
- // abort, or pipe error). Either way we want the abort listener gone.
1460
- res.on('finish', cleanup);
1461
- res.on('close', cleanup);
1462
- upstreamStream.pipe(scrubChunk).pipe(res);
1463
- }
1464
- catch (err) {
1465
- // Best-effort cleanup so an early failure (e.g. fetch threw) doesn't
1466
- // leave the close listener attached to the response object.
1467
- if (onClientClose) {
1468
- res.off('close', onClientClose);
1469
- onClientClose = null;
1470
- }
1471
- const message = err instanceof Error ? err.message : String(err);
1472
- res.setHeader('X-Proxy-Error', '1');
1473
- res.status(502).json({ error: `Proxy fetch failed: ${message}` });
1474
- }
1475
- });
1006
+ // Fetch proxy — forwards cross-origin requests from the browser to bypass CORS,
1007
+ // injecting/unmasking secrets and streaming the response with a UTF-8-safe scrub.
1008
+ registerFetchProxyRoute(app, { secretProxy });
1476
1009
  // Create the HTTP server BEFORE Vite so we can register our upgrade handler first
1477
1010
  const server = createServer(app);
1478
- if (DEV_MODE && !RUNTIME_FLAGS.hosted) {
1479
- // Dev mode: use Vite's dev server as middleware for HMR
1480
- const { createServer: createViteServer } = await import('vite');
1481
- const webappIndexHtml = resolve(process.cwd(), 'packages/webapp/index.html');
1482
- const vite = await createViteServer({
1483
- configFile: resolve(process.cwd(), 'packages/webapp/vite.config.ts'),
1484
- server: {
1485
- middlewareMode: true,
1486
- hmr: {
1487
- server, // Share the HTTP server — our upgrade handler routes /cdp and /licks-ws separately
1488
- path: '/__vite_hmr', // Dedicated path avoids conflicts with /cdp upgrade handler
1489
- },
1490
- },
1491
- appType: 'custom', // We handle index.html serving ourselves via the handler below
1492
- root: process.cwd(),
1493
- });
1494
- app.use(vite.middlewares);
1495
- app.use(async (req, res, next) => {
1496
- if (req.method !== 'GET' ||
1497
- !req.headers.accept?.includes('text/html') ||
1498
- req.path.includes('.')) {
1499
- next();
1500
- return;
1501
- }
1502
- try {
1503
- const template = readFileSync(webappIndexHtml, 'utf-8');
1504
- const html = await vite.transformIndexHtml(req.originalUrl, template);
1505
- res.status(200).setHeader('Content-Type', 'text/html');
1506
- res.end(html);
1507
- }
1508
- catch (err) {
1509
- if (err instanceof Error) {
1510
- vite.ssrFixStacktrace(err);
1511
- }
1512
- next(err);
1513
- }
1514
- });
1515
- console.log(`Vite dev server middleware attached (HMR on ${SERVE_ORIGIN}/__vite_hmr)`);
1516
- }
1517
- else {
1518
- // Production mode: serve built static files
1519
- const uiDir = resolve(Dirname, '..', 'ui');
1520
- app.use(express.static(uiDir, {
1521
- setHeaders: (res, path) => {
1522
- // Default Cache-Control for anything not classified below:
1523
- // HTML, manifest, sprinkle-sandbox.html, publicDir fonts/logos,
1524
- // favicon, etc. None of these are content-hashed and they
1525
- // reference hashed asset URLs that change on rebuild. If the
1526
- // browser serves a stale `index.html` out of its heuristic
1527
- // cache, the referenced `/assets/*` chunks 404 after an update
1528
- // — the user sees
1529
- // "Failed to fetch dynamically imported module: …/assets/<old-hash>.js"
1530
- // on every cone bootstrap until they hard-refresh.
1531
- // `no-cache` forces a conditional revalidation on every load
1532
- // (cheap — `serve-static`'s default ETag yields a 304 when
1533
- // unchanged) so the tab picks up a freshly-built `index.html`
1534
- // after `npm run build`.
1535
- //
1536
- // The single `setHeader` at the end is intentional: each
1537
- // branch overrides the default by assigning to `cacheControl`.
1538
- // To add a fourth bucket, add an `else if` ABOVE the final
1539
- // assignment — never a separate `setHeader` after, or the
1540
- // catch-all silently wins.
1541
- let cacheControl = 'no-cache';
1542
- if (path.endsWith('llm-proxy-sw.js') || path.endsWith('preview-sw.js')) {
1543
- // Service workers need `Service-Worker-Allowed: /` for the
1544
- // root-scoped registration `llm-proxy-sw.js` does (the
1545
- // `preview-sw.js` SW registers at scope `/preview/`, which
1546
- // is narrower than `/` so the broader allowance is harmless).
1547
- //
1548
- // `no-store`, not `no-cache`: the browser only re-checks
1549
- // the SW script on navigation/registration, so the safest
1550
- // signal is "always pull the latest bytes." A stale SW
1551
- // pinned in cache would intercept fetch / dispatch
1552
- // `preview/*` with outdated logic (e.g. an outdated
1553
- // forbidden-header encoding scheme that no longer matches
1554
- // the server-side restoration in `index.ts`, or a stale
1555
- // `preview-sw` VFS handler) — that's a worse failure mode
1556
- // than the `no-cache` revalidation cost.
1557
- res.setHeader('Service-Worker-Allowed', '/');
1558
- cacheControl = 'no-store';
1559
- }
1560
- else if (path.includes(`${sep}assets${sep}`)) {
1561
- // Vite emits content-hashed filenames into `/assets/` —
1562
- // the hash changes when content changes, so the file at a
1563
- // given URL is byte-for-byte immutable. Cache forever to
1564
- // avoid revalidation round-trips. The `path` parameter is
1565
- // a filesystem path (uses `sep` on Windows, `/` elsewhere),
1566
- // hence the platform-aware match.
1567
- cacheControl = 'public, max-age=31536000, immutable';
1568
- }
1569
- res.setHeader('Cache-Control', cacheControl);
1570
- },
1571
- }));
1572
- // SPA fallback — serve index.html for all non-file routes. Same
1573
- // `no-cache` reasoning as above: the served `index.html` carries
1574
- // references to the current asset hashes, and stale-cached HTML
1575
- // is the canonical post-update breakage.
1576
- app.get('/{*path}', (_req, res) => {
1577
- res.setHeader('Cache-Control', 'no-cache');
1578
- res.sendFile(join(uiDir, 'index.html'));
1579
- });
1580
- }
1581
- // 4. CDP WebSocket proxy at /cdp
1582
- // Use noServer mode so Vite's dev middleware doesn't intercept the
1583
- // upgrade. Keep the default per-message payload cap on this socket —
1584
- // the oversized-message feedback loop we have to defend against
1585
- // (see the chromeWs constructor below for the full writeup) is
1586
- // purely Chrome-to-proxy, never client-to-proxy, so raising the
1587
- // cap here would only widen the DoS surface for anything on
1588
- // localhost that can reach ws://127.0.0.1:PORT/cdp.
1589
- const wss = new WebSocketServer({ noServer: true });
1590
- server.on('upgrade', (request, socket, head) => {
1591
- const { pathname } = new URL(request.url, `http://${request.headers.host}`);
1592
- if (pathname === '/cdp') {
1593
- wss.handleUpgrade(request, socket, head, (ws) => {
1594
- wss.emit('connection', ws, request);
1595
- });
1596
- }
1597
- else if (pathname === '/licks-ws') {
1598
- lickWss.handleUpgrade(request, socket, head, (ws) => {
1599
- lickWss.emit('connection', ws, request);
1600
- });
1601
- }
1602
- // For other paths, do nothing — let Vite handle HMR upgrades
1011
+ await attachUiServing(app, server, {
1012
+ devMode: DEV_MODE,
1013
+ hosted: RUNTIME_FLAGS.hosted,
1014
+ serveOrigin: SERVE_ORIGIN,
1015
+ uiDir: resolve(Dirname, '..', 'ui'),
1603
1016
  });
1604
- // ---------------------------------------------------------------------------
1605
- // Shared CDP proxy state — Chrome's browser-level debugger URL only accepts
1606
- // ONE concurrent WebSocket connection. We keep a single chromeWs and swap
1607
- // out the active client when a new one connects.
1608
- // ---------------------------------------------------------------------------
1609
- let cdpUrl = null;
1610
- let chromeWs = null;
1611
- let activeClientWs = null;
1612
- let messageBuffer = null;
1613
- const cdpDedup = new CliLogDedup();
1614
- // Tracks per-session current URL by sniffing Chrome→Client events; feeds
1615
- // the Client→Chrome unmask gate so per-frame unmasking is scoped to
1616
- // the target tab's actual hostname (fail-closed when unknown).
1617
- const cdpSessionUrls = createCdpSessionUrlTracker();
1618
- // Ensure everything is cleaned up when CLI exits
1619
- const gracefulShutdown = async () => {
1620
- if (shuttingDown)
1621
- return;
1622
- shuttingDown = true;
1623
- console.log('\nShutting down...');
1624
- fileLogger.close();
1625
- overlayInjector?.stop();
1626
- overlayInjector = null;
1627
- // Close the shared Chrome WebSocket and all client connections
1628
- if (chromeWs) {
1629
- try {
1630
- chromeWs.close();
1631
- }
1632
- catch {
1633
- /* ignore */
1634
- }
1635
- chromeWs = null;
1636
- }
1637
- if (activeClientWs) {
1638
- try {
1639
- activeClientWs.close();
1640
- }
1641
- catch {
1642
- /* ignore */
1643
- }
1644
- activeClientWs = null;
1645
- }
1646
- for (const client of wss.clients) {
1647
- client.close();
1648
- }
1649
- wss.close();
1650
- // Stop accepting new HTTP connections
1651
- server.close();
1652
- if (launchedBrowserProcess) {
1653
- let browserExited = false;
1654
- launchedBrowserProcess.on('exit', () => {
1655
- browserExited = true;
1656
- });
1657
- try {
1658
- const res = await fetch(`http://127.0.0.1:${CDP_PORT}/json/version`);
1659
- const json = (await res.json());
1660
- const browserWs = new WebSocket(json.webSocketDebuggerUrl);
1661
- await new Promise((resolve, reject) => {
1662
- browserWs.on('open', () => {
1663
- browserWs.send(JSON.stringify({ id: 1, method: 'Browser.close' }));
1664
- resolve();
1665
- });
1666
- browserWs.on('error', reject);
1667
- });
1668
- }
1669
- catch {
1670
- // CDP not available — the launched browser may still be starting up; fall through to kill.
1671
- }
1672
- const deadline = Date.now() + 3000;
1673
- while (!browserExited && Date.now() < deadline) {
1674
- await new Promise((r) => setTimeout(r, 100));
1675
- }
1676
- if (!browserExited) {
1677
- try {
1678
- launchedBrowserProcess.kill('SIGKILL');
1679
- }
1680
- catch {
1681
- /* ignore */
1682
- }
1683
- }
1684
- console.log(`${launchedBrowserLabel} closed`);
1685
- }
1686
- process.exit(0);
1017
+ // 4. CDP WebSocket proxy at /cdp — noServer mode so Vite's dev middleware
1018
+ // doesn't intercept the upgrade; the per-message cap stays default (the
1019
+ // feedback-loop defense is Chrome→proxy only, never client→proxy).
1020
+ const wss = new WebSocketServer({ noServer: true });
1021
+ attachCdpUpgradeRouting(server, wss, lickWss);
1022
+ const cdpCtx = {
1023
+ wss,
1024
+ secretProxy,
1025
+ cdpDedup: new CliLogDedup(),
1026
+ cdpSessionUrls: createCdpSessionUrlTracker(),
1687
1027
  };
1028
+ const gracefulShutdown = createGracefulShutdown(state, {
1029
+ fileLogger,
1030
+ wss,
1031
+ server,
1032
+ cdpPort: CDP_PORT,
1033
+ });
1688
1034
  process.on('SIGINT', () => {
1689
1035
  gracefulShutdown();
1690
1036
  });
@@ -1692,279 +1038,28 @@ async function main() {
1692
1038
  gracefulShutdown();
1693
1039
  });
1694
1040
  process.on('exit', () => {
1695
- // Synchronous last-resort cleanup — kill the launched browser if it is still running.
1696
- if (!shuttingDown && launchedBrowserProcess) {
1041
+ // Synchronous last-resort cleanup — kill the launched browser if still running.
1042
+ const browser = state.launchedBrowserProcess;
1043
+ if (!state.shuttingDown && browser) {
1697
1044
  try {
1698
- launchedBrowserProcess.kill();
1045
+ browser.kill();
1699
1046
  }
1700
1047
  catch {
1701
1048
  /* ignore */
1702
1049
  }
1703
1050
  }
1704
1051
  });
1705
- // Apply the Client→Chrome unmask gate to a buffered frame on flush.
1706
- // Defined here so both buffer-drain sites (ensureChromeConnection
1707
- // already-open path + chromeWs 'open' handler) share one
1708
- // implementation; falls back to the original bytes on any error.
1709
- const flushClientFrame = (target, raw) => {
1710
- const original = String(raw);
1711
- const { output } = applyCdpUnmask(original, {
1712
- tracker: cdpSessionUrls,
1713
- pipeline: secretProxy.rawPipeline,
1714
- });
1715
- target.send(output);
1716
- };
1717
- function ensureChromeConnection(url) {
1718
- return new Promise((resolve, reject) => {
1719
- if (chromeWs && chromeWs.readyState === WebSocket.OPEN) {
1720
- // Already connected — flush any buffered messages and go direct
1721
- if (messageBuffer) {
1722
- for (const msg of messageBuffer) {
1723
- flushClientFrame(chromeWs, msg);
1724
- }
1725
- messageBuffer = null;
1726
- }
1727
- resolve();
1728
- return;
1729
- }
1730
- // Clean up old connection
1731
- if (chromeWs) {
1732
- try {
1733
- chromeWs.close();
1734
- }
1735
- catch {
1736
- /* ignore */
1737
- }
1738
- }
1739
- messageBuffer = [];
1740
- // Disable the ws library's per-message size cap (default 100 MiB).
1741
- // The slicc UI runs INSIDE the Chrome instance it's debugging, so
1742
- // Chrome's Network domain reports every CDP frame — including the
1743
- // event frames themselves — back to us as `Network.webSocketFrame*`
1744
- // messages that each embed the prior frame's payload. That produces
1745
- // an exponential feedback loop which, left unchecked, trips the
1746
- // default 100 MiB cap and closes the Chrome WebSocket (code 1006).
1747
- // Without the cap the loop is still bounded by Chrome's own frame
1748
- // limits, but the proxy no longer dies and later CDP calls like
1749
- // `Target.getTargets` keep working instead of being DROPPED.
1750
- chromeWs = new WebSocket(url, { maxPayload: 0 });
1751
- chromeWs.on('open', () => {
1752
- console.log('[cdp-proxy] chromeWs open');
1753
- // Flush buffered messages
1754
- if (messageBuffer) {
1755
- for (const msg of messageBuffer) {
1756
- flushClientFrame(chromeWs, msg);
1757
- }
1758
- messageBuffer = null;
1759
- }
1760
- resolve();
1761
- });
1762
- // The slicc UI runs inside the Chrome instance it's debugging, so
1763
- // Chrome's Network domain reports every CDP frame back through the
1764
- // same socket as `Network.webSocketFrameReceived` /
1765
- // `Network.webSocketFrameSent` events whose `payloadData` embeds
1766
- // the prior frame's bytes — a self-amplifying feedback loop that,
1767
- // left alone, drives per-frame sizes past V8's ~512 MiB string
1768
- // limit and crashes node-server with `ERR_STRING_TOO_LONG`. It
1769
- // also starves the browser's own debugger UI (the classic
1770
- // "debugger paused in another window" freeze) because the CDP
1771
- // event stream fills up with self-referential noise instead of
1772
- // the events DevTools actually needs.
1773
- //
1774
- // Peek at the raw bytes and skip the runaway event types once
1775
- // they exceed a small sniffing threshold. Legitimate CDP payloads
1776
- // we care about (screenshots, DOM snapshots, large tool results)
1777
- // are never `Network.webSocketFrame*` messages, so filtering by
1778
- // method is far safer than a blanket size cap that would also
1779
- // drop genuine large events.
1780
- const CDP_PROXY_INSPECT_BYTES = 256 * 1024;
1781
- const CDP_PROXY_HARD_FRAME_CAP = 64 * 1024 * 1024;
1782
- const loopEventPrefixes = [
1783
- '{"method":"Network.webSocketFrameReceived"',
1784
- '{"method":"Network.webSocketFrameSent"',
1785
- ];
1786
- /**
1787
- * Normalise the `ws` library's polymorphic message payload into a
1788
- * single Buffer we can safely peek at and forward. Without this,
1789
- * a later `String(data)` would coerce an `ArrayBuffer` to
1790
- * `"[object ArrayBuffer]"` and a `Buffer[]` to comma-joined
1791
- * stringified fragments, corrupting the CDP frame.
1792
- */
1793
- const toBuffer = (data) => {
1794
- if (Buffer.isBuffer(data))
1795
- return data;
1796
- if (data instanceof ArrayBuffer)
1797
- return Buffer.from(data);
1798
- if (Array.isArray(data))
1799
- return Buffer.concat(data);
1800
- // Rare fallback — string frames in text mode. Keep bytes faithful.
1801
- return Buffer.from(String(data));
1802
- };
1803
- chromeWs.on('message', (data) => {
1804
- const buf = toBuffer(data);
1805
- const byteLen = buf.length;
1806
- // Peek at the first 256 KiB only — enough to identify the event
1807
- // type cheaply without stringifying the whole runaway buffer.
1808
- const head = buf.subarray(0, CDP_PROXY_INSPECT_BYTES).toString();
1809
- if (loopEventPrefixes.some((p) => head.startsWith(p))) {
1810
- const msg = `[cdp-proxy] Dropping Chrome feedback-loop event (${byteLen} bytes, ${head.slice(1, 60)}…)`;
1811
- if (cdpDedup.shouldLog(msg))
1812
- console.debug(msg);
1813
- return;
1814
- }
1815
- // Hard safety net — still refuse anything that would blow past
1816
- // V8's string length limit (buf.toString throws ERR_STRING_TOO_LONG
1817
- // for any frame larger than ~512 MiB).
1818
- if (byteLen > CDP_PROXY_HARD_FRAME_CAP) {
1819
- const msg = `[cdp-proxy] Dropping oversized Chrome→Client frame (${byteLen} bytes)`;
1820
- if (cdpDedup.shouldLog(msg))
1821
- console.debug(msg);
1822
- return;
1823
- }
1824
- const str = buf.toString();
1825
- const preview = str.slice(0, 200);
1826
- const msg = `[cdp-proxy] Chrome→Client: ${preview}`;
1827
- if (cdpDedup.shouldLog(msg))
1828
- console.debug(msg);
1829
- // Sniff Target.attachedToTarget / targetInfoChanged / Page.frameNavigated
1830
- // so the Client→Chrome unmask gate can resolve per-session hostnames.
1831
- cdpSessionUrls.observeChromeToClient(str);
1832
- if (activeClientWs && activeClientWs.readyState === WebSocket.OPEN) {
1833
- activeClientWs.send(str);
1834
- }
1835
- });
1836
- chromeWs.on('close', (code, reason) => {
1837
- console.log(`[cdp-proxy] Chrome WS closed. code=${code}, reason=${String(reason)}`);
1838
- chromeWs = null;
1839
- });
1840
- chromeWs.on('error', (err) => {
1841
- console.log(`[cdp-proxy] Chrome WS error: ${err}`);
1842
- chromeWs = null;
1843
- reject(err);
1844
- });
1845
- });
1846
- }
1847
- wss.on('connection', async (clientWs) => {
1848
- try {
1849
- // Close previous client connection — only one client active at a time
1850
- if (activeClientWs && activeClientWs.readyState === WebSocket.OPEN) {
1851
- console.log('[cdp-proxy] Closing previous client connection');
1852
- activeClientWs.close();
1853
- }
1854
- activeClientWs = clientWs;
1855
- console.log('[cdp-proxy] New client connected');
1856
- // Initialize buffer BEFORE any await so messages arriving during
1857
- // waitForCDP or ensureChromeConnection are captured, not dropped.
1858
- if (messageBuffer === null) {
1859
- messageBuffer = [];
1860
- }
1861
- // Register ALL handlers BEFORE any async work so no messages are lost
1862
- clientWs.on('message', (data) => {
1863
- const original = String(data);
1864
- const preview = original.slice(0, 200);
1865
- if (chromeWs && chromeWs.readyState === WebSocket.OPEN && messageBuffer === null) {
1866
- const msg = `[cdp-proxy] Client→Chrome: ${preview}`;
1867
- if (cdpDedup.shouldLog(msg))
1868
- console.debug(msg);
1869
- const { output } = applyCdpUnmask(original, {
1870
- tracker: cdpSessionUrls,
1871
- pipeline: secretProxy.rawPipeline,
1872
- });
1873
- chromeWs.send(output);
1874
- }
1875
- else if (messageBuffer !== null) {
1876
- // Buffer the ORIGINAL bytes; unmask runs on flush so the
1877
- // hostname tracker reflects the state at send time.
1878
- messageBuffer.push(data);
1879
- const msg = `[cdp-proxy] Client→Chrome (buffered): ${preview}`;
1880
- if (cdpDedup.shouldLog(msg))
1881
- console.debug(msg);
1882
- }
1883
- else {
1884
- // Chrome not connected and no buffer — this shouldn't happen but log it
1885
- console.log(`[cdp-proxy] Client→Chrome (DROPPED — no connection): ${preview}`);
1886
- }
1887
- });
1888
- clientWs.on('close', () => {
1889
- console.log('[cdp-proxy] Client disconnected');
1890
- if (activeClientWs === clientWs) {
1891
- activeClientWs = null;
1892
- }
1893
- // Don't close chromeWs — keep it alive for the next client
1894
- });
1895
- clientWs.on('error', (err) => {
1896
- console.log(`[cdp-proxy] Client WS error: ${err}`);
1897
- if (activeClientWs === clientWs) {
1898
- activeClientWs = null;
1899
- }
1900
- });
1901
- // NOW do async work — messages arriving during these awaits are buffered
1902
- if (!cdpUrl) {
1903
- cdpUrl = await waitForCDP(CDP_PORT);
1904
- console.log(`[cdp-proxy] CDP available at: ${cdpUrl}`);
1905
- }
1906
- await ensureChromeConnection(cdpUrl);
1907
- }
1908
- catch (err) {
1909
- console.error('[cdp-proxy] Connection error:', err);
1910
- clientWs.close();
1911
- }
1052
+ wss.on('connection', (clientWs) => {
1053
+ void handleCdpClient(state, clientWs, cdpCtx, CDP_PORT);
1912
1054
  });
1913
- server.listen(SERVE_PORT, '127.0.0.1', () => {
1914
- console.log(`Serving UI at ${SERVE_ORIGIN}`);
1915
- console.log(`CDP proxy at ws://localhost:${SERVE_PORT}/cdp`);
1916
- fileLogger.log('info', 'CLI server started', {
1917
- port: SERVE_PORT,
1918
- cdpPort: CDP_PORT,
1919
- devMode: DEV_MODE,
1920
- electronMode: ELECTRON_MODE,
1921
- });
1922
- // Pre-connect to Chrome's CDP so the proxy is warm when the first client connects.
1923
- // Without this, the first browser automation command has to wait for CDP discovery + WS handshake.
1924
- (async () => {
1925
- try {
1926
- cdpUrl = await waitForCDP(CDP_PORT);
1927
- console.log(`[cdp-proxy] Pre-connected: CDP available at ${cdpUrl}`);
1928
- await ensureChromeConnection(cdpUrl);
1929
- console.log('[cdp-proxy] Chrome WebSocket ready (pre-warmed)');
1930
- // Register leader-restart endpoint now that CDP is ready (hosted mode only)
1931
- if (RUNTIME_FLAGS.hosted) {
1932
- registerLeaderRestartEndpoint(app, {
1933
- cdp: createHttpCdp(CDP_PORT),
1934
- localUrlPrefix: `http://localhost:${SERVE_PORT}/`,
1935
- });
1936
- console.log('[hosted] /api/leader-restart endpoint registered');
1937
- }
1938
- }
1939
- catch (err) {
1940
- console.log('[cdp-proxy] Pre-connect failed (will retry on first client):', err);
1941
- }
1942
- })();
1943
- if (ELECTRON_MODE) {
1944
- void (async () => {
1945
- try {
1946
- overlayInjector = await ElectronOverlayInjector.create({
1947
- cdpPort: CDP_PORT,
1948
- servePort: SERVE_PORT,
1949
- dev: DEV_MODE,
1950
- projectRoot: PROJECT_ROOT,
1951
- });
1952
- await overlayInjector.start();
1953
- console.log('[electron-float] Overlay injector is watching Electron page targets');
1954
- }
1955
- catch (error) {
1956
- const message = error instanceof Error ? error.message : String(error);
1957
- console.error('[electron-float] Failed to start overlay injector:', message);
1958
- }
1959
- })();
1960
- }
1961
- if (!ELECTRON_MODE) {
1962
- setTimeout(() => {
1963
- attachConsoleForwarder(CDP_PORT, String(SERVE_PORT)).catch((err) => {
1964
- console.error('[page] Console forwarder error:', err);
1965
- });
1966
- }, 2500);
1967
- }
1055
+ startCdpServer(state, {
1056
+ app,
1057
+ server,
1058
+ ctx: cdpCtx,
1059
+ fileLogger,
1060
+ servePort: SERVE_PORT,
1061
+ serveOrigin: SERVE_ORIGIN,
1062
+ cdpPort: CDP_PORT,
1968
1063
  });
1969
1064
  }
1970
1065
  // ---------------------------------------------------------------------------