sliccy 4.22.1 → 4.24.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 (80) hide show
  1. package/dist/node-server/bridge-security.d.ts +135 -0
  2. package/dist/node-server/bridge-security.js +245 -0
  3. package/dist/node-server/index.js +290 -48
  4. package/dist/node-server/launch-url.d.ts +7 -0
  5. package/dist/node-server/launch-url.js +12 -3
  6. package/dist/ui/assets/{account-store-ibgiPI6V.js → account-store-BXT0RMpF.js} +2 -2
  7. package/dist/ui/assets/{account-store-4ECV8DGT.js → account-store-CcqPXFk-.js} +2 -2
  8. package/dist/ui/assets/{adobe--TTRq2If.js → adobe-Dvkc5_oM.js} +1 -1
  9. package/dist/ui/assets/adobe-Dyvxqy9s.js +1 -0
  10. package/dist/ui/assets/{agent-message-to-chat-biPexDDJ.js → agent-message-to-chat-Cihra740.js} +1 -1
  11. package/dist/ui/assets/{apps-BOvmrPiX.js → apps-CPyXd9Mk.js} +1 -1
  12. package/dist/ui/assets/{azure-openai-Bf3TmJ0y.js → azure-openai-BEec8FHP.js} +1 -1
  13. package/dist/ui/assets/{azure-openai-DjY0l719.js → azure-openai-lMn0o-9C.js} +1 -1
  14. package/dist/ui/assets/{bsh-watchdog-BVbry18Y.js → bsh-watchdog-Dgr_OA1N.js} +1 -1
  15. package/dist/ui/assets/{cdp-Cbi5Katl.js → cdp-D4JkCTey.js} +3 -3
  16. package/dist/ui/assets/{connect-surface-DSA8Cz3z.js → connect-surface-BzhaxP0L.js} +1 -1
  17. package/dist/ui/assets/dip-CIhLisNq.js +1 -0
  18. package/dist/ui/assets/{dist-BElcUj_V.js → dist-ChTQy8at.js} +1 -1
  19. package/dist/ui/assets/{dist-DP3_V4hR.js → dist-DIjck1q7.js} +1 -1
  20. package/dist/ui/assets/{es-DcI8bAtA.js → es-B8GxLLfB.js} +1 -1
  21. package/dist/ui/assets/{fs-DKIJdRIl.js → fs-ATai1P5Q.js} +2 -2
  22. package/dist/ui/assets/{fs-BQEkHWJV.js → fs-xQUivn-8.js} +1 -1
  23. package/dist/ui/assets/{github-BQBIaABC.js → github-C6vLSHER.js} +2 -2
  24. package/dist/ui/assets/{github-0YLv6nLk.js → github-CUVBUETF.js} +1 -1
  25. package/dist/ui/assets/{github-copilot-DwyTNkwX.js → github-copilot-BqMS3_4S.js} +1 -1
  26. package/dist/ui/assets/{github-copilot-Co_dxCXe.js → github-copilot-ggchxbZd.js} +1 -1
  27. package/dist/ui/assets/{hear-DyUkFgOW.js → hear-Bm8tL0z2.js} +1 -1
  28. package/dist/ui/assets/{kernel-worker-DvmZjl24.js → kernel-worker-CqATTm08.js} +1026 -1026
  29. package/dist/ui/assets/{kokoro-engine-BnI4fL_p.js → kokoro-engine-BzgKX1A1.js} +1 -1
  30. package/dist/ui/assets/{lick-ws-bridge-B0_yyU80.js → lick-ws-bridge-DFQu2sM8.js} +1 -1
  31. package/dist/ui/assets/{local-llm-DnPuhfxf.js → local-llm-D09l_nRy.js} +1 -1
  32. package/dist/ui/assets/{main-cherry-Bap_NTtV.js → main-cherry-B1mI8ArD.js} +1 -1
  33. package/dist/ui/assets/{main-DRGnmneg.js → main-sWMjBmPA.js} +3 -3
  34. package/dist/ui/assets/{mount-ByUTNmzv.js → mount-D7RXkT90.js} +1 -1
  35. package/dist/ui/assets/{mount-CguScujU.js → mount-sEjqfa02.js} +2 -2
  36. package/dist/ui/assets/{new-session-CwnHjnd0.js → new-session-B0xwp5hO.js} +2 -2
  37. package/dist/ui/assets/{oauth-bootstrap-Py4nRtkM.js → oauth-bootstrap-Uz3oksBR.js} +2 -2
  38. package/dist/ui/assets/{openai-codex-B2qaIik0.js → openai-codex-BMr_sYj2.js} +1 -1
  39. package/dist/ui/assets/{openai-codex-0L44_lSb.js → openai-codex-d2uBt8CN.js} +1 -1
  40. package/dist/ui/assets/{panel-rpc-handlers-D7cbHQYp.js → panel-rpc-handlers-1U8M-tQU.js} +1 -1
  41. package/dist/ui/assets/{provider-BI7Y3RBy.js → provider-B_3MhsjL.js} +2 -2
  42. package/dist/ui/assets/{provider-riIj2TBx.js → provider-BbwD43pe.js} +1 -1
  43. package/dist/ui/assets/provider-store-access-Cop2VuvO.js +1 -0
  44. package/dist/ui/assets/provider-store-access-_8NADMDP.js +1 -0
  45. package/dist/ui/assets/{providers-fz0Sm6ju.js → providers-DRQ-n4v0.js} +1 -1
  46. package/dist/ui/assets/proxied-fetch-3ooUUAsF.js +1 -0
  47. package/dist/ui/assets/{quick-llm-B9FBP8Bs.js → quick-llm-CHWi-jSD.js} +1 -1
  48. package/dist/ui/assets/session-freezer-mzV5cASX.js +1 -0
  49. package/dist/ui/assets/setup-sudo-DSt9eI8G.js +1 -0
  50. package/dist/ui/assets/{speak-DVyJlZaU.js → speak-Cr05k3rj.js} +1 -1
  51. package/dist/ui/assets/{sprinkle-manager-O6TFBdwJ.js → sprinkle-manager-B7xN0WFf.js} +1 -1
  52. package/dist/ui/assets/{store-BsGfQ4o5.js → store-CfuBHukr.js} +1 -1
  53. package/dist/ui/assets/{sudo-C-n2ZT92.js → sudo-CnG9mh3w.js} +1 -1
  54. package/dist/ui/assets/{transformers-env-Dpnex-yX.js → transformers-env-BEM0HkoC.js} +1 -1
  55. package/dist/ui/assets/{tray-leave-runtime-MYPnSSR_.js → tray-leave-runtime-CD-xjbXc.js} +1 -1
  56. package/dist/ui/assets/{upgrade-detection-uSF5N6Ew.js → upgrade-detection-CwNs_M6E.js} +1 -1
  57. package/dist/ui/assets/{wc-attach--tUlEIYg.js → wc-attach-D7JTvNE3.js} +2 -2
  58. package/dist/ui/assets/{wc-detached-D6RhlJwc.js → wc-detached-DfgIMcuk.js} +1 -1
  59. package/dist/ui/assets/{wc-extension-DeqnwDO0.js → wc-extension-BNpR2nAA.js} +2 -2
  60. package/dist/ui/assets/{wc-live-DIowT4eF.js → wc-live-cbtCpYYl.js} +12 -12
  61. package/dist/ui/assets/{wc-nav-DI67gu77.js → wc-nav-BVFjm1zp.js} +2 -2
  62. package/dist/ui/assets/{wc-onboarding-BIG01YkV.js → wc-onboarding-C-b3gfT3.js} +2 -2
  63. package/dist/ui/assets/{wc-placeholder-Nry9IKS2.js → wc-placeholder-CLNwZTGs.js} +3 -3
  64. package/dist/ui/assets/{wc-settings-C8nodqL5.js → wc-settings-BRCPA5ys.js} +2 -2
  65. package/dist/ui/assets/{wc-shell-Ng-YAcjd.js → wc-shell-CeWID7O0.js} +3 -3
  66. package/dist/ui/assets/{wc-sprinkles-BWzmaQEm.js → wc-sprinkles-DYTck3a-.js} +2 -2
  67. package/dist/ui/assets/{wc-tray-CbkvHS3n.js → wc-tray-ByE1fpIQ.js} +3 -3
  68. package/dist/ui/assets/{xai-grok-uc4Un-5T.js → xai-grok-BcQs9t1L.js} +1 -1
  69. package/dist/ui/assets/{xai-grok-CZfi469b.js → xai-grok-Bx-LJDfy.js} +1 -1
  70. package/dist/ui/index.html +2 -2
  71. package/dist/ui/packages/webapp/index.html +2 -2
  72. package/package.json +1 -1
  73. package/dist/ui/assets/adobe-CQr1UAcb.js +0 -1
  74. package/dist/ui/assets/dip-6KkNyCHS.js +0 -1
  75. package/dist/ui/assets/provider-store-access-BHYTqSuW.js +0 -1
  76. package/dist/ui/assets/provider-store-access-BP2Nfrp_.js +0 -1
  77. package/dist/ui/assets/proxied-fetch-Dg114MVx.js +0 -1
  78. package/dist/ui/assets/proxy-error-9UvXMpzG.js +0 -1
  79. package/dist/ui/assets/session-freezer-BAK6UVLW.js +0 -1
  80. package/dist/ui/assets/setup-sudo-BMcUrWtK.js +0 -1
