sliccy 4.22.0 → 4.23.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 (78) hide show
  1. package/dist/node-server/bridge-security.d.ts +94 -0
  2. package/dist/node-server/bridge-security.js +132 -0
  3. package/dist/node-server/index.js +149 -15
  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-IyPhtzhJ.js → account-store-9y5Ab4vV.js} +2 -2
  7. package/dist/ui/assets/{account-store-BbOugMGQ.js → account-store-CSLAx3pa.js} +2 -2
  8. package/dist/ui/assets/{adobe-DgwUObWJ.js → adobe-Ba8U6Soe.js} +1 -1
  9. package/dist/ui/assets/{adobe-VPKLgSWT.js → adobe-BtvdR5I0.js} +1 -1
  10. package/dist/ui/assets/{agent-message-to-chat-y24E_cxL.js → agent-message-to-chat-bEHX4iF4.js} +1 -1
  11. package/dist/ui/assets/{apps-DUeem2tA.js → apps-Dh-H9LYE.js} +1 -1
  12. package/dist/ui/assets/{azure-openai-DoRxmkwZ.js → azure-openai-BpIYfrM-.js} +1 -1
  13. package/dist/ui/assets/{azure-openai-BZIgRwqd.js → azure-openai-C4YFzA3o.js} +1 -1
  14. package/dist/ui/assets/{bsh-watchdog-C9h2IaS0.js → bsh-watchdog-CNmrTRAy.js} +1 -1
  15. package/dist/ui/assets/{cdp-Cbi5Katl.js → cdp-BGgdiko1.js} +2 -2
  16. package/dist/ui/assets/{connect-surface-CVGFqUbD.js → connect-surface-DTsPWNDo.js} +1 -1
  17. package/dist/ui/assets/dip-Fvfzs0-q.js +1 -0
  18. package/dist/ui/assets/{dist-BRXlUPtR.js → dist-B5wGuNlW.js} +1 -1
  19. package/dist/ui/assets/{dist-CFUSBJZQ.js → dist-CjQIX13Y.js} +1 -1
  20. package/dist/ui/assets/{es-B9Xns6VP.js → es-t4MB1uY0.js} +1 -1
  21. package/dist/ui/assets/{fs-BCZV-DeG.js → fs-BlhR8i_q.js} +2 -2
  22. package/dist/ui/assets/{fs-BWEPe_lI.js → fs-ry06ziaM.js} +1 -1
  23. package/dist/ui/assets/{github-D1-sIysd.js → github-6R3ONWs4.js} +2 -2
  24. package/dist/ui/assets/{github-BD8TM8E4.js → github-Cr1bhcIq.js} +1 -1
  25. package/dist/ui/assets/{github-copilot-BeL7S_RM.js → github-copilot-CPpSIAl_.js} +1 -1
  26. package/dist/ui/assets/{github-copilot-CVOOYEif.js → github-copilot-_vAY24oI.js} +1 -1
  27. package/dist/ui/assets/{hear-DD8kXpu3.js → hear-DLqP08Yk.js} +1 -1
  28. package/dist/ui/assets/{kernel-worker-B1z5Mep_.js → kernel-worker-ReGrzvdD.js} +24 -24
  29. package/dist/ui/assets/{kokoro-engine-BAKhtzKA.js → kokoro-engine-BeiKy8PB.js} +1 -1
  30. package/dist/ui/assets/{lick-ws-bridge-C76WNbyz.js → lick-ws-bridge-DZy9C70j.js} +1 -1
  31. package/dist/ui/assets/{local-llm-Bx2yDR6Y.js → local-llm-ehhQ9AeC.js} +1 -1
  32. package/dist/ui/assets/{main-cherry-Bap_NTtV.js → main-cherry-X1IxoqNY.js} +1 -1
  33. package/dist/ui/assets/{main-ZPWrsiEH.js → main-zx9b4e1t.js} +3 -3
  34. package/dist/ui/assets/{mount-CJTp3rsY.js → mount-Cf2e3Vu8.js} +2 -2
  35. package/dist/ui/assets/{mount-BZGOqIjJ.js → mount-Crd-I-lS.js} +1 -1
  36. package/dist/ui/assets/{new-session-Boyp0jbl.js → new-session-PJxD5xz-.js} +2 -2
  37. package/dist/ui/assets/{oauth-bootstrap-B-FGqNIA.js → oauth-bootstrap-38UC9ABk.js} +2 -2
  38. package/dist/ui/assets/{openai-codex-kCbaxA3p.js → openai-codex-4vY4IfjZ.js} +1 -1
  39. package/dist/ui/assets/{openai-codex-2NIZy82T.js → openai-codex-CcWEg2Id.js} +1 -1
  40. package/dist/ui/assets/{panel-rpc-handlers-DB-Ug9qU.js → panel-rpc-handlers-CyLUoTYQ.js} +1 -1
  41. package/dist/ui/assets/{provider-Dz3gmrdh.js → provider-BGI7Ytuv.js} +2 -2
  42. package/dist/ui/assets/{provider-C-QlPwy9.js → provider-BRZxUH2o.js} +1 -1
  43. package/dist/ui/assets/provider-store-access-BiJN5bAM.js +1 -0
  44. package/dist/ui/assets/provider-store-access-Dp0MGqAc.js +1 -0
  45. package/dist/ui/assets/{providers-wvXqnMMs.js → providers-CcDiXx1v.js} +1 -1
  46. package/dist/ui/assets/{quick-llm-BrfU7BId.js → quick-llm-CpcpayIL.js} +1 -1
  47. package/dist/ui/assets/session-freezer-DQ--9MwT.js +1 -0
  48. package/dist/ui/assets/setup-sudo-I4S39zW5.js +1 -0
  49. package/dist/ui/assets/{speak-BfxsOvBC.js → speak-gvgsbAbr.js} +1 -1
  50. package/dist/ui/assets/{sprinkle-manager-D9bGw9q8.js → sprinkle-manager-am-b2nZK.js} +1 -1
  51. package/dist/ui/assets/{store-CvNWCf_K.js → store-2Y1B359N.js} +1 -1
  52. package/dist/ui/assets/{sudo-DNg2E0j8.js → sudo-CXuD9JQi.js} +1 -1
  53. package/dist/ui/assets/{transformers-env-Vns8FfCl.js → transformers-env-RCjmw9ix.js} +1 -1
  54. package/dist/ui/assets/{tray-leave-runtime-Dblg03Av.js → tray-leave-runtime-EjSnz0hB.js} +1 -1
  55. package/dist/ui/assets/{upgrade-detection-CWy_ieEK.js → upgrade-detection-CHScEQnq.js} +1 -1
  56. package/dist/ui/assets/{wc-attach-D2l7z2MI.js → wc-attach-BbBcc9d7.js} +2 -2
  57. package/dist/ui/assets/{wc-detached-CNJ_gW__.js → wc-detached-BsGgVyQ2.js} +1 -1
  58. package/dist/ui/assets/{wc-extension-BwrsCjZl.js → wc-extension-pjc1MPFN.js} +2 -2
  59. package/dist/ui/assets/{wc-live-DjrJGzlN.js → wc-live-BAWrPhr9.js} +5 -5
  60. package/dist/ui/assets/{wc-nav-BmtdkU_z.js → wc-nav-D1GUd0Mt.js} +2 -2
  61. package/dist/ui/assets/{wc-onboarding-Ct6Arm5m.js → wc-onboarding-Bt02HM9-.js} +2 -2
  62. package/dist/ui/assets/{wc-placeholder-CWOudEXO.js → wc-placeholder-CEMVuHe3.js} +3 -3
  63. package/dist/ui/assets/{wc-settings-3CEFgt8u.js → wc-settings-DP3TVlWk.js} +2 -2
  64. package/dist/ui/assets/{wc-shell-CjN33a2b.js → wc-shell-tnKegXuB.js} +3 -3
  65. package/dist/ui/assets/{wc-sprinkles-smmPR4Ja.js → wc-sprinkles-DEgP8Ihj.js} +2 -2
  66. package/dist/ui/assets/{wc-tray-CVAaAkxz.js → wc-tray-BsL3c-FP.js} +3 -3
  67. package/dist/ui/assets/{xai-grok-CcpOkuUW.js → xai-grok-BK-WZwtZ.js} +1 -1
  68. package/dist/ui/assets/{xai-grok-DxDfANmg.js → xai-grok-CqrCTXgB.js} +1 -1
  69. package/dist/ui/index.html +2 -2
  70. package/dist/ui/packages/webapp/index.html +2 -2
  71. package/dist/ui/slicc-diff.js +1 -1
  72. package/dist/ui/slicc-editor.js +1 -1
  73. package/package.json +2 -2
  74. package/dist/ui/assets/dip-CvR0Cqr1.js +0 -1
  75. package/dist/ui/assets/provider-store-access-DoN2yB7H.js +0 -1
  76. package/dist/ui/assets/provider-store-access-KBTPjy08.js +0 -1
  77. package/dist/ui/assets/session-freezer-BXDSvoMT.js +0 -1
  78. package/dist/ui/assets/setup-sudo-DQpQyGGT.js +0 -1
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Bridge security primitives for the standalone thin /cdp bridge.
3
+ *
4
+ * The standalone CLI runs a thin Express server that proxies CDP from a
5
+ * sliccy.ai-hosted leader tab to the local Chrome over `/cdp`. Full CDP
6
+ * pass-through = full control of the user's Chrome, so the WebSocket
7
+ * upgrade is gated by two factors plus PNA:
8
+ * 1. Origin allowlist (`isAllowedBridgeOrigin`).
9
+ * 2. Per-process subprotocol token in `Sec-WebSocket-Protocol`
10
+ * (`SUBPROTOCOL_PREFIX` + token). Never appears in a query string,
11
+ * so it does not leak into Referer / logs.
12
+ * 3. PNA preflight (`buildPnaPreflightHeaders`) — Chrome blocks
13
+ * public→private WS upgrades without `Access-Control-Allow-Private-Network`.
14
+ *
15
+ * Cross-origin /api calls from the hosted leader also need CORS; see
16
+ * `buildCorsHeaders` for the per-request response set.
17
+ *
18
+ * Pure module (no node-only imports) so the WS gate is trivially unit
19
+ * testable from `tests/bridge-security.test.ts`.
20
+ */
21
+ /**
22
+ * Origin allowlist. Production + staging worker plus the dev-mode loopback
23
+ * origins (parallel to chrome-extension's `BRIDGE_DEV_ORIGINS`). Add a new
24
+ * origin here and in the extension allowlist together — they MUST stay in
25
+ * sync, otherwise extension and standalone disagree on what's a leader.
26
+ */
27
+ export declare const BRIDGE_ALLOWED_ORIGINS: readonly string[];
28
+ /** Subprotocol prefix advertised by the leader; the per-process token is appended. */
29
+ export declare const BRIDGE_SUBPROTOCOL_PREFIX = "slicc.bridge.v1.";
30
+ /** Query-param name the launch URL uses to forward the subprotocol token to the leader. */
31
+ export declare const BRIDGE_TOKEN_QUERY_PARAM = "bridgeToken";
32
+ /** Query-param name the launch URL uses to forward the local /cdp WebSocket URL. */
33
+ export declare const BRIDGE_WS_QUERY_PARAM = "bridge";
34
+ /** True iff `origin` is in the bridge allowlist. Case-sensitive (origins are normalized lowercase). */
35
+ export declare function isAllowedBridgeOrigin(origin: string | undefined | null): boolean;
36
+ /**
37
+ * Mint a per-process bridge token. Embedded in the leader launch URL and
38
+ * required as the WebSocket subprotocol on /cdp. Use `crypto.randomUUID()`
39
+ * — 122 bits of entropy is plenty for a session-scoped capability.
40
+ */
41
+ export declare function mintBridgeToken(): string;
42
+ /**
43
+ * Parse the `Sec-WebSocket-Protocol` request header into a trimmed list. The
44
+ * header is a comma-separated list per RFC 6455; `ws` exposes it raw.
45
+ */
46
+ export declare function parseSubprotocolHeader(header: string | string[] | undefined): string[];
47
+ /**
48
+ * Pick the bridge subprotocol matching `expectedToken`, or null if absent.
49
+ * The matching protocol is what we MUST echo back in the upgrade response
50
+ * (RFC 6455 §1.9) — otherwise the browser closes the socket.
51
+ */
52
+ export declare function selectBridgeSubprotocol(protocols: readonly string[], expectedToken: string): string | null;
53
+ export interface BridgeUpgradeGateResult {
54
+ ok: boolean;
55
+ /**
56
+ * The subprotocol to echo back in the 101 response when `ok === true`.
57
+ * Always null when `ok === false`.
58
+ */
59
+ acceptedSubprotocol: string | null;
60
+ /**
61
+ * Reason exposed in logs for rejection. Intentionally coarse — does not
62
+ * tell the caller WHICH check failed, mirroring `validateBridgePin` in
63
+ * the chrome-extension bridge SW.
64
+ */
65
+ reason?: 'origin-not-allowed' | 'subprotocol-missing-or-mismatched';
66
+ }
67
+ /**
68
+ * Combined origin + subprotocol gate for a `/cdp` upgrade request.
69
+ *
70
+ * Returns `{ ok: true, acceptedSubprotocol }` only when BOTH the origin is
71
+ * in the allowlist AND a matching `slicc.bridge.v1.<expectedToken>`
72
+ * subprotocol was offered. Closes-the-socket-before-emit semantics live at
73
+ * the call site in `index.ts`.
74
+ */
75
+ export declare function validateBridgeUpgrade(input: {
76
+ origin: string | undefined | null;
77
+ subprotocolHeader: string | string[] | undefined;
78
+ expectedToken: string;
79
+ }): BridgeUpgradeGateResult;
80
+ /**
81
+ * CORS headers for an allowlisted `Origin`. Returns `null` when the origin
82
+ * is not in the allowlist (caller should NOT set CORS headers).
83
+ *
84
+ * `Access-Control-Allow-Credentials: true` is included so the hosted leader
85
+ * can carry cookies to /api/* (e.g. auth) when that's added later. Today
86
+ * the bridge token is the auth factor, not cookies.
87
+ */
88
+ export declare function buildCorsHeaders(origin: string | undefined | null): Record<string, string> | null;
89
+ /**
90
+ * PNA preflight extras. Added on OPTIONS responses for allowlisted origins
91
+ * when the request carries `Access-Control-Request-Private-Network: true`
92
+ * — Chrome blocks public→private fetch / WS otherwise.
93
+ */
94
+ export declare function buildPnaPreflightHeaders(): Record<string, string>;
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Bridge security primitives for the standalone thin /cdp bridge.
3
+ *
4
+ * The standalone CLI runs a thin Express server that proxies CDP from a
5
+ * sliccy.ai-hosted leader tab to the local Chrome over `/cdp`. Full CDP
6
+ * pass-through = full control of the user's Chrome, so the WebSocket
7
+ * upgrade is gated by two factors plus PNA:
8
+ * 1. Origin allowlist (`isAllowedBridgeOrigin`).
9
+ * 2. Per-process subprotocol token in `Sec-WebSocket-Protocol`
10
+ * (`SUBPROTOCOL_PREFIX` + token). Never appears in a query string,
11
+ * so it does not leak into Referer / logs.
12
+ * 3. PNA preflight (`buildPnaPreflightHeaders`) — Chrome blocks
13
+ * public→private WS upgrades without `Access-Control-Allow-Private-Network`.
14
+ *
15
+ * Cross-origin /api calls from the hosted leader also need CORS; see
16
+ * `buildCorsHeaders` for the per-request response set.
17
+ *
18
+ * Pure module (no node-only imports) so the WS gate is trivially unit
19
+ * testable from `tests/bridge-security.test.ts`.
20
+ */
21
+ import { randomUUID } from 'node:crypto';
22
+ /**
23
+ * Origin allowlist. Production + staging worker plus the dev-mode loopback
24
+ * origins (parallel to chrome-extension's `BRIDGE_DEV_ORIGINS`). Add a new
25
+ * origin here and in the extension allowlist together — they MUST stay in
26
+ * sync, otherwise extension and standalone disagree on what's a leader.
27
+ */
28
+ export const BRIDGE_ALLOWED_ORIGINS = Object.freeze([
29
+ 'https://www.sliccy.ai',
30
+ 'https://slicc-tray-hub-staging.minivelos.workers.dev',
31
+ 'http://localhost:5710',
32
+ 'http://127.0.0.1:5710',
33
+ ]);
34
+ /** Subprotocol prefix advertised by the leader; the per-process token is appended. */
35
+ export const BRIDGE_SUBPROTOCOL_PREFIX = 'slicc.bridge.v1.';
36
+ /** Query-param name the launch URL uses to forward the subprotocol token to the leader. */
37
+ export const BRIDGE_TOKEN_QUERY_PARAM = 'bridgeToken';
38
+ /** Query-param name the launch URL uses to forward the local /cdp WebSocket URL. */
39
+ export const BRIDGE_WS_QUERY_PARAM = 'bridge';
40
+ /** Headers we allow on cross-origin /api requests from the hosted leader. */
41
+ const CORS_ALLOW_HEADERS = 'Content-Type, X-Slicc-Raw-Body, X-Session-Id, X-Bridge-Token, Authorization';
42
+ /** Methods exposed to the hosted leader. */
43
+ const CORS_ALLOW_METHODS = 'GET, POST, PUT, DELETE, OPTIONS';
44
+ /** True iff `origin` is in the bridge allowlist. Case-sensitive (origins are normalized lowercase). */
45
+ export function isAllowedBridgeOrigin(origin) {
46
+ if (!origin)
47
+ return false;
48
+ return BRIDGE_ALLOWED_ORIGINS.includes(origin);
49
+ }
50
+ /**
51
+ * Mint a per-process bridge token. Embedded in the leader launch URL and
52
+ * required as the WebSocket subprotocol on /cdp. Use `crypto.randomUUID()`
53
+ * — 122 bits of entropy is plenty for a session-scoped capability.
54
+ */
55
+ export function mintBridgeToken() {
56
+ return randomUUID();
57
+ }
58
+ /**
59
+ * Parse the `Sec-WebSocket-Protocol` request header into a trimmed list. The
60
+ * header is a comma-separated list per RFC 6455; `ws` exposes it raw.
61
+ */
62
+ export function parseSubprotocolHeader(header) {
63
+ if (!header)
64
+ return [];
65
+ const flat = Array.isArray(header) ? header.join(',') : header;
66
+ return flat
67
+ .split(',')
68
+ .map((s) => s.trim())
69
+ .filter(Boolean);
70
+ }
71
+ /**
72
+ * Pick the bridge subprotocol matching `expectedToken`, or null if absent.
73
+ * The matching protocol is what we MUST echo back in the upgrade response
74
+ * (RFC 6455 §1.9) — otherwise the browser closes the socket.
75
+ */
76
+ export function selectBridgeSubprotocol(protocols, expectedToken) {
77
+ if (!expectedToken)
78
+ return null;
79
+ const expected = `${BRIDGE_SUBPROTOCOL_PREFIX}${expectedToken}`;
80
+ return protocols.includes(expected) ? expected : null;
81
+ }
82
+ /**
83
+ * Combined origin + subprotocol gate for a `/cdp` upgrade request.
84
+ *
85
+ * Returns `{ ok: true, acceptedSubprotocol }` only when BOTH the origin is
86
+ * in the allowlist AND a matching `slicc.bridge.v1.<expectedToken>`
87
+ * subprotocol was offered. Closes-the-socket-before-emit semantics live at
88
+ * the call site in `index.ts`.
89
+ */
90
+ export function validateBridgeUpgrade(input) {
91
+ if (!isAllowedBridgeOrigin(input.origin)) {
92
+ return { ok: false, acceptedSubprotocol: null, reason: 'origin-not-allowed' };
93
+ }
94
+ const protocols = parseSubprotocolHeader(input.subprotocolHeader);
95
+ const accepted = selectBridgeSubprotocol(protocols, input.expectedToken);
96
+ if (!accepted) {
97
+ return {
98
+ ok: false,
99
+ acceptedSubprotocol: null,
100
+ reason: 'subprotocol-missing-or-mismatched',
101
+ };
102
+ }
103
+ return { ok: true, acceptedSubprotocol: accepted };
104
+ }
105
+ /**
106
+ * CORS headers for an allowlisted `Origin`. Returns `null` when the origin
107
+ * is not in the allowlist (caller should NOT set CORS headers).
108
+ *
109
+ * `Access-Control-Allow-Credentials: true` is included so the hosted leader
110
+ * can carry cookies to /api/* (e.g. auth) when that's added later. Today
111
+ * the bridge token is the auth factor, not cookies.
112
+ */
113
+ export function buildCorsHeaders(origin) {
114
+ if (!isAllowedBridgeOrigin(origin))
115
+ return null;
116
+ return {
117
+ 'Access-Control-Allow-Origin': origin,
118
+ 'Access-Control-Allow-Credentials': 'true',
119
+ 'Access-Control-Allow-Methods': CORS_ALLOW_METHODS,
120
+ 'Access-Control-Allow-Headers': CORS_ALLOW_HEADERS,
121
+ 'Access-Control-Expose-Headers': 'Link',
122
+ Vary: 'Origin',
123
+ };
124
+ }
125
+ /**
126
+ * PNA preflight extras. Added on OPTIONS responses for allowlisted origins
127
+ * when the request carries `Access-Control-Request-Private-Network: true`
128
+ * — Chrome blocks public→private fetch / WS otherwise.
129
+ */
130
+ export function buildPnaPreflightHeaders() {
131
+ return { 'Access-Control-Allow-Private-Network': 'true' };
132
+ }
@@ -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 { buildCorsHeaders, buildPnaPreflightHeaders, mintBridgeToken, selectBridgeSubprotocol, 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
  });
