sliccy 4.23.0 → 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 (78) hide show
  1. package/dist/node-server/bridge-security.d.ts +42 -1
  2. package/dist/node-server/bridge-security.js +120 -7
  3. package/dist/node-server/index.js +153 -45
  4. package/dist/ui/assets/{account-store-CSLAx3pa.js → account-store-BXT0RMpF.js} +2 -2
  5. package/dist/ui/assets/{account-store-9y5Ab4vV.js → account-store-CcqPXFk-.js} +2 -2
  6. package/dist/ui/assets/{adobe-Ba8U6Soe.js → adobe-Dvkc5_oM.js} +1 -1
  7. package/dist/ui/assets/adobe-Dyvxqy9s.js +1 -0
  8. package/dist/ui/assets/{agent-message-to-chat-bEHX4iF4.js → agent-message-to-chat-Cihra740.js} +1 -1
  9. package/dist/ui/assets/{apps-Dh-H9LYE.js → apps-CPyXd9Mk.js} +1 -1
  10. package/dist/ui/assets/{azure-openai-BpIYfrM-.js → azure-openai-BEec8FHP.js} +1 -1
  11. package/dist/ui/assets/{azure-openai-C4YFzA3o.js → azure-openai-lMn0o-9C.js} +1 -1
  12. package/dist/ui/assets/{bsh-watchdog-CNmrTRAy.js → bsh-watchdog-Dgr_OA1N.js} +1 -1
  13. package/dist/ui/assets/{cdp-BGgdiko1.js → cdp-D4JkCTey.js} +2 -2
  14. package/dist/ui/assets/{connect-surface-DTsPWNDo.js → connect-surface-BzhaxP0L.js} +1 -1
  15. package/dist/ui/assets/dip-CIhLisNq.js +1 -0
  16. package/dist/ui/assets/{dist-B5wGuNlW.js → dist-ChTQy8at.js} +1 -1
  17. package/dist/ui/assets/{dist-CjQIX13Y.js → dist-DIjck1q7.js} +1 -1
  18. package/dist/ui/assets/{es-t4MB1uY0.js → es-B8GxLLfB.js} +1 -1
  19. package/dist/ui/assets/{fs-BlhR8i_q.js → fs-ATai1P5Q.js} +2 -2
  20. package/dist/ui/assets/{fs-ry06ziaM.js → fs-xQUivn-8.js} +1 -1
  21. package/dist/ui/assets/{github-6R3ONWs4.js → github-C6vLSHER.js} +2 -2
  22. package/dist/ui/assets/{github-Cr1bhcIq.js → github-CUVBUETF.js} +1 -1
  23. package/dist/ui/assets/{github-copilot-CPpSIAl_.js → github-copilot-BqMS3_4S.js} +1 -1
  24. package/dist/ui/assets/{github-copilot-_vAY24oI.js → github-copilot-ggchxbZd.js} +1 -1
  25. package/dist/ui/assets/{hear-DLqP08Yk.js → hear-Bm8tL0z2.js} +1 -1
  26. package/dist/ui/assets/{kernel-worker-ReGrzvdD.js → kernel-worker-CqATTm08.js} +1026 -1026
  27. package/dist/ui/assets/{kokoro-engine-BeiKy8PB.js → kokoro-engine-BzgKX1A1.js} +1 -1
  28. package/dist/ui/assets/{lick-ws-bridge-DZy9C70j.js → lick-ws-bridge-DFQu2sM8.js} +1 -1
  29. package/dist/ui/assets/{local-llm-ehhQ9AeC.js → local-llm-D09l_nRy.js} +1 -1
  30. package/dist/ui/assets/{main-cherry-X1IxoqNY.js → main-cherry-B1mI8ArD.js} +1 -1
  31. package/dist/ui/assets/{main-zx9b4e1t.js → main-sWMjBmPA.js} +3 -3
  32. package/dist/ui/assets/{mount-Crd-I-lS.js → mount-D7RXkT90.js} +1 -1
  33. package/dist/ui/assets/{mount-Cf2e3Vu8.js → mount-sEjqfa02.js} +2 -2
  34. package/dist/ui/assets/{new-session-PJxD5xz-.js → new-session-B0xwp5hO.js} +2 -2
  35. package/dist/ui/assets/{oauth-bootstrap-38UC9ABk.js → oauth-bootstrap-Uz3oksBR.js} +2 -2
  36. package/dist/ui/assets/{openai-codex-4vY4IfjZ.js → openai-codex-BMr_sYj2.js} +1 -1
  37. package/dist/ui/assets/{openai-codex-CcWEg2Id.js → openai-codex-d2uBt8CN.js} +1 -1
  38. package/dist/ui/assets/{panel-rpc-handlers-CyLUoTYQ.js → panel-rpc-handlers-1U8M-tQU.js} +1 -1
  39. package/dist/ui/assets/{provider-BGI7Ytuv.js → provider-B_3MhsjL.js} +2 -2
  40. package/dist/ui/assets/{provider-BRZxUH2o.js → provider-BbwD43pe.js} +1 -1
  41. package/dist/ui/assets/provider-store-access-Cop2VuvO.js +1 -0
  42. package/dist/ui/assets/provider-store-access-_8NADMDP.js +1 -0
  43. package/dist/ui/assets/{providers-CcDiXx1v.js → providers-DRQ-n4v0.js} +1 -1
  44. package/dist/ui/assets/proxied-fetch-3ooUUAsF.js +1 -0
  45. package/dist/ui/assets/{quick-llm-CpcpayIL.js → quick-llm-CHWi-jSD.js} +1 -1
  46. package/dist/ui/assets/session-freezer-mzV5cASX.js +1 -0
  47. package/dist/ui/assets/setup-sudo-DSt9eI8G.js +1 -0
  48. package/dist/ui/assets/{speak-gvgsbAbr.js → speak-Cr05k3rj.js} +1 -1
  49. package/dist/ui/assets/{sprinkle-manager-am-b2nZK.js → sprinkle-manager-B7xN0WFf.js} +1 -1
  50. package/dist/ui/assets/{store-2Y1B359N.js → store-CfuBHukr.js} +1 -1
  51. package/dist/ui/assets/{sudo-CXuD9JQi.js → sudo-CnG9mh3w.js} +1 -1
  52. package/dist/ui/assets/{transformers-env-RCjmw9ix.js → transformers-env-BEM0HkoC.js} +1 -1
  53. package/dist/ui/assets/{tray-leave-runtime-EjSnz0hB.js → tray-leave-runtime-CD-xjbXc.js} +1 -1
  54. package/dist/ui/assets/{upgrade-detection-CHScEQnq.js → upgrade-detection-CwNs_M6E.js} +1 -1
  55. package/dist/ui/assets/{wc-attach-BbBcc9d7.js → wc-attach-D7JTvNE3.js} +2 -2
  56. package/dist/ui/assets/{wc-detached-BsGgVyQ2.js → wc-detached-DfgIMcuk.js} +1 -1
  57. package/dist/ui/assets/{wc-extension-pjc1MPFN.js → wc-extension-BNpR2nAA.js} +2 -2
  58. package/dist/ui/assets/{wc-live-BAWrPhr9.js → wc-live-cbtCpYYl.js} +12 -12
  59. package/dist/ui/assets/{wc-nav-D1GUd0Mt.js → wc-nav-BVFjm1zp.js} +2 -2
  60. package/dist/ui/assets/{wc-onboarding-Bt02HM9-.js → wc-onboarding-C-b3gfT3.js} +2 -2
  61. package/dist/ui/assets/{wc-placeholder-CEMVuHe3.js → wc-placeholder-CLNwZTGs.js} +3 -3
  62. package/dist/ui/assets/{wc-settings-DP3TVlWk.js → wc-settings-BRCPA5ys.js} +2 -2
  63. package/dist/ui/assets/{wc-shell-tnKegXuB.js → wc-shell-CeWID7O0.js} +3 -3
  64. package/dist/ui/assets/{wc-sprinkles-DEgP8Ihj.js → wc-sprinkles-DYTck3a-.js} +2 -2
  65. package/dist/ui/assets/{wc-tray-BsL3c-FP.js → wc-tray-ByE1fpIQ.js} +3 -3
  66. package/dist/ui/assets/{xai-grok-BK-WZwtZ.js → xai-grok-BcQs9t1L.js} +1 -1
  67. package/dist/ui/assets/{xai-grok-CqrCTXgB.js → xai-grok-Bx-LJDfy.js} +1 -1
  68. package/dist/ui/index.html +2 -2
  69. package/dist/ui/packages/webapp/index.html +2 -2
  70. package/package.json +1 -1
  71. package/dist/ui/assets/adobe-BtvdR5I0.js +0 -1
  72. package/dist/ui/assets/dip-Fvfzs0-q.js +0 -1
  73. package/dist/ui/assets/provider-store-access-BiJN5bAM.js +0 -1
  74. package/dist/ui/assets/provider-store-access-Dp0MGqAc.js +0 -1
  75. package/dist/ui/assets/proxied-fetch-Dg114MVx.js +0 -1
  76. package/dist/ui/assets/proxy-error-9UvXMpzG.js +0 -1
  77. package/dist/ui/assets/session-freezer-DQ--9MwT.js +0 -1
  78. package/dist/ui/assets/setup-sudo-I4S39zW5.js +0 -1