@@ -10,6 +10,7 @@ import { homedir } from 'os';
10
10
  import { basename, dirname, join, resolve } from 'path';
11
11
  import { fileURLToPath } from 'url';
12
12
  import { WebSocket, WebSocketServer } from 'ws';
13
+ import { BRIDGE_TOKEN_HEADER, buildCorsHeaders, buildPnaPreflightHeaders, isLoopbackBridgeOrigin, mintBridgeToken, selectBridgeSubprotocol, validateBridgeToken, validateBridgeUpgrade, } from './bridge-security.js';
13
14
  import { applyCdpUnmask } from './cdp-proxy/cdp-unmask.js';
14
15
  import { createCdpSessionUrlTracker } from './cdp-proxy/session-url-tracker.js';
15
16
  import { buildChromeLaunchArgs, clearStaleDevToolsActivePort, ensureQaProfileScaffold, findChromeExecutable, legacyChromeCandidates, migrateLegacyDefaultChromeProfile, planChromeSpawn, resolveChromeLaunchProfile, waitForCdpPort, } from './chrome-launch.js';
@@ -65,6 +66,13 @@ const SERVE_ONLY = RUNTIME_FLAGS.serveOnly;
65
66
  const ELECTRON_MODE = RUNTIME_FLAGS.electron;