@@ -877,7 +954,12 @@ async function startOverlayInjector(state, cdpPort, servePort) {
877
954
  function startCdpServer(state, deps) {
878
955
  const { app, server, ctx, fileLogger, servePort, serveOrigin, cdpPort } = deps;
879
956
  server.listen(servePort, '127.0.0.1', () => {
880
- console.log(`Serving UI at ${serveOrigin}`);
957
+ if (THIN_BRIDGE_MODE) {
958
+ console.log(`Thin /cdp bridge + /api at ${serveOrigin}`);
959
+ }
960
+ else {
961
+ console.log(`Serving UI at ${serveOrigin}`);
962
+ }
881
963
  console.log(`CDP proxy at ws://localhost:${servePort}/cdp`);
882
964
  fileLogger.log('info', 'CLI server started', {
883
965
  port: servePort,
@@ -889,7 +971,8 @@ function startCdpServer(state, deps) {
889
971
  if (ELECTRON_MODE) {
890
972
  void startOverlayInjector(state, cdpPort, servePort);
891
973
  }
892
- else {
974
+ else if (!THIN_BRIDGE_MODE) {
975
+ // Thin-bridge mode has no local UI page to forward console output from.
893
976
  setTimeout(() => {
894
977
  attachConsoleForwarder(cdpPort, String(servePort)).catch((err) => {
895
978
  console.error('[page] Console forwarder error:', err);
@@ -924,6 +1007,50 @@ async function bootstrapSecrets() {
924
1007
  }
925
1008
  return { secretStore, secretProxy, oauthStore };
926
1009
  }
1010
+ /**
1011
+ * Thin-bridge CORS + PNA middleware. The hosted leader at sliccy.ai is a
1012
+ * cross-origin caller, so headers go on every response for allowlisted
1013
+ * origins (so /cdp's pre-WS preflight and every /api/* call succeed);
1014
+ * OPTIONS from an allowlisted origin short-circuits to 204 with the PNA
1015
+ * opt-in. Non-allowlisted origins fall through with no CORS headers, which
1016
+ * preserves same-origin (localhost) behavior unchanged.
1017
+ */
1018
+ function createThinBridgeCorsMiddleware() {
1019
+ return (req, res, next) => {
1020
+ const cors = buildCorsHeaders(req.headers.origin);
1021
+ if (cors) {
1022
+ for (const [k, v] of Object.entries(cors))
1023
+ res.setHeader(k, v);
1024
+ }
1025
+ if (req.method === 'OPTIONS' && cors) {
1026
+ for (const [k, v] of Object.entries(buildPnaPreflightHeaders()))
1027
+ res.setHeader(k, v);
1028
+ res.setHeader('Access-Control-Max-Age', '600');
1029
+ res.status(204).end();
1030
+ return;
1031
+ }
1032
+ next();
1033
+ };
1034
+ }
1035
+ /**
1036
+ * Build the `/cdp` `WebSocketServer`. `handleProtocols` echoes the matched
1037
+ * bridge subprotocol back in the 101 response (RFC 6455 §1.9). Returning
1038
+ * `false` makes the server omit the header, which makes the browser fail
1039
+ * the upgrade when a subprotocol was offered. Legacy modes (token=null)
1040
+ * pass the first offered subprotocol through unchanged.
1041
+ */
1042
+ function createCdpWebSocketServer(bridgeToken) {
1043
+ return new WebSocketServer({
1044
+ noServer: true,
1045
+ handleProtocols: (protocols) => {
1046
+ if (bridgeToken !== null) {
1047
+ return selectBridgeSubprotocol([...protocols], bridgeToken) ?? false;
1048
+ }
1049
+ const first = protocols.values().next();
1050
+ return first.done ? false : first.value;
1051
+ },
1052
+ });
1053
+ }
927
1054
  async function main() {
928
1055
  // Resolve ports, launch the browser, then snapshot the now-final values that
929
1056
  // the rest of boot reads. Mutable lifecycle fields stay on `state`.
@@ -938,6 +1065,9 @@ async function main() {
938
1065
  app.use(requestLogger);
939
1066
  // Append SLICC's standard RFC 8288 Link header set on every /api/* response.
940
1067
  app.use(sliccLinksMiddleware());
1068
+ if (THIN_BRIDGE_MODE) {
1069
+ app.use(createThinBridgeCorsMiddleware());
1070
+ }
941
1071
  // ---------------------------------------------------------------------------
942
1072
  // Lick system — WebSocket bridge for webhooks/crontasks (all logic in browser)
943
1073
  // ---------------------------------------------------------------------------
@@ -1012,17 +1142,21 @@ async function main() {
1012
1142
  registerFetchProxyRoute(app, { secretProxy });
1013
1143
  // Create the HTTP server BEFORE Vite so we can register our upgrade handler first
1014
1144
  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
- });
1145
+ // Thin-bridge mode: skip UI serving entirely. node-server becomes a
1146
+ // pure /cdp bridge + /api surface; Chrome opens the sliccy.ai-hosted
1147
+ // leader which talks to /cdp + /api cross-origin.
1148
+ if (!THIN_BRIDGE_MODE) {
1149
+ await attachUiServing(app, server, {
1150
+ devMode: DEV_MODE,
1151
+ hosted: RUNTIME_FLAGS.hosted,
1152
+ serveOrigin: SERVE_ORIGIN,
1153
+ uiDir: resolve(Dirname, '..', 'ui'),
1154
+ });
1155
+ }
1021
1156
  // 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);
1157
+ // doesn't intercept the upgrade.
1158
+ const wss = createCdpWebSocketServer(state.bridgeToken);
1159
+ attachCdpUpgradeRouting(server, wss, lickWss, state.bridgeToken);
1026
1160
  const cdpCtx = {
1027
1161
  wss,
1028
1162
  secretProxy,
@@ -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
  }