@@ -31,8 +31,33 @@ export declare const BRIDGE_SUBPROTOCOL_PREFIX = "slicc.bridge.v1.";
31
31
  export declare const BRIDGE_TOKEN_QUERY_PARAM = "bridgeToken";
32
32
  /** Query-param name the launch URL uses to forward the local /cdp WebSocket URL. */
33
33
  export declare const BRIDGE_WS_QUERY_PARAM = "bridge";
34
+ /**
35
+ * Request header carrying the per-process bridge token on cross-origin
36
+ * /api/* calls from a remote allowlisted leader (e.g. sliccy.ai). The
37
+ * webapp's `proxied-fetch.ts` attaches it whenever a local API base
38
+ * origin is set; the thin-bridge CORS middleware validates it. Header
39
+ * is included in `CORS_BASE_ALLOW_HEADERS` so browsers don't strip it
40
+ * on the preflight.
41
+ */
42
+ export declare const BRIDGE_TOKEN_HEADER = "X-Bridge-Token";
34
43
  /** True iff `origin` is in the bridge allowlist. Case-sensitive (origins are normalized lowercase). */
35
44
  export declare function isAllowedBridgeOrigin(origin: string | undefined | null): boolean;
45
+ /**
46
+ * True iff `origin` is a loopback host (localhost / 127.0.0.1 / ::1).
47
+ * Loopback allowlisted origins (e.g. the locally-served OAuth callback
48
+ * page at `http://localhost:5710/auth/callback` posting to
49
+ * `/api/oauth-result`) are exempt from the bridge-token requirement —
50
+ * the token's threat model is "remote allowlisted origin (sliccy.ai)
51
+ * with a hostile script", not "local server talking to itself".
52
+ */
53
+ export declare function isLoopbackBridgeOrigin(origin: string | undefined | null): boolean;
54
+ /**
55
+ * Constant-time compare for the bridge token. `presented` may be missing
56
+ * or shaped wrong (Express delivers headers as `string | string[] |
57
+ * undefined`). Returns `false` for any non-string, length mismatch, or
58
+ * empty expected — never throws.
59
+ */
60
+ export declare function validateBridgeToken(presented: string | string[] | undefined, expected: string | null): boolean;
36
61
  /**
37
62
  * Mint a per-process bridge token. Embedded in the leader launch URL and
38
63
  * required as the WebSocket subprotocol on /cdp. Use `crypto.randomUUID()`
@@ -77,6 +102,18 @@ export declare function validateBridgeUpgrade(input: {
77
102
  subprotocolHeader: string | string[] | undefined;
78
103
  expectedToken: string;
79
104
  }): BridgeUpgradeGateResult;
105
+ /**
106
+ * Resolve the `Access-Control-Allow-Headers` value for a request. Starts
107
+ * from `CORS_BASE_ALLOW_HEADERS` (the static set covering the documented
108
+ * /api endpoints + the `/api/fetch-proxy` transport headers) and unions
109
+ * in any header names from the request's `Access-Control-Request-Headers`
110
+ * that aren't already listed. This is the reflect-headers pattern: the
111
+ * agent's `bash curl -H X-Custom: …` can route through `/api/fetch-proxy`
112
+ * cross-origin without us having to enumerate every possible upstream
113
+ * header in advance. Comparison is case-insensitive; the static set's
114
+ * canonical casing wins on duplicates.
115
+ */
116
+ export declare function resolveCorsAllowHeaders(requestHeadersHeader: string | undefined | null): string;
80
117
  /**
81
118
  * CORS headers for an allowlisted `Origin`. Returns `null` when the origin
82
119
  * is not in the allowlist (caller should NOT set CORS headers).
@@ -84,8 +121,12 @@ export declare function validateBridgeUpgrade(input: {
84
121
  * `Access-Control-Allow-Credentials: true` is included so the hosted leader
85
122
  * can carry cookies to /api/* (e.g. auth) when that's added later. Today
86
123
  * the bridge token is the auth factor, not cookies.
124
+ *
125
+ * `requestHeadersHeader` should be the request's `Access-Control-Request-Headers`
126
+ * value (preflight only); on a non-preflight request pass `null` and the
127
+ * caller can omit it.
87
128
  */