66
67
  const ELECTRON_APP = RUNTIME_FLAGS.electronApp;
67
68
  const KILL_EXISTING_ELECTRON_APP = RUNTIME_FLAGS.kill;
69
+ /**
70
+ * Standalone thin-bridge mode: production CLI with no bundled UI. The
71
+ * launched Chrome opens a sliccy.ai-hosted leader; node-server is a thin
72
+ * /cdp bridge + /api surface. Dev / electron / serve-only / hosted all
73
+ * keep the bundled UI path (a separate path B / hosted task covers those).
74
+ */
75
+ const THIN_BRIDGE_MODE = !DEV_MODE && !SERVE_ONLY && !ELECTRON_MODE && !RUNTIME_FLAGS.hosted;
68
76
  // ---------------------------------------------------------------------------
69
77
  // File logger — persistent log file in ~/.slicc/logs/
70
78
  // ---------------------------------------------------------------------------
@@ -300,6 +308,7 @@ function createServerState() {
300
308
  overlayInjector: null,
301
309
  shuttingDown: false,
302
310
  discoveredTrayJoinUrl: RUNTIME_FLAGS.joinUrl ?? null,
311
+ bridgeToken: THIN_BRIDGE_MODE ? mintBridgeToken() : null,
303
312
  cdpUrl: null,
304
313
  chromeWs: null,
305
314
  activeClientWs: null,
@@ -465,15 +474,33 @@ async function launchElectronTarget(state) {
465
474
  throw error;
466
475
  }
467
476
  }
477
+ /**
478
+ * Resolve the leader origin Chrome should open in thin-bridge mode. Prefers
479
+ * an explicit `--lead <url>` / `WORKER_BASE_URL` so dev can point at staging;
480
+ * defaults to production sliccy.ai.
481
+ */
482
+ function resolveThinLeaderOrigin() {
483
+ const explicit = RUNTIME_FLAGS.leadWorkerBaseUrl ?? process.env['WORKER_BASE_URL'] ?? null;
484
+ if (explicit) {
485
+ return explicit.replace(/\/+$/, '');
486
+ }
487
+ return 'https://www.sliccy.ai';
488
+ }
468
489
  /** Build the Chrome launch URL, appending hosted-runtime + prompt query params. */
