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.
- package/dist/node-server/bridge-security.d.ts +42 -1
- package/dist/node-server/bridge-security.js +120 -7
- package/dist/node-server/index.js +153 -45
- package/dist/ui/assets/{account-store-CSLAx3pa.js → account-store-BXT0RMpF.js} +2 -2
- package/dist/ui/assets/{account-store-9y5Ab4vV.js → account-store-CcqPXFk-.js} +2 -2
- package/dist/ui/assets/{adobe-Ba8U6Soe.js → adobe-Dvkc5_oM.js} +1 -1
- package/dist/ui/assets/adobe-Dyvxqy9s.js +1 -0
- package/dist/ui/assets/{agent-message-to-chat-bEHX4iF4.js → agent-message-to-chat-Cihra740.js} +1 -1
- package/dist/ui/assets/{apps-Dh-H9LYE.js → apps-CPyXd9Mk.js} +1 -1
- package/dist/ui/assets/{azure-openai-BpIYfrM-.js → azure-openai-BEec8FHP.js} +1 -1
- package/dist/ui/assets/{azure-openai-C4YFzA3o.js → azure-openai-lMn0o-9C.js} +1 -1
- package/dist/ui/assets/{bsh-watchdog-CNmrTRAy.js → bsh-watchdog-Dgr_OA1N.js} +1 -1
- package/dist/ui/assets/{cdp-BGgdiko1.js → cdp-D4JkCTey.js} +2 -2
- package/dist/ui/assets/{connect-surface-DTsPWNDo.js → connect-surface-BzhaxP0L.js} +1 -1
- package/dist/ui/assets/dip-CIhLisNq.js +1 -0
- package/dist/ui/assets/{dist-B5wGuNlW.js → dist-ChTQy8at.js} +1 -1
- package/dist/ui/assets/{dist-CjQIX13Y.js → dist-DIjck1q7.js} +1 -1
- package/dist/ui/assets/{es-t4MB1uY0.js → es-B8GxLLfB.js} +1 -1
- package/dist/ui/assets/{fs-BlhR8i_q.js → fs-ATai1P5Q.js} +2 -2
- package/dist/ui/assets/{fs-ry06ziaM.js → fs-xQUivn-8.js} +1 -1
- package/dist/ui/assets/{github-6R3ONWs4.js → github-C6vLSHER.js} +2 -2
- package/dist/ui/assets/{github-Cr1bhcIq.js → github-CUVBUETF.js} +1 -1
- package/dist/ui/assets/{github-copilot-CPpSIAl_.js → github-copilot-BqMS3_4S.js} +1 -1
- package/dist/ui/assets/{github-copilot-_vAY24oI.js → github-copilot-ggchxbZd.js} +1 -1
- package/dist/ui/assets/{hear-DLqP08Yk.js → hear-Bm8tL0z2.js} +1 -1
- package/dist/ui/assets/{kernel-worker-ReGrzvdD.js → kernel-worker-CqATTm08.js} +1026 -1026
- package/dist/ui/assets/{kokoro-engine-BeiKy8PB.js → kokoro-engine-BzgKX1A1.js} +1 -1
- package/dist/ui/assets/{lick-ws-bridge-DZy9C70j.js → lick-ws-bridge-DFQu2sM8.js} +1 -1
- package/dist/ui/assets/{local-llm-ehhQ9AeC.js → local-llm-D09l_nRy.js} +1 -1
- package/dist/ui/assets/{main-cherry-X1IxoqNY.js → main-cherry-B1mI8ArD.js} +1 -1
- package/dist/ui/assets/{main-zx9b4e1t.js → main-sWMjBmPA.js} +3 -3
- package/dist/ui/assets/{mount-Crd-I-lS.js → mount-D7RXkT90.js} +1 -1
- package/dist/ui/assets/{mount-Cf2e3Vu8.js → mount-sEjqfa02.js} +2 -2
- package/dist/ui/assets/{new-session-PJxD5xz-.js → new-session-B0xwp5hO.js} +2 -2
- package/dist/ui/assets/{oauth-bootstrap-38UC9ABk.js → oauth-bootstrap-Uz3oksBR.js} +2 -2
- package/dist/ui/assets/{openai-codex-4vY4IfjZ.js → openai-codex-BMr_sYj2.js} +1 -1
- package/dist/ui/assets/{openai-codex-CcWEg2Id.js → openai-codex-d2uBt8CN.js} +1 -1
- package/dist/ui/assets/{panel-rpc-handlers-CyLUoTYQ.js → panel-rpc-handlers-1U8M-tQU.js} +1 -1
- package/dist/ui/assets/{provider-BGI7Ytuv.js → provider-B_3MhsjL.js} +2 -2
- package/dist/ui/assets/{provider-BRZxUH2o.js → provider-BbwD43pe.js} +1 -1
- package/dist/ui/assets/provider-store-access-Cop2VuvO.js +1 -0
- package/dist/ui/assets/provider-store-access-_8NADMDP.js +1 -0
- package/dist/ui/assets/{providers-CcDiXx1v.js → providers-DRQ-n4v0.js} +1 -1
- package/dist/ui/assets/proxied-fetch-3ooUUAsF.js +1 -0
- package/dist/ui/assets/{quick-llm-CpcpayIL.js → quick-llm-CHWi-jSD.js} +1 -1
- package/dist/ui/assets/session-freezer-mzV5cASX.js +1 -0
- package/dist/ui/assets/setup-sudo-DSt9eI8G.js +1 -0
- package/dist/ui/assets/{speak-gvgsbAbr.js → speak-Cr05k3rj.js} +1 -1
- package/dist/ui/assets/{sprinkle-manager-am-b2nZK.js → sprinkle-manager-B7xN0WFf.js} +1 -1
- package/dist/ui/assets/{store-2Y1B359N.js → store-CfuBHukr.js} +1 -1
- package/dist/ui/assets/{sudo-CXuD9JQi.js → sudo-CnG9mh3w.js} +1 -1
- package/dist/ui/assets/{transformers-env-RCjmw9ix.js → transformers-env-BEM0HkoC.js} +1 -1
- package/dist/ui/assets/{tray-leave-runtime-EjSnz0hB.js → tray-leave-runtime-CD-xjbXc.js} +1 -1
- package/dist/ui/assets/{upgrade-detection-CHScEQnq.js → upgrade-detection-CwNs_M6E.js} +1 -1
- package/dist/ui/assets/{wc-attach-BbBcc9d7.js → wc-attach-D7JTvNE3.js} +2 -2
- package/dist/ui/assets/{wc-detached-BsGgVyQ2.js → wc-detached-DfgIMcuk.js} +1 -1
- package/dist/ui/assets/{wc-extension-pjc1MPFN.js → wc-extension-BNpR2nAA.js} +2 -2
- package/dist/ui/assets/{wc-live-BAWrPhr9.js → wc-live-cbtCpYYl.js} +12 -12
- package/dist/ui/assets/{wc-nav-D1GUd0Mt.js → wc-nav-BVFjm1zp.js} +2 -2
- package/dist/ui/assets/{wc-onboarding-Bt02HM9-.js → wc-onboarding-C-b3gfT3.js} +2 -2
- package/dist/ui/assets/{wc-placeholder-CEMVuHe3.js → wc-placeholder-CLNwZTGs.js} +3 -3
- package/dist/ui/assets/{wc-settings-DP3TVlWk.js → wc-settings-BRCPA5ys.js} +2 -2
- package/dist/ui/assets/{wc-shell-tnKegXuB.js → wc-shell-CeWID7O0.js} +3 -3
- package/dist/ui/assets/{wc-sprinkles-DEgP8Ihj.js → wc-sprinkles-DYTck3a-.js} +2 -2
- package/dist/ui/assets/{wc-tray-BsL3c-FP.js → wc-tray-ByE1fpIQ.js} +3 -3
- package/dist/ui/assets/{xai-grok-BK-WZwtZ.js → xai-grok-BcQs9t1L.js} +1 -1
- package/dist/ui/assets/{xai-grok-CqrCTXgB.js → xai-grok-Bx-LJDfy.js} +1 -1
- package/dist/ui/index.html +2 -2
- package/dist/ui/packages/webapp/index.html +2 -2
- package/package.json +1 -1
- package/dist/ui/assets/adobe-BtvdR5I0.js +0 -1
- package/dist/ui/assets/dip-Fvfzs0-q.js +0 -1
- package/dist/ui/assets/provider-store-access-BiJN5bAM.js +0 -1
- package/dist/ui/assets/provider-store-access-Dp0MGqAc.js +0 -1
- package/dist/ui/assets/proxied-fetch-Dg114MVx.js +0 -1
- package/dist/ui/assets/proxy-error-9UvXMpzG.js +0 -1
- package/dist/ui/assets/session-freezer-DQ--9MwT.js +0 -1
- 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
|
-
/**
|
|
41
|
-
|
|
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':
|
|
121
|
-
'Access-Control-Expose-Headers':
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
/**
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
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
|
-
|
|
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
|
|
1056
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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:
|
|
1308
|
+
cdpPort: state.cdpPort,
|
|
1201
1309
|
});
|
|
1202
1310
|
}
|
|
1203
1311
|
// ---------------------------------------------------------------------------
|