88
- export declare function buildCorsHeaders(origin: string | undefined | null): Record<string, string> | null;
129
+ export declare function buildCorsHeaders(origin: string | undefined | null, requestHeadersHeader?: string | string[] | null): Record<string, string> | null;
89
130
  /**
90
131
  * PNA preflight extras. Added on OPTIONS responses for allowlisted origins
91
132
  * when the request carries `Access-Control-Request-Private-Network: true`
@@ -18,7 +18,7 @@
18
18
  * Pure module (no node-only imports) so the WS gate is trivially unit
19
19
  * testable from `tests/bridge-security.test.ts`.
20
20
  */
21
- import { randomUUID } from 'node:crypto';
21
+ import { randomUUID, timingSafeEqual } from 'node:crypto';
22
22
  /**
23
23
  * Origin allowlist. Production + staging worker plus the dev-mode loopback
24
24
  * origins (parallel to chrome-extension's `BRIDGE_DEV_ORIGINS`). Add a new
@@ -37,8 +37,42 @@ export const BRIDGE_SUBPROTOCOL_PREFIX = 'slicc.bridge.v1.';
37
37
  export const BRIDGE_TOKEN_QUERY_PARAM = 'bridgeToken';
38
38
  /** Query-param name the launch URL uses to forward the local /cdp WebSocket URL. */