469
490
  function buildBrowserLaunchUrl(state) {
491
+ // Thin-bridge standalone: Chrome opens the hosted leader directly; the
492
+ // local node-server serves no UI at all. Bridge coordinates ride as
493
+ // query params so the leader can discover + authenticate /cdp.
494
+ const serveOriginForLaunch = THIN_BRIDGE_MODE && state.bridgeToken ? resolveThinLeaderOrigin() : state.serveOrigin;
470
495
  let url = resolveCliBrowserLaunchUrl({
471
- serveOrigin: state.serveOrigin,
496
+ serveOrigin: serveOriginForLaunch,
472
497
  lead: RUNTIME_FLAGS.lead,
473
498
  leadWorkerBaseUrl: RUNTIME_FLAGS.leadWorkerBaseUrl,
474
499
  envWorkerBaseUrl: process.env['WORKER_BASE_URL'] ?? null,
475
500
  join: RUNTIME_FLAGS.join,
476
501
  joinUrl: RUNTIME_FLAGS.joinUrl,
502
+ bridgeWsUrl: THIN_BRIDGE_MODE && state.bridgeToken ? `ws://localhost:${state.servePort}/cdp` : null,
503
+ bridgeToken: state.bridgeToken,
477
504
  });
478
505
  if (RUNTIME_FLAGS.hosted) {
479
506
  url += `${url.includes('?') ? '&' : '?'}runtime=hosted-leader`;
@@ -487,6 +514,11 @@ function buildBrowserLaunchUrl(state) {
487
514
  else if (RUNTIME_FLAGS.lead) {
488
515
  console.log(`Lead launch URL: ${url}`);
489
516
  }
517
+ else if (THIN_BRIDGE_MODE) {
518
+ // Print WITHOUT the bridgeToken — it's a session capability.
519
+ const sanitized = url.replace(/([?&])bridgeToken=[^&]+/, '$1bridgeToken=<redacted>');
520
+ console.log(`Thin-bridge launch URL: ${sanitized}`);
521
+ }
490
522
  return url;
491
523
  }
492
524
  function resolveChromeProfileOrExit(state) {
@@ -602,11 +634,56 @@ function closeWebSocketQuietly(ws) {
602
634
  /* ignore */
603
635
  }
604
636
  }
605
- /** Route `/cdp` and `/licks-ws` upgrades to their servers; leave others for Vite HMR. */
606
- function attachCdpUpgradeRouting(server, wss, lickWss) {
637
+ /**
638
+ * Reject a WebSocket upgrade before `handleUpgrade` runs. Writes a minimal
639
+ * HTTP/1.1 response and destroys the underlying socket. RFC 6455 allows 401
640
+ * / 403 here; we use 401 so a caller that retries with the correct
641
+ * subprotocol/origin doesn't trip permanent CORS-style caches.
642
+ */
643
+ function rejectUpgradeUnauthorized(socket, reason) {
644
+ try {
645
+ socket.write(`HTTP/1.1 401 Unauthorized\r\n` +
646
+ `Content-Type: text/plain\r\n` +
647
+ `Connection: close\r\n` +
648
+ `Content-Length: ${Buffer.byteLength(reason)}\r\n` +
649
+ `\r\n${reason}`);
650
+ }
651
+ catch {
652
+ /* socket may already be gone */
653
+ }
654
+ try {
655
+ socket.destroy();
656
+ }
657
+ catch {
658
+ /* ignore */
659
+ }
660
+ }
661
+ /**
662
+ * Route `/cdp` and `/licks-ws` upgrades to their servers; leave others for Vite HMR.
663
+ *
664
+ * When `bridgeToken !== null` (thin standalone mode) the `/cdp` upgrade is
665
+ * gated by `validateBridgeUpgrade`: bad origin or missing/wrong
666
+ * `Sec-WebSocket-Protocol` token = socket destroyed before
667
+ * `wss.emit('connection', ...)` ever fires. Legacy modes (dev / electron /
668
+ * serve-only / hosted) pass `null` to keep same-origin behavior unchanged.
669
+ * `/licks-ws` is loopback-only and stays ungated.
670
+ */
671
+ function attachCdpUpgradeRouting(server, wss, lickWss, bridgeToken) {
607
672
  server.on('upgrade', (request, socket, head) => {
608
673
  const { pathname } = new URL(request.url, `http://${request.headers.host}`);
609
674
  if (pathname === '/cdp') {
675
+ if (bridgeToken !== null) {
676
+ const gate = validateBridgeUpgrade({
677
+ origin: request.headers.origin,
678
+ subprotocolHeader: request.headers['sec-websocket-protocol'],
679
+ expectedToken: bridgeToken,
680
+ });
681
+ if (!gate.ok) {
682
+ console.warn(`[cdp-proxy] /cdp upgrade rejected: ${gate.reason}`);
683
+ rejectUpgradeUnauthorized(socket, gate.reason ?? 'rejected');
684
+ return;
685
+ }
686
+ }
610
687
  wss.handleUpgrade(request, socket, head, (ws) => {
611
688
  wss.emit('connection', ws, request);
612
689
  });
@@ -736,6 +813,27 @@ function forwardClientFrame(state, data, ctx) {
736
813
  console.log(`[cdp-proxy] Client→Chrome (DROPPED — no connection): ${preview}`);
737
814
  }
738
815
  }
816
+ /**
817
+ * Wait for `state.cdpPort` to be populated (i.e. `launchChromeTarget`'s
818
+ * `waitForCdpPort` has resolved). Used by clients that race ahead of the
819
+ * browser launch — primarily the thin-bridge case where `server.listen()`
820
+ * runs BEFORE `launchBrowser` so the hosted leader can connect to /cdp
821
+ * the moment Chrome opens it, even before Chrome's own CDP port is known.
822
+ */
823
+ async function waitForServerCdpPort(state, timeoutMs = 30_000) {
824
+ if (state.cdpPort > 0)
825
+ return state.cdpPort;
826
+ const startedAt = Date.now();
827
+ while (state.cdpPort === 0 && Date.now() - startedAt < timeoutMs) {
828
+ if (state.shuttingDown)
829
+ throw new Error('Server shutting down');
830
+ await new Promise((r) => setTimeout(r, 50));
831
+ }
832
+ if (state.cdpPort === 0) {
833
+ throw new Error('Chrome CDP port did not become available in time');
834
+ }
835
+ return state.cdpPort;
836
+ }
739
837
  async function handleCdpClient(state, clientWs, ctx, cdpPort) {
740
838
  try {
741
839
  // Only one client active at a time — close the previous one.
@@ -766,7 +864,8 @@ async function handleCdpClient(state, clientWs, ctx, cdpPort) {
766
864
  });
767
865
  // NOW do async work — messages arriving during these awaits are buffered.
768
866
  if (!state.cdpUrl) {
769
- state.cdpUrl = await waitForCDP(cdpPort);
867
+ const port = cdpPort && cdpPort > 0 ? cdpPort : await waitForServerCdpPort(state);
868
+ state.cdpUrl = await waitForCDP(port);
770
869
  console.log(`[cdp-proxy] CDP available at: ${state.cdpUrl}`);
771
870
  }
772
871
  await ensureChromeConnection(state, state.cdpUrl, ctx);
@@ -834,13 +933,16 @@ function createGracefulShutdown(state, deps) {
834
933
  deps.wss.close();
835
934
  // Stop accepting new HTTP connections.
836
935
  deps.server.close();
837
- await closeLaunchedBrowserGracefully(state, deps.cdpPort);
936
+ // Read cdpPort from state — populated by `launchChromeTarget`; may be 0
937
+ // if shutdown fires before the browser ever launched.
938
+ await closeLaunchedBrowserGracefully(state, state.cdpPort);
838
939
  process.exit(0);
839
940
  };
840
941
  }
841
942
  /** Pre-connect to Chrome so the proxy is warm before the first client; hosted mode registers leader-restart once CDP is ready. */
842
- async function preconnectCdp(state, ctx, app, servePort, cdpPort) {
943
+ async function preconnectCdp(state, ctx, app, servePort) {
843
944
  try {
945
+ const cdpPort = state.cdpPort > 0 ? state.cdpPort : await waitForServerCdpPort(state);
844
946
  state.cdpUrl = await waitForCDP(cdpPort);
845
947
  console.log(`[cdp-proxy] Pre-connected: CDP available at ${state.cdpUrl}`);
846
948
  await ensureChromeConnection(state, state.cdpUrl, ctx);
@@ -873,31 +975,61 @@ async function startOverlayInjector(state, cdpPort, servePort) {
873
975
  console.error('[electron-float] Failed to start overlay injector:', message);
874
976
  }
875
977
  }
876
- /** Start listening and warm up the CDP proxy, overlay injector, and console forwarder. */
877
- function startCdpServer(state, deps) {
878
- const { app, server, ctx, fileLogger, servePort, serveOrigin, cdpPort } = deps;
879
- server.listen(servePort, '127.0.0.1', () => {
880
- console.log(`Serving UI at ${serveOrigin}`);
881
- console.log(`CDP proxy at ws://localhost:${servePort}/cdp`);
882
- fileLogger.log('info', 'CLI server started', {
883
- port: servePort,
884
- cdpPort,
885
- devMode: DEV_MODE,
886
- electronMode: ELECTRON_MODE,
978
+ /**
979
+ * Bind the HTTP server (and the /cdp WS upgrade handler attached to it) so
980
+ * incoming `ws://localhost/cdp` connections are accepted. Resolves when
981
+ * `server.listen` fires its 'listening' callback.
982
+ *
983
+ * Split out of the old `startCdpServer` so `main()` can `await` listening
984
+ * BEFORE `launchBrowser(state)` runs — without this ordering the hosted
985
+ * leader's eager `browser.connect()` (fired the moment Chrome opens
986
+ * sliccy.ai in thin-bridge mode) can race ahead of the bridge and fail
987
+ * its one-shot WebSocket handshake.
988
+ */
989
+ function startListening(deps) {
990
+ const { server, fileLogger, servePort, serveOrigin, cdpPort } = deps;
991
+ return new Promise((resolve) => {
992
+ server.listen(servePort, '127.0.0.1', () => {
993
+ if (THIN_BRIDGE_MODE) {
994
+ console.log(`Thin /cdp bridge + /api at ${serveOrigin}`);
995
+ }
996
+ else {
997
+ console.log(`Serving UI at ${serveOrigin}`);
998
+ }
999
+ console.log(`CDP proxy at ws://localhost:${servePort}/cdp`);
1000
+ fileLogger.log('info', 'CLI server started', {
1001
+ port: servePort,
1002
+ cdpPort,
1003
+ devMode: DEV_MODE,
1004
+ electronMode: ELECTRON_MODE,
1005
+ });
1006
+ resolve();
887
1007
  });
888
- void preconnectCdp(state, ctx, app, servePort, cdpPort);
889
- if (ELECTRON_MODE) {
890
- void startOverlayInjector(state, cdpPort, servePort);
891
- }
892
- else {
893
- setTimeout(() => {
894
- attachConsoleForwarder(cdpPort, String(servePort)).catch((err) => {
895
- console.error('[page] Console forwarder error:', err);
896
- });
897
- }, 2500);
898
- }
899
1008
  });
900
1009
  }
1010
+ /**
1011
+ * Post-listen warmup: pre-connects to Chrome's CDP (so the proxy is warm
1012
+ * before the first client), starts the Electron overlay injector, and
1013
+ * attaches the console forwarder. Reads `state.cdpPort` dynamically so
1014
+ * the value populated by `launchChromeTarget` is used regardless of when
1015
+ * this runs relative to the listen callback.
1016
+ */
1017
+ function runCdpProxyWarmup(state, deps) {
1018
+ const { app, ctx, servePort } = deps;
1019
+ void preconnectCdp(state, ctx, app, servePort);
1020
+ if (ELECTRON_MODE) {
1021
+ void startOverlayInjector(state, state.cdpPort, servePort);
1022
+ }
1023
+ else if (!THIN_BRIDGE_MODE) {
1024
+ // Thin-bridge mode has no local UI page to forward console output from.
1025
+ const cdpPort = state.cdpPort;
1026
+ setTimeout(() => {
1027
+ attachConsoleForwarder(cdpPort, String(servePort)).catch((err) => {
1028
+ console.error('[page] Console forwarder error:', err);
1029
+ });
1030
+ }, 2500);
1031
+ }
1032
+ }
901
1033
  /**
902
1034
  * Build the secret stores + masking pipeline shared by the fetch-proxy, the
903
1035
  * /api/secrets routes, and the S3 sign-and-forward handler. Secret-load
@@ -924,20 +1056,98 @@ async function bootstrapSecrets() {
924
1056
  }
925
1057
  return { secretStore, secretProxy, oauthStore };
926
1058
  }
1059
+ /**
1060
+ * Thin-bridge CORS + PNA middleware. The hosted leader at sliccy.ai is a
1061
+ * cross-origin caller, so headers go on every response for allowlisted
1062
+ * origins (so /cdp's pre-WS preflight and every /api/* call succeed);
1063
+ * OPTIONS from an allowlisted origin short-circuits to 204 with the PNA
1064
+ * opt-in. Non-allowlisted origins fall through with no CORS headers, which
1065
+ * preserves same-origin (localhost) behavior unchanged.
1066
+ *
1067
+ * Additionally enforces the per-process bridge token on cross-origin
1068
+ * `/api/*` requests from REMOTE allowlisted origins (sliccy.ai) — the
1069
+ * origin allowlist alone is insufficient because any script on
1070
+ * `https://www.sliccy.ai` could otherwise reach the local node-server's
1071
+ * /api surface (secrets, fetch-proxy, etc.). Loopback allowlisted
1072
+ * origins (e.g. the locally-served OAuth callback at
1073
+ * `http://localhost:5710/auth/callback` POSTing to `/api/oauth-result`)
1074
+ * are exempt — they originate from this same server. OPTIONS preflights
1075
+ * are exempt because browsers strip custom headers from preflights.
1076
+ */
1077
+ function createThinBridgeCorsMiddleware(bridgeToken) {
1078
+ return (req, res, next) => {
1079
+ // Reflect the requested headers so the agent's `bash curl -H …` (which
1080
+ // can carry arbitrary upstream headers through /api/fetch-proxy) is not
1081
+ // blocked by a hardcoded allowlist. Cross-origin preflights advertise
1082
+ // those headers via `Access-Control-Request-Headers`.
1083
+ const origin = req.headers.origin;
1084
+ const cors = buildCorsHeaders(origin, req.headers['access-control-request-headers']);
1085
+ if (cors) {
1086
+ for (const [k, v] of Object.entries(cors))
1087
+ res.setHeader(k, v);
1088
+ }
1089
+ if (req.method === 'OPTIONS' && cors) {
1090
+ for (const [k, v] of Object.entries(buildPnaPreflightHeaders()))
1091
+ res.setHeader(k, v);
1092
+ res.setHeader('Access-Control-Max-Age', '600');
1093
+ res.status(204).end();
1094
+ return;
1095
+ }
1096
+ // Token gate on /api/* from remote allowlisted origins. `cors` is only
1097
+ // truthy when the Origin is in the allowlist; loopback callers
1098
+ // (localhost/127.0.0.1) and no-Origin curl-style callers fall through
1099
+ // unchanged.
1100
+ if (cors &&
1101
+ req.path.startsWith('/api/') &&
1102
+ !isLoopbackBridgeOrigin(origin) &&
1103
+ !validateBridgeToken(req.headers[BRIDGE_TOKEN_HEADER.toLowerCase()], bridgeToken)) {
1104
+ res.status(403).json({ error: 'bridge-token-required' });
1105
+ return;
1106
+ }
1107
+ next();
1108
+ };
1109
+ }
1110
+ /**
1111
+ * Build the `/cdp` `WebSocketServer`. `handleProtocols` echoes the matched
1112
+ * bridge subprotocol back in the 101 response (RFC 6455 §1.9). Returning
1113
+ * `false` makes the server omit the header, which makes the browser fail
1114
+ * the upgrade when a subprotocol was offered. Legacy modes (token=null)
1115
+ * pass the first offered subprotocol through unchanged.
1116
+ */
1117
+ function createCdpWebSocketServer(bridgeToken) {
1118
+ return new WebSocketServer({
1119
+ noServer: true,
1120
+ handleProtocols: (protocols) => {
1121
+ if (bridgeToken !== null) {
1122
+ return selectBridgeSubprotocol([...protocols], bridgeToken) ?? false;
1123
+ }
1124
+ const first = protocols.values().next();
1125
+ return first.done ? false : first.value;
1126
+ },
1127
+ });
1128
+ }
927
1129
  async function main() {
928
- // Resolve ports, launch the browser, then snapshot the now-final values that
929
- // the rest of boot reads. Mutable lifecycle fields stay on `state`.
1130
+ // Resolve ports first; `launchBrowser` is deferred until AFTER
1131
+ // `server.listen()` so the /cdp bridge is accepting connections before
1132
+ // Chrome opens its target URL (the packaged-CLI thin-bridge mode opens
1133
+ // the hosted leader at sliccy.ai, whose eager `browser.connect()`
1134
+ // otherwise races ahead of the bridge listener and fails its one-shot
1135
+ // WebSocket handshake). `state.cdpPort` becomes valid inside
1136
+ // `launchChromeTarget` once Chrome announces its CDP port; consumers
1137
+ // that need it (`handleCdpClient`, `preconnectCdp`, the graceful-
1138
+ // shutdown handler) read it dynamically from `state`.
930
1139
  const state = createServerState();
931
1140
  await resolvePorts(state);
932
- await launchBrowser(state);
933
- const { servePort: SERVE_PORT, cdpPort: CDP_PORT, serveOrigin: SERVE_ORIGIN } = state;
934
- const discoveredTrayJoinUrl = state.discoveredTrayJoinUrl;
1141
+ const { servePort: SERVE_PORT, serveOrigin: SERVE_ORIGIN } = state;
935
1142
  // 3. Set up express app with request logging
936
1143
  const { secretStore, secretProxy, oauthStore } = await bootstrapSecrets();
937
1144
  const app = express();
938
1145
  app.use(requestLogger);
939
1146
  // Append SLICC's standard RFC 8288 Link header set on every /api/* response.
940
1147
  app.use(sliccLinksMiddleware());
1148
+ if (THIN_BRIDGE_MODE) {
1149
+ app.use(createThinBridgeCorsMiddleware(state.bridgeToken));
1150
+ }
941
1151
  // ---------------------------------------------------------------------------
942
1152
  // Lick system — WebSocket bridge for webhooks/crontasks (all logic in browser)
943
1153
  // ---------------------------------------------------------------------------
@@ -964,7 +1174,11 @@ async function main() {
964
1174
  (DEV_MODE
965
1175
  ? 'https://slicc-tray-hub-staging.minivelos.workers.dev'
966
1176
  : 'https://www.sliccy.ai'),
967
- trayJoinUrl: discoveredTrayJoinUrl ?? null,
1177
+ // Read dynamically from state — populated by `launchElectronTarget`
1178
+ // when an existing leader is discovered. `launchBrowser` now runs
1179
+ // after `server.listen()`, so this endpoint must NOT close over a
1180
+ // pre-launch snapshot or it would always return null.
1181
+ trayJoinUrl: state.discoveredTrayJoinUrl ?? null,
968
1182
  });
969
1183
  });
970
1184
  // Localhost API descriptor — the discoverable surface advertised by the
@@ -1012,17 +1226,21 @@ async function main() {
1012
1226
  registerFetchProxyRoute(app, { secretProxy });
1013
1227
  // Create the HTTP server BEFORE Vite so we can register our upgrade handler first
1014
1228
  const server = createServer(app);
1015
- await attachUiServing(app, server, {
1016
- devMode: DEV_MODE,
1017
- hosted: RUNTIME_FLAGS.hosted,
1018
- serveOrigin: SERVE_ORIGIN,
1019
- uiDir: resolve(Dirname, '..', 'ui'),
1020
- });
1229
+ // Thin-bridge mode: skip UI serving entirely. node-server becomes a
1230
+ // pure /cdp bridge + /api surface; Chrome opens the sliccy.ai-hosted
1231
+ // leader which talks to /cdp + /api cross-origin.
1232
+ if (!THIN_BRIDGE_MODE) {
1233
+ await attachUiServing(app, server, {
1234
+ devMode: DEV_MODE,
1235
+ hosted: RUNTIME_FLAGS.hosted,
1236
+ serveOrigin: SERVE_ORIGIN,
1237
+ uiDir: resolve(Dirname, '..', 'ui'),
1238
+ });
1239
+ }
1021
1240
  // 4. CDP WebSocket proxy at /cdp — noServer mode so Vite's dev middleware
1022
- // doesn't intercept the upgrade; the per-message cap stays default (the
1023
- // feedback-loop defense is Chrome→proxy only, never client→proxy).
1024
- const wss = new WebSocketServer({ noServer: true });
1025
- attachCdpUpgradeRouting(server, wss, lickWss);
1241
+ // doesn't intercept the upgrade.
1242
+ const wss = createCdpWebSocketServer(state.bridgeToken);
1243
+ attachCdpUpgradeRouting(server, wss, lickWss, state.bridgeToken);
1026
1244
  const cdpCtx = {
1027
1245
  wss,
1028
1246
  secretProxy,
@@ -1033,7 +1251,6 @@ async function main() {
1033
1251
  fileLogger,
1034
1252
  wss,
1035
1253
  server,
1036
- cdpPort: CDP_PORT,
1037
1254
  });
1038
1255
  process.on('SIGINT', () => {
1039
1256
  gracefulShutdown();
@@ -1053,17 +1270,42 @@ async function main() {
1053
1270
  }
1054
1271
  }
1055
1272
  });
1273
+ await startCdpStack(state, { app, server, wss, cdpCtx, fileLogger });
1274
+ }
1275
+ /**
1276
+ * Bottom half of `main()`: bind the /cdp WS handler, start listening on
1277
+ * the HTTP server BEFORE launching the browser so the /cdp bridge is
1278
+ * accepting the hosted leader's eager connect the instant Chrome opens
1279
+ * sliccy.ai, then launch the browser (populates `state.cdpPort`), then
1280
+ * run the post-launch warmup (preconnect / overlay / console forwarder).
1281
+ *
1282
+ * Extracted purely to keep `main()` under the lint-enforced function-size
1283
+ * cap; not a meaningful boundary, just a co-locating helper.
1284
+ */
1285
+ async function startCdpStack(state, deps) {
1286
+ const { app, server, wss, cdpCtx, fileLogger } = deps;
1287
+ const { servePort: SERVE_PORT, serveOrigin: SERVE_ORIGIN } = state;
1288
+ // Read `state.cdpPort` dynamically — populated by `launchChromeTarget`
1289
+ // AFTER `server.listen()` so we can't capture it here.
1056
1290
  wss.on('connection', (clientWs) => {
1057
- void handleCdpClient(state, clientWs, cdpCtx, CDP_PORT);
1291
+ void handleCdpClient(state, clientWs, cdpCtx, state.cdpPort);
1292
+ });
1293
+ await startListening({
1294
+ server,
1295
+ fileLogger,
1296
+ servePort: SERVE_PORT,
1297
+ serveOrigin: SERVE_ORIGIN,
1298
+ cdpPort: state.cdpPort,
1058
1299
  });
1059
- startCdpServer(state, {
1300
+ await launchBrowser(state);
1301
+ runCdpProxyWarmup(state, {
1060
1302
  app,
1061
1303
  server,
1062
1304
  ctx: cdpCtx,
1063
1305
  fileLogger,
1064
1306
  servePort: SERVE_PORT,
1065
1307
  serveOrigin: SERVE_ORIGIN,
1066
- cdpPort: CDP_PORT,
1308
+ cdpPort: state.cdpPort,
1067
1309
  });
1068
1310
  }
1069
1311
  // ---------------------------------------------------------------------------
@@ -5,5 +5,12 @@ export interface CliLaunchUrlOptions {
5
5
  envWorkerBaseUrl?: string | null;
6
6
  join: boolean;
7
7
  joinUrl?: string | null;
8
+ /**
9
+ * Thin-bridge coordinates appended as query params so the hosted leader can
10
+ * discover + authenticate the local /cdp WebSocket. Both must be set
11
+ * together; both unset = no bridge params (legacy / bundled UI path).
12
+ */
13
+ bridgeWsUrl?: string | null;
14
+ bridgeToken?: string | null;
8
15
  }
9
16
  export declare function resolveCliBrowserLaunchUrl(options: CliLaunchUrlOptions): string;
@@ -1,4 +1,13 @@
1
+ import { BRIDGE_TOKEN_QUERY_PARAM, BRIDGE_WS_QUERY_PARAM } from './bridge-security.js';
1
2
  import { buildCanonicalTrayLaunchUrl, normalizeTrayWorkerBaseUrl, parseTrayJoinUrl, } from './tray-url-shared.js';
3
+ function appendBridgeParams(url, opts) {
4
+ if (!opts.bridgeWsUrl || !opts.bridgeToken)
5
+ return url;
6
+ const params = new URLSearchParams();
7
+ params.set(BRIDGE_WS_QUERY_PARAM, opts.bridgeWsUrl);
8
+ params.set(BRIDGE_TOKEN_QUERY_PARAM, opts.bridgeToken);
9
+ return `${url}${url.includes('?') ? '&' : '?'}${params.toString()}`;
10
+ }
2
11
  function buildTrayJoinLaunchUrl(locationHref, joinUrl) {
3
12
  const parsedJoinUrl = parseTrayJoinUrl(joinUrl);
4
13
  if (!parsedJoinUrl) {
@@ -21,14 +30,14 @@ export function resolveCliBrowserLaunchUrl(options) {
21
30
  if (!options.joinUrl) {
22
31
  throw new Error('The --join launch flow requires a tray join URL via --join <url> or --join=<url>.');
23
32
  }
24
- return buildTrayJoinLaunchUrl(options.serveOrigin, options.joinUrl);
33
+ return appendBridgeParams(buildTrayJoinLaunchUrl(options.serveOrigin, options.joinUrl), options);
25
34
  }
26
35
  if (!options.lead) {
27
- return options.serveOrigin;
36
+ return appendBridgeParams(options.serveOrigin, options);
28
37
  }
29
38
  const workerBaseUrl = normalizeTrayWorkerBaseUrl(options.leadWorkerBaseUrl ?? options.envWorkerBaseUrl ?? null);
30
39
  if (!workerBaseUrl) {
31
40
  throw new Error('The --lead launch flow requires a tray worker base URL via --lead <url>, --lead=<url>, or WORKER_BASE_URL.');
32
41
  }
33
- return buildTrayLeadLaunchUrl(options.serveOrigin, workerBaseUrl);
42
+ return appendBridgeParams(buildTrayLeadLaunchUrl(options.serveOrigin, workerBaseUrl), options);
34
43
  }