39
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';
40
+ /**
41
+ * Request header carrying the per-process bridge token on cross-origin
42
+ * /api/* calls from a remote allowlisted leader (e.g. sliccy.ai). The
43
+ * webapp's `proxied-fetch.ts` attaches it whenever a local API base
44
+ * origin is set; the thin-bridge CORS middleware validates it. Header
45
+ * is included in `CORS_BASE_ALLOW_HEADERS` so browsers don't strip it
46
+ * on the preflight.
47
+ */
48
+ export const BRIDGE_TOKEN_HEADER = 'X-Bridge-Token';
49
+ /**
50
+ * Headers we allow on cross-origin /api requests from the hosted leader.
51
+ * Includes the `/api/fetch-proxy` transport headers (`X-Target-URL`, the
52
+ * forbidden-header `X-Proxy-*` bridges, `X-Slicc-Raw-Body`) so the
53
+ * webapp's `createProxiedFetch` can route through the local node-server
54
+ * cross-origin in thin-bridge mode. Custom upstream headers (any header
55
+ * the agent's `curl -H …` would pass through) are reflected via
56
+ * `Access-Control-Request-Headers` in `buildCorsHeaders` below.
57
+ */
58
+ const CORS_BASE_ALLOW_HEADERS = [
59
+ 'Content-Type',
60
+ 'X-Slicc-Raw-Body',
61
+ 'X-Session-Id',
62
+ 'X-Bridge-Token',
63
+ 'Authorization',
64
+ 'X-Target-URL',
65
+ 'X-Proxy-Cookie',
66
+ 'X-Proxy-Origin',
67
+ 'X-Proxy-Referer',
68
+ ];
69
+ /**
70
+ * Response headers the browser is allowed to read after a cross-origin
71
+ * /api call — must include the proxy's infrastructure-error marker
72
+ * (`isProxyError` reads `X-Proxy-Error`) and the forbidden-response
73
+ * bridge (`decodeForbiddenResponseHeaders` reads `X-Proxy-Set-Cookie`).
74
+ */
75
+ const CORS_EXPOSE_HEADERS = 'Link, X-Proxy-Error, X-Proxy-Set-Cookie';
42
76
  /** Methods exposed to the hosted leader. */
43
77
  const CORS_ALLOW_METHODS = 'GET, POST, PUT, DELETE, OPTIONS';
44
78
  /** True iff `origin` is in the bridge allowlist. Case-sensitive (origins are normalized lowercase). */
@@ -47,6 +81,48 @@ export function isAllowedBridgeOrigin(origin) {
47
81
  return false;
48
82
  return BRIDGE_ALLOWED_ORIGINS.includes(origin);
49
83
  }
84
+ /**
85
+ * True iff `origin` is a loopback host (localhost / 127.0.0.1 / ::1).
86
+ * Loopback allowlisted origins (e.g. the locally-served OAuth callback
87
+ * page at `http://localhost:5710/auth/callback` posting to
88
+ * `/api/oauth-result`) are exempt from the bridge-token requirement —
89
+ * the token's threat model is "remote allowlisted origin (sliccy.ai)
90
+ * with a hostile script", not "local server talking to itself".
91
+ */
92
+ export function isLoopbackBridgeOrigin(origin) {
93
+ if (!origin)
94
+ return false;
95
+ try {
96
+ const url = new URL(origin);
97
+ // Node's WHATWG URL parser keeps the brackets on IPv6 hostnames
98
+ // (`http://[::1]:5710` → `[::1]`); accept both bracketed and bare.
99
+ return (url.hostname === 'localhost' ||
100
+ url.hostname === '127.0.0.1' ||
101
+ url.hostname === '::1' ||
102
+ url.hostname === '[::1]');
103
+ }
104
+ catch {
105
+ return false;
106
+ }
107
+ }
108
+ /**
109
+ * Constant-time compare for the bridge token. `presented` may be missing
110
+ * or shaped wrong (Express delivers headers as `string | string[] |
111
+ * undefined`). Returns `false` for any non-string, length mismatch, or
112
+ * empty expected — never throws.
113
+ */
114
+ export function validateBridgeToken(presented, expected) {
115
+ if (!expected)
116
+ return false;
117
+ const value = Array.isArray(presented) ? presented[0] : presented;
118
+ if (typeof value !== 'string' || value.length === 0)
119
+ return false;
120
+ const a = Buffer.from(value);
121
+ const b = Buffer.from(expected);
122
+ if (a.length !== b.length)
123
+ return false;
124
+ return timingSafeEqual(a, b);
125
+ }
50
126
  /**
51
127
  * Mint a per-process bridge token. Embedded in the leader launch URL and
52
128
  * required as the WebSocket subprotocol on /cdp. Use `crypto.randomUUID()`
@@ -102,6 +178,36 @@ export function validateBridgeUpgrade(input) {
102
178
  }
103
179
  return { ok: true, acceptedSubprotocol: accepted };
104
180
  }
181
+ /**
182
+ * Resolve the `Access-Control-Allow-Headers` value for a request. Starts
183
+ * from `CORS_BASE_ALLOW_HEADERS` (the static set covering the documented
184
+ * /api endpoints + the `/api/fetch-proxy` transport headers) and unions
185
+ * in any header names from the request's `Access-Control-Request-Headers`
186
+ * that aren't already listed. This is the reflect-headers pattern: the
187
+ * agent's `bash curl -H X-Custom: …` can route through `/api/fetch-proxy`
188
+ * cross-origin without us having to enumerate every possible upstream
189
+ * header in advance. Comparison is case-insensitive; the static set's
190
+ * canonical casing wins on duplicates.
191
+ */
192
+ export function resolveCorsAllowHeaders(requestHeadersHeader) {
193
+ if (!requestHeadersHeader)
194
+ return CORS_BASE_ALLOW_HEADERS.join(', ');
195
+ const seen = new Set(CORS_BASE_ALLOW_HEADERS.map((h) => h.toLowerCase()));
196
+ const extras = [];
197
+ for (const raw of requestHeadersHeader.split(',')) {
198
+ const name = raw.trim();
199
+ if (!name)
200
+ continue;
201
+ const lower = name.toLowerCase();
202
+ if (seen.has(lower))
203
+ continue;
204
+ seen.add(lower);
205
+ extras.push(name);
206
+ }
207
+ if (extras.length === 0)
208
+ return CORS_BASE_ALLOW_HEADERS.join(', ');
209
+ return [...CORS_BASE_ALLOW_HEADERS, ...extras].join(', ');
210
+ }
105
211
  /**
106
212
  * CORS headers for an allowlisted `Origin`. Returns `null` when the origin
107
213
  * is not in the allowlist (caller should NOT set CORS headers).
@@ -109,17 +215,24 @@ export function validateBridgeUpgrade(input) {
109
215
  * `Access-Control-Allow-Credentials: true` is included so the hosted leader
110
216
  * can carry cookies to /api/* (e.g. auth) when that's added later. Today
111
217
  * the bridge token is the auth factor, not cookies.
218
+ *
219
+ * `requestHeadersHeader` should be the request's `Access-Control-Request-Headers`
220
+ * value (preflight only); on a non-preflight request pass `null` and the
221
+ * caller can omit it.
112
222
  */
113
- export function buildCorsHeaders(origin) {
223
+ export function buildCorsHeaders(origin, requestHeadersHeader) {
114
224
  if (!isAllowedBridgeOrigin(origin))
115
225
  return null;
226
+ const reqHeaders = Array.isArray(requestHeadersHeader)
227
+ ? requestHeadersHeader.join(', ')
228
+ : (requestHeadersHeader ?? null);
116
229
  return {
117
230
  'Access-Control-Allow-Origin': origin,
118
231
  'Access-Control-Allow-Credentials': 'true',
119
232
  'Access-Control-Allow-Methods': CORS_ALLOW_METHODS,
120
- 'Access-Control-Allow-Headers': CORS_ALLOW_HEADERS,
121
- 'Access-Control-Expose-Headers': 'Link',
122
- Vary: 'Origin',
233
+ 'Access-Control-Allow-Headers': resolveCorsAllowHeaders(reqHeaders),
234
+ 'Access-Control-Expose-Headers': CORS_EXPOSE_HEADERS,
235
+ Vary: 'Origin, Access-Control-Request-Headers',
123
236
  };
124
237
  }
125
238
  /**
@@ -10,7 +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
+ import { BRIDGE_TOKEN_HEADER, buildCorsHeaders, buildPnaPreflightHeaders, isLoopbackBridgeOrigin, mintBridgeToken, selectBridgeSubprotocol, validateBridgeToken, validateBridgeUpgrade, } from './bridge-security.js';
14
14
  import { applyCdpUnmask } from './cdp-proxy/cdp-unmask.js';
15
15
  import { createCdpSessionUrlTracker } from './cdp-proxy/session-url-tracker.js';
16
16
  import { buildChromeLaunchArgs, clearStaleDevToolsActivePort, ensureQaProfileScaffold, findChromeExecutable, legacyChromeCandidates, migrateLegacyDefaultChromeProfile, planChromeSpawn, resolveChromeLaunchProfile, waitForCdpPort, } from './chrome-launch.js';
@@ -813,6 +813,27 @@ function forwardClientFrame(state, data, ctx) {
813
813
  console.log(`[cdp-proxy] Client→Chrome (DROPPED — no connection): ${preview}`);
814
814
  }
815
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
+ }
816
837
  async function handleCdpClient(state, clientWs, ctx, cdpPort) {
817
838
  try {
818
839
  // Only one client active at a time — close the previous one.
@@ -843,7 +864,8 @@ async function handleCdpClient(state, clientWs, ctx, cdpPort) {
843
864
  });
844
865
  // NOW do async work — messages arriving during these awaits are buffered.
845
866
  if (!state.cdpUrl) {
846
- state.cdpUrl = await waitForCDP(cdpPort);
867
+ const port = cdpPort && cdpPort > 0 ? cdpPort : await waitForServerCdpPort(state);
868
+ state.cdpUrl = await waitForCDP(port);
847
869
  console.log(`[cdp-proxy] CDP available at: ${state.cdpUrl}`);
848
870
  }
849
871
  await ensureChromeConnection(state, state.cdpUrl, ctx);
@@ -911,13 +933,16 @@ function createGracefulShutdown(state, deps) {
911
933
  deps.wss.close();
912
934
  // Stop accepting new HTTP connections.
913
935
  deps.server.close();
914
- 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);
915
939
  process.exit(0);
916
940
  };
917
941
  }
918
942
  /** Pre-connect to Chrome so the proxy is warm before the first client; hosted mode registers leader-restart once CDP is ready. */
919
- async function preconnectCdp(state, ctx, app, servePort, cdpPort) {
943
+ async function preconnectCdp(state, ctx, app, servePort) {
920
944
  try {
945
+ const cdpPort = state.cdpPort > 0 ? state.cdpPort : await waitForServerCdpPort(state);
921
946
  state.cdpUrl = await waitForCDP(cdpPort);
922
947
  console.log(`[cdp-proxy] Pre-connected: CDP available at ${state.cdpUrl}`);
923
948
  await ensureChromeConnection(state, state.cdpUrl, ctx);
@@ -950,37 +975,61 @@ async function startOverlayInjector(state, cdpPort, servePort) {
950
975
  console.error('[electron-float] Failed to start overlay injector:', message);
951
976
  }
952
977
  }
953
- /** Start listening and warm up the CDP proxy, overlay injector, and console forwarder. */
954
- function startCdpServer(state, deps) {
955
- const { app, server, ctx, fileLogger, servePort, serveOrigin, cdpPort } = deps;
956
- server.listen(servePort, '127.0.0.1', () => {
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
- }
963
- console.log(`CDP proxy at ws://localhost:${servePort}/cdp`);
964
- fileLogger.log('info', 'CLI server started', {
965
- port: servePort,
966
- cdpPort,
967
- devMode: DEV_MODE,
968
- 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();
969
1007
  });
970
- void preconnectCdp(state, ctx, app, servePort, cdpPort);
971
- if (ELECTRON_MODE) {
972
- void startOverlayInjector(state, cdpPort, servePort);
973
- }
974
- else if (!THIN_BRIDGE_MODE) {
975
- // Thin-bridge mode has no local UI page to forward console output from.
976
- setTimeout(() => {
977
- attachConsoleForwarder(cdpPort, String(servePort)).catch((err) => {
978
- console.error('[page] Console forwarder error:', err);
979
- });
980
- }, 2500);
981
- }
982
1008
  });
983
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
+ }
984
1033
  /**
985
1034
  * Build the secret stores + masking pipeline shared by the fetch-proxy, the
986
1035
  * /api/secrets routes, and the S3 sign-and-forward handler. Secret-load
@@ -1014,10 +1063,25 @@ async function bootstrapSecrets() {
1014
1063
  * OPTIONS from an allowlisted origin short-circuits to 204 with the PNA
1015
1064
  * opt-in. Non-allowlisted origins fall through with no CORS headers, which
1016
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.
1017
1076
  */
1018
- function createThinBridgeCorsMiddleware() {
1077
+ function createThinBridgeCorsMiddleware(bridgeToken) {
1019
1078
  return (req, res, next) => {
1020
- const cors = buildCorsHeaders(req.headers.origin);
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']);
1021
1085
  if (cors) {
1022
1086
  for (const [k, v] of Object.entries(cors))
1023
1087
  res.setHeader(k, v);
@@ -1029,6 +1093,17 @@ function createThinBridgeCorsMiddleware() {
1029
1093
  res.status(204).end();
1030
1094
  return;
1031
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
+ }
1032
1107
  next();
1033
1108
  };
1034
1109
  }
@@ -1052,13 +1127,18 @@ function createCdpWebSocketServer(bridgeToken) {
1052
1127
  });
1053
1128
  }
1054
1129
  async function main() {
1055
- // Resolve ports, launch the browser, then snapshot the now-final values that
1056
- // 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`.
1057
1139
  const state = createServerState();
1058
1140
  await resolvePorts(state);
1059
- await launchBrowser(state);
1060
- const { servePort: SERVE_PORT, cdpPort: CDP_PORT, serveOrigin: SERVE_ORIGIN } = state;
1061
- const discoveredTrayJoinUrl = state.discoveredTrayJoinUrl;
1141
+ const { servePort: SERVE_PORT, serveOrigin: SERVE_ORIGIN } = state;
1062
1142
  // 3. Set up express app with request logging
1063
1143
  const { secretStore, secretProxy, oauthStore } = await bootstrapSecrets();
1064
1144
  const app = express();
@@ -1066,7 +1146,7 @@ async function main() {
1066
1146
  // Append SLICC's standard RFC 8288 Link header set on every /api/* response.
1067
1147
  app.use(sliccLinksMiddleware());
1068
1148
  if (THIN_BRIDGE_MODE) {
1069
- app.use(createThinBridgeCorsMiddleware());
1149
+ app.use(createThinBridgeCorsMiddleware(state.bridgeToken));
1070
1150
  }
1071
1151
  // ---------------------------------------------------------------------------
1072
1152
  // Lick system — WebSocket bridge for webhooks/crontasks (all logic in browser)
@@ -1094,7 +1174,11 @@ async function main() {
1094
1174
  (DEV_MODE
1095
1175
  ? 'https://slicc-tray-hub-staging.minivelos.workers.dev'
1096
1176
  : 'https://www.sliccy.ai'),
1097
- 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,
1098
1182
  });
1099
1183
  });
1100
1184
  // Localhost API descriptor — the discoverable surface advertised by the
@@ -1167,7 +1251,6 @@ async function main() {
1167
1251
  fileLogger,
1168
1252
  wss,
1169
1253
  server,
1170
- cdpPort: CDP_PORT,
1171
1254
  });
1172
1255
  process.on('SIGINT', () => {
1173
1256
  gracefulShutdown();
@@ -1187,17 +1270,42 @@ async function main() {
1187
1270
  }
1188
1271
  }
1189
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.
1190
1290
  wss.on('connection', (clientWs) => {
1191
- 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,
1192
1299
  });
1193
- startCdpServer(state, {
1300
+ await launchBrowser(state);
1301
+ runCdpProxyWarmup(state, {
1194
1302
  app,
1195
1303
  server,
1196
1304
  ctx: cdpCtx,
1197
1305
  fileLogger,
1198
1306
  servePort: SERVE_PORT,
1199
1307
  serveOrigin: SERVE_ORIGIN,
1200
- cdpPort: CDP_PORT,
1308
+ cdpPort: state.cdpPort,
1201
1309
  });
1202
1310
  }
1203
1311
  // ---------------------------------------------------------------------------